@hecer/yoke 1.6.1 → 1.7.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 (48) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +31 -2
  4. package/README.md +43 -9
  5. package/canon/manifest.yaml +1 -1
  6. package/canon/tools/gemini-rtk-hook.mjs +25 -0
  7. package/dist/agents/contracts.js +2 -0
  8. package/dist/agents/process-streams.js +18 -4
  9. package/dist/agents/process.js +2 -0
  10. package/dist/agents/providers.js +25 -4
  11. package/dist/agents/telemetry.js +58 -10
  12. package/dist/check/command.js +114 -0
  13. package/dist/cli.js +120 -1
  14. package/dist/context/packet.js +32 -0
  15. package/dist/dashboard/page.js +32 -0
  16. package/dist/dashboard/registry.js +68 -0
  17. package/dist/dashboard/server.js +160 -0
  18. package/dist/estimation/durations.js +20 -0
  19. package/dist/estimation/schedule.js +41 -0
  20. package/dist/execution/actions.js +24 -0
  21. package/dist/goals/command.js +187 -0
  22. package/dist/loop/cleanup.js +3 -0
  23. package/dist/loop/dispatcher.js +14 -3
  24. package/dist/loop/git.js +5 -4
  25. package/dist/loop/loop.js +23 -3
  26. package/dist/loop/parallel-adapters.js +3 -2
  27. package/dist/loop/parallel-command.js +10 -1
  28. package/dist/loop/prd.js +3 -0
  29. package/dist/loop/recovery.js +51 -0
  30. package/dist/loop/reporter.js +92 -8
  31. package/dist/loop/run-command.js +20 -3
  32. package/dist/loop/runner.js +9 -12
  33. package/dist/loop/scheduler.js +38 -1
  34. package/dist/loop/watchdog.js +2 -0
  35. package/dist/observability/events.js +67 -0
  36. package/dist/retrofit/config.js +9 -0
  37. package/dist/retrofit/gitignore.js +5 -0
  38. package/dist/retrofit/planners/gemini.js +11 -3
  39. package/dist/routing/router.js +26 -8
  40. package/dist/workspace/fingerprint.js +66 -0
  41. package/dist/workspace/state.js +20 -0
  42. package/docs/PRODUCT-DIRECTION-2026-09-05.md +183 -0
  43. package/docs/PUBLISHING.md +25 -2
  44. package/docs/VERIFIED-PROJECTS-VALIDATION.md +29 -0
  45. package/docs/VERIFIED-PROJECTS.md +147 -0
  46. package/docs/superpowers/plans/2026-09-05-verified-projects.md +83 -0
  47. package/gemini-extension.json +1 -1
  48. package/package.json +1 -1
@@ -14,12 +14,14 @@ import { runAudit } from '../audit/command.js';
14
14
  import { detectHostAgent, resolveRunnerAgent } from '../agents/host.js';
15
15
  import { buildTrustedDecisionResumeState, clearDecisionResume, decisionProcessingExists, decisionRequestId, formatPendingDecision, readPendingDecision, writeDecisionResume, } from './decision.js';
16
16
  import { makeAdaptiveRunner } from '../routing/router.js';
17
+ import { makeActionRunner } from '../execution/actions.js';
17
18
  import { runChangeApply } from '../change/inbox.js';
18
19
  import { createQualityCommandHooks } from '../quality/command.js';
19
20
  import { resolveQualityPolicy } from '../quality/types.js';
20
21
  import { runParallelLoopCommand } from './parallel-command.js';
21
22
  import { detectUiProject } from '../retrofit/ui-detect.js';
22
23
  import { designVerifier } from '../scan/gate.js';
24
+ import { prepareIsolatedWorktree } from './recovery.js';
23
25
  export const DEFAULT_IDLE_MINUTES = 20;
24
26
  const STALE_MINUTES = 20; // a running status older than this likely means the loop died
25
27
  export function relativeTime(fromIso, now) {
@@ -97,6 +99,10 @@ export function resolveIdleMs(flagMinutes, configMinutes) {
97
99
  export function runLoopCommand(targetDir, opts) {
98
100
  const parallel = opts.parallel ?? 1;
99
101
  const candidates = opts.candidates ?? 1;
102
+ if (opts.resumeWorktree && (!opts.isolate || parallel !== 1 || candidates !== 1)) {
103
+ console.error('--resume-worktree requires --isolate --parallel=1 --candidates=1');
104
+ return 2;
105
+ }
100
106
  if (!Number.isInteger(parallel) || parallel < 1) {
101
107
  console.error('--parallel must be a positive integer');
102
108
  return 2;
@@ -177,7 +183,10 @@ export function runLoopCommand(targetDir, opts) {
177
183
  maybeAutoUpgrade(config.update?.auto);
178
184
  const available = opts.isAvailable ?? isAgentAvailable;
179
185
  const runnerAgent = resolveRunnerAgent(config, opts.agent, detectHostAgent());
180
- const git = opts.git ?? realGitOps;
186
+ const git = opts.git ?? {
187
+ ...realGitOps,
188
+ addWorktree: (repo, worktree) => prepareIsolatedWorktree(repo, worktree, opts.resumeWorktree === true),
189
+ };
181
190
  let commitIdentity = opts.commitIdentity;
182
191
  if (!commitIdentity && !opts.git) {
183
192
  try {
@@ -242,7 +251,7 @@ export function runLoopCommand(targetDir, opts) {
242
251
  model: config.runner?.model,
243
252
  reasoningEffort: config.runner?.reasoningEffort,
244
253
  bare: config.runner?.bare,
245
- ...((routingEnabled || opts.routing === false) ? { nativeMultiAgent: false } : {}),
254
+ ...((runnerAgent === 'codex' && (routingEnabled || opts.routing === false)) ? { nativeMultiAgent: false } : {}),
246
255
  };
247
256
  if ((parallel > 1 || candidates > 1) && routingEnabled) {
248
257
  console.error('Adaptive routing is not available with parallel workers or quality candidates. Run with --parallel=1 --candidates=1 or disable routing.');
@@ -303,7 +312,7 @@ export function runLoopCommand(targetDir, opts) {
303
312
  if (!runner) {
304
313
  const requiredProviders = parallel > 1 || candidates > 1
305
314
  ? [...new Set(loadPrd(path).filter(story => !story.passes).map(story => story.agent ?? runnerAgent))]
306
- : [runnerAgent];
315
+ : loadPrd(path).filter(story => !story.passes).every(story => config.actions?.some(action => action.storyId === story.id)) ? [] : [runnerAgent];
307
316
  const unavailableProvider = requiredProviders.find(agent => !available(agent));
308
317
  if (unavailableProvider) {
309
318
  console.error(`Agent CLI "${unavailableProvider}" was not found on PATH. Install it, or pick another with --runner=<claude|codex|gemini>.`);
@@ -324,6 +333,7 @@ export function runLoopCommand(targetDir, opts) {
324
333
  parentSelection: runnerOpts.selection,
325
334
  orchestratorSelection: config.routing.orchestrator ?? runnerOpts.selection,
326
335
  workers: config.routing.workers,
336
+ rules: config.routing.rules,
327
337
  strategy: config.routing.strategy,
328
338
  maxCandidates: config.routing.maxCandidates,
329
339
  idleTimeoutMs: idleMs,
@@ -335,6 +345,13 @@ export function runLoopCommand(targetDir, opts) {
335
345
  const announce = opts.json ? console.error : console.log;
336
346
  announce(`Runner: ${runnerAgent} · permissions: ${permissions} · routing: ${routingEnabled ? 'on' : 'off'} · cwd: ${targetDir}`);
337
347
  }
348
+ if (config.actions?.length) {
349
+ if (parallel > 1 || candidates > 1) {
350
+ console.error('Configured tool actions currently require --parallel=1 --candidates=1');
351
+ return 2;
352
+ }
353
+ runner = makeActionRunner(config.actions, runner);
354
+ }
338
355
  let review = opts.reviewRunner;
339
356
  if (!review && (opts.review || opts.reviewer)) {
340
357
  const reviewerAgent = opts.reviewer ?? ['codex', 'gemini', 'claude'].find(agent => agent !== runnerAgent && available(agent));
@@ -1,15 +1,18 @@
1
1
  import { isAcceptanceCriterion } from './prd.js';
2
+ import { workspaceFingerprint } from '../workspace/fingerprint.js';
2
3
  import { execFileSync, execSync } from 'node:child_process';
3
4
  import { existsSync } from 'node:fs';
4
5
  import { createRequire } from 'node:module';
5
6
  import { join } from 'node:path';
6
7
  import { fileURLToPath, pathToFileURL } from 'node:url';
7
8
  import { loadContext, formatForPrompt, contextDir } from '../context/context.js';
9
+ import { contextPacket } from '../context/packet.js';
8
10
  import { buildProviderInvocation, startProviderProcess } from '../agents/providers.js';
9
11
  import { parseProviderResult, parseProviderTelemetry } from '../agents/telemetry.js';
10
12
  import { formatReviewContract, formatReviewStdoutContract, parseReviewVerdict } from '../review/verdict.js';
11
- export function contextBlockFor(targetDir) {
12
- return formatForPrompt(loadContext(contextDir(targetDir)));
13
+ export function contextBlockFor(targetDir, story) {
14
+ const context = loadContext(contextDir(targetDir));
15
+ return story ? contextPacket(context, `${story.title} ${story.area ?? ''} ${story.acceptance.map(c => typeof c === 'string' ? c : c.text).join(' ')}`) : formatForPrompt(context);
13
16
  }
14
17
  function formatAcceptance(story) {
15
18
  return story.acceptance.map(criterion => {
@@ -300,7 +303,7 @@ export function runReviewAgent(inv) {
300
303
  }
301
304
  }
302
305
  export function makeAsyncRunner(agent, opts = {}) {
303
- return (ctx) => startProviderProcess(agent, runnerInvocation(agent, buildClaudePrompt(ctx.story, contextBlockFor(ctx.targetDir), opts.onAmbiguity, opts.perfCommand), ctx.targetDir, true, opts.permissions ?? 'safe', opts.selection), opts.process);
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);
304
307
  }
305
308
  export function makeRunner(agent, idleTimeoutMs = 0, opts = {}) {
306
309
  // Claude always streams (see runnerInvocation) — capture the stream so tokens are
@@ -308,7 +311,7 @@ export function makeRunner(agent, idleTimeoutMs = 0, opts = {}) {
308
311
  // redundant for claude and meaningless elsewhere; kept for caller compatibility.
309
312
  const captureTokens = true;
310
313
  return (ctx) => {
311
- const base = runnerInvocation(agent, buildClaudePrompt(ctx.story, contextBlockFor(ctx.targetDir), opts.onAmbiguity, opts.perfCommand), ctx.targetDir, captureTokens, opts.permissions ?? 'safe', opts.selection);
314
+ const base = runnerInvocation(agent, buildClaudePrompt(ctx.story, contextBlockFor(ctx.targetDir, ctx.story), opts.onAmbiguity, opts.perfCommand), ctx.targetDir, captureTokens, opts.permissions ?? 'safe', opts.selection);
312
315
  const inv = buildWatchdogInvocation(base, idleTimeoutMs);
313
316
  if (captureTokens) {
314
317
  const capture = opts.execCapture ?? runCliCapture;
@@ -340,7 +343,7 @@ export const claudeRunner = makeRunner('claude');
340
343
  export function makeReviewRunner(agent, idleTimeoutMs = 0, exec) {
341
344
  return (ctx) => {
342
345
  const before = repositoryFingerprint(ctx.targetDir);
343
- const base = agentInvocation(agent, buildReviewPrompt(ctx.story, contextBlockFor(ctx.targetDir), undefined, agent), ctx.targetDir, 'read-only');
346
+ const base = agentInvocation(agent, buildReviewPrompt(ctx.story, contextBlockFor(ctx.targetDir, ctx.story), undefined, agent), ctx.targetDir, 'read-only');
344
347
  const inv = buildWatchdogInvocation(base, idleTimeoutMs);
345
348
  let processFailure;
346
349
  let actualModel;
@@ -380,13 +383,7 @@ export function makeReviewRunner(agent, idleTimeoutMs = 0, exec) {
380
383
  };
381
384
  }
382
385
  export function repositoryFingerprint(targetDir) {
383
- try {
384
- return execFileSync('git', ['diff', '--binary', 'HEAD'], { cwd: targetDir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] })
385
- + execFileSync('git', ['status', '--porcelain=v1', '-z'], { cwd: targetDir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
386
- }
387
- catch {
388
- return '';
389
- }
386
+ return workspaceFingerprint(targetDir);
390
387
  }
391
388
  function reviewResult(agent, storyId, verdict, reviewOutcome) {
392
389
  return verdict.approved
@@ -1,8 +1,45 @@
1
+ export function validWriteScope(scope) {
2
+ const normalized = scope.replace(/\\/gu, '/').replace(/\/$/u, '');
3
+ return Boolean(normalized) && scope === scope.trim() && !/[\0\r\n:*?<>|\[\]{}]/u.test(normalized) && normalized.split('/').every(part => part !== '' && part !== '.' && part !== '..');
4
+ }
5
+ /** Advisory declarations: unknown scopes preserve existing scheduler behavior. */
6
+ export function writeScopesOverlap(left = [], right = []) {
7
+ const normalize = (value) => value.replace(/\\/gu, '/').replace(/\/$/u, '').toLowerCase();
8
+ return left.some(a => right.some(b => {
9
+ const first = normalize(a), second = normalize(b);
10
+ return first === second || first.startsWith(`${second}/`) || second.startsWith(`${first}/`);
11
+ }));
12
+ }
13
+ /** Remaining dependency depth provides a deterministic critical-path tie-break. */
14
+ export function criticalPathRanks(stories) {
15
+ const children = new Map();
16
+ for (const story of stories.filter(story => !story.passes))
17
+ for (const need of story.needs ?? [])
18
+ children.set(need, [...(children.get(need) ?? []), story.id]);
19
+ const ranks = new Map();
20
+ const visiting = new Set();
21
+ const rank = (id) => {
22
+ if (ranks.has(id))
23
+ return ranks.get(id);
24
+ if (visiting.has(id))
25
+ return 0;
26
+ visiting.add(id);
27
+ const value = 1 + Math.max(0, ...(children.get(id) ?? []).map(rank));
28
+ visiting.delete(id);
29
+ ranks.set(id, value);
30
+ return value;
31
+ };
32
+ for (const story of stories)
33
+ rank(story.id);
34
+ return ranks;
35
+ }
1
36
  export function readyStories(stories, opts = {}) {
2
37
  const passed = new Set(stories.filter(story => story.passes).map(story => story.id));
38
+ const ranks = criticalPathRanks(stories);
3
39
  return stories
4
40
  .filter(story => !story.passes)
5
41
  .filter(story => (story.needs ?? []).every(id => passed.has(id)))
6
42
  .filter(story => !story.area || !opts.activeAreas?.has(story.area))
7
- .sort((a, b) => a.priority - b.priority || Number(b.agent === opts.agent) - Number(a.agent === opts.agent) || a.id.localeCompare(b.id));
43
+ .filter(story => !opts.activeWrites?.some(scopes => writeScopesOverlap(story.writes, scopes)))
44
+ .sort((a, b) => a.priority - b.priority || (ranks.get(b.id) ?? 0) - (ranks.get(a.id) ?? 0) || Number(b.agent === opts.agent) - Number(a.agent === opts.agent) || a.id.localeCompare(b.id));
8
45
  }
@@ -62,6 +62,8 @@ export function killProcessTreeForCleanup(pid, platform = process.platform, runT
62
62
  }
63
63
  }) {
64
64
  if (platform === 'win32') {
65
+ // taskkill reports a non-zero status when the process disappeared between
66
+ // observation and cleanup; that is already the desired terminal state.
65
67
  if (runTaskkill('taskkill', ['/PID', String(pid), '/T', '/F']) !== 0)
66
68
  return !isProcessAlive(pid);
67
69
  return confirmProcessStopped(pid, isProcessAlive);
@@ -0,0 +1,67 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { mkdirSync, readdirSync, lstatSync, readFileSync, writeFileSync, unlinkSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ export const EVENT_CAP = 1000;
5
+ export const EVENT_MAX_BYTES = 64 * 1024;
6
+ const namePattern = /^\d{13}-[a-f0-9-]{36}\.json$/u;
7
+ let lastStamp = 0;
8
+ function directory(root, create) {
9
+ const parent = join(root, '.yoke');
10
+ if (create)
11
+ mkdirSync(parent, { recursive: true });
12
+ if (lstatSync(parent).isSymbolicLink())
13
+ throw new Error('Event parent is a symbolic link');
14
+ const dir = join(parent, 'events');
15
+ if (create)
16
+ mkdirSync(dir, { recursive: true });
17
+ if (!lstatSync(dir).isDirectory() || lstatSync(dir).isSymbolicLink())
18
+ throw new Error('Invalid event directory');
19
+ return dir;
20
+ }
21
+ /** Files are created once, never rewritten. Retention removes oldest whole events. */
22
+ export function appendEvent(root, event) {
23
+ try {
24
+ const dir = directory(root, true);
25
+ const id = randomUUID();
26
+ let content = JSON.stringify({ ...event, schemaVersion: 1, id });
27
+ if (Buffer.byteLength(content) > EVENT_MAX_BYTES)
28
+ content = JSON.stringify({ ...event, data: { truncated: true }, schemaVersion: 1, id });
29
+ if (Buffer.byteLength(content) > EVENT_MAX_BYTES)
30
+ return;
31
+ lastStamp = Math.max(Date.now(), lastStamp + 1);
32
+ writeFileSync(join(dir, `${String(lastStamp).padStart(13, '0')}-${id}.json`), content, { flag: 'wx' });
33
+ const names = readdirSync(dir).filter(name => namePattern.test(name)).sort();
34
+ for (const name of names.slice(0, Math.max(0, names.length - EVENT_CAP)))
35
+ unlinkSync(join(dir, name));
36
+ }
37
+ catch { /* Local observability must never abort work. */ }
38
+ }
39
+ /** Bounded, chronological reads; malformed, oversized and linked files are ignored. */
40
+ export function readEvents(root, limit = 200) {
41
+ if (!Number.isFinite(limit) || limit <= 0)
42
+ return [];
43
+ try {
44
+ const dir = directory(root, false);
45
+ const names = readdirSync(dir).filter(name => namePattern.test(name)).sort().slice(-Math.min(EVENT_CAP, Math.floor(limit)));
46
+ return names.flatMap(name => {
47
+ try {
48
+ const file = join(dir, name);
49
+ const stat = lstatSync(file);
50
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > EVENT_MAX_BYTES)
51
+ return [];
52
+ const value = JSON.parse(readFileSync(file, 'utf8'));
53
+ if (value?.schemaVersion !== 1 || typeof value.id !== 'string' || typeof value.runId !== 'string' || typeof value.timestamp !== 'string' || !Number.isFinite(Date.parse(value.timestamp)) || !['status', 'tokens', 'phase-ended', 'attempt-ended'].includes(value.type))
54
+ return [];
55
+ if (value.durationMs !== undefined && (!Number.isFinite(value.durationMs) || value.durationMs < 0))
56
+ return [];
57
+ return [value];
58
+ }
59
+ catch {
60
+ return [];
61
+ }
62
+ }).sort((a, b) => a.timestamp.localeCompare(b.timestamp));
63
+ }
64
+ catch {
65
+ return [];
66
+ }
67
+ }
@@ -5,6 +5,7 @@ import { z } from 'zod';
5
5
  import { AgentSchema, PermissionProfileSchema } from '../agents/contracts.js';
6
6
  import { ProjectQualityDefaultsSchema } from '../quality/types.js';
7
7
  import { DEFAULT_OUTPUT_POLICY } from '../output/types.js';
8
+ import { ToolActionSchema } from '../execution/actions.js';
8
9
  const CodeGraphSchema = z.enum(['graphify', 'serena']);
9
10
  const SmokeFlowSchema = z.object({ name: z.string().min(1), path: z.string().min(1), landmark: z.string().optional() });
10
11
  const SmokeSchema = z.object({ baseUrl: z.string().min(1), flows: z.array(SmokeFlowSchema).min(1) });
@@ -30,8 +31,15 @@ const RoutingWorkerSchema = z.object({
30
31
  costTier: z.enum(['low', 'medium', 'high']).default('medium'),
31
32
  capabilities: z.array(z.string().min(1)).default([]),
32
33
  });
34
+ const RoutingRuleSchema = z.object({
35
+ area: z.string().min(1).optional(),
36
+ storyId: z.string().min(1).optional(),
37
+ worker: z.string().min(1),
38
+ escalateTo: z.string().min(1).optional(),
39
+ }).strict().refine(rule => rule.area || rule.storyId, 'A routing rule needs area or storyId');
33
40
  export const YokeConfigSchema = z.object({
34
41
  canonVersion: z.string().min(1),
42
+ actions: z.array(ToolActionSchema).max(100).optional(),
35
43
  agents: z.array(AgentSchema),
36
44
  loop: z.object({
37
45
  enabled: z.boolean(),
@@ -57,6 +65,7 @@ export const YokeConfigSchema = z.object({
57
65
  reasoningEffort: z.string().min(1).optional(),
58
66
  }).optional(),
59
67
  workers: z.array(RoutingWorkerSchema).max(12).default([]),
68
+ rules: z.array(RoutingRuleSchema).max(100).optional(),
60
69
  }).optional(),
61
70
  commit: z.object({
62
71
  authorName: z.string().min(1).optional(),
@@ -24,6 +24,11 @@ export const YOKE_IGNORE_LINES = [
24
24
  '.yoke/references/',
25
25
  '.yoke/changes/',
26
26
  '.yoke/artifacts/',
27
+ '.yoke/checks/',
28
+ '.yoke/events/',
29
+ '.yoke/goal.json',
30
+ '.yoke/goal.json.*.tmp',
31
+ '.yoke/goal.pause',
27
32
  ];
28
33
  const HEADER = '# Yoke runtime artifacts (managed by yoke retrofit)';
29
34
  // Idempotently ensure each Yoke runtime path is gitignored. Appends only the
@@ -11,7 +11,7 @@ function tomlString(s) {
11
11
  export function planGemini(canonDir, _targetDir, codeGraph = 'graphify') {
12
12
  const manifest = loadManifest(join(canonDir, 'manifest.yaml'));
13
13
  const actions = [];
14
- // GEMINI.md: baseline + rtk instruction (Gemini has no rewrite hook).
14
+ // GEMINI.md: baseline + instruction for commands outside the rewrite hook.
15
15
  const baseline = readFileSync(join(canonDir, 'AGENTS.md'), 'utf8');
16
16
  const autoSkillIndex = manifest.skills
17
17
  .filter(skill => skill.invocation === 'auto')
@@ -25,7 +25,7 @@ export function planGemini(canonDir, _targetDir, codeGraph = 'graphify') {
25
25
  kind: 'write',
26
26
  target: 'GEMINI.md',
27
27
  content: `${baseline}\n${rtkInstruction()}\n\n## Yoke automatic skills\n\nUse the matching command when its capability is relevant:\n\n${autoSkillIndex}\n\n${PRESERVE_SCAFFOLD}\n`,
28
- reason: 'baseline + rtk instruction (no hook on Gemini)',
28
+ reason: 'baseline + rtk instruction',
29
29
  });
30
30
  // One TOML slash command per skill.
31
31
  for (const skill of manifest.skills) {
@@ -44,13 +44,21 @@ export function planGemini(canonDir, _targetDir, codeGraph = 'graphify') {
44
44
  });
45
45
  actions.push(...skillPackageActions(canonDir, skill, 'gemini'));
46
46
  }
47
- // settings.json: MCP servers + read AGENTS.md as context.
47
+ actions.push({
48
+ kind: 'write',
49
+ target: '.gemini/hooks/gemini-rtk-hook.mjs',
50
+ content: readFileSync(join(canonDir, 'tools/gemini-rtk-hook.mjs'), 'utf8'),
51
+ reason: 'portable RTK BeforeTool argument adapter',
52
+ });
53
+ // Merge to preserve user MCP servers, context and unrelated hooks.
48
54
  actions.push({
49
55
  kind: 'write',
50
56
  target: '.gemini/settings.json',
57
+ merge: true,
51
58
  content: JSON.stringify({
52
59
  mcpServers: mcpServers(codeGraph),
53
60
  context: { fileName: ['AGENTS.md', 'GEMINI.md'] },
61
+ hooks: { BeforeTool: [{ matcher: '^run_shell_command$', hooks: [{ name: 'yoke-rtk', type: 'command', command: 'node .gemini/hooks/gemini-rtk-hook.mjs' }] }] },
54
62
  }, null, 2) + '\n',
55
63
  reason: 'MCP servers + AGENTS.md context',
56
64
  });
@@ -1,6 +1,6 @@
1
1
  import { buildWatchdogInvocation, makeRunner, runCapturedAgent, runnerInvocation, } from '../loop/runner.js';
2
2
  import { isAcceptanceCriterion } from '../loop/prd.js';
3
- import { historyForWorkers, projectHash, recordRoutingObservation, storyHash } from './registry.js';
3
+ import { historyForWorkers, projectHash, readRoutingObservations, recordRoutingObservation, storyHash } from './registry.js';
4
4
  const costRank = { low: 0, medium: 1, high: 2 };
5
5
  export function rankWorkers(workers, strategy, maxCandidates) {
6
6
  const history = historyForWorkers(workers);
@@ -105,6 +105,7 @@ function callUsage(role, provider, selection, tokens, durationMs, profile) {
105
105
  ...(tokens?.cachedInputTokens !== undefined ? { cachedInputTokens: tokens.cachedInputTokens } : {}),
106
106
  ...(tokens?.cacheWriteInputTokens !== undefined ? { cacheWriteInputTokens: tokens.cacheWriteInputTokens } : {}),
107
107
  outputTokens: tokens?.outputTokens ?? 0,
108
+ usageAvailable: tokens !== undefined && tokens.measurementComplete !== false,
108
109
  ...(tokens?.reasoningOutputTokens !== undefined ? { reasoningOutputTokens: tokens.reasoningOutputTokens } : {}),
109
110
  ...(tokens?.totalCostUsd !== undefined ? { totalCostUsd: tokens.totalCostUsd } : {}),
110
111
  durationMs,
@@ -114,6 +115,7 @@ export function makeAdaptiveRunner(options) {
114
115
  const now = options.now ?? Date.now;
115
116
  const available = options.isAvailable ?? (() => true);
116
117
  const eligibleWorkers = options.workers.filter(worker => available(worker.agent));
118
+ const failedStories = new Set();
117
119
  const makeWorker = options.makeWorker ?? ((agent, selection) => makeRunner(agent, options.idleTimeoutMs ?? 0, {
118
120
  ...options.runnerOpts,
119
121
  permissions: options.permissions ?? 'safe',
@@ -122,29 +124,39 @@ export function makeAdaptiveRunner(options) {
122
124
  return (ctx) => {
123
125
  // Re-rank per story so a long-running loop can use gate outcomes learned by
124
126
  // earlier stories without rebuilding the runner.
125
- const candidates = rankWorkers(eligibleWorkers, options.strategy, options.maxCandidates);
127
+ const rule = options.rules?.find(rule => (!rule.area || rule.area === ctx.story.area) && (!rule.storyId || rule.storyId === ctx.story.id) && (rule.area || rule.storyId));
128
+ if (rule) {
129
+ const project = projectHash(ctx.targetDir);
130
+ const prior = readRoutingObservations().reverse().find(event => event.projectHash === project && event.storyHash === storyHash(project, ctx.story.id) && typeof event.verificationSuccess === 'boolean');
131
+ if (prior?.verificationSuccess === false)
132
+ failedStories.add(ctx.story.id);
133
+ }
134
+ const ruleWorker = rule && failedStories.has(ctx.story.id) ? rule.escalateTo ?? 'SELF' : rule?.worker;
135
+ const candidates = rule ? eligibleWorkers : rankWorkers(eligibleWorkers, options.strategy, options.maxCandidates);
126
136
  if (candidates.length === 0) {
127
137
  return makeWorker(options.parent, options.parentSelection ?? {})(ctx);
128
138
  }
129
139
  const prompt = buildRoutingPrompt(ctx, candidates, options.strategy);
130
- const orchestratorSelection = { ...(options.parentSelection ?? {}), ...(options.orchestratorSelection ?? {}), nativeMultiAgent: false };
140
+ const orchestratorSelection = { ...(options.parentSelection ?? {}), ...(options.orchestratorSelection ?? {}), ...(options.parent === 'codex' ? { nativeMultiAgent: false } : {}) };
131
141
  const orchestratorStarted = now();
132
- const routeRun = options.captureRoute
142
+ const routeRun = rule ? { success: true, summary: 'Explicit rule', output: '', tokens: { inputTokens: 0, outputTokens: 0 } } : options.captureRoute
133
143
  ? options.captureRoute(options.parent, ctx, prompt, orchestratorSelection)
134
144
  : runCapturedAgent(options.parent, buildWatchdogInvocation(runnerInvocation(options.parent, prompt, ctx.targetDir, true, 'read-only', orchestratorSelection), options.idleTimeoutMs ?? 0));
135
145
  const orchestratorDurationMs = Math.max(0, now() - orchestratorStarted);
136
- const decision = routeRun.success ? parseRouteDecision(routeRun.output, candidates.map(worker => worker.id)) : null;
146
+ const decision = rule
147
+ ? { worker: ruleWorker === 'SELF' || candidates.some(w => w.id === ruleWorker) ? ruleWorker : 'SELF', reason: failedStories.has(ctx.story.id) ? 'gate failure escalated by project rule' : 'explicit project routing rule' }
148
+ : routeRun.success ? parseRouteDecision(routeRun.output, candidates.map(worker => worker.id)) : null;
137
149
  const selected = decision?.worker ?? 'SELF';
138
150
  const worker = selected === 'SELF' ? undefined : candidates.find(candidate => candidate.id === selected);
139
151
  const provider = worker?.agent ?? options.parent;
140
152
  const selection = worker
141
- ? { model: worker.model, reasoningEffort: worker.reasoningEffort, nativeMultiAgent: false, bare: options.parentSelection?.bare }
142
- : { ...(options.parentSelection ?? {}), nativeMultiAgent: false };
153
+ ? { model: worker.model, reasoningEffort: worker.reasoningEffort, ...(provider === 'codex' ? { nativeMultiAgent: false } : {}), ...(provider !== 'gemini' ? { bare: options.parentSelection?.bare } : {}) }
154
+ : { ...(options.parentSelection ?? {}), ...(provider === 'codex' ? { nativeMultiAgent: false } : {}) };
143
155
  const workerStarted = now();
144
156
  const result = makeWorker(provider, selection)(ctx);
145
157
  const workerDurationMs = Math.max(0, now() - workerStarted);
146
158
  const calls = [
147
- callUsage('orchestrator', options.parent, orchestratorSelection, routeRun.tokens, orchestratorDurationMs),
159
+ ...(!rule ? [callUsage('orchestrator', options.parent, orchestratorSelection, routeRun.tokens, orchestratorDurationMs)] : []),
148
160
  callUsage(worker ? 'worker' : 'parent', provider, selection, result.tokens, workerDurationMs, selected),
149
161
  ];
150
162
  const tokens = {
@@ -164,12 +176,18 @@ export function makeAdaptiveRunner(options) {
164
176
  : {}),
165
177
  ...(result.tokens?.model ? { model: result.tokens.model } : routeRun.tokens?.model ? { model: routeRun.tokens.model } : {}),
166
178
  calls,
179
+ measurementComplete: calls.every(call => call.usageAvailable),
180
+ costMeasurementComplete: calls.every(call => call.totalCostUsd !== undefined),
167
181
  };
168
182
  let recorded = false;
169
183
  const recordOutcome = (verificationSuccess) => {
170
184
  if (recorded)
171
185
  return;
172
186
  recorded = true;
187
+ if (!verificationSuccess)
188
+ failedStories.add(ctx.story.id);
189
+ else
190
+ failedStories.delete(ctx.story.id);
173
191
  const project = projectHash(ctx.targetDir);
174
192
  recordRoutingObservation({
175
193
  projectHash: project,
@@ -0,0 +1,66 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { execFileSync } from 'node:child_process';
3
+ import { lstatSync, readFileSync, readdirSync, readlinkSync, realpathSync } from 'node:fs';
4
+ import { isAbsolute, join, relative, resolve } from 'node:path';
5
+ /** Content identity, including new files. Symlinks are identified, never followed. */
6
+ export function workspaceFingerprint(directory) {
7
+ const root = realpathSync(directory);
8
+ const hash = createHash('sha256');
9
+ let names;
10
+ const git = (args) => execFileSync('git', args, { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 32 * 1024 * 1024 });
11
+ try {
12
+ git(['rev-parse', '--show-toplevel']);
13
+ }
14
+ catch (error) {
15
+ // Plain directories are supported for review fixtures and standalone checks;
16
+ // permission/corruption errors in actual repositories must never become success.
17
+ if (!String(error.stderr).includes('not a git repository'))
18
+ throw error;
19
+ const walk = (dir) => readdirSync(dir).sort().flatMap(name => {
20
+ if (['.git', 'node_modules', '.yoke'].includes(name))
21
+ return [];
22
+ const path = join(dir, name);
23
+ return lstatSync(path).isDirectory() ? walk(path) : [relative(root, path)];
24
+ });
25
+ names = walk(root);
26
+ return digest(names);
27
+ }
28
+ hash.update(git(['status', '--porcelain=v1', '-z', '--untracked-files=all', '--', '.', ':(exclude).yoke/**']));
29
+ hash.update(git(['ls-files', '--stage', '-z', '--', '.', ':(exclude).yoke/**']));
30
+ names = [...new Set(git(['ls-files', '--cached', '--others', '--exclude-standard', '-z', '--', '.', ':(exclude).yoke/**']).split('\0').filter(Boolean))].sort();
31
+ return digest(names);
32
+ function digest(files) {
33
+ // Runtime status and evidence change during checks. Executable project policy
34
+ // must still belong to the identity, even when globally ignored by Git.
35
+ for (const name of [...new Set([...files, '.yoke/acceptance.yaml', '.yoke/config.yaml', '.yoke/prd.json', '.yoke/prd.yaml'])].sort()) {
36
+ const full = resolve(root, name);
37
+ const rel = relative(root, full);
38
+ if (isAbsolute(rel) || rel === '..' || rel.startsWith('..\\') || rel.startsWith('../'))
39
+ throw new Error('Fingerprint path escapes workspace');
40
+ hash.update(JSON.stringify(name));
41
+ let stat;
42
+ try {
43
+ stat = lstatSync(full);
44
+ }
45
+ catch (error) {
46
+ if (error.code === 'ENOENT') {
47
+ hash.update('missing');
48
+ continue;
49
+ }
50
+ throw error;
51
+ }
52
+ if (stat.isSymbolicLink())
53
+ hash.update(`link:${readlinkSync(full)}`);
54
+ else if (stat.isFile()) {
55
+ if (stat.size > 64 * 1024 * 1024)
56
+ throw new Error(`File too large to fingerprint safely: ${name}`);
57
+ hash.update(`file:${stat.mode}:${stat.size}:`).update(readFileSync(full));
58
+ }
59
+ else if (stat.isDirectory())
60
+ throw new Error(`Cannot safely fingerprint a nested repository or directory entry: ${name}`);
61
+ else
62
+ throw new Error(`Unsupported workspace entry: ${name}`);
63
+ }
64
+ return hash.digest('hex');
65
+ }
66
+ }
@@ -0,0 +1,20 @@
1
+ import { lstatSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ /** Fixed internal runtime paths only. Never follow project-controlled state links. */
4
+ export function statePath(root, ...parts) {
5
+ let path = root;
6
+ for (const part of ['.yoke', ...parts]) {
7
+ if (!part || /[\\/]/u.test(part) || part === '.' || part === '..')
8
+ throw new Error('Invalid state path component');
9
+ path = join(path, part);
10
+ try {
11
+ if (lstatSync(path).isSymbolicLink())
12
+ throw new Error(`Linked Yoke state is not supported: ${part}`);
13
+ }
14
+ catch (error) {
15
+ if (error.code !== 'ENOENT')
16
+ throw error;
17
+ }
18
+ }
19
+ return path;
20
+ }