@hecer/yoke 1.0.0 → 1.1.1
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/marketplace.json +18 -0
- package/.claude-plugin/plugin.json +13 -0
- package/.codex-plugin/plugin.json +1 -1
- package/CHANGELOG.md +23 -0
- package/README.md +87 -45
- package/canon/AGENTS.md +2 -0
- package/canon/loop/loop-spec.md +4 -2
- package/canon/manifest.yaml +2 -1
- package/canon/skills/authoring-prd/SKILL.md +4 -3
- package/canon/skills/workflow/SKILL.md +4 -0
- package/canon/skills/yoke-retrofit/SKILL.md +18 -11
- package/canon/skills/yoke-workflow/SKILL.md +20 -0
- package/dist/agents/host.js +26 -0
- package/dist/agents/providers.js +2 -2
- package/dist/cli.js +170 -5
- package/dist/context/context.js +15 -2
- package/dist/loop/cleanup.js +97 -32
- package/dist/loop/decision.js +517 -0
- package/dist/loop/git.js +23 -0
- package/dist/loop/lock.js +104 -13
- package/dist/loop/loop.js +29 -0
- package/dist/loop/run-command.js +69 -8
- package/dist/loop/runner.js +7 -1
- package/dist/prd/command.js +27 -24
- package/dist/retrofit/command.js +3 -2
- package/dist/retrofit/config.js +5 -1
- package/dist/retrofit/gitignore.js +8 -0
- package/dist/retrofit/report.js +1 -1
- package/dist/setup/command.js +82 -0
- package/docs/MIGRATING-TO-1.1.md +27 -0
- package/docs/PUBLISHING.md +77 -41
- package/gemini-extension.json +6 -0
- package/package.json +4 -2
package/dist/loop/loop.js
CHANGED
|
@@ -4,6 +4,7 @@ import { loadPrd, savePrd, selectNextStory, allPass, progress } 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
|
+
import { consumeDecisionRequest } from './decision.js';
|
|
7
8
|
function blockReason(base, targetDir, git) {
|
|
8
9
|
let dirty = false;
|
|
9
10
|
try {
|
|
@@ -112,6 +113,20 @@ export function runLoop(opts) {
|
|
|
112
113
|
iterations++;
|
|
113
114
|
if (result.tokens)
|
|
114
115
|
reporter.addTokens(result.tokens);
|
|
116
|
+
let decision;
|
|
117
|
+
try {
|
|
118
|
+
decision = consumeDecisionRequest(wt, opts.targetDir, story.id);
|
|
119
|
+
}
|
|
120
|
+
catch (error) {
|
|
121
|
+
const reason = `invalid critical decision request for story ${story.id}: ${error.message}`;
|
|
122
|
+
reporter.blocked(reason);
|
|
123
|
+
return { status: 'blocked', iterations, reason, finalProgress: progress(stories) };
|
|
124
|
+
}
|
|
125
|
+
if (decision) {
|
|
126
|
+
const reason = `critical decision required for story ${story.id}: ${decision.question}`;
|
|
127
|
+
reporter.blocked(reason);
|
|
128
|
+
return { status: 'blocked', iterations, reason, finalProgress: progress(stories) };
|
|
129
|
+
}
|
|
115
130
|
const ambiguity = consumeAmbiguity(wt);
|
|
116
131
|
if (ambiguity) {
|
|
117
132
|
const reason = `story ${story.id} stopped: ambiguous acceptance criteria — ${ambiguity}`;
|
|
@@ -194,6 +209,20 @@ export function runLoop(opts) {
|
|
|
194
209
|
iterations++;
|
|
195
210
|
if (result.tokens)
|
|
196
211
|
reporter.addTokens(result.tokens);
|
|
212
|
+
let decision;
|
|
213
|
+
try {
|
|
214
|
+
decision = consumeDecisionRequest(opts.targetDir, opts.targetDir, story.id);
|
|
215
|
+
}
|
|
216
|
+
catch (error) {
|
|
217
|
+
const reason = `invalid critical decision request for story ${story.id}: ${error.message}`;
|
|
218
|
+
reporter.blocked(reason);
|
|
219
|
+
return { status: 'blocked', iterations, reason, finalProgress: progress(stories) };
|
|
220
|
+
}
|
|
221
|
+
if (decision) {
|
|
222
|
+
const reason = `critical decision required for story ${story.id}: ${decision.question}`;
|
|
223
|
+
reporter.blocked(reason);
|
|
224
|
+
return { status: 'blocked', iterations, reason, finalProgress: progress(stories) };
|
|
225
|
+
}
|
|
197
226
|
const ambiguity = consumeAmbiguity(opts.targetDir);
|
|
198
227
|
if (ambiguity) {
|
|
199
228
|
const reason = `story ${story.id} stopped: ambiguous acceptance criteria — ${ambiguity}`;
|
package/dist/loop/run-command.js
CHANGED
|
@@ -11,6 +11,8 @@ import { acquireLock, releaseLock } from './lock.js';
|
|
|
11
11
|
import { maybeAutoUpgrade } from '../update/upgrade.js';
|
|
12
12
|
import { resolveCommitIdentity } from './identity.js';
|
|
13
13
|
import { runAudit } from '../audit/command.js';
|
|
14
|
+
import { detectHostAgent, resolveRunnerAgent } from '../agents/host.js';
|
|
15
|
+
import { clearDecisionResume, decisionProcessingExists, decisionRequestId, formatPendingDecision, readPendingDecision, writeDecisionResume, } from './decision.js';
|
|
14
16
|
export const DEFAULT_IDLE_MINUTES = 20;
|
|
15
17
|
const STALE_MINUTES = 20; // a running status older than this likely means the loop died
|
|
16
18
|
export function relativeTime(fromIso, now) {
|
|
@@ -32,7 +34,7 @@ export function prdPath(targetDir) {
|
|
|
32
34
|
export function setLoopEnabled(targetDir, enabled) {
|
|
33
35
|
// TODO(C2): resolve bundled canon version instead of placeholder
|
|
34
36
|
const config = loadConfig(targetDir) ?? defaultConfig('0.0.0');
|
|
35
|
-
config.loop = { enabled };
|
|
37
|
+
config.loop = { ...config.loop, enabled };
|
|
36
38
|
saveConfig(targetDir, config);
|
|
37
39
|
}
|
|
38
40
|
export function loopStatus(targetDir, now = () => new Date()) {
|
|
@@ -77,6 +79,20 @@ export function runLoopCommand(targetDir, opts) {
|
|
|
77
79
|
console.error('Loop is disabled. Enable it with: yoke loop on');
|
|
78
80
|
return 2;
|
|
79
81
|
}
|
|
82
|
+
if (decisionProcessingExists(targetDir)) {
|
|
83
|
+
console.error(`A critical decision answer needs recovery. Run: yoke loop answer ${targetDir} --choice=<id>`);
|
|
84
|
+
return 1;
|
|
85
|
+
}
|
|
86
|
+
try {
|
|
87
|
+
if (readPendingDecision(targetDir)) {
|
|
88
|
+
console.error(`${formatPendingDecision(targetDir)}\nAnswer it with: yoke loop answer ${targetDir} --choice=<id>`);
|
|
89
|
+
return 1;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
catch (error) {
|
|
93
|
+
console.error(`Invalid pending Yoke decision: ${error.message}`);
|
|
94
|
+
return 1;
|
|
95
|
+
}
|
|
80
96
|
const path = prdPath(targetDir);
|
|
81
97
|
if (!existsSync(path)) {
|
|
82
98
|
console.error(`No PRD found at ${path}. Create one (see canon loop/prd.schema.md).`);
|
|
@@ -101,7 +117,7 @@ export function runLoopCommand(targetDir, opts) {
|
|
|
101
117
|
// it started with; a fetched upgrade applies from the next invocation.
|
|
102
118
|
maybeAutoUpgrade(config.update?.auto);
|
|
103
119
|
const available = opts.isAvailable ?? isAgentAvailable;
|
|
104
|
-
const runnerAgent = opts.agent
|
|
120
|
+
const runnerAgent = resolveRunnerAgent(config, opts.agent, detectHostAgent());
|
|
105
121
|
const git = opts.git ?? realGitOps;
|
|
106
122
|
let commitIdentity = opts.commitIdentity;
|
|
107
123
|
if (!commitIdentity && !opts.git) {
|
|
@@ -136,7 +152,7 @@ export function runLoopCommand(targetDir, opts) {
|
|
|
136
152
|
// runner switches to stream-json so cumulative usage rides on every status.
|
|
137
153
|
runner = makeRunner(runnerAgent, idleMs, {
|
|
138
154
|
tokenReport: opts.json === true,
|
|
139
|
-
onAmbiguity: opts.onAmbiguity ?? config.loop.onAmbiguity,
|
|
155
|
+
onAmbiguity: opts.decisionPolicy ?? opts.onAmbiguity ?? config.loop.decisionPolicy ?? config.loop.onAmbiguity ?? 'auto',
|
|
140
156
|
perfCommand: config.perf?.command,
|
|
141
157
|
permissions,
|
|
142
158
|
});
|
|
@@ -163,7 +179,14 @@ export function runLoopCommand(targetDir, opts) {
|
|
|
163
179
|
}
|
|
164
180
|
review = makeReviewRunner(resolvedReviewer, idleMs);
|
|
165
181
|
}
|
|
166
|
-
|
|
182
|
+
let lock;
|
|
183
|
+
try {
|
|
184
|
+
lock = acquireLock(targetDir);
|
|
185
|
+
}
|
|
186
|
+
catch (error) {
|
|
187
|
+
console.error(`Cannot acquire the Yoke loop lock: ${error.message}`);
|
|
188
|
+
return 2;
|
|
189
|
+
}
|
|
167
190
|
if (!lock.acquired) {
|
|
168
191
|
console.error(`Another loop is already running here (pid ${lock.holderPid}). If that is wrong, run: yoke loop cleanup`);
|
|
169
192
|
return 2;
|
|
@@ -172,6 +195,7 @@ export function runLoopCommand(targetDir, opts) {
|
|
|
172
195
|
console.warn(`Took over a stale loop lock (pid ${lock.stalePid} is gone).`);
|
|
173
196
|
}
|
|
174
197
|
try {
|
|
198
|
+
const reporter = opts.reporter ?? makeReporter(targetDir, { json: opts.json });
|
|
175
199
|
const result = runLoop({
|
|
176
200
|
prdPath: path,
|
|
177
201
|
targetDir,
|
|
@@ -184,15 +208,52 @@ export function runLoopCommand(targetDir, opts) {
|
|
|
184
208
|
maxIterations: opts.maxIterations,
|
|
185
209
|
isolate: (opts.parallel ?? 1) > 1 ? true : (opts.isolate ?? false),
|
|
186
210
|
review,
|
|
187
|
-
reporter
|
|
211
|
+
reporter,
|
|
188
212
|
});
|
|
213
|
+
try {
|
|
214
|
+
const pendingDecision = readPendingDecision(targetDir);
|
|
215
|
+
if (pendingDecision) {
|
|
216
|
+
writeDecisionResume(targetDir, {
|
|
217
|
+
version: 1,
|
|
218
|
+
storyId: pendingDecision.storyId,
|
|
219
|
+
requestId: decisionRequestId(pendingDecision),
|
|
220
|
+
answered: false,
|
|
221
|
+
maxIterations: opts.maxIterations,
|
|
222
|
+
agent: runnerAgent,
|
|
223
|
+
isolate: opts.isolate ?? false,
|
|
224
|
+
reviewer: opts.reviewer,
|
|
225
|
+
review: opts.review === true || opts.reviewRunner !== undefined,
|
|
226
|
+
allowSelfReview: opts.allowSelfReview ?? false,
|
|
227
|
+
timeoutMinutes: opts.timeoutMinutes ?? config.loop.timeoutMinutes,
|
|
228
|
+
json: opts.json ?? false,
|
|
229
|
+
onAmbiguity: opts.decisionPolicy
|
|
230
|
+
? undefined
|
|
231
|
+
: opts.onAmbiguity === 'resolve' || opts.onAmbiguity === 'abort'
|
|
232
|
+
? opts.onAmbiguity
|
|
233
|
+
: (config.loop.decisionPolicy ? undefined : config.loop.onAmbiguity),
|
|
234
|
+
decisionPolicy: opts.decisionPolicy
|
|
235
|
+
?? (opts.onAmbiguity
|
|
236
|
+
? (opts.onAmbiguity === 'auto' || opts.onAmbiguity === 'critical' ? opts.onAmbiguity : undefined)
|
|
237
|
+
: config.loop.decisionPolicy),
|
|
238
|
+
permissions,
|
|
239
|
+
parallel: opts.parallel ?? 1,
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
else
|
|
243
|
+
clearDecisionResume(targetDir);
|
|
244
|
+
}
|
|
245
|
+
catch (error) {
|
|
246
|
+
const reason = `could not persist trusted decision resume state: ${error.message}`;
|
|
247
|
+
reporter.blocked(reason);
|
|
248
|
+
return 1;
|
|
249
|
+
}
|
|
189
250
|
// In json mode stdout belongs to the NDJSON stream — route the narrative summary to stderr.
|
|
190
251
|
const say = opts.json ? (line) => console.error(line) : (line) => console.log(line);
|
|
191
252
|
say(`Loop ${result.status} after ${result.iterations} iteration(s): ${result.finalProgress.passed}/${result.finalProgress.total} stories pass`);
|
|
192
253
|
if (result.reason)
|
|
193
254
|
say(`Reason: ${result.reason}`);
|
|
194
|
-
if (result.reason && /api key|please run \/login|not logged in/i.test(result.reason)) {
|
|
195
|
-
say('Hint: the agent CLI has no credentials in this environment. Set ANTHROPIC_API_KEY or log the agent in for headless use.');
|
|
255
|
+
if (result.reason && /api key|please run \/login|not logged in|auth/i.test(result.reason)) {
|
|
256
|
+
say('Hint: the agent CLI has no credentials in this environment. Set ANTHROPIC_API_KEY, GEMINI_API_KEY, or OPENAI_API_KEY, or log the agent in for headless use.');
|
|
196
257
|
}
|
|
197
258
|
// Exit codes: 0 complete · 1 blocked/cap-reached · 2 config error (handled above) · 3 paused (loop.pause consumed at a story boundary)
|
|
198
259
|
if (result.status === 'complete')
|
|
@@ -202,6 +263,6 @@ export function runLoopCommand(targetDir, opts) {
|
|
|
202
263
|
return 1;
|
|
203
264
|
}
|
|
204
265
|
finally {
|
|
205
|
-
releaseLock(targetDir);
|
|
266
|
+
releaseLock(targetDir, lock.ownerToken);
|
|
206
267
|
}
|
|
207
268
|
}
|
package/dist/loop/runner.js
CHANGED
|
@@ -19,7 +19,13 @@ export function buildClaudePrompt(story, context, onAmbiguity = 'resolve', perfC
|
|
|
19
19
|
lines.push('', context);
|
|
20
20
|
lines.push('', `Story ${story.id}: ${story.title}`, 'Acceptance criteria (Definition of Done):', criteria, '', "When done, ensure the project's full test suite passes.", 'Do NOT commit — the loop commits on your behalf after verifying.', '', 'Working rules:', '- Add nothing beyond what the story requires: no extra features, abstractions, comments, or defensive code for cases that cannot happen.', '- Do not create summary, plan, or analysis documents — only files the story itself needs.', '- If a check fails, fix the root cause; never bypass it (e.g. --no-verify) or pass by weakening tests.', '- Report the outcome faithfully: if a criterion is unmet or tests fail, say so plainly instead of claiming success.', '- Never ask questions or wait for input — you run unattended and nobody can answer.', onAmbiguity === 'abort'
|
|
21
21
|
? '- If an acceptance criterion is genuinely undecidable, do NOT guess: write the open question(s) to .yoke/ambiguity.md, change nothing else, and stop.'
|
|
22
|
-
:
|
|
22
|
+
: onAmbiguity === 'critical'
|
|
23
|
+
? [
|
|
24
|
+
'- Resolve routine ambiguity yourself using the plan, acceptance criteria, existing code, and established project conventions.',
|
|
25
|
+
'- Stop only for a high-impact decision involving public architecture, security or privacy posture, destructive data migration or data loss, material external cost, legal/compliance exposure, or another irreversible choice.',
|
|
26
|
+
'- For such a critical decision, change nothing else. Write .yoke/decision-request.yaml with exactly: version: 1, storyId, question, reason, 2-4 options ({id, label, optional tradeoff}), and recommended (an option id). Then stop.',
|
|
27
|
+
].join('\n')
|
|
28
|
+
: '- If an acceptance criterion is ambiguous, resolve it yourself in the way most consistent with the other criteria and the existing code, and state your interpretation in your final message.');
|
|
23
29
|
if (perfCommand) {
|
|
24
30
|
lines.push(`- This project enforces a performance budget: \`${perfCommand}\` must exit 0 or the story is blocked. Keep hot paths efficient, and never simplify away an existing optimization without re-running that benchmark.`);
|
|
25
31
|
}
|
package/dist/prd/command.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import { existsSync } from 'node:fs';
|
|
1
|
+
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { loadConfig } from '../retrofit/config.js';
|
|
4
4
|
import { 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
|
+
import { detectHostAgent, resolveRunnerAgent } from '../agents/host.js';
|
|
7
8
|
export const PRD_TEMPLATE = `# Yoke PRD — the loop picks the lowest-priority open story each iteration.
|
|
8
9
|
# Story format (see canon/loop/prd.schema.md):
|
|
9
10
|
# - id: STORY-1
|
|
@@ -18,30 +19,19 @@ export const PRD_TEMPLATE = `# Yoke PRD — the loop picks the lowest-priority o
|
|
|
18
19
|
# passes: false
|
|
19
20
|
[]
|
|
20
21
|
`;
|
|
21
|
-
export
|
|
22
|
-
|
|
22
|
+
export const MAX_PLANNING_BRIEF_CHARS = 20_000;
|
|
23
|
+
export const MAX_PLANNING_BRIEF_BYTES = MAX_PLANNING_BRIEF_CHARS * 4;
|
|
24
|
+
export function buildPrdDraftPrompt(idea, planningBrief) {
|
|
25
|
+
const lines = [
|
|
23
26
|
'You are drafting a PRD for the Yoke autonomous loop.',
|
|
24
27
|
'',
|
|
25
28
|
`Product idea: ${idea}`,
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
'
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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',
|
|
35
|
-
'- acceptance: 2-5 testable, behavioral criteria (observable outcomes, never implementation steps)',
|
|
36
|
-
'- passes: false',
|
|
37
|
-
'',
|
|
38
|
-
'If the project has no source code yet, STORY-1 must scaffold the project skeleton with a runnable',
|
|
39
|
-
'test suite, and its acceptance must include that the verify command (verify.command in',
|
|
40
|
-
'.yoke/config.yaml) exits 0.',
|
|
41
|
-
'',
|
|
42
|
-
'Write ONLY the file .yoke/prd.yaml as a YAML array of stories in exactly that shape.',
|
|
43
|
-
'Do not modify any other file. Do not commit.',
|
|
44
|
-
].join('\n');
|
|
29
|
+
];
|
|
30
|
+
if (planningBrief?.trim()) {
|
|
31
|
+
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
|
+
}
|
|
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.');
|
|
34
|
+
return lines.join('\n');
|
|
45
35
|
}
|
|
46
36
|
export function prdFile(targetDir) {
|
|
47
37
|
return join(targetDir, '.yoke', 'prd.yaml');
|
|
@@ -69,13 +59,23 @@ export function runPrdDraft(targetDir, opts) {
|
|
|
69
59
|
}
|
|
70
60
|
const available = opts.isAvailable ?? isAgentAvailable;
|
|
71
61
|
const config = loadConfig(targetDir);
|
|
72
|
-
const agent = opts.runner
|
|
62
|
+
const agent = resolveRunnerAgent(config, opts.runner, detectHostAgent());
|
|
73
63
|
if (!available(agent)) {
|
|
74
64
|
console.error(`Agent CLI "${agent}" was not found on PATH. Install it, or pick another with --runner=<claude|codex|gemini>.`);
|
|
75
65
|
return 2;
|
|
76
66
|
}
|
|
77
67
|
const idleMs = resolveIdleMs(opts.timeoutMinutes, undefined);
|
|
78
|
-
const
|
|
68
|
+
const planPath = join(targetDir, '.yoke', 'plan.md');
|
|
69
|
+
if (existsSync(planPath) && statSync(planPath).size > MAX_PLANNING_BRIEF_BYTES) {
|
|
70
|
+
console.error(`Approved plan is too large (${statSync(planPath).size} bytes; maximum ${MAX_PLANNING_BRIEF_BYTES}). Split or condense .yoke/plan.md before drafting the PRD.`);
|
|
71
|
+
return 1;
|
|
72
|
+
}
|
|
73
|
+
const planningBrief = existsSync(planPath) ? readFileSync(planPath, 'utf8') : undefined;
|
|
74
|
+
if (planningBrief && planningBrief.length > MAX_PLANNING_BRIEF_CHARS) {
|
|
75
|
+
console.error(`Approved plan is too large (${planningBrief.length} characters; maximum ${MAX_PLANNING_BRIEF_CHARS}). Split or condense .yoke/plan.md before drafting the PRD.`);
|
|
76
|
+
return 1;
|
|
77
|
+
}
|
|
78
|
+
const inv = agentInvocation(agent, buildPrdDraftPrompt(idea, planningBrief), targetDir);
|
|
79
79
|
console.log(`Drafting PRD with ${agent}...`);
|
|
80
80
|
const run = opts.run ?? ((i) => runAgent(buildWatchdogInvocation(i, idleMs)));
|
|
81
81
|
const result = run(inv);
|
|
@@ -123,6 +123,9 @@ export function runPrdCheck(targetDir) {
|
|
|
123
123
|
// the schema allows [], but the loop's stop-the-line gate blocks it — fail fast here
|
|
124
124
|
if (s.acceptance.length === 0)
|
|
125
125
|
errors.push(`story ${s.id} has no acceptance criteria`);
|
|
126
|
+
if (s.acceptance.some(criterion => /\b(?:TBD|TODO|TO BE DECIDED|DECIDE LATER)\b|\?\?\?/i.test(criterion))) {
|
|
127
|
+
errors.push(`story ${s.id} has unresolved planning decisions in acceptance criteria`);
|
|
128
|
+
}
|
|
126
129
|
}
|
|
127
130
|
if (errors.length > 0) {
|
|
128
131
|
for (const e of errors)
|
package/dist/retrofit/command.js
CHANGED
|
@@ -7,13 +7,14 @@ import { detectProject } from './detect.js';
|
|
|
7
7
|
import { ensureGitignore } from './gitignore.js';
|
|
8
8
|
import { loadConfig, saveConfig, defaultConfig } from './config.js';
|
|
9
9
|
import { loadManifest } from '../canon/manifest.js';
|
|
10
|
+
import { detectHostAgent } from '../agents/host.js';
|
|
10
11
|
export function runRetrofit(targetDir, opts) {
|
|
11
12
|
const canonDir = resolveCanonDir();
|
|
12
13
|
const canonVersion = loadManifest(join(canonDir, 'manifest.yaml')).version;
|
|
13
14
|
const detection = detectProject(targetDir);
|
|
14
15
|
const agents = opts.agents && opts.agents.length > 0
|
|
15
16
|
? opts.agents
|
|
16
|
-
: (detection.agents.length > 0 ? detection.agents : ['claude']);
|
|
17
|
+
: (detection.agents.length > 0 ? detection.agents : [opts.host ?? detectHostAgent() ?? 'claude']);
|
|
17
18
|
const existing = loadConfig(targetDir);
|
|
18
19
|
const codeGraph = opts.codeGraph ?? existing?.codeGraph ?? 'graphify';
|
|
19
20
|
const actions = planRetrofit(canonDir, targetDir, agents, codeGraph);
|
|
@@ -28,7 +29,7 @@ export function runRetrofit(targetDir, opts) {
|
|
|
28
29
|
...(existing ?? defaultConfig(canonVersion)),
|
|
29
30
|
canonVersion,
|
|
30
31
|
agents: mergedAgents,
|
|
31
|
-
loop: { enabled: opts.loop },
|
|
32
|
+
loop: { ...existing?.loop, enabled: opts.loop },
|
|
32
33
|
codeGraph,
|
|
33
34
|
};
|
|
34
35
|
saveConfig(targetDir, config);
|
package/dist/retrofit/config.js
CHANGED
|
@@ -12,11 +12,15 @@ export const YokeConfigSchema = z.object({
|
|
|
12
12
|
loop: z.object({
|
|
13
13
|
enabled: z.boolean(),
|
|
14
14
|
timeoutMinutes: z.number().optional(),
|
|
15
|
+
decisionPolicy: z.enum(['auto', 'critical']).optional(),
|
|
15
16
|
// Ambiguous acceptance criteria: 'resolve' (default — agent decides and continues)
|
|
16
17
|
// or 'abort' (agent stops the story via .yoke/ambiguity.md for a human decision).
|
|
17
18
|
onAmbiguity: z.enum(['resolve', 'abort']).optional(),
|
|
18
19
|
}),
|
|
19
|
-
runner: z.object({
|
|
20
|
+
runner: z.object({
|
|
21
|
+
agent: AgentSchema.optional(),
|
|
22
|
+
permissions: z.enum(['safe', 'unsafe', 'read-only']).optional(),
|
|
23
|
+
}).optional(),
|
|
20
24
|
commit: z.object({
|
|
21
25
|
authorName: z.string().min(1).optional(),
|
|
22
26
|
authorEmail: z.string().email().optional(),
|
|
@@ -6,9 +6,17 @@ export const YOKE_IGNORE_LINES = [
|
|
|
6
6
|
'.yoke/loop-status.json',
|
|
7
7
|
'.yoke/loop.log',
|
|
8
8
|
'.yoke/loop.lock',
|
|
9
|
+
'.yoke/loop.lock.takeover',
|
|
10
|
+
'.yoke/loop.lock.takeover.recovery',
|
|
11
|
+
'.yoke/loop.lock.*.tmp',
|
|
9
12
|
'.yoke/loop.pause',
|
|
10
13
|
'.yoke/runner.pid',
|
|
11
14
|
'.yoke/ambiguity.md',
|
|
15
|
+
'.yoke/decision-request.yaml',
|
|
16
|
+
'.yoke/pending-decision.yaml',
|
|
17
|
+
'.yoke/decision-answering.yaml',
|
|
18
|
+
'.yoke/decision-resume*.yaml',
|
|
19
|
+
'.yoke/decision-*.yaml.*.tmp',
|
|
12
20
|
'.yoke/story-durations.json',
|
|
13
21
|
'.yoke/proof/',
|
|
14
22
|
];
|
package/dist/retrofit/report.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export function formatReport(applied, meta) {
|
|
2
2
|
const count = (s) => applied.filter(a => a.status === s).length;
|
|
3
3
|
const lines = [];
|
|
4
|
-
lines.push('Yoke retrofit
|
|
4
|
+
lines.push('Yoke retrofit:');
|
|
5
5
|
for (const a of applied) {
|
|
6
6
|
const note = a.backedUp ? ` (backup: ${a.backedUp})` : '';
|
|
7
7
|
lines.push(` ${a.status.padEnd(11)} ${a.target}${note}`);
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { createInterface } from 'node:readline/promises';
|
|
2
|
+
import { stdin as input, stdout as output } from 'node:process';
|
|
3
|
+
import { detectHostAgent } from '../agents/host.js';
|
|
4
|
+
import { loadConfig, saveConfig } from '../retrofit/config.js';
|
|
5
|
+
import { detectProject } from '../retrofit/detect.js';
|
|
6
|
+
import { runRetrofit } from '../retrofit/command.js';
|
|
7
|
+
const ALL_AGENTS = ['claude', 'codex', 'gemini'];
|
|
8
|
+
function parseAgents(value, fallback) {
|
|
9
|
+
if (value.trim().toLowerCase() === 'all')
|
|
10
|
+
return [...ALL_AGENTS];
|
|
11
|
+
const parsed = value.split(',').map(v => v.trim().toLowerCase()).filter((v) => ALL_AGENTS.includes(v));
|
|
12
|
+
return parsed.length > 0 ? [...new Set(parsed)] : fallback;
|
|
13
|
+
}
|
|
14
|
+
function yes(value, fallback) {
|
|
15
|
+
const normalized = value.trim().toLowerCase();
|
|
16
|
+
if (['y', 'yes', 'j', 'ja', 'true', '1'].includes(normalized))
|
|
17
|
+
return true;
|
|
18
|
+
if (['n', 'no', 'nein', 'false', '0'].includes(normalized))
|
|
19
|
+
return false;
|
|
20
|
+
return fallback;
|
|
21
|
+
}
|
|
22
|
+
export async function runSetup(targetDir, opts = {}) {
|
|
23
|
+
const existing = loadConfig(targetDir);
|
|
24
|
+
const detected = detectProject(targetDir);
|
|
25
|
+
const host = opts.host ?? detectHostAgent();
|
|
26
|
+
const configuredAgents = existing?.agents.filter(a => ALL_AGENTS.includes(a)) ?? [];
|
|
27
|
+
const defaultAgents = opts.agents && opts.agents.length > 0
|
|
28
|
+
? opts.agents
|
|
29
|
+
: configuredAgents.length > 0
|
|
30
|
+
? configuredAgents
|
|
31
|
+
: detected.agents.length > 0
|
|
32
|
+
? detected.agents
|
|
33
|
+
: [host ?? 'claude'];
|
|
34
|
+
const defaultGraph = opts.codeGraph ?? existing?.codeGraph ?? 'graphify';
|
|
35
|
+
const defaultLoop = opts.loop ?? existing?.loop.enabled ?? true;
|
|
36
|
+
const defaultRunner = opts.runner ?? existing?.runner?.agent ?? (host && defaultAgents.includes(host) ? host : defaultAgents[0] ?? host ?? 'claude');
|
|
37
|
+
const defaultPolicy = opts.decisionPolicy ?? existing?.loop.decisionPolicy ?? (existing?.loop.onAmbiguity === 'abort' ? 'critical' : 'auto');
|
|
38
|
+
const interactive = opts.interactive ?? (process.stdin.isTTY === true && process.stdout.isTTY === true);
|
|
39
|
+
let close;
|
|
40
|
+
let ask = opts.ask;
|
|
41
|
+
if (interactive && !ask) {
|
|
42
|
+
const rl = createInterface({ input, output });
|
|
43
|
+
ask = (question) => rl.question(question);
|
|
44
|
+
close = () => rl.close();
|
|
45
|
+
}
|
|
46
|
+
try {
|
|
47
|
+
let agents = defaultAgents;
|
|
48
|
+
let codeGraph = defaultGraph;
|
|
49
|
+
let loop = defaultLoop;
|
|
50
|
+
let runner = defaultRunner;
|
|
51
|
+
let decisionPolicy = defaultPolicy;
|
|
52
|
+
if (interactive && ask) {
|
|
53
|
+
agents = parseAgents(await ask(`Agents [${defaultAgents.join(',')}] (claude,codex,gemini|all): `), defaultAgents);
|
|
54
|
+
const graphAnswer = (await ask(`Code graph [${defaultGraph}] (graphify|serena): `)).trim().toLowerCase();
|
|
55
|
+
if (graphAnswer === 'graphify' || graphAnswer === 'serena')
|
|
56
|
+
codeGraph = graphAnswer;
|
|
57
|
+
loop = yes(await ask(`Enable autonomous loop? [${defaultLoop ? 'yes' : 'no'}]: `), defaultLoop);
|
|
58
|
+
const runnerAnswer = (await ask(`Default runner [${runner}] (claude|codex|gemini): `)).trim().toLowerCase();
|
|
59
|
+
if (ALL_AGENTS.includes(runnerAnswer))
|
|
60
|
+
runner = runnerAnswer;
|
|
61
|
+
const policyAnswer = (await ask(`Decision mode [${decisionPolicy}] (auto|critical): `)).trim().toLowerCase();
|
|
62
|
+
if (policyAnswer === 'auto' || policyAnswer === 'critical')
|
|
63
|
+
decisionPolicy = policyAnswer;
|
|
64
|
+
}
|
|
65
|
+
if (!agents.includes(runner))
|
|
66
|
+
agents = [...agents, runner];
|
|
67
|
+
const code = runRetrofit(targetDir, { loop, agents, codeGraph, host });
|
|
68
|
+
if (code !== 0)
|
|
69
|
+
return code;
|
|
70
|
+
const config = loadConfig(targetDir);
|
|
71
|
+
if (!config)
|
|
72
|
+
return 1;
|
|
73
|
+
config.loop = { ...config.loop, enabled: loop, decisionPolicy };
|
|
74
|
+
config.runner = { ...config.runner, agent: runner };
|
|
75
|
+
saveConfig(targetDir, config);
|
|
76
|
+
console.log(`Yoke setup complete: agents=${agents.join(',')} · runner=${runner} · loop=${loop ? 'on' : 'off'} · decisions=${decisionPolicy}`);
|
|
77
|
+
return 0;
|
|
78
|
+
}
|
|
79
|
+
finally {
|
|
80
|
+
close?.();
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Migrating to Yoke 1.1
|
|
2
|
+
|
|
3
|
+
Yoke 1.1 makes Claude, Codex, and Gemini share the same setup, planning, runner-selection, and decision behavior.
|
|
4
|
+
|
|
5
|
+
Run the setup wizard once in an existing project:
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npx @hecer/yoke@1.1.0 setup .
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
It preserves existing Yoke configuration and asks for target agents, code graph, loop state, default runner, and decision mode. A non-interactive agent can apply explicit choices with `--yes`.
|
|
12
|
+
|
|
13
|
+
The new config fields are optional and backward-compatible:
|
|
14
|
+
|
|
15
|
+
```yaml
|
|
16
|
+
runner:
|
|
17
|
+
agent: codex
|
|
18
|
+
permissions: safe
|
|
19
|
+
loop:
|
|
20
|
+
enabled: true
|
|
21
|
+
timeoutMinutes: 30
|
|
22
|
+
decisionPolicy: critical # auto | critical
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
`loop.onAmbiguity: resolve|abort` and `--on-ambiguity=` still work. Prefer `decisionPolicy` for new projects: `auto` resolves routine choices without asking; `critical` pauses only for high-impact decisions and continues after `yoke loop answer`. Resume restores the original isolation, review, runner, permissions, timeout, and policy flags instead of silently weakening the run. If the restart cannot begin, `yoke loop resume` retries from the request-bound state stored under Git's private state directory.
|
|
26
|
+
|
|
27
|
+
Restart an already-open Codex task after retrofit if it does not discover the newly generated `.agents/skills/yoke-workflow/SKILL.md`.
|
package/docs/PUBLISHING.md
CHANGED
|
@@ -1,41 +1,77 @@
|
|
|
1
|
-
# Publishing channels — status & playbook
|
|
2
|
-
|
|
3
|
-
Where Yoke is published, and how each channel gets updated. (
|
|
4
|
-
|
|
5
|
-
## Live
|
|
6
|
-
|
|
7
|
-
| Channel | How | Update path |
|
|
8
|
-
|---|---|---|
|
|
9
|
-
| **npm** — [`@hecer/yoke`](https://www.npmjs.com/package/@hecer/yoke) | `npm publish` (2FA) | every release |
|
|
10
|
-
| **GitHub** — [HECer/yoke](https://github.com/HECer/yoke) | push + tag | every release |
|
|
11
|
-
| **Claude Code plugin (self-marketplace)** | `.claude-plugin/plugin.json` + `marketplace.json` in this repo; users: `/plugin marketplace add HECer/yoke` → `/plugin install yoke@yoke` | bump `version` in `plugin.json` |
|
|
12
|
-
| **Gemini CLI extension** | `gemini-extension.json` + `GEMINI-EXTENSION.md` at repo root; users: `gemini extensions install https://github.com/HECer/yoke` | bump `version` in the manifest |
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
-
|
|
36
|
-
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
1
|
+
# Publishing channels — status & playbook
|
|
2
|
+
|
|
3
|
+
Where Yoke is published, and how each channel gets updated. (Reviewed 2026-07-30.)
|
|
4
|
+
|
|
5
|
+
## Live
|
|
6
|
+
|
|
7
|
+
| Channel | How | Update path |
|
|
8
|
+
|---|---|---|
|
|
9
|
+
| **npm** — [`@hecer/yoke`](https://www.npmjs.com/package/@hecer/yoke) | `npm publish` (2FA) | every release |
|
|
10
|
+
| **GitHub** — [HECer/yoke](https://github.com/HECer/yoke) | push + tag + GitHub Release | every release |
|
|
11
|
+
| **Claude Code plugin (self-marketplace)** | `.claude-plugin/plugin.json` + `marketplace.json` in this repo; users: `/plugin marketplace add HECer/yoke` → `/plugin install yoke@yoke` | bump `version` in `plugin.json` |
|
|
12
|
+
| **Gemini CLI extension** | `gemini-extension.json` + `GEMINI-EXTENSION.md` at repo root; users: `gemini extensions install https://github.com/HECer/yoke` | bump `version` in the manifest |
|
|
13
|
+
| **Codex project skills** | `npx @hecer/yoke setup .` writes the canon to `.agents/skills/` plus native Codex config/hooks; `.codex-plugin/plugin.json` is bundled for plugin-capable hosts | every npm release |
|
|
14
|
+
|
|
15
|
+
## GitHub release (required, not just a tag)
|
|
16
|
+
|
|
17
|
+
A pushed tag appears under **Tags**, but GitHub only shows an entry under **Releases** after a
|
|
18
|
+
release object is created. Use this idempotent check after the version commit reaches `main`:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
set -euo pipefail
|
|
22
|
+
VERSION=1.1.0
|
|
23
|
+
TARGET=$(git rev-parse HEAD)
|
|
24
|
+
git fetch --tags origin
|
|
25
|
+
|
|
26
|
+
REMOTE=$(git ls-remote origin "refs/tags/v$VERSION^{}" | awk 'NR == 1 { print $1 }')
|
|
27
|
+
if [ -z "$REMOTE" ]; then
|
|
28
|
+
REMOTE=$(git ls-remote origin "refs/tags/v$VERSION" | awk 'NR == 1 { print $1 }')
|
|
29
|
+
fi
|
|
30
|
+
if [ -n "$REMOTE" ] && [ "$REMOTE" != "$TARGET" ]; then
|
|
31
|
+
echo "origin/v$VERSION already points at a different commit" >&2
|
|
32
|
+
exit 1
|
|
33
|
+
fi
|
|
34
|
+
|
|
35
|
+
if git rev-parse -q --verify "refs/tags/v$VERSION" >/dev/null; then
|
|
36
|
+
test "$(git rev-list -n 1 "v$VERSION")" = "$TARGET" || {
|
|
37
|
+
echo "v$VERSION already points at a different commit" >&2
|
|
38
|
+
exit 1
|
|
39
|
+
}
|
|
40
|
+
else
|
|
41
|
+
git tag "v$VERSION"
|
|
42
|
+
fi
|
|
43
|
+
git push origin "refs/tags/v$VERSION"
|
|
44
|
+
gh release view "v$VERSION" >/dev/null 2>&1 || \
|
|
45
|
+
gh release create "v$VERSION" --title "Yoke $VERSION" --generate-notes
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Verify both surfaces before publishing npm: `gh release view "v$VERSION"` and
|
|
49
|
+
`npm view @hecer/yoke version`.
|
|
50
|
+
|
|
51
|
+
## Submitted / pending
|
|
52
|
+
|
|
53
|
+
| Channel | How | Status |
|
|
54
|
+
|---|---|---|
|
|
55
|
+
| **Gemini extensions gallery** (geminicli.com/extensions) | automatic daily crawl: needs `gemini-extension.json` at repo root + `gemini-cli-extension` repo topic — both done | wait for crawler |
|
|
56
|
+
| **Anthropic community plugin directory** (`claude-community`, surfaced in `/plugin > Discover`) | form at **platform.claude.com/plugins/submit** (Console account, Developer role; submit the public repo URL; `claude plugin validate` runs in their pipeline — passes locally). After approval: pinned to a commit SHA, CI auto-bumps on push, catalog syncs nightly | **needs a human login** — see below |
|
|
57
|
+
|
|
58
|
+
### Anthropic directory submission (manual step)
|
|
59
|
+
|
|
60
|
+
1. Log in at https://platform.claude.com (free Console account is enough; role Developer+).
|
|
61
|
+
2. Open https://platform.claude.com/plugins/submit
|
|
62
|
+
3. Submit the public repo: `https://github.com/HECer/yoke`
|
|
63
|
+
4. Suggested description: *"Cross-agent coding harness: one curated skill canon (TDD,
|
|
64
|
+
brainstorming → spec → plan, systematic debugging, cross-model review, design
|
|
65
|
+
verification) plus mechanical safety gates and an autonomous loop via the yoke CLI."*
|
|
66
|
+
5. Category: development. Plugin name (immutable): `yoke`.
|
|
67
|
+
|
|
68
|
+
## Worth doing later (community lists, PR/issue-based)
|
|
69
|
+
|
|
70
|
+
- **awesome-claude-code** (hesreallyhim) — issue-form only, explicitly human-submitted, no PRs.
|
|
71
|
+
- **ComposioHQ/awesome-claude-plugins** — PR per template (high merge latency).
|
|
72
|
+
- **davila7/claude-code-templates** (aitmpl.com) — PR per CONTRIBUTING.md.
|
|
73
|
+
- **Codex plugin directory** — public directory submission remains a separate channel. Codex
|
|
74
|
+
users do not need it: the npm setup path installs native project skills deterministically.
|
|
75
|
+
- Auto-crawled directories (crossaitools.com etc.) pick the repo up on their own once the
|
|
76
|
+
marketplace manifest exists.
|
|
77
|
+
- Launch channels (Product Hunt, Show HN, r/ClaudeAI, r/ClaudeCode) — deliberate, human-led.
|
package/package.json
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hecer/yoke",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.1",
|
|
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": {
|
|
7
|
-
"yoke": "
|
|
7
|
+
"yoke": "dist/cli.js"
|
|
8
8
|
},
|
|
9
9
|
"files": [
|
|
10
10
|
"dist",
|
|
11
11
|
"canon",
|
|
12
|
+
".claude-plugin",
|
|
12
13
|
".codex-plugin",
|
|
14
|
+
"gemini-extension.json",
|
|
13
15
|
"agents",
|
|
14
16
|
"hooks",
|
|
15
17
|
"bench/README.md",
|