@hecer/yoke 1.2.1 → 1.3.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 +44 -24
- package/README.md +126 -96
- package/canon/loop/loop-spec.md +47 -32
- package/canon/loop/prd.schema.md +30 -6
- package/canon/manifest.yaml +1 -1
- package/canon/skills/authoring-prd/SKILL.md +30 -31
- package/dist/change/inbox.js +279 -0
- package/dist/cli.js +35 -1
- package/dist/loop/evidence.js +31 -0
- package/dist/loop/gates.js +10 -1
- package/dist/loop/loop.js +127 -17
- package/dist/loop/prd.js +60 -2
- package/dist/loop/run-command.js +23 -2
- package/dist/loop/runner.js +10 -2
- package/dist/loop/verify.js +11 -0
- package/dist/prd/command.js +18 -5
- package/dist/retrofit/config.js +9 -2
- package/dist/retrofit/gitignore.js +1 -0
- package/dist/routing/router.js +4 -1
- package/gemini-extension.json +1 -1
- package/package.json +6 -6
package/dist/loop/loop.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { existsSync, unlinkSync, readFileSync } from 'node:fs';
|
|
2
2
|
import { join, relative } from 'node:path';
|
|
3
|
-
import { loadPrd, savePrd, selectNextStory, allPass, progress } from './prd.js';
|
|
3
|
+
import { isAcceptanceCriterion, loadPrd, savePrd, selectNextStory, allPass, progress, storyPathSegment } from './prd.js';
|
|
4
4
|
import { stopTheLineGate, preDispatchGate } from './gates.js';
|
|
5
5
|
import { appendDecision, contextDir } from '../context/context.js';
|
|
6
6
|
import { noopReporter } from './reporter.js';
|
|
7
7
|
import { consumeDecisionRequest } from './decision.js';
|
|
8
|
+
import { writeCriterionEvidence } from './evidence.js';
|
|
8
9
|
function blockReason(base, targetDir, git) {
|
|
9
10
|
let dirty = false;
|
|
10
11
|
try {
|
|
@@ -58,26 +59,72 @@ function consumeAmbiguity(dir) {
|
|
|
58
59
|
const compact = content.replace(/\s+/g, ' ').trim().slice(0, 500);
|
|
59
60
|
return compact || 'agent reported ambiguous acceptance criteria without details';
|
|
60
61
|
}
|
|
62
|
+
function runCompletionGate(opts, stories) {
|
|
63
|
+
if (!opts.completion)
|
|
64
|
+
return null;
|
|
65
|
+
const previous = process.env.YOKE_PHASE;
|
|
66
|
+
process.env.YOKE_PHASE = 'completion';
|
|
67
|
+
let verdict;
|
|
68
|
+
try {
|
|
69
|
+
verdict = opts.completion(opts.targetDir);
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
const reason = `integrated completion gate failed: ${error.message}`;
|
|
73
|
+
(opts.reporter ?? noopReporter).blocked(reason);
|
|
74
|
+
return { status: 'blocked', iterations: 0, reason, finalProgress: progress(stories) };
|
|
75
|
+
}
|
|
76
|
+
finally {
|
|
77
|
+
if (previous === undefined)
|
|
78
|
+
delete process.env.YOKE_PHASE;
|
|
79
|
+
else
|
|
80
|
+
process.env.YOKE_PHASE = previous;
|
|
81
|
+
}
|
|
82
|
+
if (verdict.passed)
|
|
83
|
+
return null;
|
|
84
|
+
const reason = `integrated system did not verify: ${verdict.summary}`;
|
|
85
|
+
(opts.reporter ?? noopReporter).blocked(reason);
|
|
86
|
+
return { status: 'blocked', iterations: 0, reason, finalProgress: progress(stories) };
|
|
87
|
+
}
|
|
88
|
+
function runCriterionGates(opts, executionDir, story) {
|
|
89
|
+
const criteria = story.acceptance.filter(isAcceptanceCriterion);
|
|
90
|
+
if (criteria.length === 0) {
|
|
91
|
+
return opts.requireCriterionEvidence
|
|
92
|
+
? { passed: false, summary: `story ${story.id} lacks executable criterion evidence` }
|
|
93
|
+
: { passed: true, summary: 'legacy acceptance criteria' };
|
|
94
|
+
}
|
|
95
|
+
if (!opts.verifyCriterion)
|
|
96
|
+
return { passed: false, summary: `story ${story.id} has criteria but no criterion verifier` };
|
|
97
|
+
const evidence = criteria.map(criterion => ({
|
|
98
|
+
criterion,
|
|
99
|
+
result: opts.verifyCriterion(executionDir, story, criterion),
|
|
100
|
+
}));
|
|
101
|
+
try {
|
|
102
|
+
writeCriterionEvidence(opts.targetDir, story, evidence);
|
|
103
|
+
}
|
|
104
|
+
catch (error) {
|
|
105
|
+
return { passed: false, summary: `could not persist criterion evidence: ${error.message}` };
|
|
106
|
+
}
|
|
107
|
+
const failed = evidence.find(item => !item.result.passed);
|
|
108
|
+
return failed
|
|
109
|
+
? { passed: false, summary: `${failed.criterion.id}: ${failed.result.summary}` }
|
|
110
|
+
: { passed: true, summary: `${evidence.length} acceptance criteria verified` };
|
|
111
|
+
}
|
|
61
112
|
export function runLoop(opts) {
|
|
62
113
|
let iterations = 0;
|
|
63
114
|
const reporter = opts.reporter ?? noopReporter;
|
|
64
|
-
const initial = loadPrd(opts.prdPath);
|
|
65
|
-
if (initial.length === 0) {
|
|
66
|
-
reporter.blocked('PRD has no stories');
|
|
67
|
-
return { status: 'blocked', iterations: 0, reason: 'PRD has no stories', finalProgress: { passed: 0, total: 0 } };
|
|
68
|
-
}
|
|
69
115
|
for (;;) {
|
|
70
|
-
|
|
71
|
-
if (
|
|
72
|
-
reporter.
|
|
73
|
-
return { status: '
|
|
116
|
+
let stories = loadPrd(opts.prdPath);
|
|
117
|
+
if (stories.length === 0) {
|
|
118
|
+
reporter.blocked('PRD has no stories');
|
|
119
|
+
return { status: 'blocked', iterations, reason: 'PRD has no stories', finalProgress: { passed: 0, total: 0 } };
|
|
74
120
|
}
|
|
75
|
-
|
|
121
|
+
const capReached = iterations >= opts.maxIterations;
|
|
122
|
+
if (capReached && !allPass(stories)) {
|
|
76
123
|
reporter.capReached(progress(stories));
|
|
77
124
|
return { status: 'cap-reached', iterations, finalProgress: progress(stories) };
|
|
78
125
|
}
|
|
79
|
-
//
|
|
80
|
-
//
|
|
126
|
+
// Intake is work at a story boundary. Cap, pause, and clean-tree safety must
|
|
127
|
+
// win before a planner is allowed to edit and commit the PRD.
|
|
81
128
|
const pauseFile = pauseFilePath(opts.targetDir);
|
|
82
129
|
if (existsSync(pauseFile)) {
|
|
83
130
|
try {
|
|
@@ -89,22 +136,71 @@ export function runLoop(opts) {
|
|
|
89
136
|
}
|
|
90
137
|
const pre = preDispatchGate(opts.targetDir, opts.git);
|
|
91
138
|
if (!pre.ok) {
|
|
92
|
-
|
|
93
|
-
|
|
139
|
+
const reason = opts.intake ? `change intake blocked by dirty worktree: ${pre.reason ?? 'pre-dispatch gate failed'}` : pre.reason;
|
|
140
|
+
reporter.blocked(reason ?? 'pre-dispatch gate failed');
|
|
141
|
+
return { status: 'blocked', iterations, reason, finalProgress: progress(stories) };
|
|
142
|
+
}
|
|
143
|
+
// A bounded run that finished its final story may report completion, but it
|
|
144
|
+
// must not plan additional queued work after the requested cap.
|
|
145
|
+
if (capReached) {
|
|
146
|
+
const completionFailure = runCompletionGate(opts, stories);
|
|
147
|
+
if (completionFailure)
|
|
148
|
+
return { ...completionFailure, iterations };
|
|
149
|
+
reporter.complete(progress(stories));
|
|
150
|
+
return { status: 'complete', iterations, finalProgress: progress(stories) };
|
|
151
|
+
}
|
|
152
|
+
if (opts.intake) {
|
|
153
|
+
let intake;
|
|
154
|
+
try {
|
|
155
|
+
intake = opts.intake();
|
|
156
|
+
}
|
|
157
|
+
catch (error) {
|
|
158
|
+
const stories = loadPrd(opts.prdPath);
|
|
159
|
+
const reason = `change intake failed: ${error.message}`;
|
|
160
|
+
reporter.blocked(reason);
|
|
161
|
+
return { status: 'blocked', iterations, reason, finalProgress: progress(stories) };
|
|
162
|
+
}
|
|
163
|
+
if (!intake.ok) {
|
|
164
|
+
const stories = loadPrd(opts.prdPath);
|
|
165
|
+
const reason = `change intake failed: ${intake.summary}`;
|
|
166
|
+
reporter.blocked(reason);
|
|
167
|
+
return { status: 'blocked', iterations, reason, finalProgress: progress(stories) };
|
|
168
|
+
}
|
|
169
|
+
if (intake.added > 0) {
|
|
170
|
+
const afterIntake = preDispatchGate(opts.targetDir, opts.git);
|
|
171
|
+
if (!afterIntake.ok) {
|
|
172
|
+
const stories = loadPrd(opts.prdPath);
|
|
173
|
+
const reason = `change intake left a dirty worktree: ${afterIntake.reason ?? 'pre-dispatch gate failed'}`;
|
|
174
|
+
reporter.blocked(reason);
|
|
175
|
+
return { status: 'blocked', iterations, reason, finalProgress: progress(stories) };
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
stories = loadPrd(opts.prdPath);
|
|
180
|
+
if (stories.length === 0) {
|
|
181
|
+
reporter.blocked('PRD has no stories');
|
|
182
|
+
return { status: 'blocked', iterations, reason: 'PRD has no stories', finalProgress: { passed: 0, total: 0 } };
|
|
183
|
+
}
|
|
184
|
+
if (allPass(stories)) {
|
|
185
|
+
const completionFailure = runCompletionGate(opts, stories);
|
|
186
|
+
if (completionFailure)
|
|
187
|
+
return { ...completionFailure, iterations };
|
|
188
|
+
reporter.complete(progress(stories));
|
|
189
|
+
return { status: 'complete', iterations, finalProgress: progress(stories) };
|
|
94
190
|
}
|
|
95
191
|
const story = selectNextStory(stories);
|
|
96
192
|
if (!story) {
|
|
97
193
|
reporter.complete(progress(stories));
|
|
98
194
|
return { status: 'complete', iterations, finalProgress: progress(stories) };
|
|
99
195
|
}
|
|
100
|
-
const stl = stopTheLineGate(story);
|
|
196
|
+
const stl = stopTheLineGate(story, opts.requireCriterionEvidence);
|
|
101
197
|
if (!stl.ok) {
|
|
102
198
|
reporter.blocked(stl.reason ?? 'stop-the-line gate failed');
|
|
103
199
|
return { status: 'blocked', iterations, reason: stl.reason, finalProgress: progress(stories) };
|
|
104
200
|
}
|
|
105
201
|
reporter.storyStart({ id: story.id, title: story.title }, iterations + 1, progress(stories));
|
|
106
202
|
if (opts.isolate) {
|
|
107
|
-
const wt = join(opts.targetDir, '.yoke', 'worktrees', story.id);
|
|
203
|
+
const wt = join(opts.targetDir, '.yoke', 'worktrees', storyPathSegment(story.id));
|
|
108
204
|
const wtPrd = join(wt, relative(opts.targetDir, opts.prdPath));
|
|
109
205
|
let landed = null;
|
|
110
206
|
try {
|
|
@@ -133,6 +229,13 @@ export function runLoop(opts) {
|
|
|
133
229
|
reporter.blocked(reason);
|
|
134
230
|
return { status: 'blocked', iterations, reason, finalProgress: progress(stories) };
|
|
135
231
|
}
|
|
232
|
+
const criteriaVerdict = runCriterionGates(opts, wt, story);
|
|
233
|
+
if (!criteriaVerdict.passed) {
|
|
234
|
+
result.routing?.recordOutcome(false);
|
|
235
|
+
const reason = blockReason(`story ${story.id} lacks acceptance evidence: ${criteriaVerdict.summary}`, opts.targetDir, opts.git);
|
|
236
|
+
reporter.blocked(reason);
|
|
237
|
+
return { status: 'blocked', iterations, reason, finalProgress: progress(stories) };
|
|
238
|
+
}
|
|
136
239
|
// Verify is the source of truth — NOT the runner's exit code. A spurious non-zero
|
|
137
240
|
// exit (e.g. a Windows .cmd wrapper ghost) must not block a story whose tests are green.
|
|
138
241
|
reporter.phase('verifying');
|
|
@@ -234,6 +337,13 @@ export function runLoop(opts) {
|
|
|
234
337
|
reporter.blocked(reason);
|
|
235
338
|
return { status: 'blocked', iterations, reason, finalProgress: progress(stories) };
|
|
236
339
|
}
|
|
340
|
+
const criteriaVerdict = runCriterionGates(opts, opts.targetDir, story);
|
|
341
|
+
if (!criteriaVerdict.passed) {
|
|
342
|
+
result.routing?.recordOutcome(false);
|
|
343
|
+
const reason = blockReason(`story ${story.id} lacks acceptance evidence: ${criteriaVerdict.summary}`, opts.targetDir, opts.git);
|
|
344
|
+
reporter.blocked(reason);
|
|
345
|
+
return { status: 'blocked', iterations, reason, finalProgress: progress(stories) };
|
|
346
|
+
}
|
|
237
347
|
// Verify is the source of truth — NOT the runner's exit code. A spurious non-zero
|
|
238
348
|
// exit (e.g. a Windows .cmd wrapper ghost) must not block a story whose tests are green.
|
|
239
349
|
reporter.phase('verifying');
|
package/dist/loop/prd.js
CHANGED
|
@@ -1,19 +1,77 @@
|
|
|
1
1
|
import { readFileSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { parse, stringify } from 'yaml';
|
|
3
3
|
import { z } from 'zod';
|
|
4
|
+
export const AcceptanceCriterionSchema = z.object({
|
|
5
|
+
id: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/),
|
|
6
|
+
text: z.string().min(1),
|
|
7
|
+
verify: z.array(z.string().min(1)).min(1),
|
|
8
|
+
});
|
|
9
|
+
export function criterionCommandProblem(criterion) {
|
|
10
|
+
const normalizedId = criterion.id.toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
11
|
+
for (const raw of criterion.verify) {
|
|
12
|
+
const command = raw.trim();
|
|
13
|
+
if (/(?:[&;|<>`\r\n]|\$\()/.test(command)) {
|
|
14
|
+
return `criterion ${criterion.id} must use one single test command without shell control operators`;
|
|
15
|
+
}
|
|
16
|
+
const isTestCommand = /^(?:(?:npm|pnpm|yarn|bun)(?:\s+run)?\s+(?:test|check|verify)(?::[A-Za-z0-9._-]+)?|npx\s+(?:vitest|jest|playwright|cypress)|(?:vitest|jest|pytest|phpunit)|python\s+-m\s+pytest|cargo\s+test|go\s+test|dotnet\s+test|mvn(?:w)?\s+test|gradle(?:w)?\s+test|bundle\s+exec\s+(?:rspec|rails\s+test)|bin\/rails\s+test|vendor\/bin\/phpunit|mix\s+test|swift\s+test|xcodebuild\s+test)(?:\s|$)/i.test(command);
|
|
17
|
+
if (!isTestCommand)
|
|
18
|
+
return `criterion ${criterion.id} must use an approved test command`;
|
|
19
|
+
const normalizedCommand = command.toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
20
|
+
if (!normalizedCommand.includes(normalizedId)) {
|
|
21
|
+
return `criterion ${criterion.id} must target its criterion id in every verify command`;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
4
26
|
export const StorySchema = z.object({
|
|
27
|
+
// Existing projects may already use human-readable IDs. Keep loading them;
|
|
28
|
+
// every filesystem use goes through storyPathSegment instead.
|
|
5
29
|
id: z.string().min(1),
|
|
6
30
|
title: z.string().min(1),
|
|
7
31
|
priority: z.number(),
|
|
8
|
-
|
|
32
|
+
// String criteria remain readable for existing projects. New strict projects
|
|
33
|
+
// use structured criteria so Yoke can execute proof for each outcome instead
|
|
34
|
+
// of trusting an unrelated green test suite.
|
|
35
|
+
acceptance: z.array(z.union([z.string().min(1), AcceptanceCriterionSchema])),
|
|
9
36
|
passes: z.boolean(),
|
|
10
37
|
needs: z.array(z.string().min(1)).optional(),
|
|
11
38
|
area: z.string().min(1).optional(),
|
|
12
39
|
agent: z.enum(['claude', 'codex', 'gemini']).optional(),
|
|
40
|
+
/** Inbox request that created this story. Used for idempotent append-only intake. */
|
|
41
|
+
sourceChange: z.string().min(1).optional(),
|
|
42
|
+
}).superRefine((story, ctx) => {
|
|
43
|
+
const structured = story.acceptance.filter(isAcceptanceCriterion);
|
|
44
|
+
const ids = structured.map(criterion => criterion.id);
|
|
45
|
+
if (structured.length > 0 && (structured.length < 2 || structured.length > 5)) {
|
|
46
|
+
ctx.addIssue({
|
|
47
|
+
code: 'custom',
|
|
48
|
+
path: ['acceptance'],
|
|
49
|
+
message: 'structured stories must have 2-5 structured acceptance criteria',
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
if (new Set(ids).size !== ids.length) {
|
|
53
|
+
ctx.addIssue({
|
|
54
|
+
code: 'custom',
|
|
55
|
+
path: ['acceptance'],
|
|
56
|
+
message: 'structured criterion ids must be unique within a story',
|
|
57
|
+
});
|
|
58
|
+
}
|
|
13
59
|
});
|
|
60
|
+
export function storyPathSegment(id) {
|
|
61
|
+
return `story-${Buffer.from(id, 'utf8').toString('base64url')}`;
|
|
62
|
+
}
|
|
63
|
+
export function isAcceptanceCriterion(value) {
|
|
64
|
+
return typeof value !== 'string';
|
|
65
|
+
}
|
|
66
|
+
export function acceptanceText(value) {
|
|
67
|
+
return typeof value === 'string' ? value : value.text;
|
|
68
|
+
}
|
|
14
69
|
const PrdSchema = z.array(StorySchema);
|
|
70
|
+
export function parsePrd(file) {
|
|
71
|
+
return PrdSchema.parse(parse(readFileSync(file, 'utf8')));
|
|
72
|
+
}
|
|
15
73
|
export function loadPrd(file) {
|
|
16
|
-
const stories =
|
|
74
|
+
const stories = parsePrd(file);
|
|
17
75
|
const issues = validateDependencies(stories);
|
|
18
76
|
if (issues.length)
|
|
19
77
|
throw new Error(`Invalid PRD dependency graph:\n${issues.join('\n')}`);
|
package/dist/loop/run-command.js
CHANGED
|
@@ -3,9 +3,9 @@ import { existsSync } from 'node:fs';
|
|
|
3
3
|
import { loadConfig, saveConfig, defaultConfig, resolveVerifyCommand } from '../retrofit/config.js';
|
|
4
4
|
import { loadPrd, progress } from './prd.js';
|
|
5
5
|
import { runLoop } from './loop.js';
|
|
6
|
-
import { realGitOps } from './git.js';
|
|
6
|
+
import { commitPaths, realGitOps } from './git.js';
|
|
7
7
|
import { makeRunner, makeReviewRunner, isAgentAvailable } from './runner.js';
|
|
8
|
-
import { commandVerifier, retryingVerifier } from './verify.js';
|
|
8
|
+
import { commandVerifier, commandsVerifier, retryingVerifier } from './verify.js';
|
|
9
9
|
import { readStatus, makeReporter, fmtDuration } from './reporter.js';
|
|
10
10
|
import { acquireLock, releaseLock } from './lock.js';
|
|
11
11
|
import { maybeAutoUpgrade } from '../update/upgrade.js';
|
|
@@ -14,6 +14,7 @@ import { runAudit } from '../audit/command.js';
|
|
|
14
14
|
import { detectHostAgent, resolveRunnerAgent } from '../agents/host.js';
|
|
15
15
|
import { clearDecisionResume, decisionProcessingExists, decisionRequestId, formatPendingDecision, readPendingDecision, writeDecisionResume, } from './decision.js';
|
|
16
16
|
import { makeAdaptiveRunner } from '../routing/router.js';
|
|
17
|
+
import { runChangeApply } from '../change/inbox.js';
|
|
17
18
|
export const DEFAULT_IDLE_MINUTES = 20;
|
|
18
19
|
const STALE_MINUTES = 20; // a running status older than this likely means the loop died
|
|
19
20
|
export function relativeTime(fromIso, now) {
|
|
@@ -114,6 +115,9 @@ export function runLoopCommand(targetDir, opts) {
|
|
|
114
115
|
if (!perf && config.perf?.command) {
|
|
115
116
|
perf = retryingVerifier(commandVerifier(config.perf.command), config.perf.retries ?? 1);
|
|
116
117
|
}
|
|
118
|
+
const completion = config.completion?.command
|
|
119
|
+
? retryingVerifier(commandVerifier(config.completion.command), config.completion.retries ?? 1)
|
|
120
|
+
: undefined;
|
|
117
121
|
// Opt-in self-update, loop START only — this run keeps executing the version
|
|
118
122
|
// it started with; a fetched upgrade applies from the next invocation.
|
|
119
123
|
maybeAutoUpgrade(config.update?.auto);
|
|
@@ -148,6 +152,19 @@ export function runLoopCommand(targetDir, opts) {
|
|
|
148
152
|
console.error('Adaptive routing was requested, but no worker profiles are configured. Run yoke setup . --routing or add routing.workers to .yoke/config.yaml.');
|
|
149
153
|
return 2;
|
|
150
154
|
}
|
|
155
|
+
const intake = opts.intake ?? (() => runChangeApply(targetDir, {
|
|
156
|
+
runner: runnerAgent,
|
|
157
|
+
reviewer: opts.reviewer ?? runnerAgent,
|
|
158
|
+
timeoutMs: idleMs,
|
|
159
|
+
isAvailable: available,
|
|
160
|
+
permissions,
|
|
161
|
+
selection: {
|
|
162
|
+
model: config.runner?.model,
|
|
163
|
+
reasoningEffort: config.runner?.reasoningEffort,
|
|
164
|
+
bare: config.runner?.bare,
|
|
165
|
+
},
|
|
166
|
+
commit: (_path, request) => commitPaths(targetDir, ['.yoke/prd.yaml'], `yoke: plan change ${request.id}`, commitIdentity),
|
|
167
|
+
}));
|
|
151
168
|
let runner = opts.runner;
|
|
152
169
|
if (!runner) {
|
|
153
170
|
if (!available(runnerAgent)) {
|
|
@@ -230,6 +247,10 @@ export function runLoopCommand(targetDir, opts) {
|
|
|
230
247
|
git,
|
|
231
248
|
commitIdentity,
|
|
232
249
|
verify,
|
|
250
|
+
verifyCriterion: (dir, _story, criterion) => commandsVerifier(criterion.verify)(dir),
|
|
251
|
+
requireCriterionEvidence: config.verify?.requireCriteria ?? false,
|
|
252
|
+
completion,
|
|
253
|
+
intake,
|
|
233
254
|
perf,
|
|
234
255
|
audit,
|
|
235
256
|
maxIterations,
|
package/dist/loop/runner.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isAcceptanceCriterion } from './prd.js';
|
|
1
2
|
import { execFileSync, execSync } from 'node:child_process';
|
|
2
3
|
import { existsSync, mkdirSync, rmSync } from 'node:fs';
|
|
3
4
|
import { join } from 'node:path';
|
|
@@ -9,8 +10,15 @@ import { formatReviewContract, readReviewVerdict, reviewVerdictPath } from '../r
|
|
|
9
10
|
export function contextBlockFor(targetDir) {
|
|
10
11
|
return formatForPrompt(loadContext(contextDir(targetDir)));
|
|
11
12
|
}
|
|
13
|
+
function formatAcceptance(story) {
|
|
14
|
+
return story.acceptance.map(criterion => {
|
|
15
|
+
if (!isAcceptanceCriterion(criterion))
|
|
16
|
+
return `- ${criterion}`;
|
|
17
|
+
return `- [${criterion.id}] ${criterion.text}\n Proof: ${criterion.verify.join(' && ')}`;
|
|
18
|
+
}).join('\n');
|
|
19
|
+
}
|
|
12
20
|
export function buildClaudePrompt(story, context, onAmbiguity = 'resolve', perfCommand) {
|
|
13
|
-
const criteria = story
|
|
21
|
+
const criteria = formatAcceptance(story);
|
|
14
22
|
const lines = [
|
|
15
23
|
'You are an autonomous coding agent running inside the Yoke loop.',
|
|
16
24
|
'Implement ONLY this story and nothing else. Follow test-driven development.',
|
|
@@ -33,7 +41,7 @@ export function buildClaudePrompt(story, context, onAmbiguity = 'resolve', perfC
|
|
|
33
41
|
return lines.join('\n');
|
|
34
42
|
}
|
|
35
43
|
export function buildReviewPrompt(story, context, verdictPath) {
|
|
36
|
-
const criteria = story
|
|
44
|
+
const criteria = formatAcceptance(story);
|
|
37
45
|
const lines = [
|
|
38
46
|
'You are an independent reviewer inside the Yoke loop. You did NOT implement this change.',
|
|
39
47
|
'Review the current uncommitted working-tree changes against the story below.',
|
package/dist/loop/verify.js
CHANGED
|
@@ -16,6 +16,17 @@ export function commandVerifier(command) {
|
|
|
16
16
|
}
|
|
17
17
|
};
|
|
18
18
|
}
|
|
19
|
+
/** Execute the proof commands attached to one acceptance criterion. */
|
|
20
|
+
export function commandsVerifier(commands) {
|
|
21
|
+
return (targetDir) => {
|
|
22
|
+
for (const command of commands) {
|
|
23
|
+
const result = commandVerifier(command)(targetDir);
|
|
24
|
+
if (!result.passed)
|
|
25
|
+
return result;
|
|
26
|
+
}
|
|
27
|
+
return { passed: true, summary: `${commands.length} criterion command${commands.length === 1 ? '' : 's'} passed` };
|
|
28
|
+
};
|
|
29
|
+
}
|
|
19
30
|
// Re-run a failing verifier up to `retries` times; the first pass wins. Lets a
|
|
20
31
|
// transient flake (e.g. a load-induced async timeout) self-heal while a real
|
|
21
32
|
// failure still fails (it stays red across every attempt).
|
package/dist/prd/command.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { loadConfig } from '../retrofit/config.js';
|
|
4
|
-
import { loadPrd, progress } from '../loop/prd.js';
|
|
4
|
+
import { acceptanceText, criterionCommandProblem, isAcceptanceCriterion, loadPrd, progress } from '../loop/prd.js';
|
|
5
5
|
import { agentInvocation, buildWatchdogInvocation, runAgent, isAgentAvailable, } from '../loop/runner.js';
|
|
6
6
|
import { resolveIdleMs } from '../loop/run-command.js';
|
|
7
7
|
import { detectHostAgent, resolveRunnerAgent } from '../agents/host.js';
|
|
@@ -14,8 +14,12 @@ export const PRD_TEMPLATE = `# Yoke PRD — the loop picks the lowest-priority o
|
|
|
14
14
|
# area: foundation # optional collision domain for parallel runs
|
|
15
15
|
# agent: codex # optional claude|codex|gemini affinity
|
|
16
16
|
# acceptance:
|
|
17
|
-
# -
|
|
18
|
-
#
|
|
17
|
+
# - id: suite-runs
|
|
18
|
+
# text: "the project test suite can run"
|
|
19
|
+
# verify: ["npm run test:suite-runs"]
|
|
20
|
+
# - id: scaffold-starts
|
|
21
|
+
# text: "the scaffolded application starts"
|
|
22
|
+
# verify: ["npm run test:scaffold-starts"]
|
|
19
23
|
# passes: false
|
|
20
24
|
[]
|
|
21
25
|
`;
|
|
@@ -30,7 +34,7 @@ export function buildPrdDraftPrompt(idea, planningBrief) {
|
|
|
30
34
|
if (planningBrief?.trim()) {
|
|
31
35
|
lines.push('', '## Approved planning brief (treat these decisions as settled)', planningBrief.trim(), '', 'Do not reopen settled choices or invent alternatives that contradict this brief.');
|
|
32
36
|
}
|
|
33
|
-
lines.push('', 'Break the idea into 5-12 small, independently shippable stories; each must fit one loop iteration.', 'Each story needs:', '- id: STORY-1, STORY-2, ... (unique)', '- title: one imperative sentence', '- priority: dense integers from 1 (lower = built first)', '- needs: optional list of story IDs that must pass first; the graph must be acyclic', '- area: optional collision domain for safe parallel scheduling', '- agent: optional claude|codex|gemini affinity', '- acceptance: 2-5 testable, behavioral criteria (observable outcomes, never implementation steps)', '- passes: false', '', 'If the project has no source code yet, STORY-1 must scaffold the project skeleton with a runnable', 'test suite, and its acceptance must include that the verify command (verify.command in', '.yoke/config.yaml) exits 0.', '', 'Write ONLY the file .yoke/prd.yaml as a YAML array of stories in exactly that shape.', 'Do not modify any other file. Do not commit.');
|
|
37
|
+
lines.push('', 'Break the idea into 5-12 small, independently shippable stories; each must fit one loop iteration.', 'Each story needs:', '- id: STORY-1, STORY-2, ... (unique)', '- title: one imperative sentence', '- priority: dense integers from 1 (lower = built first)', '- needs: optional list of story IDs that must pass first; the graph must be acyclic', '- area: optional collision domain for safe parallel scheduling', '- agent: optional claude|codex|gemini affinity', '- acceptance: 2-5 testable, behavioral criteria (observable outcomes, never implementation steps)', ' Each criterion is an object with a stable id, behavioral text, and verify: [one or more approved test commands].', ' Every criterion id must appear in every verify command; use one test command without shell control operators.', '- passes: false', '', 'If the project has no source code yet, STORY-1 must scaffold the project skeleton with a runnable', 'test suite, and its acceptance must include that the verify command (verify.command in', '.yoke/config.yaml) exits 0.', '', 'Write ONLY the file .yoke/prd.yaml as a YAML array of stories in exactly that shape.', 'Do not modify any other file. Do not commit.');
|
|
34
38
|
return lines.join('\n');
|
|
35
39
|
}
|
|
36
40
|
export function prdFile(targetDir) {
|
|
@@ -113,6 +117,7 @@ export function runPrdCheck(targetDir) {
|
|
|
113
117
|
return 1;
|
|
114
118
|
}
|
|
115
119
|
const errors = [];
|
|
120
|
+
const requireCriteria = loadConfig(targetDir)?.verify?.requireCriteria ?? false;
|
|
116
121
|
if (stories.length === 0)
|
|
117
122
|
errors.push('PRD has no stories');
|
|
118
123
|
const seen = new Set();
|
|
@@ -123,7 +128,15 @@ export function runPrdCheck(targetDir) {
|
|
|
123
128
|
// the schema allows [], but the loop's stop-the-line gate blocks it — fail fast here
|
|
124
129
|
if (s.acceptance.length === 0)
|
|
125
130
|
errors.push(`story ${s.id} has no acceptance criteria`);
|
|
126
|
-
if (s.acceptance.some(criterion =>
|
|
131
|
+
if (requireCriteria && s.acceptance.some(criterion => !isAcceptanceCriterion(criterion))) {
|
|
132
|
+
errors.push(`story ${s.id} lacks executable criterion evidence`);
|
|
133
|
+
}
|
|
134
|
+
for (const criterion of s.acceptance.filter(isAcceptanceCriterion)) {
|
|
135
|
+
const problem = criterionCommandProblem(criterion);
|
|
136
|
+
if (problem)
|
|
137
|
+
errors.push(problem);
|
|
138
|
+
}
|
|
139
|
+
if (s.acceptance.some(criterion => /\b(?:TBD|TODO|TO BE DECIDED|DECIDE LATER)\b|\?\?\?/i.test(acceptanceText(criterion)))) {
|
|
127
140
|
errors.push(`story ${s.id} has unresolved planning decisions in acceptance criteria`);
|
|
128
141
|
}
|
|
129
142
|
}
|
package/dist/retrofit/config.js
CHANGED
|
@@ -53,7 +53,14 @@ export const YokeConfigSchema = z.object({
|
|
|
53
53
|
suppressionsVersion: z.literal(1).optional(),
|
|
54
54
|
suppressions: z.array(z.object({ ruleId: z.string().min(1), file: z.string().min(1).optional(), reason: z.string(), expires: z.string().optional() })).optional(),
|
|
55
55
|
}).optional(),
|
|
56
|
-
verify: z.object({
|
|
56
|
+
verify: z.object({
|
|
57
|
+
command: z.string().min(1).optional(),
|
|
58
|
+
retries: z.number().int().nonnegative().optional(),
|
|
59
|
+
requireCriteria: z.boolean().optional(),
|
|
60
|
+
}).optional(),
|
|
61
|
+
// Runs only when no open stories remain. This proves the current integrated
|
|
62
|
+
// system, without introducing release objects or a persistent stale graph.
|
|
63
|
+
completion: z.object({ command: z.string().min(1), retries: z.number().int().nonnegative().optional() }).optional(),
|
|
57
64
|
// Optional performance budget gate: a benchmark command that must exit 0 for a
|
|
58
65
|
// story to land (runs after verify). Benchmarks are noisy → retried like verify.
|
|
59
66
|
perf: z.object({ command: z.string().min(1), retries: z.number().int().nonnegative().optional() }).optional(),
|
|
@@ -63,7 +70,7 @@ export const YokeConfigSchema = z.object({
|
|
|
63
70
|
update: z.object({ auto: z.boolean() }).optional(),
|
|
64
71
|
});
|
|
65
72
|
export function defaultConfig(canonVersion) {
|
|
66
|
-
return { canonVersion, agents: [], loop: { enabled: false } };
|
|
73
|
+
return { canonVersion, agents: [], loop: { enabled: false }, verify: { requireCriteria: true } };
|
|
67
74
|
}
|
|
68
75
|
export function configPath(targetDir) {
|
|
69
76
|
return join(targetDir, '.yoke', 'config.yaml');
|
|
@@ -19,6 +19,7 @@ export const YOKE_IGNORE_LINES = [
|
|
|
19
19
|
'.yoke/decision-*.yaml.*.tmp',
|
|
20
20
|
'.yoke/story-durations.json',
|
|
21
21
|
'.yoke/proof/',
|
|
22
|
+
'.yoke/changes/',
|
|
22
23
|
];
|
|
23
24
|
const HEADER = '# Yoke runtime artifacts (managed by yoke retrofit)';
|
|
24
25
|
// Idempotently ensure each Yoke runtime path is gitignored. Appends only the
|
package/dist/routing/router.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { buildWatchdogInvocation, makeRunner, runCapturedAgent, runnerInvocation, } from '../loop/runner.js';
|
|
2
|
+
import { isAcceptanceCriterion } from '../loop/prd.js';
|
|
2
3
|
import { historyForWorkers, projectHash, recordRoutingObservation, storyHash } from './registry.js';
|
|
3
4
|
const costRank = { low: 0, medium: 1, high: 2 };
|
|
4
5
|
export function rankWorkers(workers, strategy, maxCandidates) {
|
|
@@ -40,7 +41,9 @@ export function buildRoutingPrompt(ctx, workers, strategy) {
|
|
|
40
41
|
'',
|
|
41
42
|
`Story ${ctx.story.id}: ${ctx.story.title}`,
|
|
42
43
|
'Acceptance criteria:',
|
|
43
|
-
...ctx.story.acceptance.map(item =>
|
|
44
|
+
...ctx.story.acceptance.map(item => isAcceptanceCriterion(item)
|
|
45
|
+
? `- [${item.id}] ${item.text} (proof: ${item.verify.join(' && ')})`
|
|
46
|
+
: `- ${item}`),
|
|
44
47
|
'',
|
|
45
48
|
'Allowed candidates:',
|
|
46
49
|
'- SELF: strong parent; highest confidence; highest expected cost',
|
package/gemini-extension.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "yoke",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"description": "Cross-agent coding harness: curated skill canon, mechanical safety gates, autonomous loop with proof artifacts. CLI: npm i -g @hecer/yoke",
|
|
5
5
|
"contextFileName": "GEMINI-EXTENSION.md"
|
|
6
6
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hecer/yoke",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"description": "One harness, three agents, zero trust in \"done\" — cross-agent coding harness for Claude Code, Codex CLI, and Gemini CLI: one skill canon, mechanical safety gates, an autonomous loop with screenshot/video proofs.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -16,11 +16,11 @@
|
|
|
16
16
|
"hooks",
|
|
17
17
|
"bench/README.md",
|
|
18
18
|
"bench/RESULTS.md",
|
|
19
|
-
"bench/result-schema.mjs",
|
|
20
|
-
"bench/run.mjs",
|
|
21
|
-
"bench/run-large.mjs",
|
|
22
|
-
"bench/run-matrix.mjs",
|
|
23
|
-
"bench/analyze-routing-study.mjs",
|
|
19
|
+
"bench/result-schema.mjs",
|
|
20
|
+
"bench/run.mjs",
|
|
21
|
+
"bench/run-large.mjs",
|
|
22
|
+
"bench/run-matrix.mjs",
|
|
23
|
+
"bench/analyze-routing-study.mjs",
|
|
24
24
|
"bench/fixtures",
|
|
25
25
|
"bench/results",
|
|
26
26
|
"docs",
|