taskflow-core 0.1.6 → 0.1.8
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 +10 -8
- package/dist/agents.js.map +1 -1
- package/dist/cache.d.ts.map +1 -1
- package/dist/compile.js.map +1 -1
- package/dist/context-store.js.map +1 -1
- package/dist/deterministic.d.ts +33 -0
- package/dist/deterministic.d.ts.map +1 -0
- package/dist/deterministic.js +56 -0
- package/dist/deterministic.js.map +1 -0
- package/dist/flowir/translate.d.ts.map +1 -1
- package/dist/flowir/translate.js +25 -7
- package/dist/flowir/translate.js.map +1 -1
- package/dist/frontmatter.d.ts.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/interpolate.d.ts +18 -0
- package/dist/interpolate.d.ts.map +1 -1
- package/dist/interpolate.js +45 -0
- package/dist/interpolate.js.map +1 -1
- package/dist/library/search.d.ts +3 -2
- package/dist/library/search.d.ts.map +1 -1
- package/dist/library/search.js +2 -1
- package/dist/library/search.js.map +1 -1
- package/dist/peek.js.map +1 -1
- package/dist/reflexion.js.map +1 -1
- package/dist/replay.d.ts +75 -0
- package/dist/replay.d.ts.map +1 -0
- package/dist/replay.js +25 -0
- package/dist/replay.js.map +1 -0
- package/dist/runtime.d.ts +8 -11
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +170 -43
- package/dist/runtime.js.map +1 -1
- package/dist/schema.d.ts +10 -10
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +18 -0
- package/dist/schema.js.map +1 -1
- package/dist/scorers.d.ts +11 -3
- package/dist/scorers.d.ts.map +1 -1
- package/dist/scorers.js +38 -7
- package/dist/scorers.js.map +1 -1
- package/dist/store.d.ts +57 -9
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +165 -83
- package/dist/store.js.map +1 -1
- package/dist/trace.d.ts +150 -0
- package/dist/trace.d.ts.map +1 -0
- package/dist/trace.js +191 -0
- package/dist/trace.js.map +1 -0
- package/dist/workspace.d.ts.map +1 -1
- package/package.json +1 -1
package/dist/runtime.js
CHANGED
|
@@ -29,7 +29,12 @@ const noRunnerInjected = async (_cwd, _agents, agentName, task) => ({
|
|
|
29
29
|
import { aggregateUsage, emptyUsage } from "./usage.js";
|
|
30
30
|
import { dependenciesOf, finalPhase, LOOP_DEFAULT_MAX_ITERATIONS, LOOP_HARD_MAX_ITERATIONS, MAX_DYNAMIC_MAP_ITEMS, MAX_DYNAMIC_NESTING, parseTtlMs, resolveArgs, topoLayers, TOURNAMENT_DEFAULT_VARIANTS, TOURNAMENT_HARD_MAX_VARIANTS, validateTaskflow } from "./schema.js";
|
|
31
31
|
import { verifyTaskflow } from "./verify.js";
|
|
32
|
-
import { combineScores, combineWithJudge, evaluatePureScorer, formatScorerReport, parseJudgeOutput, SCORE_DEFAULT_THRESHOLD, scoreResultJSON, scorerShapeErrors } from "./scorers.js";
|
|
32
|
+
import { combineScores, combineWithJudge, evaluatePureScorer, formatScorerReport, parseJudgeOutput, SCORE_DEFAULT_THRESHOLD, scoreResultJSON, scorerShapeErrors, WINNER_TOKEN_RE } from "./scorers.js";
|
|
33
|
+
import { parseGateVerdict, overBudget as overBudgetCheck } from "./deterministic.js";
|
|
34
|
+
import {} from "./trace.js";
|
|
35
|
+
// Re-export so existing `import { parseGateVerdict } from "./runtime.ts"` callers
|
|
36
|
+
// (and tests) keep working; the implementation now lives in deterministic.ts.
|
|
37
|
+
export { parseGateVerdict };
|
|
33
38
|
import { runCodeCompilesScorer } from "./scorer-runtime.js";
|
|
34
39
|
import { buildReflexionSummary, isContractViolation, REFLEXION_SENTINEL } from "./reflexion.js";
|
|
35
40
|
import { hashInput, newRunId, runsDir } from "./store.js";
|
|
@@ -268,14 +273,12 @@ function overBudget(state) {
|
|
|
268
273
|
const budget = state.def.budget;
|
|
269
274
|
if (!budget)
|
|
270
275
|
return { over: false, reason: "" };
|
|
271
|
-
const
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
}
|
|
278
|
-
return { over: false, reason: "" };
|
|
276
|
+
const input = {
|
|
277
|
+
maxUSD: budget.maxUSD,
|
|
278
|
+
maxTokens: budget.maxTokens,
|
|
279
|
+
usages: Object.values(state.phases).map((p) => p.usage ?? emptyUsage()),
|
|
280
|
+
};
|
|
281
|
+
return overBudgetCheck(input);
|
|
279
282
|
}
|
|
280
283
|
/** Merge several sub-results into a single PhaseState (for map/parallel). */
|
|
281
284
|
function mergePhaseState(id, results, inputHash, parseJson) {
|
|
@@ -331,6 +334,55 @@ function mergePhaseState(id, results, inputHash, parseJson) {
|
|
|
331
334
|
* A live-update sink that mirrors a subagent's streaming progress into a single
|
|
332
335
|
* phase's state row, then notifies the TUI. Shared by all single-agent phases.
|
|
333
336
|
*/
|
|
337
|
+
/** Fail-open trace emit: a throwing/missing sink must NEVER crash a run.
|
|
338
|
+
* Mirrors the `safeEmit` discipline used for `persist`/`onProgress`. */
|
|
339
|
+
function traceEmit(deps, event) {
|
|
340
|
+
try {
|
|
341
|
+
deps.trace?.emit(event);
|
|
342
|
+
}
|
|
343
|
+
catch {
|
|
344
|
+
/* trace is best-effort; never run-breaking */
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
/** Fail-open trace flush at phase-end. */
|
|
348
|
+
function traceFlush(deps, phaseId) {
|
|
349
|
+
try {
|
|
350
|
+
deps.trace?.flush(phaseId);
|
|
351
|
+
}
|
|
352
|
+
catch {
|
|
353
|
+
/* trace is best-effort; never run-breaking */
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
/** Emit a `decision: unreplayable` marker for a phase whose inputs the trace
|
|
357
|
+
* cannot fully capture (Shared Context Tree, inner sub-flows, context files,
|
|
358
|
+
* unobservable interpolation deps). A future replay marks such phases
|
|
359
|
+
* `needs-live-rerun` instead of silently reusing a recorded output.
|
|
360
|
+
* Single-phase analog of `hasUnobservedDependencies`. Fail-open. */
|
|
361
|
+
function emitUnreplayableMarker(deps, state, phase) {
|
|
362
|
+
const reason = unreplayableReason(state, phase);
|
|
363
|
+
if (!reason)
|
|
364
|
+
return;
|
|
365
|
+
traceEmit(deps, {
|
|
366
|
+
ts: Date.now(), runId: state.runId, phaseId: phase.id, kind: "decision",
|
|
367
|
+
decision: { type: "unreplayable", reason },
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
/** Why (if at all) a single phase cannot be deterministically replayed. */
|
|
371
|
+
function unreplayableReason(state, phase) {
|
|
372
|
+
if (phase.shareContext === true || state.def.contextSharing === true)
|
|
373
|
+
return "context-sharing";
|
|
374
|
+
if (phase.type === "flow")
|
|
375
|
+
return "inner-flow";
|
|
376
|
+
if (phase.context && phase.context.length > 0)
|
|
377
|
+
return "context-files";
|
|
378
|
+
// Interpolation refs that don't resolve through steps.*/args.*/item.* are
|
|
379
|
+
// unobservable to the trace (previous.output is observable via dependsOn).
|
|
380
|
+
const scan = (text) => !!text && /\{(previous\.output|item\b|item\.)/.test(text);
|
|
381
|
+
if (scan(phase.task) || scan(phase.when) || scan(phase.until) || (Array.isArray(phase.eval) && phase.eval.some(scan))) {
|
|
382
|
+
return "unobservable-deps";
|
|
383
|
+
}
|
|
384
|
+
return undefined;
|
|
385
|
+
}
|
|
334
386
|
function liveSink(state, phaseId, emitProgress) {
|
|
335
387
|
return (l) => {
|
|
336
388
|
const live = state.phases[phaseId];
|
|
@@ -556,6 +608,38 @@ async function runSpawnedChildren(assignments, ctxDir, parentNodeId, phase, deps
|
|
|
556
608
|
return { reports: `\n\n<!-- ctx_spawn: ${lines.length} child report(s) -->\n${lines.join("\n\n")}`, usage };
|
|
557
609
|
}
|
|
558
610
|
async function executePhase(phase, state, deps, prior, emitProgress, _retryDepth = 0, opts) {
|
|
611
|
+
// Trace: phase-start (fail-open). Record whether this phase is replayable
|
|
612
|
+
// up front so a future replay can short-circuit it without walking events.
|
|
613
|
+
traceEmit(deps, { ts: Date.now(), runId: state.runId, phaseId: phase.id, kind: "phase-start" });
|
|
614
|
+
if (deps.trace)
|
|
615
|
+
emitUnreplayableMarker(deps, state, phase);
|
|
616
|
+
let result;
|
|
617
|
+
let threw = false;
|
|
618
|
+
try {
|
|
619
|
+
result = await executePhaseImpl(phase, state, deps, prior, emitProgress, _retryDepth, opts);
|
|
620
|
+
}
|
|
621
|
+
catch (e) {
|
|
622
|
+
threw = true;
|
|
623
|
+
// Trace: phase-end on failure (fail-open) before re-throwing.
|
|
624
|
+
traceEmit(deps, {
|
|
625
|
+
ts: Date.now(), runId: state.runId, phaseId: phase.id, kind: "phase-end",
|
|
626
|
+
status: "failed", error: e instanceof Error ? e.message : String(e),
|
|
627
|
+
});
|
|
628
|
+
traceFlush(deps, phase.id);
|
|
629
|
+
throw e;
|
|
630
|
+
}
|
|
631
|
+
if (threw)
|
|
632
|
+
return result; // unreachable; satisfies TS
|
|
633
|
+
// Trace: phase-end with the real status, then flush buffered events.
|
|
634
|
+
traceEmit(deps, {
|
|
635
|
+
ts: Date.now(), runId: state.runId, phaseId: phase.id, kind: "phase-end",
|
|
636
|
+
status: result.status, error: result.error,
|
|
637
|
+
});
|
|
638
|
+
traceFlush(deps, phase.id);
|
|
639
|
+
return result;
|
|
640
|
+
}
|
|
641
|
+
/** The pre-trace body of executePhase (workspace lifecycle + stamping). */
|
|
642
|
+
async function executePhaseImpl(phase, state, deps, prior, emitProgress, _retryDepth = 0, opts) {
|
|
559
643
|
// Side-effect classification: stamp the marker at the single exit point so
|
|
560
644
|
// every type branch inside executePhaseInner is covered. A skipped phase ran
|
|
561
645
|
// nothing — no side effect to record.
|
|
@@ -855,6 +939,29 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
|
|
|
855
939
|
if (usages.length > 1)
|
|
856
940
|
last.usage = aggregateUsage(usages);
|
|
857
941
|
last.attempts = usages.length;
|
|
942
|
+
// Trace: record this subagent call (the load-bearing record a future
|
|
943
|
+
// replay consumes). Fail-open. nodePath carries the item/variant
|
|
944
|
+
// discriminator (e.g. "review", "review-item-3", "gate-judge").
|
|
945
|
+
traceEmit(deps, {
|
|
946
|
+
ts: Date.now(),
|
|
947
|
+
runId: state.runId,
|
|
948
|
+
phaseId: phase.id,
|
|
949
|
+
kind: "subagent-call",
|
|
950
|
+
input: {
|
|
951
|
+
agent: agentName,
|
|
952
|
+
model: phase.model,
|
|
953
|
+
task,
|
|
954
|
+
preRead,
|
|
955
|
+
nodePath: ctxNodeId ?? phase.id,
|
|
956
|
+
attempt: usages.length > 0 ? usages.length - 1 : undefined,
|
|
957
|
+
},
|
|
958
|
+
output: {
|
|
959
|
+
text: last.output,
|
|
960
|
+
model: last.model,
|
|
961
|
+
usage: last.usage,
|
|
962
|
+
stopReason: last.stopReason,
|
|
963
|
+
},
|
|
964
|
+
});
|
|
858
965
|
return last;
|
|
859
966
|
};
|
|
860
967
|
const parseJson = phase.output === "json";
|
|
@@ -1162,7 +1269,7 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
|
|
|
1162
1269
|
if (phase.task) {
|
|
1163
1270
|
const agentName = resolveAgent(phase.agent, deps, state);
|
|
1164
1271
|
const text = interpolate(phase.task, freshCtx).text;
|
|
1165
|
-
const fullTask = `${preRead}${text}\n\n---\n\n${report}
|
|
1272
|
+
const fullTask = appendGateFormatSuffix(`${preRead}${text}\n\n---\n\n${report}`, phase);
|
|
1166
1273
|
const ckT = cacheKeys(cc, [phase.id, agentName, phase.model ?? "", fullTask, scoreId]);
|
|
1167
1274
|
const inputHash = ckT.key;
|
|
1168
1275
|
const cachedT = cachedPhase(cc, ckT);
|
|
@@ -1253,7 +1360,7 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
|
|
|
1253
1360
|
const interpM = interpolate(phase.task ?? "", ctx);
|
|
1254
1361
|
const textM = interpM.text;
|
|
1255
1362
|
const refWarningM = warnUnresolvedRefs(phase.id, interpM.missing);
|
|
1256
|
-
const fullTaskM = preRead + textM;
|
|
1363
|
+
const fullTaskM = appendGateFormatSuffix(preRead + textM, phase);
|
|
1257
1364
|
const agentNameM = resolveAgent(phase.agent, deps, state);
|
|
1258
1365
|
const ckM = cacheKeys(cc, [phase.id, agentNameM, phase.model ?? "", fullTaskM]);
|
|
1259
1366
|
const cachedM = cachedPhase(cc, ckM);
|
|
@@ -1272,7 +1379,7 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
|
|
|
1272
1379
|
const interp = interpolate(phase.task ?? "", ctx);
|
|
1273
1380
|
const text = interp.text;
|
|
1274
1381
|
const refWarning = warnUnresolvedRefs(phase.id, interp.missing);
|
|
1275
|
-
const fullTask = preRead + text;
|
|
1382
|
+
const fullTask = appendGateFormatSuffix(preRead + text, phase);
|
|
1276
1383
|
const agentName = resolveAgent(phase.agent, deps, state);
|
|
1277
1384
|
const ck = cacheKeys(cc, [phase.id, agentName, phase.model ?? "", fullTask]);
|
|
1278
1385
|
const inputHash = ck.key;
|
|
@@ -1285,8 +1392,16 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
|
|
|
1285
1392
|
ps.reads = readRefsToReads(readRefs, state);
|
|
1286
1393
|
if (refWarning)
|
|
1287
1394
|
ps.warnings = [...(ps.warnings ?? []), refWarning];
|
|
1288
|
-
if (type === "gate" && ps.status === "done")
|
|
1395
|
+
if (type === "gate" && ps.status === "done") {
|
|
1289
1396
|
ps.gate = parseGateVerdict(r.output);
|
|
1397
|
+
// Trace: record the gate verdict decision (fail-open). A future replay
|
|
1398
|
+
// re-adjudicates this against a changed threshold.
|
|
1399
|
+
if (ps.gate)
|
|
1400
|
+
traceEmit(deps, {
|
|
1401
|
+
ts: Date.now(), runId: state.runId, phaseId: phase.id, kind: "decision",
|
|
1402
|
+
decision: { type: "gate-verdict", value: ps.gate.verdict, reason: ps.gate.reason },
|
|
1403
|
+
});
|
|
1404
|
+
}
|
|
1290
1405
|
// Shared Context Tree: register this node, mark its terminal status, and
|
|
1291
1406
|
// pick up any ctx_spawn intents the subagent queued. The spawned child
|
|
1292
1407
|
// tasks run here (supervision loop) and their reports are folded into this
|
|
@@ -1339,7 +1454,7 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
|
|
|
1339
1454
|
}
|
|
1340
1455
|
const retryCtx = buildInterpolationContext(state, lastCompletedOutput(state, phase));
|
|
1341
1456
|
const retryText = interpolate(phase.task ?? "", retryCtx).text;
|
|
1342
|
-
const retryTask = preRead + retryText;
|
|
1457
|
+
const retryTask = appendGateFormatSuffix(preRead + retryText, phase);
|
|
1343
1458
|
const retryIH = cacheKeys(cc, [phase.id, agentName, phase.model ?? "", retryTask]).key;
|
|
1344
1459
|
const retryR = await runOne(agentName, retryTask, liveSink(state, phase.id, emitProgress), undefined, contractCheck);
|
|
1345
1460
|
gatePs = resultToPhaseState(phase.id, retryR, retryIH, parseJson);
|
|
@@ -2354,38 +2469,50 @@ function defaultAgent(deps) {
|
|
|
2354
2469
|
return deps.agents[0]?.name ?? "default";
|
|
2355
2470
|
}
|
|
2356
2471
|
/**
|
|
2357
|
-
* Parse a gate phase's output into a verdict. Blocks the flow
|
|
2358
|
-
*
|
|
2359
|
-
*
|
|
2360
|
-
* `VERDICT: PASS|BLOCK|FAIL|STOP|OK|REJECT|HALT` (last occurrence wins).
|
|
2472
|
+
* Parse a gate phase's output into a verdict. Blocks the flow on an explicit
|
|
2473
|
+
* negative signal OR on ambiguous, unparseable model output. Accepts JSON
|
|
2474
|
+
* ({continue|pass: bool} or {verdict: "..."}) or a text marker
|
|
2475
|
+
* `VERDICT: PASS|BLOCK|FAIL|STOP|OK|REJECT|HALT` (last occurrence wins). The text
|
|
2476
|
+
* matcher tolerates common Markdown emphasis around the verdict word
|
|
2477
|
+
* (`VERDICT: **BLOCK**`, `### VERDICT: __BLOCK__`, `VERDICT: `BLOCK``) so a
|
|
2478
|
+
* genuine BLOCK is never silently downgraded to PASS (issue #54).
|
|
2479
|
+
*
|
|
2480
|
+
* **Fail-closed:** if the model produced output but no verdict could be parsed,
|
|
2481
|
+
* the gate BLOCKS. A gate that cannot reach a verdict cannot be trusted to pass;
|
|
2482
|
+
* halting is recoverable (prior phases persist, the run is resumable) whereas a
|
|
2483
|
+
* rubber-stamped PASS is silent and potentially ships broken work. Note that a
|
|
2484
|
+
* JSON verdict object whose value is non-blocking (e.g. `{"verdict":"No issues
|
|
2485
|
+
* found"}`) is an *explicit* pass, not ambiguity, and still resolves to pass.
|
|
2361
2486
|
*/
|
|
2362
|
-
|
|
2363
|
-
|
|
2364
|
-
|
|
2365
|
-
const o = json;
|
|
2366
|
-
if (typeof o.continue === "boolean")
|
|
2367
|
-
return { verdict: o.continue ? "pass" : "block", reason: asReason(o.reason) };
|
|
2368
|
-
if (typeof o.pass === "boolean")
|
|
2369
|
-
return { verdict: o.pass ? "pass" : "block", reason: asReason(o.reason) };
|
|
2370
|
-
if (typeof o.verdict === "string") {
|
|
2371
|
-
// Note: do NOT include standalone "no" — natural-language verdicts like
|
|
2372
|
-
// "No issues found" / "no errors" would otherwise be false-positive BLOCK.
|
|
2373
|
-
// Fail-open covers any ambiguous text.
|
|
2374
|
-
const block = /block|fail|stop|reject|halt/i.test(o.verdict);
|
|
2375
|
-
return { verdict: block ? "block" : "pass", reason: asReason(o.reason) };
|
|
2376
|
-
}
|
|
2377
|
-
}
|
|
2378
|
-
const matches = [...output.matchAll(/VERDICT\s*[:=]\s*(PASS|BLOCK|FAIL|STOP|OK|REJECT|HALT)/gi)];
|
|
2379
|
-
if (matches.length) {
|
|
2380
|
-
const v = matches[matches.length - 1][1].toUpperCase();
|
|
2381
|
-
const pass = v === "PASS" || v === "OK";
|
|
2382
|
-
return { verdict: pass ? "pass" : "block" };
|
|
2383
|
-
}
|
|
2384
|
-
return { verdict: "pass" };
|
|
2385
|
-
}
|
|
2487
|
+
// `parseGateVerdict` is implemented in `deterministic.ts` (a pure seam a
|
|
2488
|
+
// future replay can import without dragging in the process-spawning runner).
|
|
2489
|
+
// It is imported at the top of this file and re-exported from the barrel.
|
|
2386
2490
|
function asReason(v) {
|
|
2387
2491
|
return typeof v === "string" && v.trim() ? v.trim() : undefined;
|
|
2388
2492
|
}
|
|
2493
|
+
/**
|
|
2494
|
+
* If a gate phase relies on free-text verdict parsing (no `output:"json"` +
|
|
2495
|
+
* `expect` contract) and its task does not already demand a `VERDICT:` marker,
|
|
2496
|
+
* append a hard output-format suffix. This pushes the model toward the exact
|
|
2497
|
+
* machine-readable terminator the parser expects, so a genuine verdict is not
|
|
2498
|
+
* lost to an authoring slip or a model that "forgets" to emit one (issue #54).
|
|
2499
|
+
* When the phase already enforces a JSON contract, no suffix is needed — the
|
|
2500
|
+
* `expect` schema validates the output deterministically.
|
|
2501
|
+
*/
|
|
2502
|
+
function appendGateFormatSuffix(task, phase) {
|
|
2503
|
+
// Only free-text GATE phases need the verdict terminator. Agent/map/reduce/loop
|
|
2504
|
+
// phases pass through untouched — they have no verdict to parse.
|
|
2505
|
+
if (phase.type !== "gate")
|
|
2506
|
+
return task;
|
|
2507
|
+
if (phase.output === "json" && phase.expect)
|
|
2508
|
+
return task;
|
|
2509
|
+
// Already asks for a verdict marker (any case) — don't duplicate.
|
|
2510
|
+
if (/VERDICT\s*[:=]/i.test(task))
|
|
2511
|
+
return task;
|
|
2512
|
+
return (`${task}\n\n--- Required output format ---\n` +
|
|
2513
|
+
`End your response with exactly one line in this exact form (no Markdown, no bold, no extra words):\n` +
|
|
2514
|
+
`VERDICT: PASS\nor\nVERDICT: BLOCK`);
|
|
2515
|
+
}
|
|
2389
2516
|
/**
|
|
2390
2517
|
* Parse a judge's pick of the winning variant. Accepts JSON ({"winner":n} or
|
|
2391
2518
|
* {"best":n}) or a `WINNER: n` line (last match wins). Clamps to [1, count].
|
|
@@ -2402,7 +2529,7 @@ export function parseTournamentWinner(output, count) {
|
|
|
2402
2529
|
if (Number.isFinite(n))
|
|
2403
2530
|
return { winner: clamp(n), reason: asReason(o.reason) };
|
|
2404
2531
|
}
|
|
2405
|
-
const matches = [...output.matchAll(
|
|
2532
|
+
const matches = [...output.matchAll(WINNER_TOKEN_RE)];
|
|
2406
2533
|
if (matches.length) {
|
|
2407
2534
|
const n = Number(matches[matches.length - 1][1]);
|
|
2408
2535
|
if (Number.isFinite(n))
|