@hecer/yoke 0.2.0 → 0.7.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/CHANGELOG.md +112 -0
- package/README.md +557 -494
- package/bench/.runs/claude-2026-07-09T22-34-01/.yoke/config.yaml +6 -0
- package/bench/.runs/claude-2026-07-09T22-34-01/.yoke/context/DECISIONS.md +9 -0
- package/bench/.runs/claude-2026-07-09T22-34-01/.yoke/prd.yaml +38 -0
- package/bench/.runs/claude-2026-07-09T22-34-01/bench-verify.mjs +15 -0
- package/bench/.runs/claude-2026-07-09T22-34-01/package.json +9 -0
- package/bench/.runs/claude-2026-07-09T22-34-01/src/index.mjs +48 -0
- package/bench/.runs/claude-2026-07-09T22-34-01/tests/STORY-1.test.mjs +24 -0
- package/bench/.runs/claude-2026-07-09T22-34-01/tests/STORY-2.test.mjs +28 -0
- package/bench/.runs/claude-2026-07-09T22-34-01/tests/STORY-3.test.mjs +25 -0
- package/bench/.runs/gemini-2026-07-09T22-34-02/.yoke/config.yaml +6 -0
- package/bench/.runs/gemini-2026-07-09T22-34-02/.yoke/prd.yaml +32 -0
- package/bench/.runs/gemini-2026-07-09T22-34-02/bench-verify.mjs +15 -0
- package/bench/.runs/gemini-2026-07-09T22-34-02/package.json +9 -0
- package/bench/.runs/gemini-2026-07-09T22-34-02/src/index.mjs +3 -0
- package/bench/.runs/gemini-2026-07-09T22-34-02/tests/STORY-1.test.mjs +24 -0
- package/bench/.runs/gemini-2026-07-09T22-34-02/tests/STORY-2.test.mjs +28 -0
- package/bench/.runs/gemini-2026-07-09T22-34-02/tests/STORY-3.test.mjs +25 -0
- package/bench/README.md +42 -0
- package/bench/RESULTS.md +36 -0
- package/bench/fixtures/string-kit/.yoke/config.yaml +6 -0
- package/bench/fixtures/string-kit/.yoke/prd.yaml +32 -0
- package/bench/fixtures/string-kit/bench-verify.mjs +15 -0
- package/bench/fixtures/string-kit/package.json +9 -0
- package/bench/fixtures/string-kit/src/index.mjs +3 -0
- package/bench/fixtures/string-kit/tests/STORY-1.test.mjs +24 -0
- package/bench/fixtures/string-kit/tests/STORY-2.test.mjs +28 -0
- package/bench/fixtures/string-kit/tests/STORY-3.test.mjs +25 -0
- package/bench/results/claude-2026-07-09T22-34-01.json +40 -0
- package/bench/run.mjs +115 -0
- package/canon/loop/loop-spec.md +5 -1
- package/canon/manifest.yaml +3 -0
- package/canon/skills/yoke-retrofit/SKILL.md +1 -0
- package/canon/tools/claude-mem.md +15 -0
- package/canon/tools/ui-ux-pro-max.md +15 -0
- package/dist/cli.js +32 -6
- package/dist/loop/cleanup.js +44 -3
- package/dist/loop/loop.js +27 -2
- package/dist/loop/reporter.js +21 -3
- package/dist/loop/run-command.js +19 -6
- package/dist/loop/runner.js +130 -13
- package/dist/loop/watchdog.js +67 -17
- package/dist/retrofit/apply.js +10 -3
- package/dist/retrofit/config.js +2 -0
- package/dist/retrofit/gitignore.js +2 -0
- package/dist/retrofit/planners/claude.js +4 -1
- package/dist/retrofit/planners/gemini.js +2 -1
- package/dist/retrofit/preserve.js +51 -0
- package/dist/update/check.js +86 -0
- package/dist/update/refresh.js +28 -0
- package/dist/update/upgrade.js +30 -0
- package/docs/PUBLISHING.md +41 -0
- package/docs/superpowers/plans/2026-06-27-forge-a-canon.md +815 -0
- package/docs/superpowers/plans/2026-06-27-forge-b1-retrofit-claude.md +842 -0
- package/docs/superpowers/plans/2026-06-27-forge-b2-codex-gemini-tools.md +884 -0
- package/docs/superpowers/plans/2026-06-27-forge-c1-loop-engine.md +891 -0
- package/docs/superpowers/plans/2026-06-28-baustein-e-context-layer.md +981 -0
- package/docs/superpowers/plans/2026-06-28-forge-b3-settings-merge.md +364 -0
- package/docs/superpowers/plans/2026-06-28-forge-c2-loop-verify.md +502 -0
- package/docs/superpowers/plans/2026-06-28-forge-c3-multi-agent-runners.md +377 -0
- package/docs/superpowers/plans/2026-06-28-forge-c4-worktree-isolation.md +413 -0
- package/docs/superpowers/plans/2026-06-28-forge-c5-review-iteration.md +419 -0
- package/docs/superpowers/plans/2026-06-28-forge-cleanup-dep0190.md +233 -0
- package/docs/superpowers/plans/2026-06-28-forge-d-codegraph-choice-minimal-code.md +523 -0
- package/docs/superpowers/plans/2026-06-29-baustein-f-routing.md +258 -0
- package/docs/superpowers/plans/2026-06-29-baustein-g-loop-observability.md +1006 -0
- package/docs/superpowers/plans/2026-06-29-baustein-h-loop-robustness.md +374 -0
- package/docs/superpowers/plans/2026-06-30-baustein-i-visual-design-verification.md +450 -0
- package/docs/superpowers/plans/2026-07-02-baustein-j-cross-model-review-gstack-compose.md +645 -0
- package/docs/superpowers/plans/2026-07-02-baustein-k-zero-to-100-bootstrap.md +1024 -0
- package/docs/superpowers/plans/2026-07-02-baustein-m-flow-smoke-proofs.md +574 -0
- package/docs/superpowers/specs/2026-06-27-forge-cross-agent-harness-design.md +144 -0
- package/docs/superpowers/specs/2026-06-28-baustein-e-context-layer-design.md +146 -0
- package/docs/superpowers/specs/2026-06-29-baustein-f-routing-design.md +106 -0
- package/docs/superpowers/specs/2026-06-29-baustein-g-loop-observability-design.md +186 -0
- package/docs/superpowers/specs/2026-06-29-baustein-h-loop-robustness-design.md +113 -0
- package/docs/superpowers/specs/2026-06-30-baustein-i-visual-design-verification-design.md +98 -0
- package/docs/superpowers/specs/2026-07-02-baustein-j-cross-model-review-gstack-compose-design.md +137 -0
- package/docs/superpowers/specs/2026-07-02-baustein-k-zero-to-100-bootstrap-design.md +200 -0
- package/docs/superpowers/specs/2026-07-02-baustein-m-flow-smoke-proofs-design.md +155 -0
- package/docs/superpowers/specs/2026-07-10-companion-tools-decision.md +33 -0
- package/docs/superpowers/specs/2026-07-10-multi-agent-parallel-loop-design.md +99 -0
- package/package.json +8 -2
package/bench/run.mjs
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Yoke benchmark harness.
|
|
3
|
+
//
|
|
4
|
+
// node bench/run.mjs --runner=claude [--max=6] [--timeout=10] [--label=note]
|
|
5
|
+
//
|
|
6
|
+
// Copies the fixture into bench/.runs/<runner>-<stamp>, git-inits it, then drives
|
|
7
|
+
// `yoke loop run --json` and measures from the OUTSIDE (the loop itself records no
|
|
8
|
+
// durations): per-story wall-clock from NDJSON event timestamps, tokens/model from
|
|
9
|
+
// the loop's token hook (claude runner only), and quality as the fixture's own
|
|
10
|
+
// pre-written tests — run per story AFTER the loop finishes, on the final tree.
|
|
11
|
+
import { spawn, spawnSync } from 'node:child_process'
|
|
12
|
+
import { cpSync, mkdirSync, readFileSync, writeFileSync, readdirSync, statSync } from 'node:fs'
|
|
13
|
+
import { join, dirname } from 'node:path'
|
|
14
|
+
import { fileURLToPath } from 'node:url'
|
|
15
|
+
|
|
16
|
+
const benchDir = dirname(fileURLToPath(import.meta.url))
|
|
17
|
+
const repoRoot = dirname(benchDir)
|
|
18
|
+
const cli = join(repoRoot, 'dist', 'cli.js')
|
|
19
|
+
|
|
20
|
+
const args = Object.fromEntries(
|
|
21
|
+
process.argv.slice(2).filter(a => a.startsWith('--')).map(a => {
|
|
22
|
+
const [k, v] = a.slice(2).split('=')
|
|
23
|
+
return [k, v ?? true]
|
|
24
|
+
}),
|
|
25
|
+
)
|
|
26
|
+
const runner = args.runner
|
|
27
|
+
if (!['claude', 'codex', 'gemini'].includes(runner)) {
|
|
28
|
+
console.error('usage: node bench/run.mjs --runner=<claude|codex|gemini> [--max=6] [--timeout=10] [--label=note]')
|
|
29
|
+
process.exit(2)
|
|
30
|
+
}
|
|
31
|
+
const max = Number(args.max ?? 6)
|
|
32
|
+
const timeout = Number(args.timeout ?? 10)
|
|
33
|
+
|
|
34
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)
|
|
35
|
+
const runDir = join(benchDir, '.runs', `${runner}-${stamp}`)
|
|
36
|
+
mkdirSync(runDir, { recursive: true })
|
|
37
|
+
cpSync(join(benchDir, 'fixtures', 'string-kit'), runDir, { recursive: true })
|
|
38
|
+
|
|
39
|
+
const git = (...a) => {
|
|
40
|
+
const r = spawnSync('git', ['-C', runDir, ...a], { encoding: 'utf8' })
|
|
41
|
+
if (r.status !== 0) throw new Error(`git ${a.join(' ')} failed: ${r.stderr}`)
|
|
42
|
+
}
|
|
43
|
+
git('init', '-q')
|
|
44
|
+
git('-c', 'user.name=bench', '-c', 'user.email=bench@yoke', 'add', '-A')
|
|
45
|
+
git('-c', 'user.name=bench', '-c', 'user.email=bench@yoke', 'commit', '-q', '-m', 'bench: fixture baseline')
|
|
46
|
+
|
|
47
|
+
// A nested Claude Code session refuses some operations; scrub session markers.
|
|
48
|
+
const env = { ...process.env }
|
|
49
|
+
for (const k of Object.keys(env)) if (k.startsWith('CLAUDE_CODE') || k === 'CLAUDECODE') delete env[k]
|
|
50
|
+
|
|
51
|
+
console.error(`[bench] ${runner} → ${runDir}`)
|
|
52
|
+
const t0 = Date.now()
|
|
53
|
+
const events = []
|
|
54
|
+
const child = spawn(process.execPath, [cli, 'loop', 'run', runDir, '--json', `--runner=${runner}`, `--max=${max}`, `--timeout=${timeout}`], {
|
|
55
|
+
env, stdio: ['ignore', 'pipe', 'inherit'],
|
|
56
|
+
})
|
|
57
|
+
let buf = ''
|
|
58
|
+
child.stdout.on('data', d => {
|
|
59
|
+
buf += d
|
|
60
|
+
let i
|
|
61
|
+
while ((i = buf.indexOf('\n')) >= 0) {
|
|
62
|
+
const line = buf.slice(0, i).trim()
|
|
63
|
+
buf = buf.slice(i + 1)
|
|
64
|
+
if (!line) continue
|
|
65
|
+
try { events.push({ at: Date.now(), ...JSON.parse(line) }) } catch { /* non-JSON noise */ }
|
|
66
|
+
}
|
|
67
|
+
})
|
|
68
|
+
const exitCode = await new Promise(res => child.on('close', res))
|
|
69
|
+
const wallClockMs = Date.now() - t0
|
|
70
|
+
|
|
71
|
+
// Per-story duration: first event mentioning the story -> first event mentioning the next story (or end).
|
|
72
|
+
const storyIds = ['STORY-1', 'STORY-2', 'STORY-3']
|
|
73
|
+
const firstSeen = {}
|
|
74
|
+
for (const e of events) if (e.story && !(e.story in firstSeen)) firstSeen[e.story] = e.at
|
|
75
|
+
const stories = storyIds.map((id, idx) => {
|
|
76
|
+
const start = firstSeen[id]
|
|
77
|
+
const next = storyIds.slice(idx + 1).map(n => firstSeen[n]).find(v => v !== undefined)
|
|
78
|
+
const durationMs = start === undefined ? null : (next ?? t0 + wallClockMs) - start
|
|
79
|
+
const iterations = new Set(events.filter(e => e.story === id).map(e => e.iteration)).size
|
|
80
|
+
// Quality: the fixture's own tests for this story, on the final tree.
|
|
81
|
+
const q = spawnSync(process.execPath, ['--test', `tests/${id}.test.mjs`], { cwd: runDir, encoding: 'utf8' })
|
|
82
|
+
return { id, durationMs, iterations, finalTestsPass: q.status === 0 }
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
const last = events[events.length - 1] ?? {}
|
|
86
|
+
let status = {}
|
|
87
|
+
try { status = JSON.parse(readFileSync(join(runDir, '.yoke', 'loop-status.json'), 'utf8')) } catch { /* loop may have refused before writing status */ }
|
|
88
|
+
|
|
89
|
+
// Source size (LOC in src/) as a code-economy proxy.
|
|
90
|
+
const loc = (dir) => readdirSync(dir).reduce((n, f) => {
|
|
91
|
+
const p = join(dir, f)
|
|
92
|
+
if (statSync(p).isDirectory()) return n + loc(p)
|
|
93
|
+
return n + readFileSync(p, 'utf8').split('\n').filter(l => l.trim() !== '').length
|
|
94
|
+
}, 0)
|
|
95
|
+
|
|
96
|
+
const result = {
|
|
97
|
+
runner,
|
|
98
|
+
label: args.label ?? null,
|
|
99
|
+
yokeVersion: JSON.parse(readFileSync(join(repoRoot, 'package.json'), 'utf8')).version,
|
|
100
|
+
fixture: 'string-kit',
|
|
101
|
+
startedAt: new Date(t0).toISOString(),
|
|
102
|
+
wallClockMs,
|
|
103
|
+
exitCode,
|
|
104
|
+
finalState: last.state ?? null,
|
|
105
|
+
progress: last.progress ?? null,
|
|
106
|
+
tokens: status.tokens ?? null, // claude runner only; gemini/codex report none (documented gap)
|
|
107
|
+
stories,
|
|
108
|
+
srcLoc: loc(join(runDir, 'src')),
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
mkdirSync(join(benchDir, 'results'), { recursive: true })
|
|
112
|
+
const out = join(benchDir, 'results', `${runner}-${stamp}.json`)
|
|
113
|
+
writeFileSync(out, JSON.stringify(result, null, 2) + '\n')
|
|
114
|
+
console.error(`[bench] done: ${out}`)
|
|
115
|
+
console.log(JSON.stringify(result, null, 2))
|
package/canon/loop/loop-spec.md
CHANGED
|
@@ -10,6 +10,8 @@ Pass `--isolate` to run each iteration in a fresh git worktree: the agent works
|
|
|
10
10
|
|
|
11
11
|
Pass `--review` (or `--reviewer=<claude|codex|gemini>` for a different agent) to add a role-separated review step: after the tests pass, an independent reviewer agent must approve the change before the story is committed and marked done. A rejection blocks the story (no commit). The reviewer is a fresh agent pass — the implementer never reviews its own work.
|
|
12
12
|
|
|
13
|
+
Pass `--json` for machine mode: each status transition is emitted as one NDJSON line on stdout (the `.yoke/loop-status.json` shape, tagged `"type":"status"`) instead of the human narrative, so a supervisor can consume the stream instead of polling the file.
|
|
14
|
+
|
|
13
15
|
When enabled and run, each iteration:
|
|
14
16
|
|
|
15
17
|
1. Pre-dispatch gate: the git worktree must be clean, else `blocked`.
|
|
@@ -24,7 +26,9 @@ When enabled and run, each iteration:
|
|
|
24
26
|
`passes: true`, committed atomically, and a decision logged. If verify fails: `blocked`.
|
|
25
27
|
6. Stop when all stories `passes: true` (`complete`), or the iteration cap is reached (`cap-reached`).
|
|
26
28
|
|
|
27
|
-
|
|
29
|
+
A supervisor can pause the loop by creating `.yoke/loop.pause`: at the next story boundary (before the next story is selected — the running story always finishes) the loop consumes the file, records `paused` in the status file and log, and exits with code `3`. Running `yoke loop run` again resumes.
|
|
30
|
+
|
|
31
|
+
State lives outside the model context: the PRD file + git. The agent runner is pluggable. The PRD is re-read from disk at every story boundary, so stories appended to `.yoke/prd.yaml` mid-run are picked up at the next iteration without a restart.
|
|
28
32
|
|
|
29
33
|
## Limitations
|
|
30
34
|
- The loop verifies via the project's test command and an optional agent review; it has no formal merge-queue or multi-reviewer quorum.
|
package/canon/manifest.yaml
CHANGED
|
@@ -45,3 +45,6 @@ tools:
|
|
|
45
45
|
- { id: graphify, path: tools/graphify.md }
|
|
46
46
|
- { id: playwright-mcp, path: tools/playwright-mcp.md }
|
|
47
47
|
- { id: serena, path: tools/serena.md }
|
|
48
|
+
# optional companions (external installers; documented wiring only)
|
|
49
|
+
- { id: claude-mem, path: tools/claude-mem.md }
|
|
50
|
+
- { id: ui-ux-pro-max, path: tools/ui-ux-pro-max.md }
|
|
@@ -14,5 +14,6 @@ Set up (or update) the Yoke harness in the current project.
|
|
|
14
14
|
2. Run `yoke retrofit . --agent=all --code-graph=<choice>` (or a subset of agents). Non-destructive — existing files are backed up under `.yoke/backup/` before any overwrite; `.claude/settings.json` is merged, not replaced. Generated per agent: Claude (`.claude/skills/`, `AGENTS.md`, `CLAUDE.md`, `.mcp.json`, rtk hook when WSL is available); Codex (`AGENTS.md`, `.codex/config.toml`, `RTK.md`); Gemini (`GEMINI.md`, `.gemini/commands/*.toml`, `.gemini/settings.json`).
|
|
15
15
|
3. **Ask whether to enable the autonomous Loop** (default off). If yes, add `--loop`. Toggle any time with `yoke loop on|off`.
|
|
16
16
|
4. Show the printed report (created/overwritten/unchanged/merged + detected agents) and where backups went. Note that the generated MCP launch commands may need adjusting to the user's local tool installs.
|
|
17
|
+
5. **Preserve project content.** If the pre-retrofit `CLAUDE.md`/`GEMINI.md` had project-specific instructions (tech stack, workflow, `@`-includes), move them from the backup into the preserve block the generated file ships (`<!-- yoke:preserve:start -->` … `<!-- yoke:preserve:end -->`). Everything inside these markers survives every future `yoke retrofit` — in any yoke-written file; content outside them is replaced (but backed up).
|
|
17
18
|
|
|
18
19
|
The harness includes a `minimal-code` skill (YAGNI / lazy-senior-dev) that nudges every agent to write the least code that solves the task — saving tokens and reducing maintenance.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Tool: claude-mem (persistent cross-session memory) — optional companion
|
|
2
|
+
|
|
3
|
+
External install (not bundled): `npx claude-mem install` — https://github.com/thedotmack/claude-mem (Apache-2.0).
|
|
4
|
+
Captures tool usage via lifecycle hooks, compresses it into SQLite + vector search, and injects
|
|
5
|
+
relevant context into future sessions.
|
|
6
|
+
|
|
7
|
+
Per-agent wiring:
|
|
8
|
+
|
|
9
|
+
- **Claude Code:** first-class support — its installer registers the lifecycle hooks itself. Yoke does not duplicate that wiring; run the installer once per machine.
|
|
10
|
+
- **Codex CLI / Gemini CLI:** supported by claude-mem's own multi-agent adapters; follow its docs.
|
|
11
|
+
|
|
12
|
+
Interaction with the Yoke loop (deliberate boundary):
|
|
13
|
+
|
|
14
|
+
- The loop's memory is **explicit and versioned**: `context/PROJECT.md`, `KNOWLEDGE.md`, `DECISIONS.md` plus the PRD. Every loop iteration starts from fresh context on purpose — reproducible, reviewable, in git.
|
|
15
|
+
- claude-mem's automatic injection is **for interactive sessions**, not for loop runs. Do not enable its hooks inside a loop runner profile: injected memory is non-deterministic and bypasses the context files the gates rely on.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Tool: ui-ux-pro-max (design intelligence) — optional companion
|
|
2
|
+
|
|
3
|
+
External install (not bundled): `npm i -g ui-ux-pro-max-cli && uipro init --ai claude` —
|
|
4
|
+
https://github.com/nextlevelbuilder/ui-ux-pro-max-skill (MIT).
|
|
5
|
+
A data-driven design skill: UI styles, industry reasoning rules, palettes, font pairings,
|
|
6
|
+
chart types, per-stack guidance. Its own installer targets Claude Code, Codex CLI, and
|
|
7
|
+
Gemini CLI natively — run `uipro init` once per agent you use.
|
|
8
|
+
|
|
9
|
+
Why it pairs with Yoke instead of being vendored:
|
|
10
|
+
|
|
11
|
+
- **Division of labour:** ui-ux-pro-max makes the *generation* side better (what to build);
|
|
12
|
+
Yoke's `unslop-ui` skill, `yoke design-scan`, and `visual-verification` are the *verification*
|
|
13
|
+
side (prove it doesn't look AI-generated, prove it renders). Generate with pro-max, gate with Yoke.
|
|
14
|
+
- **Not vendored on purpose:** it ships its own installer and update cadence; a copy frozen into
|
|
15
|
+
the canon would go stale. Install it directly and let `uipro update` keep it current.
|
package/dist/cli.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { pathToFileURL } from 'node:url';
|
|
3
|
+
import { realpathSync } from 'node:fs';
|
|
3
4
|
import { validateCanon } from './canon/validate.js';
|
|
4
5
|
import { runRetrofit } from './retrofit/command.js';
|
|
5
6
|
import { setLoopEnabled, loopStatus, runLoopCommand } from './loop/run-command.js';
|
|
@@ -10,6 +11,8 @@ import { runNew } from './new/command.js';
|
|
|
10
11
|
import { runPrdDraft, runPrdCheck } from './prd/command.js';
|
|
11
12
|
import { runLoopCleanup } from './loop/cleanup.js';
|
|
12
13
|
import { runFlowSmoke } from './smoke/command.js';
|
|
14
|
+
import { maybeNotifyUpdate, currentYokeVersion } from './update/check.js';
|
|
15
|
+
import { runUpgrade } from './update/upgrade.js';
|
|
13
16
|
export { runRetrofit } from './retrofit/command.js';
|
|
14
17
|
export function runValidate(canonDir) {
|
|
15
18
|
const issues = validateCanon(canonDir);
|
|
@@ -113,6 +116,7 @@ function main(argv) {
|
|
|
113
116
|
reviewer = reviewerArg;
|
|
114
117
|
}
|
|
115
118
|
const review = rest.includes('--review');
|
|
119
|
+
const json = rest.includes('--json');
|
|
116
120
|
const toArg = rest.find(a => a.startsWith('--timeout='));
|
|
117
121
|
let timeoutMinutes;
|
|
118
122
|
if (toArg) {
|
|
@@ -123,9 +127,9 @@ function main(argv) {
|
|
|
123
127
|
}
|
|
124
128
|
timeoutMinutes = v;
|
|
125
129
|
}
|
|
126
|
-
return runLoopCommand(targetDir, { maxIterations: rawMax, agent, isolate, reviewer, review, timeoutMinutes });
|
|
130
|
+
return runLoopCommand(targetDir, { maxIterations: rawMax, agent, isolate, reviewer, review, timeoutMinutes, json });
|
|
127
131
|
}
|
|
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]');
|
|
132
|
+
console.log('usage: yoke loop <on|off|status|cleanup|run [--max=N] [--runner=<claude|codex|gemini>] [--reviewer=<claude|codex|gemini>] [--review] [--isolate] [--timeout=<minutes>] [--json]> [targetDir]');
|
|
129
133
|
return 1;
|
|
130
134
|
}
|
|
131
135
|
case 'new': {
|
|
@@ -233,12 +237,34 @@ function main(argv) {
|
|
|
233
237
|
}
|
|
234
238
|
return runDesignScan(targetDir, { max, report });
|
|
235
239
|
}
|
|
240
|
+
case 'upgrade':
|
|
241
|
+
return runUpgrade();
|
|
236
242
|
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>]>');
|
|
243
|
+
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>] | upgrade>');
|
|
238
244
|
return cmd ? 1 : 0;
|
|
239
245
|
}
|
|
240
246
|
}
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
247
|
+
// Is this module the process entry point? Node realpaths the ESM entry for
|
|
248
|
+
// import.meta.url but argv[1] keeps the path as typed — a globally npm-installed
|
|
249
|
+
// CLI reaches this file THROUGH the node_modules/yoke symlink, so argv[1] must be
|
|
250
|
+
// realpathed before comparing or the global binary silently exits without running.
|
|
251
|
+
export function isMainEntry(argv1, moduleUrl) {
|
|
252
|
+
if (!argv1)
|
|
253
|
+
return false;
|
|
254
|
+
let resolved = argv1;
|
|
255
|
+
try {
|
|
256
|
+
resolved = realpathSync(argv1);
|
|
257
|
+
}
|
|
258
|
+
catch { /* nonexistent path — compare as given */ }
|
|
259
|
+
return pathToFileURL(resolved).href === moduleUrl;
|
|
260
|
+
}
|
|
261
|
+
if (isMainEntry(process.argv[1], import.meta.url)) {
|
|
262
|
+
const code = await main(process.argv.slice(2));
|
|
263
|
+
// Non-blocking version hint (npm/gh-style): reads a cache, maybe spawns a
|
|
264
|
+
// detached refresher. Never allowed to affect the command's outcome.
|
|
265
|
+
try {
|
|
266
|
+
maybeNotifyUpdate(currentYokeVersion());
|
|
267
|
+
}
|
|
268
|
+
catch { /* never fatal */ }
|
|
269
|
+
process.exit(code);
|
|
244
270
|
}
|
package/dist/loop/cleanup.js
CHANGED
|
@@ -1,12 +1,53 @@
|
|
|
1
|
-
import { existsSync, readdirSync, rmSync } from 'node:fs';
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, rmSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { execFileSync } from 'node:child_process';
|
|
4
4
|
import { lockPath, readLock, isPidAlive } from './lock.js';
|
|
5
|
-
|
|
6
|
-
//
|
|
5
|
+
import { killProcessTree } from './watchdog.js';
|
|
6
|
+
// Reap orphaned runners PROJECT-SCOPED: kill only pids recorded in this project's
|
|
7
|
+
// .yoke/runner.pid files (main dir + each worktree). Never by process-name or
|
|
8
|
+
// command-line pattern — that takes down runners belonging to OTHER projects,
|
|
9
|
+
// which then stall mid-story. Skipped entirely while the loop lock holder is
|
|
10
|
+
// alive: a live loop's runner is healthy, not an orphan.
|
|
11
|
+
function reapRecordedRunners(targetDir, wtDir, isAlive, killTree) {
|
|
12
|
+
const pidFiles = [join(targetDir, '.yoke', 'runner.pid')];
|
|
13
|
+
if (existsSync(wtDir)) {
|
|
14
|
+
for (const name of readdirSync(wtDir))
|
|
15
|
+
pidFiles.push(join(wtDir, name, '.yoke', 'runner.pid'));
|
|
16
|
+
}
|
|
17
|
+
let killed = 0;
|
|
18
|
+
for (const file of pidFiles) {
|
|
19
|
+
if (!existsSync(file))
|
|
20
|
+
continue;
|
|
21
|
+
try {
|
|
22
|
+
const rec = JSON.parse(readFileSync(file, 'utf8'));
|
|
23
|
+
// Child (agent tree) first, then the watchdog wrapper.
|
|
24
|
+
for (const pid of [rec.childPid, rec.watchdogPid]) {
|
|
25
|
+
if (typeof pid === 'number' && Number.isInteger(pid) && pid > 0 && isAlive(pid)) {
|
|
26
|
+
killTree(pid);
|
|
27
|
+
killed++;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
catch { /* malformed record — still consume the file below */ }
|
|
32
|
+
rmSync(file, { force: true });
|
|
33
|
+
}
|
|
34
|
+
return killed;
|
|
35
|
+
}
|
|
36
|
+
// Cleans ONLY yoke-created runtime artifacts: recorded orphan runners,
|
|
37
|
+
// .yoke/worktrees/*, and a stale loop.lock. Never touches user-created
|
|
38
|
+
// worktrees, a lock whose holder is alive, or any process it did not record.
|
|
7
39
|
export function runLoopCleanup(targetDir, opts = {}) {
|
|
8
40
|
const git = opts.git ?? ((args, cwd) => { execFileSync('git', args, { cwd, stdio: 'pipe' }); });
|
|
41
|
+
const isAlive = opts.isAlive ?? isPidAlive;
|
|
42
|
+
const killTree = opts.killTree ?? killProcessTree;
|
|
9
43
|
const wtDir = join(targetDir, '.yoke', 'worktrees');
|
|
44
|
+
const holder = readLock(targetDir);
|
|
45
|
+
const lockHeld = holder !== null && isPidAlive(holder.pid);
|
|
46
|
+
if (!lockHeld) {
|
|
47
|
+
const killed = reapRecordedRunners(targetDir, wtDir, isAlive, killTree);
|
|
48
|
+
if (killed > 0)
|
|
49
|
+
console.log(`Killed ${killed} orphaned runner process tree(s) recorded in runner.pid files.`);
|
|
50
|
+
}
|
|
10
51
|
let removed = 0;
|
|
11
52
|
let failed = 0;
|
|
12
53
|
if (existsSync(wtDir)) {
|
package/dist/loop/loop.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { existsSync, unlinkSync } from 'node:fs';
|
|
1
2
|
import { join, relative } from 'node:path';
|
|
2
3
|
import { loadPrd, savePrd, selectNextStory, allPass, progress } from './prd.js';
|
|
3
4
|
import { stopTheLineGate, preDispatchGate } from './gates.js';
|
|
@@ -13,6 +14,11 @@ function blockReason(base, targetDir, git) {
|
|
|
13
14
|
? `${base} (working tree has uncommitted changes from the blocked story — review/clean before re-running)`
|
|
14
15
|
: base;
|
|
15
16
|
}
|
|
17
|
+
// Control file a supervisor drops to pause the loop at the next story boundary.
|
|
18
|
+
// The loop consumes (deletes) it and stops with state 'paused' — never mid-story.
|
|
19
|
+
export function pauseFilePath(targetDir) {
|
|
20
|
+
return join(targetDir, '.yoke', 'loop.pause');
|
|
21
|
+
}
|
|
16
22
|
export function runLoop(opts) {
|
|
17
23
|
let iterations = 0;
|
|
18
24
|
const reporter = opts.reporter ?? noopReporter;
|
|
@@ -31,6 +37,17 @@ export function runLoop(opts) {
|
|
|
31
37
|
reporter.capReached(progress(stories));
|
|
32
38
|
return { status: 'cap-reached', iterations, finalProgress: progress(stories) };
|
|
33
39
|
}
|
|
40
|
+
// Story boundary: honour a pause signal before selecting the next story.
|
|
41
|
+
// complete/cap-reached above still win — pausing an already-finished loop is meaningless.
|
|
42
|
+
const pauseFile = pauseFilePath(opts.targetDir);
|
|
43
|
+
if (existsSync(pauseFile)) {
|
|
44
|
+
try {
|
|
45
|
+
unlinkSync(pauseFile);
|
|
46
|
+
}
|
|
47
|
+
catch { /* consumed best-effort — pausing still wins */ }
|
|
48
|
+
reporter.paused(progress(stories));
|
|
49
|
+
return { status: 'paused', iterations, finalProgress: progress(stories) };
|
|
50
|
+
}
|
|
34
51
|
const pre = preDispatchGate(opts.targetDir, opts.git);
|
|
35
52
|
if (!pre.ok) {
|
|
36
53
|
reporter.blocked(pre.reason ?? 'pre-dispatch gate failed');
|
|
@@ -54,6 +71,8 @@ export function runLoop(opts) {
|
|
|
54
71
|
opts.git.addWorktree(opts.targetDir, wt);
|
|
55
72
|
const result = opts.runner({ targetDir: wt, story });
|
|
56
73
|
iterations++;
|
|
74
|
+
if (result.tokens)
|
|
75
|
+
reporter.addTokens(result.tokens);
|
|
57
76
|
// Verify is the source of truth — NOT the runner's exit code. A spurious non-zero
|
|
58
77
|
// exit (e.g. a Windows .cmd wrapper ghost) must not block a story whose tests are green.
|
|
59
78
|
reporter.phase('verifying');
|
|
@@ -118,6 +137,8 @@ export function runLoop(opts) {
|
|
|
118
137
|
}
|
|
119
138
|
const result = opts.runner({ targetDir: opts.targetDir, story });
|
|
120
139
|
iterations++;
|
|
140
|
+
if (result.tokens)
|
|
141
|
+
reporter.addTokens(result.tokens);
|
|
121
142
|
// Verify is the source of truth — NOT the runner's exit code. A spurious non-zero
|
|
122
143
|
// exit (e.g. a Windows .cmd wrapper ghost) must not block a story whose tests are green.
|
|
123
144
|
reporter.phase('verifying');
|
|
@@ -169,13 +190,17 @@ export function runLoop(opts) {
|
|
|
169
190
|
title: story.title,
|
|
170
191
|
summary,
|
|
171
192
|
});
|
|
172
|
-
|
|
193
|
+
// Re-read the PRD from disk before persisting passes:true — a story injected
|
|
194
|
+
// mid-iteration (hot-reload) must survive this save, not be clobbered by the
|
|
195
|
+
// stale top-of-iteration copy.
|
|
196
|
+
const onDisk = loadPrd(opts.prdPath);
|
|
197
|
+
const updated = onDisk.map(s => (s.id === story.id ? { ...s, passes: true } : s));
|
|
173
198
|
savePrd(opts.prdPath, updated);
|
|
174
199
|
try {
|
|
175
200
|
opts.git.commitAll(opts.targetDir, `yoke: complete ${story.id} ${story.title}`);
|
|
176
201
|
}
|
|
177
202
|
catch (e) {
|
|
178
|
-
savePrd(opts.prdPath,
|
|
203
|
+
savePrd(opts.prdPath, onDisk); // revert — never persist passes:true without a commit
|
|
179
204
|
dec.rollback(); // and never leave an orphan decision
|
|
180
205
|
const reason = blockReason(`commit failed for ${story.id}: ${e.message}`, opts.targetDir, opts.git);
|
|
181
206
|
reporter.blocked(reason);
|
package/dist/loop/reporter.js
CHANGED
|
@@ -49,14 +49,20 @@ export function makeReporter(dir, opts = {}, now = () => new Date()) {
|
|
|
49
49
|
const emitConsole = (line) => { if (!opts.quiet)
|
|
50
50
|
sink(line); };
|
|
51
51
|
let current = null;
|
|
52
|
-
|
|
52
|
+
let tokens;
|
|
53
|
+
const persist = (status, logLabel, consoleLine) => {
|
|
54
|
+
const next = tokens ? { ...status, tokens: { ...tokens } } : status;
|
|
53
55
|
current = next;
|
|
54
56
|
try {
|
|
55
57
|
writeStatus(dir, next);
|
|
56
58
|
appendLog(dir, `${next.updatedAt} ${logLabel} ${next.story ?? '-'} ${next.reason ?? ''}`.trimEnd());
|
|
57
59
|
}
|
|
58
60
|
catch { /* observability must never abort the loop */ }
|
|
59
|
-
|
|
61
|
+
// json mode owns stdout: one machine-readable line per status write, no narrative.
|
|
62
|
+
if (opts.json)
|
|
63
|
+
sink(JSON.stringify({ type: 'status', ...next }));
|
|
64
|
+
else
|
|
65
|
+
emitConsole(consoleLine);
|
|
60
66
|
};
|
|
61
67
|
return {
|
|
62
68
|
storyStart(story, iteration, progress) {
|
|
@@ -81,11 +87,23 @@ export function makeReporter(dir, opts = {}, now = () => new Date()) {
|
|
|
81
87
|
persist({ ...(current ?? emptyStatus(now().toISOString())), state: 'cap-reached', phase: undefined,
|
|
82
88
|
progress, updatedAt: now().toISOString() }, 'cap-reached', `◾ iteration cap reached — ${progress.passed}/${progress.total}`);
|
|
83
89
|
},
|
|
90
|
+
paused(progress) {
|
|
91
|
+
persist({ ...(current ?? emptyStatus(now().toISOString())), state: 'paused', phase: undefined,
|
|
92
|
+
progress, updatedAt: now().toISOString() }, 'paused', `⏸ loop paused — ${progress.passed}/${progress.total}`);
|
|
93
|
+
},
|
|
94
|
+
addTokens(usage) {
|
|
95
|
+
const model = usage.model ?? tokens?.model;
|
|
96
|
+
tokens = {
|
|
97
|
+
inputTokens: (tokens?.inputTokens ?? 0) + usage.inputTokens,
|
|
98
|
+
outputTokens: (tokens?.outputTokens ?? 0) + usage.outputTokens,
|
|
99
|
+
...(model ? { model } : {}),
|
|
100
|
+
};
|
|
101
|
+
},
|
|
84
102
|
};
|
|
85
103
|
}
|
|
86
104
|
function emptyStatus(ts) {
|
|
87
105
|
return { state: 'running', iteration: 0, progress: { passed: 0, total: 0 }, startedAt: ts, updatedAt: ts };
|
|
88
106
|
}
|
|
89
107
|
export const noopReporter = {
|
|
90
|
-
storyStart() { }, phase() { }, blocked() { }, complete() { }, capReached() { },
|
|
108
|
+
storyStart() { }, phase() { }, blocked() { }, complete() { }, capReached() { }, paused() { }, addTokens() { },
|
|
91
109
|
};
|
package/dist/loop/run-command.js
CHANGED
|
@@ -8,6 +8,7 @@ import { makeRunner, makeReviewRunner, isAgentAvailable } from './runner.js';
|
|
|
8
8
|
import { commandVerifier, retryingVerifier } from './verify.js';
|
|
9
9
|
import { readStatus, makeReporter } from './reporter.js';
|
|
10
10
|
import { acquireLock, releaseLock } from './lock.js';
|
|
11
|
+
import { maybeAutoUpgrade } from '../update/upgrade.js';
|
|
11
12
|
export const DEFAULT_IDLE_MINUTES = 20;
|
|
12
13
|
const STALE_MINUTES = 20; // a running status older than this likely means the loop died
|
|
13
14
|
export function relativeTime(fromIso, now) {
|
|
@@ -80,6 +81,9 @@ export function runLoopCommand(targetDir, opts) {
|
|
|
80
81
|
}
|
|
81
82
|
verify = retryingVerifier(commandVerifier(command), config.verify?.retries ?? 1);
|
|
82
83
|
}
|
|
84
|
+
// Opt-in self-update, loop START only — this run keeps executing the version
|
|
85
|
+
// it started with; a fetched upgrade applies from the next invocation.
|
|
86
|
+
maybeAutoUpgrade(config.update?.auto);
|
|
83
87
|
const available = opts.isAvailable ?? isAgentAvailable;
|
|
84
88
|
const runnerAgent = opts.agent ?? config.agents[0] ?? 'claude';
|
|
85
89
|
const idleMs = resolveIdleMs(opts.timeoutMinutes, config.loop.timeoutMinutes);
|
|
@@ -89,7 +93,9 @@ export function runLoopCommand(targetDir, opts) {
|
|
|
89
93
|
console.error(`Agent CLI "${runnerAgent}" was not found on PATH. Install it, or pick another with --runner=<claude|codex|gemini>.`);
|
|
90
94
|
return 2;
|
|
91
95
|
}
|
|
92
|
-
|
|
96
|
+
// Token reporting is part of the machine interface: in --json mode a claude
|
|
97
|
+
// runner switches to stream-json so cumulative usage rides on every status.
|
|
98
|
+
runner = makeRunner(runnerAgent, idleMs, { tokenReport: opts.json === true });
|
|
93
99
|
}
|
|
94
100
|
let review = opts.reviewRunner;
|
|
95
101
|
if (!review && (opts.review || opts.reviewer)) {
|
|
@@ -118,15 +124,22 @@ export function runLoopCommand(targetDir, opts) {
|
|
|
118
124
|
maxIterations: opts.maxIterations,
|
|
119
125
|
isolate: opts.isolate ?? false,
|
|
120
126
|
review,
|
|
121
|
-
reporter: opts.reporter ?? makeReporter(targetDir),
|
|
127
|
+
reporter: opts.reporter ?? makeReporter(targetDir, { json: opts.json }),
|
|
122
128
|
});
|
|
123
|
-
|
|
129
|
+
// In json mode stdout belongs to the NDJSON stream — route the narrative summary to stderr.
|
|
130
|
+
const say = opts.json ? (line) => console.error(line) : (line) => console.log(line);
|
|
131
|
+
say(`Loop ${result.status} after ${result.iterations} iteration(s): ${result.finalProgress.passed}/${result.finalProgress.total} stories pass`);
|
|
124
132
|
if (result.reason)
|
|
125
|
-
|
|
133
|
+
say(`Reason: ${result.reason}`);
|
|
126
134
|
if (result.reason && /api key|please run \/login|not logged in/i.test(result.reason)) {
|
|
127
|
-
|
|
135
|
+
say('Hint: the agent CLI has no credentials in this environment. Set ANTHROPIC_API_KEY or log the agent in for headless use.');
|
|
128
136
|
}
|
|
129
|
-
|
|
137
|
+
// Exit codes: 0 complete · 1 blocked/cap-reached · 2 config error (handled above) · 3 paused (loop.pause consumed at a story boundary)
|
|
138
|
+
if (result.status === 'complete')
|
|
139
|
+
return 0;
|
|
140
|
+
if (result.status === 'paused')
|
|
141
|
+
return 3;
|
|
142
|
+
return 1;
|
|
130
143
|
}
|
|
131
144
|
finally {
|
|
132
145
|
releaseLock(targetDir);
|