@hecer/yoke 1.8.0 → 1.10.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/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/CHANGELOG.md +34 -1
- package/README.md +8 -6
- package/canon/loop/prd.schema.md +6 -0
- package/canon/skills/authoring-prd/SKILL.md +7 -0
- package/dist/agents/process-incarnation.js +1 -1
- package/dist/agents/process.js +74 -6
- package/dist/agents/supervision.js +153 -0
- package/dist/agents/windows-launch.js +80 -0
- package/dist/change/inbox.js +23 -5
- package/dist/cli.js +18 -2
- package/dist/dashboard/page.js +102 -8
- package/dist/dashboard/panels.js +84 -8
- package/dist/goals/command.js +29 -4
- package/dist/loop/git.js +12 -4
- package/dist/loop/loop.js +48 -3
- package/dist/loop/parallel-adapters.js +2 -3
- package/dist/loop/parallel-command.js +14 -7
- package/dist/loop/prd.js +4 -0
- package/dist/loop/reporter.js +8 -1
- package/dist/loop/run-command.js +25 -5
- package/dist/loop/runner.js +27 -31
- package/dist/loop/watchdog.js +87 -11
- package/dist/loop/worker.js +30 -1
- package/dist/prd/assess.js +145 -0
- package/dist/prd/command.js +59 -21
- package/dist/quality/command.js +11 -5
- package/dist/retrofit/config.js +15 -1
- package/dist/retrofit/gitignore.js +1 -1
- package/dist/routing/assessment.js +66 -0
- package/dist/routing/capability.js +91 -0
- package/dist/routing/contracts.js +60 -0
- package/dist/routing/planning.js +12 -0
- package/dist/routing/router.js +85 -2
- package/dist/setup/command.js +23 -9
- package/docs/BATCH-PLANNING-VALIDATION.md +67 -0
- package/docs/CAPABILITY-ROUTING.md +82 -0
- package/docs/DASHBOARD-EVOLUTION.md +33 -0
- package/docs/PRODUCT-DIRECTION-2026-09-05.md +28 -0
- package/docs/WINDOWS-RUNNER-VALIDATION.md +104 -0
- package/docs/assets/yoke-logo.png +0 -0
- package/gemini-extension.json +1 -1
- package/package.json +1 -1
|
@@ -2,7 +2,7 @@ import { execFileSync, spawnSync } from 'node:child_process';
|
|
|
2
2
|
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
3
3
|
import { dirname, join } from 'node:path';
|
|
4
4
|
import { isProviderTreeAlive, reapProviderProcesses } from './cleanup.js';
|
|
5
|
-
import { realGitOps,
|
|
5
|
+
import { realGitOps, stageImplementation } from './git.js';
|
|
6
6
|
import { isPidAlive } from './lock.js';
|
|
7
7
|
import { storyPathSegment } from './prd.js';
|
|
8
8
|
import { killProcessTreeForCleanup } from './watchdog.js';
|
|
@@ -130,8 +130,7 @@ function rebaseCandidate(targetDir, input) {
|
|
|
130
130
|
}
|
|
131
131
|
let candidateTree;
|
|
132
132
|
try {
|
|
133
|
-
|
|
134
|
-
execFileSync('git', ['add', '-A', '--', '.', ...RUNTIME_EXCLUDES], { cwd: input.worktree.path, stdio: 'pipe' });
|
|
133
|
+
stageImplementation(input.worktree.path);
|
|
135
134
|
const unstaged = spawnSync('git', ['diff', '--quiet', '--ignore-submodules=none'], { cwd: input.worktree.path, stdio: 'pipe' });
|
|
136
135
|
if (unstaged.error || unstaged.status !== 0)
|
|
137
136
|
return { kind: 'reopen', reason: 'candidate contains changes Git cannot snapshot' };
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync, rmSync } from 'node:fs';
|
|
2
2
|
import { acceptanceProtectionProblem } from '../check/command.js';
|
|
3
3
|
import { makeAsyncAdaptiveRunner } from '../routing/router.js';
|
|
4
|
+
import { resolvePlanner } from '../routing/planning.js';
|
|
4
5
|
import { startProviderProcess } from '../agents/process.js';
|
|
5
6
|
import { runnerInvocation, isAgentAvailable } from './runner.js';
|
|
6
7
|
import { createDispatcher } from './dispatcher.js';
|
|
@@ -253,13 +254,19 @@ function reportIntegrator(reporter, worker, phase) {
|
|
|
253
254
|
function asyncRunner(input, provider, signal, workerId) {
|
|
254
255
|
if (input.routing) {
|
|
255
256
|
const routed = makeAsyncAdaptiveRunner({
|
|
256
|
-
parent: provider.provider,
|
|
257
|
-
parentSelection: { ...input.selection, model: provider.model, reasoningEffort: provider.reasoningEffort,
|
|
257
|
+
parent: input.routing.strategy === 'capability' ? input.runnerAgent : provider.provider,
|
|
258
|
+
parentSelection: input.routing.strategy === 'capability' ? input.selection : { ...input.selection, model: provider.model, reasoningEffort: provider.reasoningEffort, nativeMultiAgent: false },
|
|
258
259
|
projectRoot: input.targetDir,
|
|
259
260
|
workers: input.routing.workers,
|
|
260
261
|
rules: input.routing.rules,
|
|
261
262
|
strategy: input.routing.strategy,
|
|
262
263
|
maxCandidates: input.routing.maxCandidates,
|
|
264
|
+
maxAttempts: input.routing.maxAttempts,
|
|
265
|
+
planner: resolvePlanner({ planning: input.planning }, input.runnerAgent, input.selection),
|
|
266
|
+
assessmentPolicy: input.routing.assessmentPolicy,
|
|
267
|
+
fallback: input.routing.fallback,
|
|
268
|
+
maxTier: input.routing.maxTier,
|
|
269
|
+
onDecision: (id, decision) => input.reporter.routingDecision?.(id, decision),
|
|
263
270
|
orchestratorSelection: input.routing.orchestrator,
|
|
264
271
|
isAvailable: input.isAvailable ?? isAgentAvailable,
|
|
265
272
|
captureRoute: async (agent, context, prompt, selection) => {
|
|
@@ -275,7 +282,7 @@ function asyncRunner(input, provider, signal, workerId) {
|
|
|
275
282
|
return providerProcessResultToAgentResult(agent, context.story.id, await run(context).completion);
|
|
276
283
|
},
|
|
277
284
|
});
|
|
278
|
-
return context => context.story.agent
|
|
285
|
+
return context => context.story.agent && input.routing?.strategy !== 'capability'
|
|
279
286
|
? asyncRunner({ ...input, routing: undefined }, provider, signal, workerId)(context)
|
|
280
287
|
: routed(context);
|
|
281
288
|
}
|
|
@@ -305,10 +312,10 @@ export function providerProcessResultToAgentResult(agent, storyId, result) {
|
|
|
305
312
|
const telemetry = tokens ? { tokens } : {};
|
|
306
313
|
switch (result.kind) {
|
|
307
314
|
case 'succeeded': return { success: true, summary: `${agent} implemented ${storyId}`, ...telemetry };
|
|
308
|
-
case 'cancelled': return { success: false, summary: result.reason, ...telemetry };
|
|
309
|
-
case 'timed-out': return { success: false, summary: result.reason, ...telemetry };
|
|
310
|
-
case 'spawn-failed': return { success: false, summary: result.error, ...telemetry };
|
|
311
|
-
case 'failed': return { success: false, summary: `${agent} exited ${result.exitCode ?? 'without a code'}`, ...telemetry };
|
|
315
|
+
case 'cancelled': return { success: false, infrastructureFailure: true, summary: result.reason, ...telemetry };
|
|
316
|
+
case 'timed-out': return { success: false, infrastructureFailure: true, summary: result.reason, ...telemetry };
|
|
317
|
+
case 'spawn-failed': return { success: false, infrastructureFailure: true, summary: result.error, ...telemetry };
|
|
318
|
+
case 'failed': return { success: false, infrastructureFailure: true, summary: `${agent} exited ${result.exitCode ?? 'without a code'}`, ...telemetry };
|
|
312
319
|
default: return assertNever(result);
|
|
313
320
|
}
|
|
314
321
|
}
|
package/dist/loop/prd.js
CHANGED
|
@@ -4,6 +4,7 @@ import { parse, stringify } from 'yaml';
|
|
|
4
4
|
import { z } from 'zod';
|
|
5
5
|
import { StoryQualityDeclarationSchema } from '../quality/types.js';
|
|
6
6
|
import { validWriteScope } from './scheduler.js';
|
|
7
|
+
import { AssessmentSchema } from '../routing/assessment.js';
|
|
7
8
|
export const AcceptanceCriterionSchema = z.object({
|
|
8
9
|
id: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/),
|
|
9
10
|
text: z.string().min(1),
|
|
@@ -45,6 +46,9 @@ export const StorySchema = z.object({
|
|
|
45
46
|
/** Inbox request that created this story. Used for idempotent append-only intake. */
|
|
46
47
|
sourceChange: z.string().min(1).optional(),
|
|
47
48
|
quality: StoryQualityDeclarationSchema.optional(),
|
|
49
|
+
assessment: AssessmentSchema.optional(),
|
|
50
|
+
/** Binding to the task, upstream contracts and approved planning brief. */
|
|
51
|
+
assessmentFor: z.string().regex(/^[a-f0-9]{64}$/).optional(),
|
|
48
52
|
}).superRefine((story, ctx) => {
|
|
49
53
|
const structured = story.acceptance.filter(isAcceptanceCriterion);
|
|
50
54
|
const ids = structured.map(criterion => criterion.id);
|
package/dist/loop/reporter.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { readSupervision } from "../agents/supervision.js";
|
|
1
2
|
import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync, appendFileSync, statSync, rmSync } from 'node:fs';
|
|
2
3
|
import { join } from 'node:path';
|
|
3
4
|
import { randomUUID } from 'node:crypto';
|
|
@@ -76,7 +77,9 @@ export function readStatus(dir) {
|
|
|
76
77
|
if (!existsSync(file))
|
|
77
78
|
return null;
|
|
78
79
|
try {
|
|
79
|
-
|
|
80
|
+
const status = JSON.parse(readFileSync(file, 'utf8'));
|
|
81
|
+
const supervision = readSupervision(dir, status.startedAt);
|
|
82
|
+
return supervision.length ? { ...status, supervision } : status;
|
|
80
83
|
}
|
|
81
84
|
catch {
|
|
82
85
|
return null;
|
|
@@ -182,6 +185,10 @@ export function makeReporter(dir, opts = {}, now = () => new Date()) {
|
|
|
182
185
|
emitConsole(consoleLine);
|
|
183
186
|
};
|
|
184
187
|
return {
|
|
188
|
+
routingDecision(storyId, decision) {
|
|
189
|
+
if (current)
|
|
190
|
+
persist({ ...current, routingDecisions: { ...current.routingDecisions, [storyId]: decision }, updatedAt: now().toISOString() }, "routing", ` · route ${storyId}: ${decision.profile} (${decision.reason})`);
|
|
191
|
+
},
|
|
185
192
|
execution(provider, requestedModel) {
|
|
186
193
|
if (current)
|
|
187
194
|
persist({ ...current, execution: { provider, requestedModel, startedAt: now().toISOString() }, updatedAt: now().toISOString() }, 'execution', ` · ${provider}/${requestedModel ?? 'provider-default'}`);
|
package/dist/loop/run-command.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { roleSelection } from "../routing/capability.js";
|
|
1
2
|
import { join } from 'node:path';
|
|
2
3
|
import { existsSync } from 'node:fs';
|
|
3
4
|
import { loadConfig, saveConfig, defaultConfig, resolveOutputPolicy, resolveVerifyCommand } from '../retrofit/config.js';
|
|
@@ -16,6 +17,7 @@ import { buildTrustedDecisionResumeState, clearDecisionResume, decisionProcessin
|
|
|
16
17
|
import { makeAdaptiveRunner } from '../routing/router.js';
|
|
17
18
|
import { makeActionRunner } from '../execution/actions.js';
|
|
18
19
|
import { runChangeApply } from '../change/inbox.js';
|
|
20
|
+
import { resolvePlanner } from '../routing/planning.js';
|
|
19
21
|
import { createQualityCommandHooks } from '../quality/command.js';
|
|
20
22
|
import { resolveQualityPolicy } from '../quality/types.js';
|
|
21
23
|
import { runParallelLoopCommand } from './parallel-command.js';
|
|
@@ -60,9 +62,12 @@ export function loopStatus(targetDir, now = () => new Date()) {
|
|
|
60
62
|
return `Loop: ${enabled ? 'enabled' : 'disabled'}\nPRD: ${prog}`;
|
|
61
63
|
const head = `Loop: ${st.state.toUpperCase()}${st.story ? ` on ${st.story}${st.storyTitle ? ` "${st.storyTitle}"` : ''}` : ''}`;
|
|
62
64
|
const pct = st.percent !== undefined ? ` (${st.percent}%)` : '';
|
|
63
|
-
const meta = [st.phase, `iteration ${st.iteration}`,
|
|
65
|
+
const meta = [st.phase, `iteration ${st.iteration}`, `backlog ${st.progress.passed}/${st.progress.total}${pct}`, `updated ${relativeTime(st.updatedAt, now())}`]
|
|
64
66
|
.filter(Boolean).join(' · ');
|
|
65
67
|
const lines = [head, ` ${meta}`];
|
|
68
|
+
for (const process of st.supervision ?? []) {
|
|
69
|
+
lines.push(` provider PID ${process.childPid ?? 'not started'}: ${process.state} · attempt ${process.retry + 1} · identity/liveness ${process.liveness ?? 'unknown'} · supervisor heartbeat ${relativeTime(process.heartbeatAt, now())} · last output ${process.lastOutputAt ? relativeTime(process.lastOutputAt, now()) : 'none'} · last successful tool/edit ${process.lastProgressAt ? relativeTime(process.lastProgressAt, now()) : 'none'}${process.reason ? ' · ' + process.reason : ''}`);
|
|
70
|
+
}
|
|
66
71
|
if (st.state === 'running' && st.eta && st.eta.remainingStories > 0) {
|
|
67
72
|
lines.push(` ~${fmtDuration(st.eta.etaMs)} remaining (Ø ${fmtDuration(st.eta.avgStoryMs)}/story)`);
|
|
68
73
|
}
|
|
@@ -244,7 +249,7 @@ export function runLoopCommand(targetDir, opts) {
|
|
|
244
249
|
let executionReporter;
|
|
245
250
|
const quality = createQualityCommandHooks({
|
|
246
251
|
targetDir,
|
|
247
|
-
config,
|
|
252
|
+
config: opts.routing === false ? { ...config, routing: undefined } : config,
|
|
248
253
|
runnerAgent,
|
|
249
254
|
idleMs,
|
|
250
255
|
onUsage: usage => executionReporter?.addTokens(usage),
|
|
@@ -261,7 +266,7 @@ export function runLoopCommand(targetDir, opts) {
|
|
|
261
266
|
}
|
|
262
267
|
const permissions = opts.permissions ?? config.runner?.permissions ?? 'safe';
|
|
263
268
|
const routingRequested = opts.routing ?? config.routing?.enabled ?? true;
|
|
264
|
-
const routingEnabled = routingRequested && Boolean(config.routing
|
|
269
|
+
const routingEnabled = routingRequested && Boolean(config.routing && (config.routing.workers.length || config.routing.fallback === 'block' || config.routing.maxTier || config.routing.assessmentPolicy === 'prepared'));
|
|
265
270
|
const runnerSelection = {
|
|
266
271
|
model: config.runner?.model,
|
|
267
272
|
reasoningEffort: config.runner?.reasoningEffort,
|
|
@@ -273,7 +278,7 @@ export function runLoopCommand(targetDir, opts) {
|
|
|
273
278
|
...(runnerSelection.model ? { model: runnerSelection.model } : {}),
|
|
274
279
|
...(runnerSelection.reasoningEffort ? { reasoningEffort: runnerSelection.reasoningEffort } : {}),
|
|
275
280
|
}];
|
|
276
|
-
const parallelAffinityProviders = (config.routing?.workers ?? []).map(worker => ({
|
|
281
|
+
const parallelAffinityProviders = config.routing?.strategy === 'capability' ? config.agents.map(agent => ({ provider: agent, ...(agent === runnerAgent ? runnerSelection : {}) })) : (config.routing?.workers ?? []).map(worker => ({
|
|
277
282
|
provider: worker.agent,
|
|
278
283
|
...(worker.model ? { model: worker.model } : {}),
|
|
279
284
|
...(worker.reasoningEffort ? { reasoningEffort: worker.reasoningEffort } : {}),
|
|
@@ -349,6 +354,12 @@ export function runLoopCommand(targetDir, opts) {
|
|
|
349
354
|
rules: config.routing.rules,
|
|
350
355
|
strategy: config.routing.strategy,
|
|
351
356
|
maxCandidates: config.routing.maxCandidates,
|
|
357
|
+
maxAttempts: config.routing.maxAttempts,
|
|
358
|
+
planner: resolvePlanner(config, runnerAgent, runnerSelection),
|
|
359
|
+
assessmentPolicy: config.routing.assessmentPolicy,
|
|
360
|
+
fallback: config.routing.fallback,
|
|
361
|
+
maxTier: config.routing.maxTier,
|
|
362
|
+
onDecision: (id, decision) => executionReporter?.routingDecision?.(id, decision),
|
|
352
363
|
idleTimeoutMs: idleMs,
|
|
353
364
|
permissions,
|
|
354
365
|
runnerOpts,
|
|
@@ -385,7 +396,15 @@ export function runLoopCommand(targetDir, opts) {
|
|
|
385
396
|
console.error(`Reviewer agent CLI "${resolvedReviewer}" was not found on PATH. Install it, or pick another with --reviewer=<claude|codex|gemini>.`);
|
|
386
397
|
return 2;
|
|
387
398
|
}
|
|
388
|
-
review =
|
|
399
|
+
review = context => {
|
|
400
|
+
const implementer = readStatus(targetDir)?.routingDecisions?.[context.story.id]?.provider ?? runnerAgent;
|
|
401
|
+
const selectedReviewer = !opts.reviewer && resolvedReviewer === implementer
|
|
402
|
+
? ["codex", "claude", "gemini"].find(agent => agent !== implementer && available(agent)) ?? resolvedReviewer : resolvedReviewer;
|
|
403
|
+
if (selectedReviewer === implementer && !opts.allowSelfReview)
|
|
404
|
+
return { success: false, summary: "Independent review requires a provider distinct from the routed implementer", reviewOutcome: { kind: "infrastructure", summary: "Routed implementation and reviewer share a provider" } };
|
|
405
|
+
reviewProvider = selectedReviewer;
|
|
406
|
+
return makeReviewRunner(selectedReviewer, idleMs, undefined, routingEnabled ? roleSelection(targetDir, config, context.story, selectedReviewer, "reviewer") : undefined)(context);
|
|
407
|
+
};
|
|
389
408
|
}
|
|
390
409
|
if (review) {
|
|
391
410
|
const reviewRunner = review;
|
|
@@ -471,6 +490,7 @@ export function runLoopCommand(targetDir, opts) {
|
|
|
471
490
|
providers: parallelProviders,
|
|
472
491
|
affinityProviders: parallelAffinityProviders,
|
|
473
492
|
routing: routingEnabled ? config.routing : undefined,
|
|
493
|
+
planning: config.planning,
|
|
474
494
|
isAvailable: available,
|
|
475
495
|
onAmbiguity: ambiguityPolicy,
|
|
476
496
|
git: opts.git,
|
package/dist/loop/runner.js
CHANGED
|
@@ -10,6 +10,8 @@ import { contextPacket } from '../context/packet.js';
|
|
|
10
10
|
import { buildProviderInvocation, startProviderProcess } from '../agents/providers.js';
|
|
11
11
|
import { parseProviderResult, parseProviderTelemetry } from '../agents/telemetry.js';
|
|
12
12
|
import { formatReviewContract, formatReviewStdoutContract, parseReviewVerdict } from '../review/verdict.js';
|
|
13
|
+
import { prepareWindowsInvocation } from '../agents/windows-launch.js';
|
|
14
|
+
import { readSupervision } from '../agents/supervision.js';
|
|
13
15
|
export function contextBlockFor(targetDir, story) {
|
|
14
16
|
const context = loadContext(contextDir(targetDir));
|
|
15
17
|
return story ? contextPacket(context, `${story.title} ${story.area ?? ''} ${story.acceptance.map(c => typeof c === 'string' ? c : c.text).join(' ')}`) : formatForPrompt(context);
|
|
@@ -29,7 +31,7 @@ export function buildClaudePrompt(story, context, onAmbiguity = 'resolve', perfC
|
|
|
29
31
|
];
|
|
30
32
|
if (context)
|
|
31
33
|
lines.push('', context);
|
|
32
|
-
lines.push('', `Story ${story.id}: ${story.title}`, 'Acceptance criteria (Definition of Done):', criteria, '', "When done, ensure the project's full test suite passes.", 'Do NOT commit — the loop commits on your behalf after verifying.', '', 'Working rules:', '- Add nothing beyond what the story requires: no extra features, abstractions, comments, or defensive code for cases that cannot happen.', '- Do not create summary, plan, or analysis documents — only files the story itself needs.', '- If a check fails, fix the root cause; never bypass it (e.g. --no-verify) or pass by weakening tests.', '- Report the outcome faithfully: if a criterion is unmet or tests fail, say so plainly instead of claiming success.', '- Never ask questions or wait for input — you run unattended and nobody can answer.', onAmbiguity === 'abort'
|
|
34
|
+
lines.push('', `Story ${story.id}: ${story.title}`, 'Acceptance criteria (Definition of Done):', criteria, ...(story.assessment ? ['Planner approach:', story.assessment.approach] : []), '', "When done, ensure the project's full test suite passes.", 'Do NOT commit — the loop commits on your behalf after verifying.', '', 'Working rules:', '- Add nothing beyond what the story requires: no extra features, abstractions, comments, or defensive code for cases that cannot happen.', '- Do not create summary, plan, or analysis documents — only files the story itself needs.', '- If a check fails, fix the root cause; never bypass it (e.g. --no-verify) or pass by weakening tests.', '- Report the outcome faithfully: if a criterion is unmet or tests fail, say so plainly instead of claiming success.', '- Never ask questions or wait for input — you run unattended and nobody can answer.', onAmbiguity === 'abort'
|
|
33
35
|
? '- If an acceptance criterion is genuinely undecidable, do NOT guess: write the open question(s) to .yoke/ambiguity.md, change nothing else, and stop.'
|
|
34
36
|
: onAmbiguity === 'critical'
|
|
35
37
|
? [
|
|
@@ -167,8 +169,8 @@ function watchdogArgs() {
|
|
|
167
169
|
// killing by process-name/command-line pattern takes down other projects'
|
|
168
170
|
// runners too. (Plain repos, e.g. `yoke review` outside a yoke project, get
|
|
169
171
|
// no pid file rather than a littered .yoke dir.)
|
|
170
|
-
export function buildWatchdogInvocation(inv, idleTimeoutMs, ownershipRoot = inv.cwd) {
|
|
171
|
-
if (idleTimeoutMs <= 0)
|
|
172
|
+
export function buildWatchdogInvocation(inv, idleTimeoutMs, ownershipRoot = inv.cwd, force = false) {
|
|
173
|
+
if (idleTimeoutMs <= 0 && !force)
|
|
172
174
|
return inv;
|
|
173
175
|
const yokeDir = join(ownershipRoot, '.yoke');
|
|
174
176
|
const pidArgs = existsSync(yokeDir) ? [`--pid-file=${join(yokeDir, 'runner.pid')}`] : [];
|
|
@@ -193,20 +195,14 @@ export function win32CommandString(command, args) {
|
|
|
193
195
|
return [command, ...args].map(q).join(' ');
|
|
194
196
|
}
|
|
195
197
|
function runCli(inv) {
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
})
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
execFileSync(inv.command, inv.args, {
|
|
205
|
-
cwd: inv.cwd,
|
|
206
|
-
input: inv.input,
|
|
207
|
-
stdio: ['pipe', 'inherit', 'inherit'],
|
|
208
|
-
});
|
|
209
|
-
}
|
|
198
|
+
const launch = process.platform === 'win32' ? prepareWindowsInvocation(inv) : inv;
|
|
199
|
+
execFileSync(launch.command, launch.args, {
|
|
200
|
+
cwd: inv.cwd,
|
|
201
|
+
input: inv.input,
|
|
202
|
+
stdio: ['pipe', 'inherit', 'inherit'],
|
|
203
|
+
...('env' in launch ? { env: launch.env } : {}),
|
|
204
|
+
windowsHide: true,
|
|
205
|
+
});
|
|
210
206
|
}
|
|
211
207
|
// Like runCli, but with stdout PIPED and returned (stderr stays inherited) — for
|
|
212
208
|
// token reporting, where the agent's stdout is a machine-readable stream-json feed.
|
|
@@ -214,9 +210,8 @@ function runCli(inv) {
|
|
|
214
210
|
// through it. Throws on a non-zero exit; the error carries the partial stdout.
|
|
215
211
|
function runCliCapture(inv) {
|
|
216
212
|
const opts = { cwd: inv.cwd, input: inv.input, stdio: ['pipe', 'pipe', 'inherit'], encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 };
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
: execFileSync(inv.command, inv.args, opts);
|
|
213
|
+
const launch = process.platform === 'win32' ? prepareWindowsInvocation(inv) : inv;
|
|
214
|
+
return execFileSync(launch.command, launch.args, { ...opts, ...('env' in launch ? { env: launch.env } : {}), windowsHide: true });
|
|
220
215
|
}
|
|
221
216
|
// Reviews have a machine-readable result file, so their console stream is not
|
|
222
217
|
// the result channel. Buffer stderr to preserve the provider's actual failure
|
|
@@ -231,10 +226,8 @@ function runReviewCli(inv) {
|
|
|
231
226
|
encoding: 'utf8',
|
|
232
227
|
maxBuffer: 64 * 1024 * 1024,
|
|
233
228
|
};
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
else
|
|
237
|
-
execFileSync(inv.command, inv.args, opts);
|
|
229
|
+
const launch = process.platform === 'win32' ? prepareWindowsInvocation(inv) : inv;
|
|
230
|
+
execFileSync(launch.command, launch.args, { ...opts, ...('env' in launch ? { env: launch.env } : {}), windowsHide: true });
|
|
238
231
|
}
|
|
239
232
|
function processFailureSummary(error) {
|
|
240
233
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -303,7 +296,7 @@ export function runReviewAgent(inv) {
|
|
|
303
296
|
}
|
|
304
297
|
}
|
|
305
298
|
export function makeAsyncRunner(agent, opts = {}) {
|
|
306
|
-
return (ctx) => startProviderProcess(agent, runnerInvocation(agent, buildClaudePrompt(ctx.story, contextBlockFor(ctx.targetDir, ctx.story), opts.onAmbiguity, opts.perfCommand), ctx.targetDir, true, opts.permissions ?? 'safe', opts.selection), opts.process);
|
|
299
|
+
return (ctx) => startProviderProcess(agent, runnerInvocation(agent, buildClaudePrompt(ctx.story, contextBlockFor(ctx.targetDir, ctx.story) + (ctx.feedback ? "\nPrior independent failure; preserve useful existing changes and fix the root cause:\n" + ctx.feedback.slice(0, 8000) : ""), opts.onAmbiguity, opts.perfCommand), ctx.targetDir, true, opts.permissions ?? 'safe', opts.selection), { ...opts.process, attempt: ctx.attempt });
|
|
307
300
|
}
|
|
308
301
|
export function makeRunner(agent, idleTimeoutMs = 0, opts = {}) {
|
|
309
302
|
// Claude always streams (see runnerInvocation) — capture the stream so tokens are
|
|
@@ -314,8 +307,10 @@ export function makeRunner(agent, idleTimeoutMs = 0, opts = {}) {
|
|
|
314
307
|
opts.onStart?.(agent, opts.selection ?? {});
|
|
315
308
|
const started = Date.now();
|
|
316
309
|
const attributed = (tokens) => tokens ? { ...tokens, provider: agent, role: 'parent', storyId: ctx.story.id, durationMs: Date.now() - started } : undefined;
|
|
317
|
-
const base = runnerInvocation(agent, buildClaudePrompt(ctx.story, contextBlockFor(ctx.targetDir, ctx.story), opts.onAmbiguity, opts.perfCommand), ctx.targetDir, captureTokens, opts.permissions ?? 'safe', opts.selection);
|
|
318
|
-
const inv = buildWatchdogInvocation(base, idleTimeoutMs);
|
|
310
|
+
const base = runnerInvocation(agent, buildClaudePrompt(ctx.story, contextBlockFor(ctx.targetDir, ctx.story) + (ctx.feedback ? "\nPrior independent failure; preserve useful existing changes and fix the root cause:\n" + ctx.feedback.slice(0, 8000) : ""), opts.onAmbiguity, opts.perfCommand), ctx.targetDir, captureTokens, opts.permissions ?? 'safe', opts.selection);
|
|
311
|
+
const inv = buildWatchdogInvocation(base, idleTimeoutMs, ctx.targetDir, true);
|
|
312
|
+
if (ctx.attempt)
|
|
313
|
+
inv.args.splice(inv.args.indexOf('--'), 0, `--attempt=${ctx.attempt}`);
|
|
319
314
|
if (captureTokens) {
|
|
320
315
|
const capture = opts.execCapture ?? runCliCapture;
|
|
321
316
|
try {
|
|
@@ -327,7 +322,8 @@ export function makeRunner(agent, idleTimeoutMs = 0, opts = {}) {
|
|
|
327
322
|
// Salvage usage from whatever the agent streamed before dying — those tokens were spent.
|
|
328
323
|
const partial = e.stdout;
|
|
329
324
|
const tokens = partial == null ? undefined : parseProviderTelemetry(agent, String(partial).split(/\r?\n/)).tokens;
|
|
330
|
-
|
|
325
|
+
const reason = readSupervision(ctx.targetDir, new Date(started).toISOString())[0]?.reason;
|
|
326
|
+
return { success: false, infrastructureFailure: true, summary: `${agent} failed on ${ctx.story.id}: ${reason ?? e.message}`, tokens: attributed(tokens) };
|
|
331
327
|
}
|
|
332
328
|
}
|
|
333
329
|
try {
|
|
@@ -338,15 +334,15 @@ export function makeRunner(agent, idleTimeoutMs = 0, opts = {}) {
|
|
|
338
334
|
return { success: true, summary: `${agent} implemented ${ctx.story.id}` };
|
|
339
335
|
}
|
|
340
336
|
catch (e) {
|
|
341
|
-
return { success: false, summary: `${agent} failed on ${ctx.story.id}: ${e.message}` };
|
|
337
|
+
return { success: false, infrastructureFailure: true, summary: `${agent} failed on ${ctx.story.id}: ${e.message}` };
|
|
342
338
|
}
|
|
343
339
|
};
|
|
344
340
|
}
|
|
345
341
|
export const claudeRunner = makeRunner('claude');
|
|
346
|
-
export function makeReviewRunner(agent, idleTimeoutMs = 0, exec) {
|
|
342
|
+
export function makeReviewRunner(agent, idleTimeoutMs = 0, exec, selection = {}) {
|
|
347
343
|
return (ctx) => {
|
|
348
344
|
const before = repositoryFingerprint(ctx.targetDir);
|
|
349
|
-
const base = agentInvocation(agent, buildReviewPrompt(ctx.story, contextBlockFor(ctx.targetDir, ctx.story), undefined, agent), ctx.targetDir, 'read-only', { nativeMultiAgent: false });
|
|
345
|
+
const base = agentInvocation(agent, buildReviewPrompt(ctx.story, contextBlockFor(ctx.targetDir, ctx.story), undefined, agent), ctx.targetDir, 'read-only', { ...selection, nativeMultiAgent: false });
|
|
350
346
|
const inv = buildWatchdogInvocation(base, idleTimeoutMs);
|
|
351
347
|
let processFailure;
|
|
352
348
|
let actualModel;
|
package/dist/loop/watchdog.js
CHANGED
|
@@ -3,6 +3,8 @@ import { constants } from 'node:os';
|
|
|
3
3
|
import { writeFileSync, rmSync } from 'node:fs';
|
|
4
4
|
import { pathToFileURL } from 'node:url';
|
|
5
5
|
import { processIncarnation } from '../agents/process-incarnation.js';
|
|
6
|
+
import { prepareWindowsInvocation } from '../agents/windows-launch.js';
|
|
7
|
+
import { createSupervision, supervisionLimits, assertPreviousProvidersStopped } from '../agents/supervision.js';
|
|
6
8
|
// Kill one recorded process tree, platform-appropriately. Exported for
|
|
7
9
|
// `yoke loop cleanup` (scoped reaping of recorded runner pids).
|
|
8
10
|
export function killProcessTree(pid, force = true) {
|
|
@@ -30,7 +32,7 @@ function confirmProcessStopped(pid, isProcessAlive) {
|
|
|
30
32
|
}
|
|
31
33
|
return false;
|
|
32
34
|
}
|
|
33
|
-
export function killProcessForCleanup(pid, platform = process.platform, runTaskkill = (command, args) => spawnSync(command, args, { stdio: 'ignore' }).status, sendSignal = (target, signal) => { process.kill(target, signal); }, isProcessAlive = (target) => {
|
|
35
|
+
export function killProcessForCleanup(pid, platform = process.platform, runTaskkill = (command, args) => spawnSync(command, args, { stdio: 'ignore', timeout: 5000, windowsHide: true }).status, sendSignal = (target, signal) => { process.kill(target, signal); }, isProcessAlive = (target) => {
|
|
34
36
|
try {
|
|
35
37
|
process.kill(target, 0);
|
|
36
38
|
return true;
|
|
@@ -52,7 +54,7 @@ export function killProcessForCleanup(pid, platform = process.platform, runTaskk
|
|
|
52
54
|
}
|
|
53
55
|
return confirmProcessStopped(pid, isProcessAlive);
|
|
54
56
|
}
|
|
55
|
-
export function killProcessTreeForCleanup(pid, platform = process.platform, runTaskkill = (command, args) => spawnSync(command, args, { stdio: 'ignore' }).status, sendSignal = (target, signal) => { process.kill(target, signal); }, isProcessAlive = (target) => {
|
|
57
|
+
export function killProcessTreeForCleanup(pid, platform = process.platform, runTaskkill = (command, args) => spawnSync(command, args, { stdio: 'ignore', timeout: 5000, windowsHide: true }).status, sendSignal = (target, signal) => { process.kill(target, signal); }, isProcessAlive = (target) => {
|
|
56
58
|
try {
|
|
57
59
|
process.kill(target, 0);
|
|
58
60
|
return true;
|
|
@@ -91,7 +93,27 @@ export function runWatchdog(opts) {
|
|
|
91
93
|
const spawnFn = opts.spawnFn ?? spawn;
|
|
92
94
|
const out = opts.out ?? ((d) => process.stdout.write(d));
|
|
93
95
|
const err = opts.err ?? ((d) => process.stderr.write(d));
|
|
94
|
-
|
|
96
|
+
let fail = () => { };
|
|
97
|
+
let progress = () => { };
|
|
98
|
+
const limits = opts.spawnFn ? { totalMs: 30 * 60_000, progressMs: 20 * 60_000 } : supervisionLimits(process.cwd());
|
|
99
|
+
const supervision = opts.spawnFn ? undefined : createSupervision(process.cwd(), reason => fail(reason), () => progress(), opts.attempt);
|
|
100
|
+
let launch;
|
|
101
|
+
try {
|
|
102
|
+
if (!opts.spawnFn)
|
|
103
|
+
assertPreviousProvidersStopped(process.cwd());
|
|
104
|
+
launch = process.platform === 'win32' && !opts.spawnFn
|
|
105
|
+
? prepareWindowsInvocation({ command: opts.command, args: opts.args, input: '', cwd: process.cwd() })
|
|
106
|
+
: { command: opts.command, args: opts.args, env: process.env };
|
|
107
|
+
}
|
|
108
|
+
catch (error) {
|
|
109
|
+
const reason = error.message;
|
|
110
|
+
supervision?.stop(reason);
|
|
111
|
+
err(`Yoke infrastructure: ${reason}\n`);
|
|
112
|
+
return Promise.resolve(125);
|
|
113
|
+
}
|
|
114
|
+
const child = spawnFn(launch.command, launch.args, { shell: false, detached: process.platform !== 'win32', env: launch.env, windowsHide: true });
|
|
115
|
+
const incarnation = child.pid === undefined || opts.spawnFn ? undefined : processIncarnation(child.pid);
|
|
116
|
+
supervision?.start(child.pid, 'shell' in launch ? launch.shell : undefined, incarnation);
|
|
95
117
|
if (opts.stdin && child.stdin) {
|
|
96
118
|
try {
|
|
97
119
|
opts.stdin.pipe(child.stdin);
|
|
@@ -115,7 +137,17 @@ export function runWatchdog(opts) {
|
|
|
115
137
|
const graceMs = opts.graceMs ?? 5000;
|
|
116
138
|
// Explicitly-passed killTree wins (including an explicit undefined, which pins
|
|
117
139
|
// the per-process signal path — tests use this to be platform-independent).
|
|
118
|
-
const killTree = 'killTree' in opts ? opts.killTree : (pid, _force) =>
|
|
140
|
+
const killTree = 'killTree' in opts ? opts.killTree : opts.spawnFn ? undefined : (pid, _force) => {
|
|
141
|
+
try {
|
|
142
|
+
process.kill(pid, 0);
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
return true;
|
|
146
|
+
}
|
|
147
|
+
if (!opts.spawnFn && (!incarnation || processIncarnation(pid) !== incarnation))
|
|
148
|
+
return false;
|
|
149
|
+
return killProcessTreeForCleanup(pid);
|
|
150
|
+
};
|
|
119
151
|
// Terminate the child — via the tree-killer when we have one and a pid,
|
|
120
152
|
// otherwise per-process signals (POSIX default; SIGKILL is uncatchable).
|
|
121
153
|
let terminationRequested = false;
|
|
@@ -135,6 +167,9 @@ export function runWatchdog(opts) {
|
|
|
135
167
|
let timer;
|
|
136
168
|
let graceTimer;
|
|
137
169
|
let killedForIdle = false;
|
|
170
|
+
let reason;
|
|
171
|
+
let totalTimer;
|
|
172
|
+
let progressTimer;
|
|
138
173
|
// Clear BOTH the idle timer and the post-SIGTERM grace timer so no dangling
|
|
139
174
|
// timers survive on any terminal path (close/error) or on each re-arm.
|
|
140
175
|
const clear = () => {
|
|
@@ -160,6 +195,7 @@ export function runWatchdog(opts) {
|
|
|
160
195
|
timer = setTimeout(() => {
|
|
161
196
|
timer = undefined;
|
|
162
197
|
killedForIdle = true;
|
|
198
|
+
reason = 'provider-output-timeout';
|
|
163
199
|
terminate(child, false);
|
|
164
200
|
// Escalation: a child that catches/ignores the soft kill would never emit
|
|
165
201
|
// 'close' and the promise would hang forever — defeating the watchdog.
|
|
@@ -169,14 +205,52 @@ export function runWatchdog(opts) {
|
|
|
169
205
|
graceTimer = setTimeout(() => {
|
|
170
206
|
graceTimer = undefined;
|
|
171
207
|
terminate(child, true);
|
|
208
|
+
supervision?.stop(reason, terminationConfirmed);
|
|
209
|
+
if (totalTimer)
|
|
210
|
+
clearTimeout(totalTimer);
|
|
211
|
+
if (progressTimer)
|
|
212
|
+
clearTimeout(progressTimer);
|
|
213
|
+
resolve(124);
|
|
172
214
|
}, graceMs);
|
|
173
215
|
}, opts.idleMs);
|
|
174
216
|
};
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
217
|
+
fail = (failure) => {
|
|
218
|
+
if (killedForIdle)
|
|
219
|
+
return;
|
|
220
|
+
killedForIdle = true;
|
|
221
|
+
reason = failure;
|
|
222
|
+
err(`Yoke infrastructure: ${failure}\n`);
|
|
179
223
|
clear();
|
|
224
|
+
terminate(child, false);
|
|
225
|
+
graceTimer = setTimeout(() => {
|
|
226
|
+
terminate(child, true);
|
|
227
|
+
supervision?.stop(reason, terminationConfirmed);
|
|
228
|
+
if (totalTimer)
|
|
229
|
+
clearTimeout(totalTimer);
|
|
230
|
+
if (progressTimer)
|
|
231
|
+
clearTimeout(progressTimer);
|
|
232
|
+
resolve(125);
|
|
233
|
+
}, graceMs);
|
|
234
|
+
};
|
|
235
|
+
progress = () => {
|
|
236
|
+
if (progressTimer)
|
|
237
|
+
clearTimeout(progressTimer);
|
|
238
|
+
if ((opts.progressMs ?? limits.progressMs) > 0)
|
|
239
|
+
progressTimer = setTimeout(() => fail('provider-progress-timeout'), opts.progressMs ?? limits.progressMs);
|
|
240
|
+
};
|
|
241
|
+
if ((opts.totalMs ?? limits.totalMs) > 0)
|
|
242
|
+
totalTimer = setTimeout(() => fail('provider-total-timeout'), opts.totalMs ?? limits.totalMs);
|
|
243
|
+
progress();
|
|
244
|
+
child.stdout.on('data', (d) => { out(d); supervision?.output('stdout', String(d)); arm(); });
|
|
245
|
+
child.stderr.on('data', (d) => { err(d); supervision?.output('stderr', String(d)); arm(); });
|
|
246
|
+
const clearAll = () => { clear(); if (totalTimer)
|
|
247
|
+
clearTimeout(totalTimer); if (progressTimer)
|
|
248
|
+
clearTimeout(progressTimer); };
|
|
249
|
+
child.on('error', () => { clearAll(); supervision?.stop('provider-spawn-failed'); removePidFile(); resolve(127); });
|
|
250
|
+
child.on('close', (code, signal) => {
|
|
251
|
+
supervision?.flush();
|
|
252
|
+
clearAll();
|
|
253
|
+
supervision?.stop(reason ?? (code === 0 ? 'provider-exited' : 'provider-exit-failed'), !terminationRequested || terminationConfirmed);
|
|
180
254
|
if (!terminationRequested || terminationConfirmed)
|
|
181
255
|
removePidFile();
|
|
182
256
|
if (killedForIdle) {
|
|
@@ -199,16 +273,18 @@ export function parseWatchdogArgs(argv) {
|
|
|
199
273
|
const rest = sep === -1 ? [] : argv.slice(sep + 1);
|
|
200
274
|
const idleArg = flags.find((a) => a.startsWith('--idle-ms='));
|
|
201
275
|
const idleMs = idleArg ? Number(idleArg.slice('--idle-ms='.length)) : 0;
|
|
276
|
+
const totalArg = flags.find(a => a.startsWith('--total-ms='))?.slice('--total-ms='.length);
|
|
277
|
+
const attempt = Number(flags.find(a => a.startsWith('--attempt='))?.slice('--attempt='.length));
|
|
202
278
|
const pidFile = flags.find((a) => a.startsWith('--pid-file='))?.slice('--pid-file='.length);
|
|
203
279
|
const [command, ...args] = rest;
|
|
204
|
-
return { idleMs: Number.isFinite(idleMs) ? idleMs : 0, command: command ?? '', args, ...(pidFile ? { pidFile } : {}) };
|
|
280
|
+
return { idleMs: Number.isFinite(idleMs) ? idleMs : 0, command: command ?? '', args, ...(pidFile ? { pidFile } : {}), ...(Number.isInteger(attempt) && attempt > 0 ? { attempt } : {}), ...(totalArg && Number.isFinite(Number(totalArg)) && Number(totalArg) > 0 ? { totalMs: Number(totalArg) } : {}) };
|
|
205
281
|
}
|
|
206
282
|
const isMain = process.argv[1] ? pathToFileURL(process.argv[1]).href === import.meta.url : false;
|
|
207
283
|
if (isMain) {
|
|
208
|
-
const { idleMs, command, args, pidFile } = parseWatchdogArgs(process.argv.slice(2));
|
|
284
|
+
const { idleMs, totalMs, attempt, command, args, pidFile } = parseWatchdogArgs(process.argv.slice(2));
|
|
209
285
|
if (!command) {
|
|
210
286
|
process.stderr.write('watchdog: no command given\n');
|
|
211
287
|
process.exit(2);
|
|
212
288
|
}
|
|
213
|
-
runWatchdog({ command, args, idleMs, stdin: process.stdin, pidFile }).then((code) => process.exit(code));
|
|
289
|
+
runWatchdog({ command, args, idleMs, totalMs, attempt, stdin: process.stdin, pidFile }).then((code) => process.exit(code));
|
|
214
290
|
}
|
package/dist/loop/worker.js
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import { knownInfrastructureFailure } from "../routing/capability.js";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { acceptanceProtectionProblem } from "../check/command.js";
|
|
1
5
|
import { isAcceptanceCriterion } from './prd.js';
|
|
2
6
|
import { runQualityRepairLoop } from '../quality/loop.js';
|
|
3
7
|
function emptyEvidence() { return { criteria: [] }; }
|
|
@@ -167,7 +171,7 @@ export async function runStoryWorker(input) {
|
|
|
167
171
|
}
|
|
168
172
|
let implementation;
|
|
169
173
|
try {
|
|
170
|
-
implementation = await input
|
|
174
|
+
implementation = await runWorkerImplementation(input, context, evidence);
|
|
171
175
|
}
|
|
172
176
|
catch (error) {
|
|
173
177
|
return finalResult(input, {
|
|
@@ -178,6 +182,10 @@ export async function runStoryWorker(input) {
|
|
|
178
182
|
}
|
|
179
183
|
if (implementation.tokens)
|
|
180
184
|
input.reporter?.addTokens(implementation.tokens);
|
|
185
|
+
if (implementation.infrastructureFailure)
|
|
186
|
+
implementation.routing?.recordOutcome(false, 'infrastructure');
|
|
187
|
+
if (implementation.infrastructureFailure || implementation.routing?.blocked)
|
|
188
|
+
return finalResult(input, { ...baseResult(input, evidence, implementation.summary), kind: "mechanical-failure", stage: "implementation" });
|
|
181
189
|
const afterImplementationCancellation = cancellationReason(input.cancellation);
|
|
182
190
|
if (afterImplementationCancellation) {
|
|
183
191
|
return finalResult(input, { ...baseResult(input, evidence, afterImplementationCancellation), kind: 'cancelled' });
|
|
@@ -263,3 +271,24 @@ export async function runStoryWorker(input) {
|
|
|
263
271
|
}
|
|
264
272
|
return finalResult(input, result);
|
|
265
273
|
}
|
|
274
|
+
async function runWorkerImplementation(input, context, evidence) {
|
|
275
|
+
let feedback;
|
|
276
|
+
for (let attempt = 0;; attempt++) {
|
|
277
|
+
const result = await input.runner({ ...context, feedback });
|
|
278
|
+
if (!result.routing?.canRetry || result.routing.blocked || attempt >= 7 || cancellationReason(input.cancellation) || input.pause?.())
|
|
279
|
+
return result;
|
|
280
|
+
if (["decision-request.yaml", "ambiguity.md", "loop.pause"].some(name => existsSync(join(context.targetDir, ".yoke", name))) || acceptanceProtectionProblem(context.targetDir))
|
|
281
|
+
return result;
|
|
282
|
+
const gates = runMechanicalGates(input, context, evidence);
|
|
283
|
+
if (gates.kind !== "failed")
|
|
284
|
+
return result;
|
|
285
|
+
if (knownInfrastructureFailure(gates.summary)) {
|
|
286
|
+
result.routing.recordOutcome(false, 'infrastructure');
|
|
287
|
+
return { ...result, success: false, infrastructureFailure: true, summary: gates.summary, routing: { ...result.routing, blocked: true, canRetry: false } };
|
|
288
|
+
}
|
|
289
|
+
result.routing.recordOutcome(false);
|
|
290
|
+
if (result.tokens)
|
|
291
|
+
input.reporter?.addTokens(result.tokens);
|
|
292
|
+
feedback = gates.summary;
|
|
293
|
+
}
|
|
294
|
+
}
|