@hecer/yoke 0.2.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/LICENSE +21 -0
- package/README.md +494 -0
- package/canon/AGENTS.md +28 -0
- package/canon/context/DECISIONS.md +4 -0
- package/canon/context/KNOWLEDGE.md +4 -0
- package/canon/context/PROJECT.md +15 -0
- package/canon/loop/loop-spec.md +30 -0
- package/canon/loop/prd.schema.md +14 -0
- package/canon/manifest.yaml +47 -0
- package/canon/policy/gates.md +7 -0
- package/canon/policy/roles.md +9 -0
- package/canon/skills/ATTRIBUTION.md +71 -0
- package/canon/skills/authoring-prd/SKILL.md +44 -0
- package/canon/skills/brainstorming/SKILL.md +164 -0
- package/canon/skills/dispatching-parallel-agents/SKILL.md +182 -0
- package/canon/skills/document-release/SKILL.md +297 -0
- package/canon/skills/executing-plans/SKILL.md +70 -0
- package/canon/skills/finishing-a-development-branch/SKILL.md +200 -0
- package/canon/skills/health/SKILL.md +177 -0
- package/canon/skills/maintaining-context/SKILL.md +34 -0
- package/canon/skills/minimal-code/SKILL.md +21 -0
- package/canon/skills/plan-ceo-review/SKILL.md +541 -0
- package/canon/skills/plan-eng-review/SKILL.md +362 -0
- package/canon/skills/receiving-code-review/SKILL.md +213 -0
- package/canon/skills/requesting-code-review/SKILL.md +105 -0
- package/canon/skills/retro/SKILL.md +397 -0
- package/canon/skills/review/SKILL.md +246 -0
- package/canon/skills/ship/SKILL.md +696 -0
- package/canon/skills/subagent-driven-development/SKILL.md +277 -0
- package/canon/skills/systematic-debugging/SKILL.md +296 -0
- package/canon/skills/tdd/SKILL.md +371 -0
- package/canon/skills/unslop-ui/SKILL.md +34 -0
- package/canon/skills/using-git-worktrees/SKILL.md +218 -0
- package/canon/skills/verification-before-completion/SKILL.md +139 -0
- package/canon/skills/visual-verification/SKILL.md +54 -0
- package/canon/skills/workflow/SKILL.md +18 -0
- package/canon/skills/writing-plans/SKILL.md +152 -0
- package/canon/skills/writing-skills/SKILL.md +655 -0
- package/canon/skills/yoke-retrofit/SKILL.md +18 -0
- package/canon/tools/graphify.md +3 -0
- package/canon/tools/playwright-mcp.md +3 -0
- package/canon/tools/rtk.md +7 -0
- package/canon/tools/serena.md +7 -0
- package/dist/canon/frontmatter.js +10 -0
- package/dist/canon/manifest.js +26 -0
- package/dist/canon/validate.js +73 -0
- package/dist/cli.js +244 -0
- package/dist/context/command.js +33 -0
- package/dist/context/context.js +57 -0
- package/dist/loop/cleanup.js +42 -0
- package/dist/loop/gates.js +12 -0
- package/dist/loop/git.js +25 -0
- package/dist/loop/lock.js +45 -0
- package/dist/loop/loop.js +190 -0
- package/dist/loop/prd.js +29 -0
- package/dist/loop/reporter.js +91 -0
- package/dist/loop/run-command.js +134 -0
- package/dist/loop/runner.js +157 -0
- package/dist/loop/verify.js +38 -0
- package/dist/loop/watchdog.js +86 -0
- package/dist/new/command.js +53 -0
- package/dist/prd/command.js +129 -0
- package/dist/retrofit/apply.js +54 -0
- package/dist/retrofit/canon-dir.js +22 -0
- package/dist/retrofit/command.js +37 -0
- package/dist/retrofit/config.js +53 -0
- package/dist/retrofit/context-actions.js +21 -0
- package/dist/retrofit/detect.js +17 -0
- package/dist/retrofit/gitignore.js +26 -0
- package/dist/retrofit/gstack.js +19 -0
- package/dist/retrofit/merge-json.js +38 -0
- package/dist/retrofit/plan.js +29 -0
- package/dist/retrofit/planners/claude.js +67 -0
- package/dist/retrofit/planners/codex.js +36 -0
- package/dist/retrofit/planners/gemini.js +54 -0
- package/dist/retrofit/report.js +14 -0
- package/dist/retrofit/tools.js +23 -0
- package/dist/retrofit/wsl.js +15 -0
- package/dist/review/command.js +43 -0
- package/dist/scan/design.js +79 -0
- package/dist/smoke/command.js +141 -0
- package/package.json +61 -0
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { pathToFileURL } from 'node:url';
|
|
3
|
+
export function runWatchdog(opts) {
|
|
4
|
+
const spawnFn = opts.spawnFn ?? spawn;
|
|
5
|
+
const out = opts.out ?? ((d) => process.stdout.write(d));
|
|
6
|
+
const err = opts.err ?? ((d) => process.stderr.write(d));
|
|
7
|
+
const child = spawnFn(opts.command, opts.args, { shell: process.platform === 'win32' });
|
|
8
|
+
if (opts.stdin && child.stdin) {
|
|
9
|
+
try {
|
|
10
|
+
opts.stdin.pipe(child.stdin);
|
|
11
|
+
}
|
|
12
|
+
catch { /* no stdin */ }
|
|
13
|
+
}
|
|
14
|
+
const graceMs = opts.graceMs ?? 5000;
|
|
15
|
+
return new Promise((resolve) => {
|
|
16
|
+
let timer;
|
|
17
|
+
let graceTimer;
|
|
18
|
+
let killedForIdle = false;
|
|
19
|
+
// Clear BOTH the idle timer and the post-SIGTERM grace timer so no dangling
|
|
20
|
+
// timers survive on any terminal path (close/error) or on each re-arm.
|
|
21
|
+
const clear = () => {
|
|
22
|
+
if (timer) {
|
|
23
|
+
clearTimeout(timer);
|
|
24
|
+
timer = undefined;
|
|
25
|
+
}
|
|
26
|
+
if (graceTimer) {
|
|
27
|
+
clearTimeout(graceTimer);
|
|
28
|
+
graceTimer = undefined;
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
const arm = () => {
|
|
32
|
+
if (opts.idleMs <= 0)
|
|
33
|
+
return;
|
|
34
|
+
// Once we've committed to killing, output no longer rescinds the escalation:
|
|
35
|
+
// a child that catches SIGTERM and keeps emitting heartbeats must not be able
|
|
36
|
+
// to reset the idle clock / cancel the pending SIGKILL. Forwarding still happens
|
|
37
|
+
// because the data handlers call out(d)/err(d) BEFORE arm().
|
|
38
|
+
if (killedForIdle)
|
|
39
|
+
return;
|
|
40
|
+
clear();
|
|
41
|
+
timer = setTimeout(() => {
|
|
42
|
+
timer = undefined;
|
|
43
|
+
killedForIdle = true;
|
|
44
|
+
try {
|
|
45
|
+
child.kill('SIGTERM');
|
|
46
|
+
}
|
|
47
|
+
catch { /* already gone */ }
|
|
48
|
+
// Escalation: a child that catches/ignores SIGTERM would never emit
|
|
49
|
+
// 'close' and the promise would hang forever — defeating the watchdog.
|
|
50
|
+
// SIGKILL is uncatchable, so the child must close and we resolve.
|
|
51
|
+
// win32 limitation: with shell:true, SIGKILL on the shell may orphan
|
|
52
|
+
// grandchild processes (a resource-cleanup edge, not a hang) — not fixed here.
|
|
53
|
+
graceTimer = setTimeout(() => {
|
|
54
|
+
graceTimer = undefined;
|
|
55
|
+
try {
|
|
56
|
+
child.kill('SIGKILL');
|
|
57
|
+
}
|
|
58
|
+
catch { /* already gone */ }
|
|
59
|
+
}, graceMs);
|
|
60
|
+
}, opts.idleMs);
|
|
61
|
+
};
|
|
62
|
+
child.stdout.on('data', (d) => { out(d); arm(); });
|
|
63
|
+
child.stderr.on('data', (d) => { err(d); arm(); });
|
|
64
|
+
child.on('error', () => { clear(); resolve(127); });
|
|
65
|
+
child.on('close', (code) => { clear(); resolve(killedForIdle ? 124 : (code ?? 0)); });
|
|
66
|
+
arm();
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
export function parseWatchdogArgs(argv) {
|
|
70
|
+
const sep = argv.indexOf('--');
|
|
71
|
+
const flags = sep === -1 ? argv : argv.slice(0, sep);
|
|
72
|
+
const rest = sep === -1 ? [] : argv.slice(sep + 1);
|
|
73
|
+
const idleArg = flags.find((a) => a.startsWith('--idle-ms='));
|
|
74
|
+
const idleMs = idleArg ? Number(idleArg.slice('--idle-ms='.length)) : 0;
|
|
75
|
+
const [command, ...args] = rest;
|
|
76
|
+
return { idleMs: Number.isFinite(idleMs) ? idleMs : 0, command: command ?? '', args };
|
|
77
|
+
}
|
|
78
|
+
const isMain = process.argv[1] ? pathToFileURL(process.argv[1]).href === import.meta.url : false;
|
|
79
|
+
if (isMain) {
|
|
80
|
+
const { idleMs, command, args } = parseWatchdogArgs(process.argv.slice(2));
|
|
81
|
+
if (!command) {
|
|
82
|
+
process.stderr.write('watchdog: no command given\n');
|
|
83
|
+
process.exit(2);
|
|
84
|
+
}
|
|
85
|
+
runWatchdog({ command, args, idleMs, stdin: process.stdin }).then((code) => process.exit(code));
|
|
86
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readdirSync, statSync, writeFileSync, appendFileSync } from 'node:fs';
|
|
2
|
+
import { join, basename, resolve } from 'node:path';
|
|
3
|
+
import { execFileSync } from 'node:child_process';
|
|
4
|
+
import { runRetrofit } from '../retrofit/command.js';
|
|
5
|
+
import { runContextInit } from '../context/command.js';
|
|
6
|
+
import { runPrdDraft, PRD_TEMPLATE } from '../prd/command.js';
|
|
7
|
+
export function runNew(dir, opts = {}) {
|
|
8
|
+
const git = opts.git ?? ((args, cwd) => { execFileSync('git', args, { cwd, stdio: 'pipe' }); });
|
|
9
|
+
const target = resolve(dir);
|
|
10
|
+
if (existsSync(target) && (!statSync(target).isDirectory() || readdirSync(target).length > 0)) {
|
|
11
|
+
console.error(`${dir} already exists and is not empty — yoke new is greenfield-only (use yoke retrofit for existing projects).`);
|
|
12
|
+
return 1;
|
|
13
|
+
}
|
|
14
|
+
mkdirSync(target, { recursive: true });
|
|
15
|
+
git(['init'], target);
|
|
16
|
+
const name = basename(target);
|
|
17
|
+
writeFileSync(join(target, 'README.md'), `# ${name}\n${opts.idea ? `\n${opts.idea}\n` : ''}`);
|
|
18
|
+
writeFileSync(join(target, '.gitignore'), 'node_modules/\ndist/\n.env\n');
|
|
19
|
+
runRetrofit(target, { loop: opts.loop ?? false, agents: opts.agents });
|
|
20
|
+
runContextInit(target);
|
|
21
|
+
if (opts.idea) {
|
|
22
|
+
appendFileSync(join(target, '.yoke', 'context', 'PROJECT.md'), `\n## Idea\n\n${opts.idea}\n`);
|
|
23
|
+
}
|
|
24
|
+
writeFileSync(join(target, '.yoke', 'prd.yaml'), PRD_TEMPLATE);
|
|
25
|
+
git(['add', '-A'], target);
|
|
26
|
+
git(['-c', 'commit.gpgsign=false', 'commit', '-m', `chore: bootstrap ${name} with yoke`], target);
|
|
27
|
+
let code = 0;
|
|
28
|
+
if (opts.idea) {
|
|
29
|
+
const draft = runPrdDraft(target, {
|
|
30
|
+
idea: opts.idea,
|
|
31
|
+
runner: opts.runner,
|
|
32
|
+
timeoutMinutes: opts.timeoutMinutes,
|
|
33
|
+
isAvailable: opts.isAvailable,
|
|
34
|
+
run: opts.run,
|
|
35
|
+
});
|
|
36
|
+
if (draft === 0) {
|
|
37
|
+
git(['add', '-A'], target);
|
|
38
|
+
git(['-c', 'commit.gpgsign=false', 'commit', '-m', 'docs: draft PRD from idea'], target);
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
console.error(`PRD draft did not succeed. The project is ready anyway; retry with: yoke prd draft ${dir} --idea="..."`);
|
|
42
|
+
code = draft;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
console.log([
|
|
46
|
+
`✓ ${name} bootstrapped.`,
|
|
47
|
+
'Next steps:',
|
|
48
|
+
' 1. Review .yoke/prd.yaml (or draft it: yoke prd draft --idea="...")',
|
|
49
|
+
' 2. Set verify.command in .yoke/config.yaml (e.g. "npm test")',
|
|
50
|
+
` 3. yoke loop on ${dir} && yoke loop run ${dir} --isolate`,
|
|
51
|
+
].join('\n'));
|
|
52
|
+
return code;
|
|
53
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { loadConfig } from '../retrofit/config.js';
|
|
4
|
+
import { loadPrd, progress } from '../loop/prd.js';
|
|
5
|
+
import { agentInvocation, buildWatchdogInvocation, runAgent, isAgentAvailable, } from '../loop/runner.js';
|
|
6
|
+
import { resolveIdleMs } from '../loop/run-command.js';
|
|
7
|
+
export const PRD_TEMPLATE = `# Yoke PRD — the loop picks the lowest-priority open story each iteration.
|
|
8
|
+
# Story format (see canon/loop/prd.schema.md):
|
|
9
|
+
# - id: STORY-1
|
|
10
|
+
# title: scaffold the project with a runnable test suite
|
|
11
|
+
# priority: 1
|
|
12
|
+
# acceptance:
|
|
13
|
+
# - "the verify command exits 0"
|
|
14
|
+
# - "a placeholder test exists and passes"
|
|
15
|
+
# passes: false
|
|
16
|
+
[]
|
|
17
|
+
`;
|
|
18
|
+
export function buildPrdDraftPrompt(idea) {
|
|
19
|
+
return [
|
|
20
|
+
'You are drafting a PRD for the Yoke autonomous loop.',
|
|
21
|
+
'',
|
|
22
|
+
`Product idea: ${idea}`,
|
|
23
|
+
'',
|
|
24
|
+
'Break the idea into 5-12 small, independently shippable stories; each must fit one loop iteration.',
|
|
25
|
+
'Each story needs:',
|
|
26
|
+
'- id: STORY-1, STORY-2, ... (unique)',
|
|
27
|
+
'- title: one imperative sentence',
|
|
28
|
+
'- priority: dense integers from 1 (lower = built first)',
|
|
29
|
+
'- acceptance: 2-5 testable, behavioral criteria (observable outcomes, never implementation steps)',
|
|
30
|
+
'- passes: false',
|
|
31
|
+
'',
|
|
32
|
+
'If the project has no source code yet, STORY-1 must scaffold the project skeleton with a runnable',
|
|
33
|
+
'test suite, and its acceptance must include that the verify command (verify.command in',
|
|
34
|
+
'.yoke/config.yaml) exits 0.',
|
|
35
|
+
'',
|
|
36
|
+
'Write ONLY the file .yoke/prd.yaml as a YAML array of stories in exactly that shape.',
|
|
37
|
+
'Do not modify any other file. Do not commit.',
|
|
38
|
+
].join('\n');
|
|
39
|
+
}
|
|
40
|
+
export function prdFile(targetDir) {
|
|
41
|
+
return join(targetDir, '.yoke', 'prd.yaml');
|
|
42
|
+
}
|
|
43
|
+
export function runPrdDraft(targetDir, opts) {
|
|
44
|
+
const idea = opts.idea?.trim();
|
|
45
|
+
if (!idea) {
|
|
46
|
+
console.error('yoke prd draft requires --idea="..."');
|
|
47
|
+
return 1;
|
|
48
|
+
}
|
|
49
|
+
const path = prdFile(targetDir);
|
|
50
|
+
if (existsSync(path) && !opts.force) {
|
|
51
|
+
try {
|
|
52
|
+
const existing = loadPrd(path);
|
|
53
|
+
if (existing.length > 0) {
|
|
54
|
+
console.error(`PRD already has ${existing.length} stories — use --force to overwrite.`);
|
|
55
|
+
return 1;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
// an unparseable PRD is likely a hand-edit typo, not consent to overwrite
|
|
60
|
+
console.error('Existing .yoke/prd.yaml is unparseable — fix it, or pass --force to overwrite it.');
|
|
61
|
+
return 1;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
const available = opts.isAvailable ?? isAgentAvailable;
|
|
65
|
+
const config = loadConfig(targetDir);
|
|
66
|
+
const agent = opts.runner ?? config?.agents[0] ?? 'claude';
|
|
67
|
+
if (!available(agent)) {
|
|
68
|
+
console.error(`Agent CLI "${agent}" was not found on PATH. Install it, or pick another with --runner=<claude|codex|gemini>.`);
|
|
69
|
+
return 2;
|
|
70
|
+
}
|
|
71
|
+
const idleMs = resolveIdleMs(opts.timeoutMinutes, undefined);
|
|
72
|
+
const inv = agentInvocation(agent, buildPrdDraftPrompt(idea), targetDir);
|
|
73
|
+
console.log(`Drafting PRD with ${agent}...`);
|
|
74
|
+
const run = opts.run ?? ((i) => runAgent(buildWatchdogInvocation(i, idleMs)));
|
|
75
|
+
const result = run(inv);
|
|
76
|
+
if (!result.success) {
|
|
77
|
+
console.error(`PRD draft failed: ${result.summary}`);
|
|
78
|
+
return 1;
|
|
79
|
+
}
|
|
80
|
+
let count;
|
|
81
|
+
try {
|
|
82
|
+
count = loadPrd(path).length;
|
|
83
|
+
}
|
|
84
|
+
catch (e) {
|
|
85
|
+
console.error(`PRD draft produced an invalid PRD: ${e.message}`);
|
|
86
|
+
return 1;
|
|
87
|
+
}
|
|
88
|
+
if (count === 0) {
|
|
89
|
+
console.error('PRD draft failed: agent produced an empty PRD.');
|
|
90
|
+
return 1;
|
|
91
|
+
}
|
|
92
|
+
console.log(`Drafted ${count} stories → ${path}`);
|
|
93
|
+
return 0;
|
|
94
|
+
}
|
|
95
|
+
export function runPrdCheck(targetDir) {
|
|
96
|
+
const path = prdFile(targetDir);
|
|
97
|
+
if (!existsSync(path)) {
|
|
98
|
+
console.error(`No PRD at ${path} — create one with yoke prd draft or yoke new.`);
|
|
99
|
+
return 1;
|
|
100
|
+
}
|
|
101
|
+
let stories;
|
|
102
|
+
try {
|
|
103
|
+
stories = loadPrd(path);
|
|
104
|
+
}
|
|
105
|
+
catch (e) {
|
|
106
|
+
console.error(`Invalid PRD: ${e.message}`);
|
|
107
|
+
return 1;
|
|
108
|
+
}
|
|
109
|
+
const errors = [];
|
|
110
|
+
if (stories.length === 0)
|
|
111
|
+
errors.push('PRD has no stories');
|
|
112
|
+
const seen = new Set();
|
|
113
|
+
for (const s of stories) {
|
|
114
|
+
if (seen.has(s.id))
|
|
115
|
+
errors.push(`duplicate story id: ${s.id}`);
|
|
116
|
+
seen.add(s.id);
|
|
117
|
+
// the schema allows [], but the loop's stop-the-line gate blocks it — fail fast here
|
|
118
|
+
if (s.acceptance.length === 0)
|
|
119
|
+
errors.push(`story ${s.id} has no acceptance criteria`);
|
|
120
|
+
}
|
|
121
|
+
if (errors.length > 0) {
|
|
122
|
+
for (const e of errors)
|
|
123
|
+
console.error(`ERROR ${e}`);
|
|
124
|
+
return 1;
|
|
125
|
+
}
|
|
126
|
+
const p = progress(stories);
|
|
127
|
+
console.log(`✓ PRD valid — ${p.total} stories, ${p.passed} pass`);
|
|
128
|
+
return 0;
|
|
129
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, copyFileSync } from 'node:fs';
|
|
2
|
+
import { join, dirname } from 'node:path';
|
|
3
|
+
import { mergeJson } from './merge-json.js';
|
|
4
|
+
export function applyActions(actions, targetDir, opts) {
|
|
5
|
+
const results = [];
|
|
6
|
+
for (const action of actions) {
|
|
7
|
+
const dest = join(targetDir, action.target);
|
|
8
|
+
let status;
|
|
9
|
+
let backedUp;
|
|
10
|
+
if (existsSync(dest)) {
|
|
11
|
+
if (action.ifAbsent) {
|
|
12
|
+
results.push({ target: action.target, status: 'unchanged', reason: `${action.reason} (exists, left untouched)` });
|
|
13
|
+
continue;
|
|
14
|
+
}
|
|
15
|
+
const current = readFileSync(dest, 'utf8');
|
|
16
|
+
if (action.merge) {
|
|
17
|
+
let parsedCurrent;
|
|
18
|
+
try {
|
|
19
|
+
parsedCurrent = JSON.parse(current);
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
throw new Error(`yoke: cannot merge ${action.target} — existing file is not valid JSON. Fix or delete it and re-run.`);
|
|
23
|
+
}
|
|
24
|
+
const merged = JSON.stringify(mergeJson(parsedCurrent, JSON.parse(action.content)), null, 2) + '\n';
|
|
25
|
+
if (merged === current) {
|
|
26
|
+
results.push({ target: action.target, status: 'unchanged', reason: action.reason });
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
backedUp = join(opts.backupDir, action.target);
|
|
30
|
+
mkdirSync(dirname(backedUp), { recursive: true });
|
|
31
|
+
copyFileSync(dest, backedUp);
|
|
32
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
33
|
+
writeFileSync(dest, merged);
|
|
34
|
+
results.push({ target: action.target, status: 'merged', backedUp, reason: action.reason });
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
if (current === action.content) {
|
|
38
|
+
results.push({ target: action.target, status: 'unchanged', reason: action.reason });
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
backedUp = join(opts.backupDir, action.target);
|
|
42
|
+
mkdirSync(dirname(backedUp), { recursive: true });
|
|
43
|
+
copyFileSync(dest, backedUp);
|
|
44
|
+
status = 'overwritten';
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
status = 'created';
|
|
48
|
+
}
|
|
49
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
50
|
+
writeFileSync(dest, action.content);
|
|
51
|
+
results.push({ target: action.target, status, backedUp, reason: action.reason });
|
|
52
|
+
}
|
|
53
|
+
return results;
|
|
54
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
// Walk up from this module to the package root (the dir containing package.json),
|
|
5
|
+
// then return its `canon/` directory. Works under both tsx (src/) and built dist/.
|
|
6
|
+
export function resolveCanonDir() {
|
|
7
|
+
let dir = dirname(fileURLToPath(import.meta.url));
|
|
8
|
+
for (let i = 0; i < 10; i++) {
|
|
9
|
+
if (existsSync(join(dir, 'package.json'))) {
|
|
10
|
+
const canon = join(dir, 'canon');
|
|
11
|
+
if (existsSync(join(canon, 'manifest.yaml'))) {
|
|
12
|
+
return canon;
|
|
13
|
+
}
|
|
14
|
+
throw new Error(`yoke installation incomplete — canon/ missing at ${canon}`);
|
|
15
|
+
}
|
|
16
|
+
const parent = dirname(dir);
|
|
17
|
+
if (parent === dir)
|
|
18
|
+
break;
|
|
19
|
+
dir = parent;
|
|
20
|
+
}
|
|
21
|
+
throw new Error('could not locate package root to resolve canon/');
|
|
22
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { resolveCanonDir } from './canon-dir.js';
|
|
3
|
+
import { planRetrofit } from './plan.js';
|
|
4
|
+
import { applyActions } from './apply.js';
|
|
5
|
+
import { formatReport } from './report.js';
|
|
6
|
+
import { detectProject } from './detect.js';
|
|
7
|
+
import { ensureGitignore } from './gitignore.js';
|
|
8
|
+
import { loadConfig, saveConfig, defaultConfig } from './config.js';
|
|
9
|
+
import { loadManifest } from '../canon/manifest.js';
|
|
10
|
+
export function runRetrofit(targetDir, opts) {
|
|
11
|
+
const canonDir = resolveCanonDir();
|
|
12
|
+
const canonVersion = loadManifest(join(canonDir, 'manifest.yaml')).version;
|
|
13
|
+
const detection = detectProject(targetDir);
|
|
14
|
+
const agents = opts.agents && opts.agents.length > 0
|
|
15
|
+
? opts.agents
|
|
16
|
+
: (detection.agents.length > 0 ? detection.agents : ['claude']);
|
|
17
|
+
const existing = loadConfig(targetDir);
|
|
18
|
+
const codeGraph = opts.codeGraph ?? existing?.codeGraph ?? 'graphify';
|
|
19
|
+
const actions = planRetrofit(canonDir, targetDir, agents, codeGraph);
|
|
20
|
+
const backupDir = join(targetDir, '.yoke', 'backup', String(Date.now()));
|
|
21
|
+
const applied = applyActions(actions, targetDir, { backupDir });
|
|
22
|
+
// Ensure runtime artifacts (loop status/log, worktrees, backups) are gitignored
|
|
23
|
+
// so the loop's clean-tree pre-dispatch gate is not broken by untracked files.
|
|
24
|
+
ensureGitignore(targetDir);
|
|
25
|
+
const priorAgents = existing?.agents ?? [];
|
|
26
|
+
const mergedAgents = [...new Set([...priorAgents, ...agents])];
|
|
27
|
+
const config = {
|
|
28
|
+
...(existing ?? defaultConfig(canonVersion)),
|
|
29
|
+
canonVersion,
|
|
30
|
+
agents: mergedAgents,
|
|
31
|
+
loop: { enabled: opts.loop },
|
|
32
|
+
codeGraph,
|
|
33
|
+
};
|
|
34
|
+
saveConfig(targetDir, config);
|
|
35
|
+
console.log(formatReport(applied, { loopEnabled: config.loop.enabled, detectedAgents: detection.agents }));
|
|
36
|
+
return 0;
|
|
37
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join, dirname } from 'node:path';
|
|
3
|
+
import { parse, stringify } from 'yaml';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
const AgentSchema = z.enum(['claude', 'codex', 'gemini']);
|
|
6
|
+
const CodeGraphSchema = z.enum(['graphify', 'serena']);
|
|
7
|
+
const SmokeFlowSchema = z.object({ name: z.string().min(1), path: z.string().min(1), landmark: z.string().optional() });
|
|
8
|
+
const SmokeSchema = z.object({ baseUrl: z.string().min(1), flows: z.array(SmokeFlowSchema).min(1) });
|
|
9
|
+
export const YokeConfigSchema = z.object({
|
|
10
|
+
canonVersion: z.string().min(1),
|
|
11
|
+
agents: z.array(AgentSchema),
|
|
12
|
+
loop: z.object({ enabled: z.boolean(), timeoutMinutes: z.number().optional() }),
|
|
13
|
+
verify: z.object({ command: z.string().min(1), retries: z.number().int().nonnegative().optional() }).optional(),
|
|
14
|
+
codeGraph: CodeGraphSchema.optional(),
|
|
15
|
+
smoke: SmokeSchema.optional(),
|
|
16
|
+
});
|
|
17
|
+
export function defaultConfig(canonVersion) {
|
|
18
|
+
return { canonVersion, agents: [], loop: { enabled: false } };
|
|
19
|
+
}
|
|
20
|
+
export function configPath(targetDir) {
|
|
21
|
+
return join(targetDir, '.yoke', 'config.yaml');
|
|
22
|
+
}
|
|
23
|
+
export function loadConfig(targetDir) {
|
|
24
|
+
const file = configPath(targetDir);
|
|
25
|
+
if (!existsSync(file))
|
|
26
|
+
return null;
|
|
27
|
+
return YokeConfigSchema.parse(parse(readFileSync(file, 'utf8')));
|
|
28
|
+
}
|
|
29
|
+
export function saveConfig(targetDir, config) {
|
|
30
|
+
const file = configPath(targetDir);
|
|
31
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
32
|
+
writeFileSync(file, stringify(config));
|
|
33
|
+
}
|
|
34
|
+
// Decide which command verifies a story is done: explicit config wins; otherwise
|
|
35
|
+
// detect an npm test script; otherwise null (caller must refuse to run blindly).
|
|
36
|
+
export function resolveVerifyCommand(targetDir, config) {
|
|
37
|
+
if (config.verify?.command)
|
|
38
|
+
return config.verify.command;
|
|
39
|
+
const pkgPath = join(targetDir, 'package.json');
|
|
40
|
+
if (existsSync(pkgPath)) {
|
|
41
|
+
try {
|
|
42
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
|
|
43
|
+
const testScript = pkg?.scripts?.test;
|
|
44
|
+
if (typeof testScript === 'string' && testScript.trim() !== '' && !testScript.includes('no test specified')) {
|
|
45
|
+
return 'npm test';
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
// ignore malformed package.json
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
const CONTEXT_FILES = ['PROJECT.md', 'DECISIONS.md', 'KNOWLEDGE.md'];
|
|
4
|
+
export function baseContextActions(canonDir) {
|
|
5
|
+
return CONTEXT_FILES.map(name => {
|
|
6
|
+
let content;
|
|
7
|
+
try {
|
|
8
|
+
content = readFileSync(join(canonDir, 'context', name), 'utf8');
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
throw new Error(`yoke: missing context template ${name} in ${canonDir}/context — run \`yoke validate canon\` to diagnose the canon.`);
|
|
12
|
+
}
|
|
13
|
+
return {
|
|
14
|
+
kind: 'write',
|
|
15
|
+
target: `.yoke/context/${name}`,
|
|
16
|
+
content,
|
|
17
|
+
reason: `context scaffold: ${name}`,
|
|
18
|
+
ifAbsent: true,
|
|
19
|
+
};
|
|
20
|
+
});
|
|
21
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
export function detectProject(targetDir) {
|
|
4
|
+
const has = (...parts) => existsSync(join(targetDir, ...parts));
|
|
5
|
+
const agents = [];
|
|
6
|
+
if (has('.claude') || has('CLAUDE.md'))
|
|
7
|
+
agents.push('claude');
|
|
8
|
+
if (has('.codex') || has('AGENTS.md'))
|
|
9
|
+
agents.push('codex');
|
|
10
|
+
if (has('.gemini') || has('GEMINI.md'))
|
|
11
|
+
agents.push('gemini');
|
|
12
|
+
return {
|
|
13
|
+
agents,
|
|
14
|
+
hasAgentsMd: has('AGENTS.md'),
|
|
15
|
+
hasYokeConfig: has('.yoke', 'config.yaml'),
|
|
16
|
+
};
|
|
17
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
export const YOKE_IGNORE_LINES = [
|
|
4
|
+
'.yoke/worktrees/',
|
|
5
|
+
'.yoke/backup/',
|
|
6
|
+
'.yoke/loop-status.json',
|
|
7
|
+
'.yoke/loop.log',
|
|
8
|
+
'.yoke/loop.lock',
|
|
9
|
+
'.yoke/proof/',
|
|
10
|
+
];
|
|
11
|
+
const HEADER = '# Yoke runtime artifacts (managed by yoke retrofit)';
|
|
12
|
+
// Idempotently ensure each Yoke runtime path is gitignored. Appends only the
|
|
13
|
+
// lines not already present (matched verbatim, line-wise). Preserves existing
|
|
14
|
+
// content. Returns true if the file changed.
|
|
15
|
+
export function ensureGitignore(targetDir) {
|
|
16
|
+
const file = join(targetDir, '.gitignore');
|
|
17
|
+
const current = existsSync(file) ? readFileSync(file, 'utf8') : '';
|
|
18
|
+
const present = new Set(current.split(/\r?\n/).map((l) => l.trim()));
|
|
19
|
+
const missing = YOKE_IGNORE_LINES.filter((l) => !present.has(l));
|
|
20
|
+
if (missing.length === 0)
|
|
21
|
+
return false;
|
|
22
|
+
const prefix = current === '' ? '' : current.endsWith('\n') ? '' : '\n';
|
|
23
|
+
const block = `${prefix}${current === '' ? '' : '\n'}${HEADER}\n${missing.join('\n')}\n`;
|
|
24
|
+
writeFileSync(file, current + block);
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
// Best-effort, non-fatal detection of a gstack install (garrytan/gstack).
|
|
5
|
+
// gstack lives at ~/.claude/skills/gstack (global) or .claude/skills/gstack (repo-local).
|
|
6
|
+
export function detectGstack(targetDir, home = homedir()) {
|
|
7
|
+
const candidates = [
|
|
8
|
+
join(targetDir, '.claude', 'skills', 'gstack'),
|
|
9
|
+
join(home, '.claude', 'skills', 'gstack'),
|
|
10
|
+
];
|
|
11
|
+
return candidates.some(p => {
|
|
12
|
+
try {
|
|
13
|
+
return existsSync(p);
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
function isPlainObject(v) {
|
|
2
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
3
|
+
}
|
|
4
|
+
// Order-independent serialization: object keys are sorted recursively so two
|
|
5
|
+
// structurally-equal values compare equal regardless of key order. Used to
|
|
6
|
+
// de-dupe array items (e.g. a hand-edited hook block with reordered keys must
|
|
7
|
+
// not duplicate our entry).
|
|
8
|
+
function stableStringify(v) {
|
|
9
|
+
if (Array.isArray(v))
|
|
10
|
+
return '[' + v.map(stableStringify).join(',') + ']';
|
|
11
|
+
if (isPlainObject(v)) {
|
|
12
|
+
return '{' + Object.keys(v).sort().map(k => JSON.stringify(k) + ':' + stableStringify(v[k])).join(',') + '}';
|
|
13
|
+
}
|
|
14
|
+
return JSON.stringify(v);
|
|
15
|
+
}
|
|
16
|
+
// Deep-merge two parsed JSON values. Objects merge by key (recursing on shared
|
|
17
|
+
// keys). Arrays concatenate with structural de-duplication. For any other type
|
|
18
|
+
// mismatch or primitive collision, the incoming value wins.
|
|
19
|
+
export function mergeJson(base, incoming) {
|
|
20
|
+
if (isPlainObject(base) && isPlainObject(incoming)) {
|
|
21
|
+
// null prototype: incoming __proto__ becomes a plain own key, never pollutes.
|
|
22
|
+
const out = Object.assign(Object.create(null), base);
|
|
23
|
+
for (const [key, value] of Object.entries(incoming)) {
|
|
24
|
+
out[key] = key in base ? mergeJson(base[key], value) : value;
|
|
25
|
+
}
|
|
26
|
+
return out;
|
|
27
|
+
}
|
|
28
|
+
if (Array.isArray(base) && Array.isArray(incoming)) {
|
|
29
|
+
const out = [...base];
|
|
30
|
+
for (const item of incoming) {
|
|
31
|
+
if (!out.some(existing => stableStringify(existing) === stableStringify(item))) {
|
|
32
|
+
out.push(item);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
return incoming;
|
|
38
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { planClaude } from './planners/claude.js';
|
|
2
|
+
import { planCodex } from './planners/codex.js';
|
|
3
|
+
import { planGemini } from './planners/gemini.js';
|
|
4
|
+
import { baseContextActions } from './context-actions.js';
|
|
5
|
+
export function planClaudeRetrofit(canonDir, targetDir) {
|
|
6
|
+
return planClaude(canonDir, targetDir);
|
|
7
|
+
}
|
|
8
|
+
export const PLANNERS = {
|
|
9
|
+
claude: (c, t, cg) => planClaude(c, t, undefined, cg),
|
|
10
|
+
codex: planCodex,
|
|
11
|
+
gemini: planGemini,
|
|
12
|
+
};
|
|
13
|
+
export function planRetrofit(canonDir, targetDir, agents, codeGraph = 'graphify') {
|
|
14
|
+
const seen = new Set();
|
|
15
|
+
const merged = [];
|
|
16
|
+
for (const action of baseContextActions(canonDir)) {
|
|
17
|
+
seen.add(action.target);
|
|
18
|
+
merged.push(action);
|
|
19
|
+
}
|
|
20
|
+
for (const agent of agents) {
|
|
21
|
+
for (const action of PLANNERS[agent](canonDir, targetDir, codeGraph)) {
|
|
22
|
+
if (seen.has(action.target))
|
|
23
|
+
continue;
|
|
24
|
+
seen.add(action.target);
|
|
25
|
+
merged.push(action);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return merged;
|
|
29
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { loadManifest } from '../../canon/manifest.js';
|
|
4
|
+
import { mcpServers, rtkInstruction } from '../tools.js';
|
|
5
|
+
import { hasWsl } from '../wsl.js';
|
|
6
|
+
import { detectGstack } from '../gstack.js';
|
|
7
|
+
const GSTACK_COMPOSE = `## Composed tools (gstack detected)
|
|
8
|
+
|
|
9
|
+
This project also has [gstack](https://github.com/garrytan/gstack) installed. For capabilities Yoke does not ship, prefer gstack's skills:
|
|
10
|
+
|
|
11
|
+
- Live-browser QA → \`/qa\`
|
|
12
|
+
- Security audit → \`/cso\`
|
|
13
|
+
- Ship / deploy → \`/ship\`, \`/land-and-deploy\`
|
|
14
|
+
`;
|
|
15
|
+
const claudeMd = (rtkNote, composeNote) => `# Project Instructions
|
|
16
|
+
|
|
17
|
+
This project uses the Yoke harness. Baseline instructions:
|
|
18
|
+
|
|
19
|
+
@AGENTS.md
|
|
20
|
+
${rtkNote ? `\n${rtkNote}\n` : ''}${composeNote ? `\n${composeNote}\n` : ''}`;
|
|
21
|
+
export function planClaude(canonDir, targetDir, wslAvailable = hasWsl(), codeGraph = 'graphify', gstackDetected = detectGstack(targetDir)) {
|
|
22
|
+
const manifest = loadManifest(join(canonDir, 'manifest.yaml'));
|
|
23
|
+
const actions = [];
|
|
24
|
+
for (const skill of manifest.skills) {
|
|
25
|
+
actions.push({
|
|
26
|
+
kind: 'write',
|
|
27
|
+
target: `.claude/skills/${skill.id}/SKILL.md`,
|
|
28
|
+
content: readFileSync(join(canonDir, skill.path, 'SKILL.md'), 'utf8'),
|
|
29
|
+
reason: `skill: ${skill.id}`,
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
actions.push({
|
|
33
|
+
kind: 'write',
|
|
34
|
+
target: 'AGENTS.md',
|
|
35
|
+
content: readFileSync(join(canonDir, 'AGENTS.md'), 'utf8'),
|
|
36
|
+
reason: 'baseline instructions',
|
|
37
|
+
});
|
|
38
|
+
// rtk: PreToolUse hook needs WSL on Windows; otherwise fall back to instruction mode.
|
|
39
|
+
const rtkHookable = wslAvailable;
|
|
40
|
+
actions.push({
|
|
41
|
+
kind: 'write',
|
|
42
|
+
target: 'CLAUDE.md',
|
|
43
|
+
content: claudeMd(rtkHookable ? '' : rtkInstruction(), gstackDetected ? GSTACK_COMPOSE : ''),
|
|
44
|
+
reason: 'Claude entry importing AGENTS.md',
|
|
45
|
+
});
|
|
46
|
+
actions.push({
|
|
47
|
+
kind: 'write',
|
|
48
|
+
target: '.mcp.json',
|
|
49
|
+
content: JSON.stringify({ mcpServers: mcpServers(codeGraph) }, null, 2) + '\n',
|
|
50
|
+
reason: 'MCP servers (code-graph + playwright)',
|
|
51
|
+
});
|
|
52
|
+
if (rtkHookable) {
|
|
53
|
+
actions.push({
|
|
54
|
+
kind: 'write',
|
|
55
|
+
target: '.claude/settings.json',
|
|
56
|
+
merge: true,
|
|
57
|
+
content: JSON.stringify({
|
|
58
|
+
_yoke: 'Generated by yoke retrofit. A pre-existing .claude/settings.json (if any) was backed up under .yoke/backup/.',
|
|
59
|
+
hooks: {
|
|
60
|
+
PreToolUse: [{ matcher: 'Bash', hooks: [{ type: 'command', command: 'rtk hook claude' }] }],
|
|
61
|
+
},
|
|
62
|
+
}, null, 2) + '\n',
|
|
63
|
+
reason: 'rtk PreToolUse hook (WSL detected)',
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
return actions;
|
|
67
|
+
}
|