@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.
Files changed (82) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +494 -0
  3. package/canon/AGENTS.md +28 -0
  4. package/canon/context/DECISIONS.md +4 -0
  5. package/canon/context/KNOWLEDGE.md +4 -0
  6. package/canon/context/PROJECT.md +15 -0
  7. package/canon/loop/loop-spec.md +30 -0
  8. package/canon/loop/prd.schema.md +14 -0
  9. package/canon/manifest.yaml +47 -0
  10. package/canon/policy/gates.md +7 -0
  11. package/canon/policy/roles.md +9 -0
  12. package/canon/skills/ATTRIBUTION.md +71 -0
  13. package/canon/skills/authoring-prd/SKILL.md +44 -0
  14. package/canon/skills/brainstorming/SKILL.md +164 -0
  15. package/canon/skills/dispatching-parallel-agents/SKILL.md +182 -0
  16. package/canon/skills/document-release/SKILL.md +297 -0
  17. package/canon/skills/executing-plans/SKILL.md +70 -0
  18. package/canon/skills/finishing-a-development-branch/SKILL.md +200 -0
  19. package/canon/skills/health/SKILL.md +177 -0
  20. package/canon/skills/maintaining-context/SKILL.md +34 -0
  21. package/canon/skills/minimal-code/SKILL.md +21 -0
  22. package/canon/skills/plan-ceo-review/SKILL.md +541 -0
  23. package/canon/skills/plan-eng-review/SKILL.md +362 -0
  24. package/canon/skills/receiving-code-review/SKILL.md +213 -0
  25. package/canon/skills/requesting-code-review/SKILL.md +105 -0
  26. package/canon/skills/retro/SKILL.md +397 -0
  27. package/canon/skills/review/SKILL.md +246 -0
  28. package/canon/skills/ship/SKILL.md +696 -0
  29. package/canon/skills/subagent-driven-development/SKILL.md +277 -0
  30. package/canon/skills/systematic-debugging/SKILL.md +296 -0
  31. package/canon/skills/tdd/SKILL.md +371 -0
  32. package/canon/skills/unslop-ui/SKILL.md +34 -0
  33. package/canon/skills/using-git-worktrees/SKILL.md +218 -0
  34. package/canon/skills/verification-before-completion/SKILL.md +139 -0
  35. package/canon/skills/visual-verification/SKILL.md +54 -0
  36. package/canon/skills/workflow/SKILL.md +18 -0
  37. package/canon/skills/writing-plans/SKILL.md +152 -0
  38. package/canon/skills/writing-skills/SKILL.md +655 -0
  39. package/canon/skills/yoke-retrofit/SKILL.md +18 -0
  40. package/canon/tools/graphify.md +3 -0
  41. package/canon/tools/playwright-mcp.md +3 -0
  42. package/canon/tools/rtk.md +7 -0
  43. package/canon/tools/serena.md +7 -0
  44. package/dist/canon/frontmatter.js +10 -0
  45. package/dist/canon/manifest.js +26 -0
  46. package/dist/canon/validate.js +73 -0
  47. package/dist/cli.js +244 -0
  48. package/dist/context/command.js +33 -0
  49. package/dist/context/context.js +57 -0
  50. package/dist/loop/cleanup.js +42 -0
  51. package/dist/loop/gates.js +12 -0
  52. package/dist/loop/git.js +25 -0
  53. package/dist/loop/lock.js +45 -0
  54. package/dist/loop/loop.js +190 -0
  55. package/dist/loop/prd.js +29 -0
  56. package/dist/loop/reporter.js +91 -0
  57. package/dist/loop/run-command.js +134 -0
  58. package/dist/loop/runner.js +157 -0
  59. package/dist/loop/verify.js +38 -0
  60. package/dist/loop/watchdog.js +86 -0
  61. package/dist/new/command.js +53 -0
  62. package/dist/prd/command.js +129 -0
  63. package/dist/retrofit/apply.js +54 -0
  64. package/dist/retrofit/canon-dir.js +22 -0
  65. package/dist/retrofit/command.js +37 -0
  66. package/dist/retrofit/config.js +53 -0
  67. package/dist/retrofit/context-actions.js +21 -0
  68. package/dist/retrofit/detect.js +17 -0
  69. package/dist/retrofit/gitignore.js +26 -0
  70. package/dist/retrofit/gstack.js +19 -0
  71. package/dist/retrofit/merge-json.js +38 -0
  72. package/dist/retrofit/plan.js +29 -0
  73. package/dist/retrofit/planners/claude.js +67 -0
  74. package/dist/retrofit/planners/codex.js +36 -0
  75. package/dist/retrofit/planners/gemini.js +54 -0
  76. package/dist/retrofit/report.js +14 -0
  77. package/dist/retrofit/tools.js +23 -0
  78. package/dist/retrofit/wsl.js +15 -0
  79. package/dist/review/command.js +43 -0
  80. package/dist/scan/design.js +79 -0
  81. package/dist/smoke/command.js +141 -0
  82. package/package.json +61 -0
@@ -0,0 +1,10 @@
1
+ import { parse } from 'yaml';
2
+ export function parseFrontmatter(content) {
3
+ const m = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
4
+ if (!m)
5
+ return null;
6
+ const parsed = parse(m[1]);
7
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
8
+ ? parsed
9
+ : null;
10
+ }
@@ -0,0 +1,26 @@
1
+ import { z } from 'zod';
2
+ import { parse } from 'yaml';
3
+ import { readFileSync } from 'node:fs';
4
+ export const AgentSchema = z.enum(['claude', 'codex', 'gemini']);
5
+ export const SkillEntrySchema = z.object({
6
+ id: z.string().min(1),
7
+ path: z.string().min(1),
8
+ kind: z.enum(['methodology', 'role']),
9
+ });
10
+ export const ToolEntrySchema = z.object({
11
+ id: z.string().min(1),
12
+ path: z.string().min(1),
13
+ });
14
+ export const ManifestSchema = z.object({
15
+ name: z.string().min(1),
16
+ version: z.string().min(1),
17
+ agents: z.array(AgentSchema).min(1),
18
+ skills: z.array(SkillEntrySchema),
19
+ policy: z.array(z.object({ path: z.string().min(1) })),
20
+ loop: z.object({ spec: z.string().min(1), prdSchema: z.string().min(1) }),
21
+ tools: z.array(ToolEntrySchema),
22
+ });
23
+ export function loadManifest(file) {
24
+ const raw = parse(readFileSync(file, 'utf8'));
25
+ return ManifestSchema.parse(raw);
26
+ }
@@ -0,0 +1,73 @@
1
+ import { existsSync, readFileSync, statSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { loadManifest } from './manifest.js';
4
+ import { parseFrontmatter } from './frontmatter.js';
5
+ export function validateCanon(canonDir) {
6
+ const issues = [];
7
+ const manifestPath = join(canonDir, 'manifest.yaml');
8
+ if (!existsSync(manifestPath)) {
9
+ return [{ level: 'error', message: `manifest.yaml not found in ${canonDir}` }];
10
+ }
11
+ let manifest;
12
+ try {
13
+ manifest = loadManifest(manifestPath);
14
+ }
15
+ catch (e) {
16
+ return [{ level: 'error', message: `manifest.yaml invalid: ${e.message}` }];
17
+ }
18
+ const seenSkill = new Set();
19
+ for (const s of manifest.skills) {
20
+ if (seenSkill.has(s.id))
21
+ issues.push({ level: 'error', message: `duplicate skill id: ${s.id}` });
22
+ seenSkill.add(s.id);
23
+ const dir = join(canonDir, s.path);
24
+ if (!existsSync(dir) || !statSync(dir).isDirectory()) {
25
+ issues.push({ level: 'error', message: `skill ${s.id}: path not found: ${s.path}` });
26
+ continue;
27
+ }
28
+ const skillMd = join(dir, 'SKILL.md');
29
+ if (!existsSync(skillMd)) {
30
+ issues.push({ level: 'error', message: `skill ${s.id}: SKILL.md missing` });
31
+ continue;
32
+ }
33
+ const fm = parseFrontmatter(readFileSync(skillMd, 'utf8'));
34
+ if (!fm) {
35
+ issues.push({ level: 'error', message: `skill ${s.id}: SKILL.md has no frontmatter` });
36
+ }
37
+ else {
38
+ if (!fm.name)
39
+ issues.push({ level: 'error', message: `skill ${s.id}: frontmatter missing name` });
40
+ if (!fm.description)
41
+ issues.push({ level: 'error', message: `skill ${s.id}: frontmatter missing description` });
42
+ }
43
+ }
44
+ for (const p of manifest.policy) {
45
+ if (!existsSync(join(canonDir, p.path))) {
46
+ issues.push({ level: 'error', message: `policy file not found: ${p.path}` });
47
+ }
48
+ }
49
+ const loopChecks = [
50
+ ['loop.spec', manifest.loop.spec],
51
+ ['loop.prdSchema', manifest.loop.prdSchema],
52
+ ];
53
+ for (const [label, rel] of loopChecks) {
54
+ if (!existsSync(join(canonDir, rel))) {
55
+ issues.push({ level: 'error', message: `${label} not found: ${rel}` });
56
+ }
57
+ }
58
+ for (const name of ['PROJECT.md', 'DECISIONS.md', 'KNOWLEDGE.md']) {
59
+ if (!existsSync(join(canonDir, 'context', name))) {
60
+ issues.push({ level: 'error', message: `context template not found: context/${name}` });
61
+ }
62
+ }
63
+ const seenTool = new Set();
64
+ for (const t of manifest.tools) {
65
+ if (seenTool.has(t.id))
66
+ issues.push({ level: 'error', message: `duplicate tool id: ${t.id}` });
67
+ seenTool.add(t.id);
68
+ if (!existsSync(join(canonDir, t.path))) {
69
+ issues.push({ level: 'error', message: `tool ${t.id}: path not found: ${t.path}` });
70
+ }
71
+ }
72
+ return issues;
73
+ }
package/dist/cli.js ADDED
@@ -0,0 +1,244 @@
1
+ #!/usr/bin/env node
2
+ import { pathToFileURL } from 'node:url';
3
+ import { validateCanon } from './canon/validate.js';
4
+ import { runRetrofit } from './retrofit/command.js';
5
+ import { setLoopEnabled, loopStatus, runLoopCommand } from './loop/run-command.js';
6
+ import { runContextInit, runContextStatus } from './context/command.js';
7
+ import { runReview } from './review/command.js';
8
+ import { scanDir } from './scan/design.js';
9
+ import { runNew } from './new/command.js';
10
+ import { runPrdDraft, runPrdCheck } from './prd/command.js';
11
+ import { runLoopCleanup } from './loop/cleanup.js';
12
+ import { runFlowSmoke } from './smoke/command.js';
13
+ export { runRetrofit } from './retrofit/command.js';
14
+ export function runValidate(canonDir) {
15
+ const issues = validateCanon(canonDir);
16
+ for (const i of issues) {
17
+ const line = `${i.level === 'error' ? 'ERROR' : 'warn '} ${i.message}`;
18
+ if (i.level === 'error')
19
+ console.error(line);
20
+ else
21
+ console.log(line);
22
+ }
23
+ const errors = issues.filter(i => i.level === 'error');
24
+ if (errors.length === 0) {
25
+ console.log(`✓ canon valid (${canonDir})`);
26
+ return 0;
27
+ }
28
+ console.log(`✗ ${errors.length} error(s)`);
29
+ return 1;
30
+ }
31
+ export function runDesignScan(targetDir, opts) {
32
+ const { findings, score } = scanDir(targetDir);
33
+ for (const f of findings) {
34
+ console.log(` ${f.file}:${f.line} ${f.tell} — ${f.hint}`);
35
+ }
36
+ const label = `Design scan: score ${score} (${findings.length} tell${findings.length === 1 ? '' : 's'}), budget ${opts.max}`;
37
+ if (opts.report) {
38
+ console.log(`${label} — report only`);
39
+ return 0;
40
+ }
41
+ if (score > opts.max) {
42
+ console.log(`${label} — ✗ over budget`);
43
+ return 1;
44
+ }
45
+ console.log(`${label} — ✓`);
46
+ return 0;
47
+ }
48
+ function main(argv) {
49
+ const [cmd, ...rest] = argv;
50
+ switch (cmd) {
51
+ case 'validate':
52
+ return runValidate(rest[0] ?? 'canon');
53
+ case 'retrofit': {
54
+ const targetDir = rest.find(a => !a.startsWith('-')) ?? '.';
55
+ const loop = rest.includes('--loop');
56
+ const agentArg = rest.find(a => a.startsWith('--agent='))?.slice('--agent='.length);
57
+ const all = ['claude', 'codex', 'gemini'];
58
+ const agents = !agentArg || agentArg === 'all'
59
+ ? (agentArg === 'all' ? all : undefined)
60
+ : agentArg.split(',').filter((a) => all.includes(a));
61
+ if (agentArg && agentArg !== 'all' && agents !== undefined && agents.length === 0) {
62
+ console.warn('Unknown agent(s) in --agent; falling back to detection');
63
+ }
64
+ const cgArg = rest.find(a => a.startsWith('--code-graph='))?.slice('--code-graph='.length);
65
+ const codeGraph = cgArg === 'serena' || cgArg === 'graphify' ? cgArg : undefined;
66
+ if (cgArg && !codeGraph) {
67
+ console.error(`Invalid --code-graph value: ${cgArg} (expected graphify|serena)`);
68
+ return 1;
69
+ }
70
+ return runRetrofit(targetDir, { loop, agents, codeGraph });
71
+ }
72
+ case 'loop': {
73
+ const sub = rest[0];
74
+ const targetDir = rest.slice(1).find(a => !a.startsWith('-')) ?? '.';
75
+ if (sub === 'on') {
76
+ setLoopEnabled(targetDir, true);
77
+ console.log('Loop enabled.');
78
+ return 0;
79
+ }
80
+ if (sub === 'off') {
81
+ setLoopEnabled(targetDir, false);
82
+ console.log('Loop disabled.');
83
+ return 0;
84
+ }
85
+ if (sub === 'status') {
86
+ console.log(loopStatus(targetDir));
87
+ return 0;
88
+ }
89
+ if (sub === 'cleanup')
90
+ return runLoopCleanup(targetDir);
91
+ if (sub === 'run') {
92
+ const maxArg = rest.find(a => a.startsWith('--max='));
93
+ const rawMax = maxArg ? Number(maxArg.slice('--max='.length)) : 25;
94
+ if (!Number.isFinite(rawMax) || rawMax <= 0) {
95
+ console.error(`Invalid --max value: ${maxArg}`);
96
+ return 1;
97
+ }
98
+ const runnerArg = rest.find(a => a.startsWith('--runner='))?.slice('--runner='.length);
99
+ const valid = ['claude', 'codex', 'gemini'];
100
+ const agent = runnerArg && valid.includes(runnerArg) ? runnerArg : undefined;
101
+ if (runnerArg && !agent) {
102
+ console.error(`Invalid --runner value: ${runnerArg} (expected claude|codex|gemini)`);
103
+ return 1;
104
+ }
105
+ const isolate = rest.includes('--isolate');
106
+ const reviewerArg = rest.find(a => a.startsWith('--reviewer='))?.slice('--reviewer='.length);
107
+ let reviewer;
108
+ if (reviewerArg) {
109
+ if (!valid.includes(reviewerArg)) {
110
+ console.error(`Invalid --reviewer value: ${reviewerArg} (expected claude|codex|gemini)`);
111
+ return 1;
112
+ }
113
+ reviewer = reviewerArg;
114
+ }
115
+ const review = rest.includes('--review');
116
+ const toArg = rest.find(a => a.startsWith('--timeout='));
117
+ let timeoutMinutes;
118
+ if (toArg) {
119
+ const v = Number(toArg.slice('--timeout='.length));
120
+ if (!Number.isFinite(v) || v < 0) {
121
+ console.error(`Invalid --timeout value: ${toArg}`);
122
+ return 1;
123
+ }
124
+ timeoutMinutes = v;
125
+ }
126
+ return runLoopCommand(targetDir, { maxIterations: rawMax, agent, isolate, reviewer, review, timeoutMinutes });
127
+ }
128
+ console.log('usage: yoke loop <on|off|status|cleanup|run [--max=N] [--runner=<claude|codex|gemini>] [--reviewer=<claude|codex|gemini>] [--review] [--isolate] [--timeout=<minutes>]> [targetDir]');
129
+ return 1;
130
+ }
131
+ case 'new': {
132
+ const dir = rest.find(a => !a.startsWith('-'));
133
+ if (!dir) {
134
+ console.error('usage: yoke new <dir> [--idea="..."] [--agent=claude,codex,gemini|all] [--runner=<claude|codex|gemini>] [--loop]');
135
+ return 1;
136
+ }
137
+ const idea = rest.find(a => a.startsWith('--idea='))?.slice('--idea='.length);
138
+ const loop = rest.includes('--loop');
139
+ const agentArg = rest.find(a => a.startsWith('--agent='))?.slice('--agent='.length);
140
+ const all = ['claude', 'codex', 'gemini'];
141
+ const agents = !agentArg || agentArg === 'all'
142
+ ? (agentArg === 'all' ? all : undefined)
143
+ : agentArg.split(',').filter((a) => all.includes(a));
144
+ if (agentArg && agentArg !== 'all' && agents !== undefined && agents.length === 0) {
145
+ console.warn('Unknown agent(s) in --agent; falling back to detection');
146
+ }
147
+ const runnerArg = rest.find(a => a.startsWith('--runner='))?.slice('--runner='.length);
148
+ if (runnerArg && !all.includes(runnerArg)) {
149
+ console.error(`Invalid --runner value: ${runnerArg} (expected claude|codex|gemini)`);
150
+ return 1;
151
+ }
152
+ return runNew(dir, { idea, agents, runner: runnerArg, loop });
153
+ }
154
+ case 'prd': {
155
+ const sub = rest[0];
156
+ const targetDir = rest.slice(1).find(a => !a.startsWith('-')) ?? '.';
157
+ if (sub === 'draft') {
158
+ const idea = rest.find(a => a.startsWith('--idea='))?.slice('--idea='.length);
159
+ if (!idea) {
160
+ console.error('usage: yoke prd draft [dir] --idea="..." [--runner=<claude|codex|gemini>] [--force] [--timeout=<minutes>]');
161
+ return 1;
162
+ }
163
+ const valid = ['claude', 'codex', 'gemini'];
164
+ const runnerArg = rest.find(a => a.startsWith('--runner='))?.slice('--runner='.length);
165
+ if (runnerArg && !valid.includes(runnerArg)) {
166
+ console.error(`Invalid --runner value: ${runnerArg} (expected claude|codex|gemini)`);
167
+ return 1;
168
+ }
169
+ const force = rest.includes('--force');
170
+ const toArg = rest.find(a => a.startsWith('--timeout='));
171
+ let timeoutMinutes;
172
+ if (toArg) {
173
+ const v = Number(toArg.slice('--timeout='.length));
174
+ if (!Number.isFinite(v) || v < 0) {
175
+ console.error(`Invalid --timeout value: ${toArg}`);
176
+ return 1;
177
+ }
178
+ timeoutMinutes = v;
179
+ }
180
+ return runPrdDraft(targetDir, { idea, runner: runnerArg, force, timeoutMinutes });
181
+ }
182
+ if (sub === 'check')
183
+ return runPrdCheck(targetDir);
184
+ console.log('usage: yoke prd <draft|check> [dir] [--idea="..."] [--runner=<claude|codex|gemini>] [--force] [--timeout=<minutes>]');
185
+ return 1;
186
+ }
187
+ case 'context': {
188
+ const sub = rest[0];
189
+ const targetDir = rest.slice(1).find(a => !a.startsWith('-')) ?? '.';
190
+ if (sub === 'init')
191
+ return runContextInit(targetDir);
192
+ if (sub === 'status')
193
+ return runContextStatus(targetDir);
194
+ console.log('usage: yoke context <init|status> [targetDir]');
195
+ return 1;
196
+ }
197
+ case 'review': {
198
+ const targetDir = rest.find(a => !a.startsWith('-')) ?? '.';
199
+ const valid = ['claude', 'codex', 'gemini'];
200
+ const reviewerArg = rest.find(a => a.startsWith('--reviewer='))?.slice('--reviewer='.length);
201
+ if (reviewerArg && !valid.includes(reviewerArg)) {
202
+ console.error(`Invalid --reviewer value: ${reviewerArg} (expected claude|codex|gemini)`);
203
+ return 1;
204
+ }
205
+ const base = rest.find(a => a.startsWith('--base='))?.slice('--base='.length);
206
+ const focus = rest.find(a => a.startsWith('--focus='))?.slice('--focus='.length);
207
+ const toArg = rest.find(a => a.startsWith('--timeout='));
208
+ let timeoutMinutes;
209
+ if (toArg) {
210
+ const v = Number(toArg.slice('--timeout='.length));
211
+ if (!Number.isFinite(v) || v < 0) {
212
+ console.error(`Invalid --timeout value: ${toArg}`);
213
+ return 1;
214
+ }
215
+ timeoutMinutes = v;
216
+ }
217
+ return runReview(targetDir, { reviewer: reviewerArg, base, focus, timeoutMinutes });
218
+ }
219
+ case 'flow-smoke': {
220
+ const targetDir = rest.find(a => !a.startsWith('-')) ?? '.';
221
+ const url = rest.find(a => a.startsWith('--url='))?.slice('--url='.length);
222
+ const label = rest.find(a => a.startsWith('--label='))?.slice('--label='.length);
223
+ return runFlowSmoke(targetDir, { url, label });
224
+ }
225
+ case 'design-scan': {
226
+ const targetDir = rest.find(a => !a.startsWith('-')) ?? '.';
227
+ const report = rest.includes('--report');
228
+ const maxArg = rest.find(a => a.startsWith('--max='));
229
+ const max = maxArg ? Number(maxArg.slice('--max='.length)) : 4;
230
+ if (!Number.isFinite(max) || max < 0) {
231
+ console.error(`Invalid --max value: ${maxArg}`);
232
+ return 1;
233
+ }
234
+ return runDesignScan(targetDir, { max, report });
235
+ }
236
+ default:
237
+ console.log('usage: yoke <new <dir> [--idea="..."] | validate [canonDir] | retrofit [targetDir] [--agent=claude,codex,gemini|all] [--code-graph=graphify|serena] [--loop] | prd <draft|check> [dir] | loop <on|off|status|run|cleanup> | context <init|status> | review [dir] [--reviewer=<claude|codex|gemini>] [--base=<ref>] [--focus="..."] | design-scan [dir] [--max=N] [--report] | flow-smoke [dir] [--url=<baseUrl>] [--label=<name>]>');
238
+ return cmd ? 1 : 0;
239
+ }
240
+ }
241
+ const isMain = process.argv[1] ? pathToFileURL(process.argv[1]).href === import.meta.url : false;
242
+ if (isMain) {
243
+ process.exit(await main(process.argv.slice(2)));
244
+ }
@@ -0,0 +1,33 @@
1
+ import { existsSync, readFileSync, statSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { resolveCanonDir } from '../retrofit/canon-dir.js';
4
+ import { baseContextActions } from '../retrofit/context-actions.js';
5
+ import { applyActions } from '../retrofit/apply.js';
6
+ import { contextDir } from './context.js';
7
+ export function runContextInit(targetDir) {
8
+ const canonDir = resolveCanonDir();
9
+ const actions = baseContextActions(canonDir);
10
+ const applied = applyActions(actions, targetDir, { backupDir: join(targetDir, '.yoke', 'backup', 'context') });
11
+ for (const a of applied)
12
+ console.log(` ${a.status.padEnd(11)} ${a.target}`);
13
+ return 0;
14
+ }
15
+ export function runContextStatus(targetDir) {
16
+ const dir = contextDir(targetDir);
17
+ const files = ['PROJECT.md', 'DECISIONS.md', 'KNOWLEDGE.md'];
18
+ if (!files.some(f => existsSync(join(dir, f)))) {
19
+ console.log('Context not initialised (no .yoke/context). Run: yoke context init');
20
+ return 0;
21
+ }
22
+ for (const f of files) {
23
+ const p = join(dir, f);
24
+ console.log(existsSync(p) ? ` ${f.padEnd(13)} ${statSync(p).size} bytes` : ` ${f.padEnd(13)} (missing)`);
25
+ }
26
+ const decisions = join(dir, 'DECISIONS.md');
27
+ if (existsSync(decisions)) {
28
+ const last = readFileSync(decisions, 'utf8').split('\n').filter(l => l.startsWith('## ')).pop();
29
+ if (last)
30
+ console.log(` last decision: ${last.slice(3)}`);
31
+ }
32
+ return 0;
33
+ }
@@ -0,0 +1,57 @@
1
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, rmSync } from 'node:fs';
2
+ import { join, dirname } from 'node:path';
3
+ export const MAX_CONTEXT_CHARS = 2000;
4
+ export function contextDir(targetDir) {
5
+ return join(targetDir, '.yoke', 'context');
6
+ }
7
+ function readIf(file) {
8
+ return existsSync(file) ? readFileSync(file, 'utf8') : '';
9
+ }
10
+ export function loadContext(dir) {
11
+ return {
12
+ project: readIf(join(dir, 'PROJECT.md')),
13
+ decisions: readIf(join(dir, 'DECISIONS.md')),
14
+ knowledge: readIf(join(dir, 'KNOWLEDGE.md')),
15
+ };
16
+ }
17
+ function boundHead(s, max) {
18
+ return s.length <= max ? s : s.slice(0, max) + '\n… (truncated)';
19
+ }
20
+ function boundTail(s, max) {
21
+ return s.length <= max ? s : '… (truncated)\n' + s.slice(s.length - max);
22
+ }
23
+ // `max` bounds the body of each file (after trimming), not the rendered section —
24
+ // the `###` sub-heading and truncation marker add a small fixed overhead on top.
25
+ export function formatForPrompt(ctx, max = MAX_CONTEXT_CHARS) {
26
+ const parts = [];
27
+ if (ctx.project.trim())
28
+ parts.push(`### North star (PROJECT.md)\n${boundHead(ctx.project.trim(), max)}`);
29
+ if (ctx.knowledge.trim())
30
+ parts.push(`### Known gotchas (KNOWLEDGE.md)\n${boundHead(ctx.knowledge.trim(), max)}`);
31
+ if (ctx.decisions.trim())
32
+ parts.push(`### Recent decisions (DECISIONS.md)\n${boundTail(ctx.decisions.trim(), max)}`);
33
+ if (parts.length === 0)
34
+ return '';
35
+ return ['## Project context (from .yoke/context — read before implementing)', ...parts].join('\n\n');
36
+ }
37
+ export function appendDecision(dir, entry, now = new Date()) {
38
+ const file = join(dir, 'DECISIONS.md');
39
+ const existed = existsSync(file);
40
+ const prior = existed ? readFileSync(file, 'utf8') : '';
41
+ const date = now.toISOString().slice(0, 10);
42
+ const block = `\n## ${date} — ${entry.storyId}: ${entry.title}\n${entry.summary}\n`;
43
+ mkdirSync(dirname(file), { recursive: true });
44
+ writeFileSync(file, prior + block);
45
+ return {
46
+ rollback: () => {
47
+ if (existed)
48
+ writeFileSync(file, prior);
49
+ else {
50
+ try {
51
+ rmSync(file);
52
+ }
53
+ catch { /* best-effort cleanup */ }
54
+ }
55
+ },
56
+ };
57
+ }
@@ -0,0 +1,42 @@
1
+ import { existsSync, readdirSync, rmSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { execFileSync } from 'node:child_process';
4
+ import { lockPath, readLock, isPidAlive } from './lock.js';
5
+ // Cleans ONLY yoke-created runtime artifacts: .yoke/worktrees/* and a stale loop.lock.
6
+ // Never touches user-created worktrees or a lock whose holder is alive.
7
+ export function runLoopCleanup(targetDir, opts = {}) {
8
+ const git = opts.git ?? ((args, cwd) => { execFileSync('git', args, { cwd, stdio: 'pipe' }); });
9
+ const wtDir = join(targetDir, '.yoke', 'worktrees');
10
+ let removed = 0;
11
+ let failed = 0;
12
+ if (existsSync(wtDir)) {
13
+ for (const name of readdirSync(wtDir)) {
14
+ const path = join(wtDir, name);
15
+ try {
16
+ git(['worktree', 'remove', '--force', path], targetDir);
17
+ removed++;
18
+ }
19
+ catch (e) {
20
+ console.error(`Failed to remove worktree ${path}: ${e.message}`);
21
+ failed++;
22
+ }
23
+ }
24
+ try {
25
+ git(['worktree', 'prune'], targetDir);
26
+ }
27
+ catch { /* best-effort */ }
28
+ }
29
+ const lockFile = lockPath(targetDir);
30
+ if (existsSync(lockFile)) {
31
+ const holder = readLock(targetDir);
32
+ if (holder && isPidAlive(holder.pid)) {
33
+ console.log(`Loop lock held by a live process (pid ${holder.pid}) — left in place.`);
34
+ }
35
+ else {
36
+ rmSync(lockFile, { force: true });
37
+ console.log('Removed stale loop lock.');
38
+ }
39
+ }
40
+ console.log(removed === 0 && failed === 0 ? 'Nothing to clean.' : `Removed ${removed} worktree(s)${failed > 0 ? `, ${failed} failed` : ''}.`);
41
+ return failed === 0 ? 0 : 1;
42
+ }
@@ -0,0 +1,12 @@
1
+ export function stopTheLineGate(story) {
2
+ if (story.acceptance.length === 0) {
3
+ return { ok: false, reason: `story ${story.id} has no acceptance criteria (Stop-the-Line)` };
4
+ }
5
+ return { ok: true };
6
+ }
7
+ export function preDispatchGate(targetDir, git) {
8
+ if (!git.isClean(targetDir)) {
9
+ return { ok: false, reason: 'git worktree is dirty — commit or stash before running the loop' };
10
+ }
11
+ return { ok: true };
12
+ }
@@ -0,0 +1,25 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ export const realGitOps = {
3
+ isClean(dir) {
4
+ const out = execFileSync('git', ['status', '--porcelain'], { cwd: dir }).toString();
5
+ return out.trim() === '';
6
+ },
7
+ commitAll(dir, message) {
8
+ execFileSync('git', ['add', '-A'], { cwd: dir, stdio: 'pipe' });
9
+ const status = execFileSync('git', ['status', '--porcelain'], { cwd: dir }).toString().trim();
10
+ if (status === '') {
11
+ throw new Error('nothing to commit after agent run');
12
+ }
13
+ execFileSync('git', ['-c', 'commit.gpgsign=false', 'commit', '-m', message], { cwd: dir, stdio: 'pipe' });
14
+ },
15
+ addWorktree(repoDir, worktreePath) {
16
+ execFileSync('git', ['worktree', 'add', '--detach', worktreePath, 'HEAD'], { cwd: repoDir, stdio: 'pipe' });
17
+ },
18
+ removeWorktree(repoDir, worktreePath) {
19
+ execFileSync('git', ['worktree', 'remove', '--force', worktreePath], { cwd: repoDir, stdio: 'pipe' });
20
+ },
21
+ integrate(repoDir, worktreePath) {
22
+ const sha = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: worktreePath }).toString().trim();
23
+ execFileSync('git', ['merge', '--ff-only', sha], { cwd: repoDir, stdio: 'pipe' });
24
+ },
25
+ };
@@ -0,0 +1,45 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'node:fs';
2
+ import { join, dirname } from 'node:path';
3
+ export function lockPath(targetDir) {
4
+ return join(targetDir, '.yoke', 'loop.lock');
5
+ }
6
+ // Liveness probe via signal 0. EPERM means "exists but not ours" — alive.
7
+ export function isPidAlive(pid) {
8
+ if (!Number.isInteger(pid) || pid <= 0)
9
+ return false;
10
+ try {
11
+ process.kill(pid, 0);
12
+ return true;
13
+ }
14
+ catch (e) {
15
+ return e.code === 'EPERM';
16
+ }
17
+ }
18
+ export function readLock(targetDir) {
19
+ const file = lockPath(targetDir);
20
+ if (!existsSync(file))
21
+ return null;
22
+ try {
23
+ const parsed = JSON.parse(readFileSync(file, 'utf8'));
24
+ return typeof parsed?.pid === 'number' ? parsed : null;
25
+ }
26
+ catch {
27
+ return null;
28
+ }
29
+ }
30
+ export function acquireLock(targetDir, pid = process.pid) {
31
+ const file = lockPath(targetDir);
32
+ const holder = readLock(targetDir);
33
+ if (holder && isPidAlive(holder.pid))
34
+ return { acquired: false, holderPid: holder.pid };
35
+ mkdirSync(dirname(file), { recursive: true });
36
+ writeFileSync(file, JSON.stringify({ pid, startedAt: new Date().toISOString() }));
37
+ return holder ? { acquired: true, stalePid: holder.pid } : { acquired: true };
38
+ }
39
+ // Best-effort: a missing file or an unwritable disk must never crash loop teardown.
40
+ export function releaseLock(targetDir) {
41
+ try {
42
+ rmSync(lockPath(targetDir), { force: true });
43
+ }
44
+ catch { /* best-effort */ }
45
+ }