@hecer/yoke 0.9.0 → 1.0.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/.codex-plugin/plugin.json +7 -0
- package/CHANGELOG.md +169 -149
- package/README.md +24 -16
- package/TODOS.md +8 -0
- package/agents/docs.toml +6 -0
- package/agents/implementer.toml +6 -0
- package/agents/reviewer.toml +6 -0
- package/agents/security.toml +6 -0
- package/bench/README.md +45 -42
- package/bench/RESULTS.md +46 -36
- package/bench/result-schema.mjs +12 -0
- package/bench/results/claude-2026-07-27T18-03-26.json +50 -0
- package/bench/results/codex-unavailable-1785175418318.json +15 -0
- package/bench/results/gemini-2026-07-27T18-03-44.json +46 -0
- package/bench/run-matrix.mjs +26 -0
- package/bench/run.mjs +127 -115
- package/canon/loop/prd.schema.md +5 -0
- package/canon/manifest.yaml +1 -1
- package/canon/skills/authoring-prd/SKILL.md +6 -0
- package/canon/skills/ship/SKILL.md +2 -7
- package/canon/tools/codex-rtk-hook.mjs +36 -0
- package/dist/agents/providers.js +23 -0
- package/dist/agents/telemetry.js +30 -0
- package/dist/agents/types.js +1 -0
- package/dist/audit/changes.js +6 -0
- package/dist/audit/command.js +64 -0
- package/dist/audit/dependencies.js +21 -0
- package/dist/audit/secrets.js +16 -0
- package/dist/audit/types.js +1 -0
- package/dist/cli.js +22 -4
- package/dist/loop/claims.js +57 -0
- package/dist/loop/cleanup.js +10 -4
- package/dist/loop/git.js +8 -2
- package/dist/loop/identity.js +27 -0
- package/dist/loop/loop.js +20 -2
- package/dist/loop/merge-queue.js +20 -0
- package/dist/loop/parallel.js +39 -0
- package/dist/loop/prd.js +48 -2
- package/dist/loop/run-command.js +51 -6
- package/dist/loop/runner.js +41 -29
- package/dist/loop/scheduler.js +8 -0
- package/dist/prd/command.js +6 -0
- package/dist/retrofit/config.js +12 -0
- package/dist/retrofit/planners/codex.js +64 -19
- package/dist/review/command.js +52 -12
- package/dist/review/verdict.js +45 -0
- package/docs/MIGRATING-TO-1.0.md +33 -0
- package/docs/superpowers/plans/2026-07-27-yoke-1.0-release.md +205 -0
- package/docs/superpowers/specs/2026-07-27-yoke-1.0-hardening-and-codex-parity-design.md +164 -0
- package/hooks/hooks.json +19 -0
- package/package.json +82 -67
- package/bench/.runs/claude-2026-07-09T22-34-01/.yoke/config.yaml +0 -6
- package/bench/.runs/claude-2026-07-09T22-34-01/.yoke/context/DECISIONS.md +0 -9
- package/bench/.runs/claude-2026-07-09T22-34-01/.yoke/prd.yaml +0 -38
- package/bench/.runs/claude-2026-07-09T22-34-01/bench-verify.mjs +0 -15
- package/bench/.runs/claude-2026-07-09T22-34-01/package.json +0 -9
- package/bench/.runs/claude-2026-07-09T22-34-01/src/index.mjs +0 -48
- package/bench/.runs/claude-2026-07-09T22-34-01/tests/STORY-1.test.mjs +0 -24
- package/bench/.runs/claude-2026-07-09T22-34-01/tests/STORY-2.test.mjs +0 -28
- package/bench/.runs/claude-2026-07-09T22-34-01/tests/STORY-3.test.mjs +0 -25
- package/bench/.runs/gemini-2026-07-09T22-34-02/.yoke/config.yaml +0 -6
- package/bench/.runs/gemini-2026-07-09T22-34-02/.yoke/prd.yaml +0 -32
- package/bench/.runs/gemini-2026-07-09T22-34-02/bench-verify.mjs +0 -15
- package/bench/.runs/gemini-2026-07-09T22-34-02/package.json +0 -9
- package/bench/.runs/gemini-2026-07-09T22-34-02/src/index.mjs +0 -3
- package/bench/.runs/gemini-2026-07-09T22-34-02/tests/STORY-1.test.mjs +0 -24
- package/bench/.runs/gemini-2026-07-09T22-34-02/tests/STORY-2.test.mjs +0 -28
- package/bench/.runs/gemini-2026-07-09T22-34-02/tests/STORY-3.test.mjs +0 -25
package/dist/loop/runner.js
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { execFileSync, execSync } from 'node:child_process';
|
|
2
|
-
import { existsSync } from 'node:fs';
|
|
2
|
+
import { existsSync, mkdirSync, rmSync } from 'node:fs';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
5
|
import { loadContext, formatForPrompt, contextDir } from '../context/context.js';
|
|
6
|
+
import { buildProviderInvocation } from '../agents/providers.js';
|
|
7
|
+
import { parseProviderTelemetry } from '../agents/telemetry.js';
|
|
8
|
+
import { formatReviewContract, readReviewVerdict, reviewVerdictPath } from '../review/verdict.js';
|
|
6
9
|
export function contextBlockFor(targetDir) {
|
|
7
10
|
return formatForPrompt(loadContext(contextDir(targetDir)));
|
|
8
11
|
}
|
|
@@ -23,7 +26,7 @@ export function buildClaudePrompt(story, context, onAmbiguity = 'resolve', perfC
|
|
|
23
26
|
lines.push('- Keep your final message to a few short sentences: what changed and what you verified.');
|
|
24
27
|
return lines.join('\n');
|
|
25
28
|
}
|
|
26
|
-
export function buildReviewPrompt(story, context) {
|
|
29
|
+
export function buildReviewPrompt(story, context, verdictPath) {
|
|
27
30
|
const criteria = story.acceptance.map(a => `- ${a}`).join('\n');
|
|
28
31
|
const lines = [
|
|
29
32
|
'You are an independent reviewer inside the Yoke loop. You did NOT implement this change.',
|
|
@@ -31,10 +34,12 @@ export function buildReviewPrompt(story, context) {
|
|
|
31
34
|
];
|
|
32
35
|
if (context)
|
|
33
36
|
lines.push('', context);
|
|
34
|
-
lines.push('', `Story ${story.id}: ${story.title}`, 'Acceptance criteria:', criteria, '', 'Approve
|
|
37
|
+
lines.push('', `Story ${story.id}: ${story.title}`, 'Acceptance criteria:', criteria, '', 'Approve ONLY if every acceptance criterion is met and the change is sound.', 'If you find ANY blocking issue (an unmet criterion, a bug, a missing test), reject.', 'Base your verdict only on what the diff and test runs actually show — never assume unverified behavior.', 'Do not modify files. Do not commit.', 'Keep your verdict to a few short sentences.');
|
|
38
|
+
if (verdictPath)
|
|
39
|
+
lines.push('', formatReviewContract(verdictPath));
|
|
35
40
|
return lines.join('\n');
|
|
36
41
|
}
|
|
37
|
-
export function buildStandaloneReviewPrompt(scope, focus) {
|
|
42
|
+
export function buildStandaloneReviewPrompt(scope, focus, verdictPath) {
|
|
38
43
|
const lines = [
|
|
39
44
|
'You are an independent reviewer. You did NOT write this change.',
|
|
40
45
|
`Review ${scope}. Run git yourself to see the diff (e.g. \`git diff\`, or \`git diff <base>..HEAD\`).`,
|
|
@@ -43,6 +48,8 @@ export function buildStandaloneReviewPrompt(scope, focus) {
|
|
|
43
48
|
if (focus)
|
|
44
49
|
lines.push(`Pay particular attention to: ${focus}.`);
|
|
45
50
|
lines.push('', 'Approve by exiting 0 ONLY if the change is sound and complete.', 'If you find ANY blocking issue, exit non-zero to reject and explain what is wrong.', 'Base your verdict only on what the diff and test runs actually show — never assume unverified behavior.', 'Do not modify files. Do not commit.', 'Keep your verdict to a few short sentences.');
|
|
51
|
+
if (verdictPath)
|
|
52
|
+
lines.push('', formatReviewContract(verdictPath));
|
|
46
53
|
return lines.join('\n');
|
|
47
54
|
}
|
|
48
55
|
// Headless agents must run non-interactively: with plain `-p` the CLI denies
|
|
@@ -51,16 +58,8 @@ export function buildStandaloneReviewPrompt(scope, focus) {
|
|
|
51
58
|
// and falsely marks the story done. Granting autonomous permissions makes the
|
|
52
59
|
// implementer actually able to write files and run the verify command.
|
|
53
60
|
// (The loop is opt-in and scoped to the target project dir.)
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
codex: { command: 'codex', baseArgs: ['exec', '--dangerously-bypass-approvals-and-sandbox'] },
|
|
57
|
-
// gemini: no `-p` — current Gemini CLI (0.33+) requires a value after -p, and
|
|
58
|
-
// piped (non-TTY) stdin already selects headless mode on its own.
|
|
59
|
-
gemini: { command: 'gemini', baseArgs: ['--yolo'] },
|
|
60
|
-
};
|
|
61
|
-
export function agentInvocation(agent, prompt, cwd) {
|
|
62
|
-
const spec = AGENT_SPECS[agent];
|
|
63
|
-
return { command: spec.command, args: spec.baseArgs, input: prompt, cwd };
|
|
61
|
+
export function agentInvocation(agent, prompt, cwd, permissions = 'safe') {
|
|
62
|
+
return buildProviderInvocation(agent, prompt, cwd, permissions);
|
|
64
63
|
}
|
|
65
64
|
export function claudeInvocation(prompt, cwd) {
|
|
66
65
|
return agentInvocation('claude', prompt, cwd);
|
|
@@ -69,7 +68,7 @@ export function claudeInvocation(prompt, cwd) {
|
|
|
69
68
|
// (--verbose is required by the CLI for stream-json in -p mode). Prompt still via stdin.
|
|
70
69
|
// Derived from the base spec so the headless permission-bypass flag rides along.
|
|
71
70
|
export function claudeStreamJsonInvocation(prompt, cwd) {
|
|
72
|
-
return
|
|
71
|
+
return buildProviderInvocation('claude', prompt, cwd, 'safe');
|
|
73
72
|
}
|
|
74
73
|
// Pick the runner invocation. Claude ALWAYS runs in stream-json mode: plain `-p`
|
|
75
74
|
// prints nothing until the run finishes, so the idle watchdog saw a healthy
|
|
@@ -77,10 +76,8 @@ export function claudeStreamJsonInvocation(prompt, cwd) {
|
|
|
77
76
|
// the user saw dead air the whole time. stream-json emits per-message output,
|
|
78
77
|
// which doubles as liveness. Token usage rides along for free. Other agents
|
|
79
78
|
// keep their plain invocation (no machine-readable stream to gain).
|
|
80
|
-
export function runnerInvocation(agent, prompt, cwd, _tokenReport = false) {
|
|
81
|
-
|
|
82
|
-
return claudeStreamJsonInvocation(prompt, cwd);
|
|
83
|
-
return agentInvocation(agent, prompt, cwd);
|
|
79
|
+
export function runnerInvocation(agent, prompt, cwd, _tokenReport = false, permissions = 'safe') {
|
|
80
|
+
return buildProviderInvocation(agent, prompt, cwd, permissions);
|
|
84
81
|
}
|
|
85
82
|
// Parse claude stream-json output into cumulative token usage. Defensive by design:
|
|
86
83
|
// non-JSON lines and unknown message shapes are ignored. The final "result" message
|
|
@@ -230,20 +227,21 @@ export function makeRunner(agent, idleTimeoutMs = 0, opts = {}) {
|
|
|
230
227
|
// Claude always streams (see runnerInvocation) — capture the stream so tokens are
|
|
231
228
|
// always reported; other agents keep inherit stdio. opts.tokenReport is now
|
|
232
229
|
// redundant for claude and meaningless elsewhere; kept for caller compatibility.
|
|
233
|
-
const captureTokens =
|
|
230
|
+
const captureTokens = true;
|
|
234
231
|
return (ctx) => {
|
|
235
|
-
const base = runnerInvocation(agent, buildClaudePrompt(ctx.story, contextBlockFor(ctx.targetDir), opts.onAmbiguity, opts.perfCommand), ctx.targetDir, captureTokens);
|
|
232
|
+
const base = runnerInvocation(agent, buildClaudePrompt(ctx.story, contextBlockFor(ctx.targetDir), opts.onAmbiguity, opts.perfCommand), ctx.targetDir, captureTokens, opts.permissions ?? 'safe');
|
|
236
233
|
const inv = buildWatchdogInvocation(base, idleTimeoutMs);
|
|
237
234
|
if (captureTokens) {
|
|
238
235
|
const capture = opts.execCapture ?? runCliCapture;
|
|
239
236
|
try {
|
|
240
237
|
const out = capture(inv);
|
|
241
|
-
|
|
238
|
+
const telemetry = parseProviderTelemetry(agent, out.split(/\r?\n/));
|
|
239
|
+
return { success: true, summary: `${agent} implemented ${ctx.story.id}`, tokens: telemetry.tokens };
|
|
242
240
|
}
|
|
243
241
|
catch (e) {
|
|
244
242
|
// Salvage usage from whatever the agent streamed before dying — those tokens were spent.
|
|
245
243
|
const partial = e.stdout;
|
|
246
|
-
const tokens = partial == null ? undefined :
|
|
244
|
+
const tokens = partial == null ? undefined : parseProviderTelemetry(agent, String(partial).split(/\r?\n/)).tokens;
|
|
247
245
|
return { success: false, summary: `${agent} failed on ${ctx.story.id}: ${e.message}`, tokens };
|
|
248
246
|
}
|
|
249
247
|
}
|
|
@@ -260,21 +258,35 @@ export function makeRunner(agent, idleTimeoutMs = 0, opts = {}) {
|
|
|
260
258
|
};
|
|
261
259
|
}
|
|
262
260
|
export const claudeRunner = makeRunner('claude');
|
|
263
|
-
export function makeReviewRunner(agent, idleTimeoutMs = 0) {
|
|
261
|
+
export function makeReviewRunner(agent, idleTimeoutMs = 0, exec = runCli) {
|
|
264
262
|
return (ctx) => {
|
|
265
|
-
const
|
|
263
|
+
const verdictPath = reviewVerdictPath(ctx.targetDir);
|
|
264
|
+
mkdirSync(join(ctx.targetDir, '.yoke'), { recursive: true });
|
|
265
|
+
rmSync(verdictPath, { force: true });
|
|
266
|
+
const base = agentInvocation(agent, buildReviewPrompt(ctx.story, contextBlockFor(ctx.targetDir), verdictPath), ctx.targetDir, 'safe');
|
|
266
267
|
const inv = buildWatchdogInvocation(base, idleTimeoutMs);
|
|
268
|
+
let processFailure;
|
|
267
269
|
try {
|
|
268
|
-
|
|
269
|
-
return { success: true, summary: `${agent} approved ${ctx.story.id}` };
|
|
270
|
+
exec(inv);
|
|
270
271
|
}
|
|
271
272
|
catch (e) {
|
|
272
|
-
|
|
273
|
+
processFailure = e.message;
|
|
274
|
+
}
|
|
275
|
+
try {
|
|
276
|
+
const verdict = readReviewVerdict(verdictPath);
|
|
277
|
+
if (processFailure)
|
|
278
|
+
return { success: false, summary: `review process failed: ${processFailure}; verdict: ${verdict.summary}` };
|
|
279
|
+
return verdict.approved
|
|
280
|
+
? { success: true, summary: `${agent} approved ${ctx.story.id}: ${verdict.summary}` }
|
|
281
|
+
: { success: false, summary: `${agent} rejected ${ctx.story.id}: ${verdict.summary}` };
|
|
282
|
+
}
|
|
283
|
+
catch (e) {
|
|
284
|
+
return { success: false, summary: `${processFailure ? `review process failed: ${processFailure}; ` : ''}${e.message}` };
|
|
273
285
|
}
|
|
274
286
|
};
|
|
275
287
|
}
|
|
276
288
|
// Probe whether the agent's CLI is on PATH (so the loop can refuse upfront with a
|
|
277
289
|
// clear message instead of failing mid-run with spawn ENOENT). Never throws.
|
|
278
290
|
export function isAgentAvailable(agent) {
|
|
279
|
-
return probeVersion(
|
|
291
|
+
return probeVersion(agent);
|
|
280
292
|
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export function readyStories(stories, opts = {}) {
|
|
2
|
+
const passed = new Set(stories.filter(story => story.passes).map(story => story.id));
|
|
3
|
+
return stories
|
|
4
|
+
.filter(story => !story.passes)
|
|
5
|
+
.filter(story => (story.needs ?? []).every(id => passed.has(id)))
|
|
6
|
+
.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));
|
|
8
|
+
}
|
package/dist/prd/command.js
CHANGED
|
@@ -9,6 +9,9 @@ export const PRD_TEMPLATE = `# Yoke PRD — the loop picks the lowest-priority o
|
|
|
9
9
|
# - id: STORY-1
|
|
10
10
|
# title: scaffold the project with a runnable test suite
|
|
11
11
|
# priority: 1
|
|
12
|
+
# needs: [] # optional story IDs that must pass first
|
|
13
|
+
# area: foundation # optional collision domain for parallel runs
|
|
14
|
+
# agent: codex # optional claude|codex|gemini affinity
|
|
12
15
|
# acceptance:
|
|
13
16
|
# - "the verify command exits 0"
|
|
14
17
|
# - "a placeholder test exists and passes"
|
|
@@ -26,6 +29,9 @@ export function buildPrdDraftPrompt(idea) {
|
|
|
26
29
|
'- id: STORY-1, STORY-2, ... (unique)',
|
|
27
30
|
'- title: one imperative sentence',
|
|
28
31
|
'- priority: dense integers from 1 (lower = built first)',
|
|
32
|
+
'- needs: optional list of story IDs that must pass first; the graph must be acyclic',
|
|
33
|
+
'- area: optional collision domain for safe parallel scheduling',
|
|
34
|
+
'- agent: optional claude|codex|gemini affinity',
|
|
29
35
|
'- acceptance: 2-5 testable, behavioral criteria (observable outcomes, never implementation steps)',
|
|
30
36
|
'- passes: false',
|
|
31
37
|
'',
|
package/dist/retrofit/config.js
CHANGED
|
@@ -16,6 +16,18 @@ export const YokeConfigSchema = z.object({
|
|
|
16
16
|
// or 'abort' (agent stops the story via .yoke/ambiguity.md for a human decision).
|
|
17
17
|
onAmbiguity: z.enum(['resolve', 'abort']).optional(),
|
|
18
18
|
}),
|
|
19
|
+
runner: z.object({ permissions: z.enum(['safe', 'unsafe', 'read-only']) }).optional(),
|
|
20
|
+
commit: z.object({
|
|
21
|
+
authorName: z.string().min(1).optional(),
|
|
22
|
+
authorEmail: z.string().email().optional(),
|
|
23
|
+
allowCoAuthors: z.boolean().optional(),
|
|
24
|
+
}).optional(),
|
|
25
|
+
audit: z.object({
|
|
26
|
+
enabled: z.boolean(),
|
|
27
|
+
command: z.string().min(1).optional(),
|
|
28
|
+
suppressionsVersion: z.literal(1).optional(),
|
|
29
|
+
suppressions: z.array(z.object({ ruleId: z.string().min(1), file: z.string().min(1).optional(), reason: z.string(), expires: z.string().optional() })).optional(),
|
|
30
|
+
}).optional(),
|
|
19
31
|
verify: z.object({ command: z.string().min(1), retries: z.number().int().nonnegative().optional() }).optional(),
|
|
20
32
|
// Optional performance budget gate: a benchmark command that must exit 0 for a
|
|
21
33
|
// story to land (runs after verify). Benchmarks are noisy → retried like verify.
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
+
import { loadManifest } from '../../canon/manifest.js';
|
|
3
4
|
import { mcpServers, rtkInstruction } from '../tools.js';
|
|
4
5
|
function tomlMcp(codeGraph) {
|
|
5
6
|
const servers = mcpServers(codeGraph);
|
|
@@ -13,24 +14,68 @@ function tomlMcp(codeGraph) {
|
|
|
13
14
|
.join('\n');
|
|
14
15
|
}
|
|
15
16
|
export function planCodex(canonDir, _targetDir, codeGraph = 'graphify') {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
{
|
|
30
|
-
kind: 'write',
|
|
31
|
-
target: 'RTK.md',
|
|
32
|
-
content: rtkInstruction() + '\n',
|
|
33
|
-
reason: 'rtk instruction (Codex has no rewrite hook)',
|
|
34
|
-
},
|
|
17
|
+
const manifest = loadManifest(join(canonDir, 'manifest.yaml'));
|
|
18
|
+
const baseline = readFileSync(join(canonDir, 'AGENTS.md'), 'utf8');
|
|
19
|
+
const actions = manifest.skills.map(skill => ({
|
|
20
|
+
kind: 'write',
|
|
21
|
+
target: `.agents/skills/${skill.id}/SKILL.md`,
|
|
22
|
+
content: readFileSync(join(canonDir, skill.path, 'SKILL.md'), 'utf8'),
|
|
23
|
+
reason: `skill: ${skill.id}`,
|
|
24
|
+
}));
|
|
25
|
+
const roles = [
|
|
26
|
+
['implementer', 'Implementation specialist for one scoped story.', 'workspace-write', 'Implement only the assigned scope. Use tests first, run verification, and do not review or commit your own work.'],
|
|
27
|
+
['reviewer', 'Read-only reviewer for correctness and acceptance criteria.', 'read-only', 'Review observed diffs and test evidence. Do not modify files. Return only findings grounded in evidence.'],
|
|
28
|
+
['security', 'Read-only security reviewer for changed code.', 'read-only', 'Inspect changed code for exploitable security regressions. Do not modify files and avoid speculative findings.'],
|
|
29
|
+
['docs', 'Documentation specialist for release and API consistency.', 'workspace-write', 'Update only documentation required by the assigned change. Verify commands and version references against the repository.'],
|
|
35
30
|
];
|
|
31
|
+
actions.push({
|
|
32
|
+
kind: 'write',
|
|
33
|
+
target: 'AGENTS.md',
|
|
34
|
+
content: `${baseline.trimEnd()}\n\n@RTK.md\n`,
|
|
35
|
+
reason: 'baseline instructions (Codex reads AGENTS.md natively)',
|
|
36
|
+
}, {
|
|
37
|
+
kind: 'write',
|
|
38
|
+
target: '.codex/config.toml',
|
|
39
|
+
content: `# Yoke project configuration. Codex loads this in trusted repositories.\n\n[features]\nhooks = true\n\n${tomlMcp(codeGraph)}`,
|
|
40
|
+
reason: 'MCP servers (code-graph + playwright)',
|
|
41
|
+
}, {
|
|
42
|
+
kind: 'write',
|
|
43
|
+
target: '.codex/hooks.json',
|
|
44
|
+
merge: true,
|
|
45
|
+
content: JSON.stringify({
|
|
46
|
+
description: 'Yoke command compression for Codex',
|
|
47
|
+
hooks: {
|
|
48
|
+
PreToolUse: [{
|
|
49
|
+
matcher: '^Bash$',
|
|
50
|
+
hooks: [{
|
|
51
|
+
type: 'command',
|
|
52
|
+
command: 'node "$(git rev-parse --show-toplevel)/.codex/hooks/rtk.mjs"',
|
|
53
|
+
commandWindows: 'powershell -NoProfile -ExecutionPolicy Bypass -Command "$root = git rev-parse --show-toplevel; node (Join-Path $root \'.codex/hooks/rtk.mjs\')"',
|
|
54
|
+
timeout: 5,
|
|
55
|
+
statusMessage: 'Compressing command output with RTK',
|
|
56
|
+
}],
|
|
57
|
+
}],
|
|
58
|
+
},
|
|
59
|
+
}, null, 2) + '\n',
|
|
60
|
+
reason: 'rtk PreToolUse hook adapter',
|
|
61
|
+
}, {
|
|
62
|
+
kind: 'write',
|
|
63
|
+
target: '.codex/hooks/rtk.mjs',
|
|
64
|
+
content: readFileSync(join(canonDir, 'tools', 'codex-rtk-hook.mjs'), 'utf8'),
|
|
65
|
+
reason: 'rtk Codex hook adapter',
|
|
66
|
+
}, {
|
|
67
|
+
kind: 'write',
|
|
68
|
+
target: 'RTK.md',
|
|
69
|
+
content: rtkInstruction() + '\n',
|
|
70
|
+
reason: 'rtk instruction (Codex has no rewrite hook)',
|
|
71
|
+
});
|
|
72
|
+
for (const [name, description, sandbox, instructions] of roles) {
|
|
73
|
+
actions.push({
|
|
74
|
+
kind: 'write',
|
|
75
|
+
target: `.codex/agents/${name}.toml`,
|
|
76
|
+
content: `name = "${name}"\ndescription = "${description}"\nsandbox_mode = "${sandbox}"\ndeveloper_instructions = """\n${instructions}\n"""\n`,
|
|
77
|
+
reason: `Codex role agent: ${name}`,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
return actions;
|
|
36
81
|
}
|
package/dist/review/command.js
CHANGED
|
@@ -1,43 +1,83 @@
|
|
|
1
1
|
import { agentInvocation, buildStandaloneReviewPrompt, buildWatchdogInvocation, runAgent, isAgentAvailable, } from '../loop/runner.js';
|
|
2
2
|
import { resolveIdleMs } from '../loop/run-command.js';
|
|
3
|
+
import { existsSync, mkdirSync, rmSync, rmdirSync } from 'node:fs';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { loadConfig } from '../retrofit/config.js';
|
|
6
|
+
import { readReviewVerdict, reviewVerdictPath } from './verdict.js';
|
|
3
7
|
// Resolve to the first available agent, preferring a *second* model so the review
|
|
4
8
|
// is genuinely cross-model. claude last => a Claude-only box degrades to self-review.
|
|
5
9
|
const RESOLUTION_ORDER = ['codex', 'gemini', 'claude'];
|
|
6
10
|
export function runReview(targetDir, opts = {}) {
|
|
7
11
|
const available = opts.isAvailable ?? isAgentAvailable;
|
|
12
|
+
const implementer = opts.implementer ?? loadConfig(targetDir)?.agents[0] ?? 'claude';
|
|
8
13
|
let reviewer = opts.reviewer;
|
|
9
14
|
if (reviewer) {
|
|
10
15
|
if (!available(reviewer)) {
|
|
11
16
|
console.error(`Reviewer agent CLI "${reviewer}" was not found on PATH. Install it, or pick another with --reviewer=<claude|codex|gemini>.`);
|
|
12
17
|
return 2;
|
|
13
18
|
}
|
|
19
|
+
if (reviewer === implementer && !opts.allowSelfReview) {
|
|
20
|
+
console.error(`Reviewer "${reviewer}" is also the implementer. Pick another agent or pass --allow-self-review explicitly.`);
|
|
21
|
+
return 2;
|
|
22
|
+
}
|
|
14
23
|
}
|
|
15
24
|
else {
|
|
16
|
-
reviewer = RESOLUTION_ORDER.find(a => available(a));
|
|
25
|
+
reviewer = RESOLUTION_ORDER.find(a => a !== implementer && available(a));
|
|
26
|
+
if (!reviewer && opts.allowSelfReview && available(implementer))
|
|
27
|
+
reviewer = implementer;
|
|
17
28
|
if (!reviewer) {
|
|
18
|
-
console.error('No
|
|
29
|
+
console.error('No independent reviewer CLI is available. Install a second agent, select one with --reviewer, or pass --allow-self-review explicitly.');
|
|
19
30
|
return 2;
|
|
20
31
|
}
|
|
21
|
-
if (reviewer === 'claude') {
|
|
22
|
-
console.log('Note: only Claude is available — this is a self-review, not cross-model.');
|
|
23
|
-
}
|
|
24
32
|
}
|
|
25
33
|
const scope = opts.base
|
|
26
34
|
? `the diff ${opts.base}..HEAD`
|
|
27
35
|
: 'the uncommitted working-tree changes (working tree + staged)';
|
|
28
|
-
const
|
|
36
|
+
const verdictPath = reviewVerdictPath(targetDir);
|
|
37
|
+
const yokeDir = join(targetDir, '.yoke');
|
|
38
|
+
const createdYokeDir = !existsSync(yokeDir);
|
|
39
|
+
mkdirSync(yokeDir, { recursive: true });
|
|
40
|
+
rmSync(verdictPath, { force: true });
|
|
41
|
+
const prompt = buildStandaloneReviewPrompt(scope, opts.focus, verdictPath);
|
|
29
42
|
const idleMs = resolveIdleMs(opts.timeoutMinutes, undefined);
|
|
30
43
|
// Pass the *agent* invocation to the runner so callers (and tests) see the
|
|
31
44
|
// reviewer command. The default runner adds the watchdog wrapper before exec;
|
|
32
45
|
// an injected run() gets the raw invocation.
|
|
33
|
-
const inv = agentInvocation(reviewer, prompt, targetDir);
|
|
34
|
-
console.
|
|
46
|
+
const inv = agentInvocation(reviewer, prompt, targetDir, 'safe');
|
|
47
|
+
const say = opts.json ? console.error : console.log;
|
|
48
|
+
say(`Reviewing ${scope} with ${reviewer}...`);
|
|
35
49
|
const run = opts.run ?? ((i) => runAgent(buildWatchdogInvocation(i, idleMs)));
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
50
|
+
const processResult = run(inv);
|
|
51
|
+
let verdict;
|
|
52
|
+
try {
|
|
53
|
+
verdict = readReviewVerdict(verdictPath);
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
say(`✗ ${reviewer} produced no valid verdict (${error.message})${processResult.success ? '' : `; process: ${processResult.summary}`}`);
|
|
57
|
+
if (createdYokeDir) {
|
|
58
|
+
try {
|
|
59
|
+
rmdirSync(yokeDir);
|
|
60
|
+
}
|
|
61
|
+
catch { }
|
|
62
|
+
}
|
|
63
|
+
return 1;
|
|
64
|
+
}
|
|
65
|
+
if (createdYokeDir) {
|
|
66
|
+
try {
|
|
67
|
+
rmdirSync(yokeDir);
|
|
68
|
+
}
|
|
69
|
+
catch { }
|
|
70
|
+
}
|
|
71
|
+
if (opts.json)
|
|
72
|
+
console.log(JSON.stringify({ reviewer, process: processResult, verdict }));
|
|
73
|
+
if (!processResult.success) {
|
|
74
|
+
say(`✗ ${reviewer} process failed (${processResult.summary}); verdict: ${verdict.summary}`);
|
|
75
|
+
return 1;
|
|
76
|
+
}
|
|
77
|
+
if (verdict.approved) {
|
|
78
|
+
say(`✓ ${reviewer} approved: ${verdict.summary}`);
|
|
39
79
|
return 0;
|
|
40
80
|
}
|
|
41
|
-
|
|
81
|
+
say(`✗ ${reviewer} rejected: ${verdict.summary}`);
|
|
42
82
|
return 1;
|
|
43
83
|
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { existsSync, readFileSync, rmSync } from 'node:fs';
|
|
2
|
+
import { join, resolve } from 'node:path';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
export const ReviewFindingSchema = z.object({
|
|
5
|
+
severity: z.enum(['blocking', 'warning', 'info']),
|
|
6
|
+
message: z.string().min(1),
|
|
7
|
+
file: z.string().min(1).optional(),
|
|
8
|
+
line: z.number().int().positive().optional(),
|
|
9
|
+
});
|
|
10
|
+
export const ReviewVerdictSchema = z.object({
|
|
11
|
+
approved: z.boolean(),
|
|
12
|
+
summary: z.string().min(1),
|
|
13
|
+
findings: z.array(ReviewFindingSchema),
|
|
14
|
+
});
|
|
15
|
+
export function reviewVerdictPath(targetDir) {
|
|
16
|
+
return resolve(join(targetDir, '.yoke', 'review-verdict.json'));
|
|
17
|
+
}
|
|
18
|
+
export function readReviewVerdict(path) {
|
|
19
|
+
if (!existsSync(path))
|
|
20
|
+
throw new Error(`Review verdict is missing: ${path}`);
|
|
21
|
+
try {
|
|
22
|
+
let value;
|
|
23
|
+
try {
|
|
24
|
+
value = JSON.parse(readFileSync(path, 'utf8'));
|
|
25
|
+
}
|
|
26
|
+
catch (error) {
|
|
27
|
+
throw new Error(`Review verdict is malformed JSON: ${error.message}`);
|
|
28
|
+
}
|
|
29
|
+
const result = ReviewVerdictSchema.safeParse(value);
|
|
30
|
+
if (!result.success)
|
|
31
|
+
throw new Error(`Review verdict is invalid: ${result.error.message}`);
|
|
32
|
+
return result.data;
|
|
33
|
+
}
|
|
34
|
+
finally {
|
|
35
|
+
rmSync(path, { force: true });
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
export function formatReviewContract(path) {
|
|
39
|
+
return [
|
|
40
|
+
`Write your final verdict to this absolute path: ${path}`,
|
|
41
|
+
'The file must contain exactly one JSON object with this contract:',
|
|
42
|
+
'{"approved":boolean,"summary":"non-empty string","findings":[{"severity":"blocking|warning|info","message":"non-empty string","file":"optional path","line":1}]}',
|
|
43
|
+
'Set approved=false when any blocking finding exists. Create the file even when the process also exits non-zero.',
|
|
44
|
+
].join('\n');
|
|
45
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Migrating to Yoke 1.0
|
|
2
|
+
|
|
3
|
+
Yoke 1.0 changes unsafe implicit behavior into explicit policy.
|
|
4
|
+
|
|
5
|
+
## Runner permissions
|
|
6
|
+
|
|
7
|
+
The default is `runner.permissions: safe`. Automation that intentionally requires a full
|
|
8
|
+
sandbox bypass must pass `--unsafe` or configure `runner.permissions: unsafe`. Use
|
|
9
|
+
`read-only` for planning and probing.
|
|
10
|
+
|
|
11
|
+
## Reviews
|
|
12
|
+
|
|
13
|
+
Reviewers write `.yoke/review-verdict.json`; Yoke validates and consumes it. The reviewer must
|
|
14
|
+
differ from the implementer unless `--allow-self-review` is explicit. CI can add `--json`.
|
|
15
|
+
|
|
16
|
+
## Commit ownership
|
|
17
|
+
|
|
18
|
+
Yoke resolves identity before implementation. Configure it when Git has no identity:
|
|
19
|
+
|
|
20
|
+
```yaml
|
|
21
|
+
commit:
|
|
22
|
+
authorName: HECer
|
|
23
|
+
authorEmail: hec_er@web.de
|
|
24
|
+
allowCoAuthors: false
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## PRDs, audit, and cleanup
|
|
28
|
+
|
|
29
|
+
Existing PRDs remain valid. Optional `needs`, `area`, and `agent` fields add dependencies,
|
|
30
|
+
collision domains, and affinity. Enable the story audit gate with `audit.enabled: true` and
|
|
31
|
+
version suppressions with `suppressionsVersion: 1`.
|
|
32
|
+
|
|
33
|
+
`yoke loop cleanup` now reports retained worktrees. Add `--remove-worktrees` for deletion.
|