@hecer/yoke 1.6.2 → 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.
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/CHANGELOG.md +19 -0
- package/README.md +43 -9
- package/canon/manifest.yaml +1 -1
- package/canon/tools/gemini-rtk-hook.mjs +25 -0
- package/dist/agents/contracts.js +2 -0
- package/dist/agents/process-streams.js +18 -4
- package/dist/agents/providers.js +24 -3
- package/dist/agents/telemetry.js +58 -10
- package/dist/check/command.js +114 -0
- package/dist/cli.js +120 -1
- package/dist/context/packet.js +32 -0
- package/dist/dashboard/page.js +32 -0
- package/dist/dashboard/registry.js +68 -0
- package/dist/dashboard/server.js +160 -0
- package/dist/estimation/durations.js +20 -0
- package/dist/estimation/schedule.js +41 -0
- package/dist/execution/actions.js +24 -0
- package/dist/goals/command.js +187 -0
- package/dist/loop/dispatcher.js +14 -3
- package/dist/loop/git.js +5 -4
- package/dist/loop/loop.js +23 -3
- package/dist/loop/parallel-adapters.js +3 -2
- package/dist/loop/parallel-command.js +10 -1
- package/dist/loop/prd.js +3 -0
- package/dist/loop/recovery.js +51 -0
- package/dist/loop/reporter.js +88 -7
- package/dist/loop/run-command.js +20 -3
- package/dist/loop/runner.js +9 -12
- package/dist/loop/scheduler.js +38 -1
- package/dist/observability/events.js +67 -0
- package/dist/retrofit/config.js +9 -0
- package/dist/retrofit/gitignore.js +5 -0
- package/dist/retrofit/planners/gemini.js +11 -3
- package/dist/routing/router.js +26 -8
- package/dist/workspace/fingerprint.js +66 -0
- package/dist/workspace/state.js +20 -0
- package/docs/PRODUCT-DIRECTION-2026-09-05.md +183 -0
- package/docs/VERIFIED-PROJECTS-VALIDATION.md +29 -0
- package/docs/VERIFIED-PROJECTS.md +147 -0
- package/docs/superpowers/plans/2026-09-05-verified-projects.md +83 -0
- package/gemini-extension.json +1 -1
- package/package.json +1 -1
package/dist/loop/run-command.js
CHANGED
|
@@ -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 ??
|
|
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));
|
package/dist/loop/runner.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
package/dist/loop/scheduler.js
CHANGED
|
@@ -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
|
-
.
|
|
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
|
}
|
|
@@ -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
|
+
}
|
package/dist/retrofit/config.js
CHANGED
|
@@ -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 +
|
|
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
|
|
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
|
-
|
|
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
|
});
|
package/dist/routing/router.js
CHANGED
|
@@ -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
|
|
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 =
|
|
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
|
+
}
|