@hecer/yoke 0.2.0 → 0.8.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 (84) hide show
  1. package/CHANGELOG.md +130 -0
  2. package/README.md +583 -494
  3. package/bench/.runs/claude-2026-07-09T22-34-01/.yoke/config.yaml +6 -0
  4. package/bench/.runs/claude-2026-07-09T22-34-01/.yoke/context/DECISIONS.md +9 -0
  5. package/bench/.runs/claude-2026-07-09T22-34-01/.yoke/prd.yaml +38 -0
  6. package/bench/.runs/claude-2026-07-09T22-34-01/bench-verify.mjs +15 -0
  7. package/bench/.runs/claude-2026-07-09T22-34-01/package.json +9 -0
  8. package/bench/.runs/claude-2026-07-09T22-34-01/src/index.mjs +48 -0
  9. package/bench/.runs/claude-2026-07-09T22-34-01/tests/STORY-1.test.mjs +24 -0
  10. package/bench/.runs/claude-2026-07-09T22-34-01/tests/STORY-2.test.mjs +28 -0
  11. package/bench/.runs/claude-2026-07-09T22-34-01/tests/STORY-3.test.mjs +25 -0
  12. package/bench/.runs/gemini-2026-07-09T22-34-02/.yoke/config.yaml +6 -0
  13. package/bench/.runs/gemini-2026-07-09T22-34-02/.yoke/prd.yaml +32 -0
  14. package/bench/.runs/gemini-2026-07-09T22-34-02/bench-verify.mjs +15 -0
  15. package/bench/.runs/gemini-2026-07-09T22-34-02/package.json +9 -0
  16. package/bench/.runs/gemini-2026-07-09T22-34-02/src/index.mjs +3 -0
  17. package/bench/.runs/gemini-2026-07-09T22-34-02/tests/STORY-1.test.mjs +24 -0
  18. package/bench/.runs/gemini-2026-07-09T22-34-02/tests/STORY-2.test.mjs +28 -0
  19. package/bench/.runs/gemini-2026-07-09T22-34-02/tests/STORY-3.test.mjs +25 -0
  20. package/bench/README.md +42 -0
  21. package/bench/RESULTS.md +36 -0
  22. package/bench/fixtures/string-kit/.yoke/config.yaml +6 -0
  23. package/bench/fixtures/string-kit/.yoke/prd.yaml +32 -0
  24. package/bench/fixtures/string-kit/bench-verify.mjs +15 -0
  25. package/bench/fixtures/string-kit/package.json +9 -0
  26. package/bench/fixtures/string-kit/src/index.mjs +3 -0
  27. package/bench/fixtures/string-kit/tests/STORY-1.test.mjs +24 -0
  28. package/bench/fixtures/string-kit/tests/STORY-2.test.mjs +28 -0
  29. package/bench/fixtures/string-kit/tests/STORY-3.test.mjs +25 -0
  30. package/bench/results/claude-2026-07-09T22-34-01.json +40 -0
  31. package/bench/run.mjs +115 -0
  32. package/canon/loop/loop-spec.md +5 -1
  33. package/canon/manifest.yaml +3 -0
  34. package/canon/skills/yoke-retrofit/SKILL.md +1 -0
  35. package/canon/tools/claude-mem.md +15 -0
  36. package/canon/tools/ui-ux-pro-max.md +15 -0
  37. package/dist/cli.js +37 -6
  38. package/dist/loop/cleanup.js +44 -3
  39. package/dist/loop/loop.js +67 -2
  40. package/dist/loop/reporter.js +88 -4
  41. package/dist/loop/run-command.js +25 -8
  42. package/dist/loop/runner.js +133 -14
  43. package/dist/loop/watchdog.js +67 -17
  44. package/dist/retrofit/apply.js +10 -3
  45. package/dist/retrofit/config.js +9 -1
  46. package/dist/retrofit/gitignore.js +4 -0
  47. package/dist/retrofit/planners/claude.js +4 -1
  48. package/dist/retrofit/planners/gemini.js +2 -1
  49. package/dist/retrofit/preserve.js +51 -0
  50. package/dist/update/check.js +86 -0
  51. package/dist/update/refresh.js +28 -0
  52. package/dist/update/upgrade.js +30 -0
  53. package/docs/PUBLISHING.md +41 -0
  54. package/docs/superpowers/plans/2026-06-27-forge-a-canon.md +815 -0
  55. package/docs/superpowers/plans/2026-06-27-forge-b1-retrofit-claude.md +842 -0
  56. package/docs/superpowers/plans/2026-06-27-forge-b2-codex-gemini-tools.md +884 -0
  57. package/docs/superpowers/plans/2026-06-27-forge-c1-loop-engine.md +891 -0
  58. package/docs/superpowers/plans/2026-06-28-baustein-e-context-layer.md +981 -0
  59. package/docs/superpowers/plans/2026-06-28-forge-b3-settings-merge.md +364 -0
  60. package/docs/superpowers/plans/2026-06-28-forge-c2-loop-verify.md +502 -0
  61. package/docs/superpowers/plans/2026-06-28-forge-c3-multi-agent-runners.md +377 -0
  62. package/docs/superpowers/plans/2026-06-28-forge-c4-worktree-isolation.md +413 -0
  63. package/docs/superpowers/plans/2026-06-28-forge-c5-review-iteration.md +419 -0
  64. package/docs/superpowers/plans/2026-06-28-forge-cleanup-dep0190.md +233 -0
  65. package/docs/superpowers/plans/2026-06-28-forge-d-codegraph-choice-minimal-code.md +523 -0
  66. package/docs/superpowers/plans/2026-06-29-baustein-f-routing.md +258 -0
  67. package/docs/superpowers/plans/2026-06-29-baustein-g-loop-observability.md +1006 -0
  68. package/docs/superpowers/plans/2026-06-29-baustein-h-loop-robustness.md +374 -0
  69. package/docs/superpowers/plans/2026-06-30-baustein-i-visual-design-verification.md +450 -0
  70. package/docs/superpowers/plans/2026-07-02-baustein-j-cross-model-review-gstack-compose.md +645 -0
  71. package/docs/superpowers/plans/2026-07-02-baustein-k-zero-to-100-bootstrap.md +1024 -0
  72. package/docs/superpowers/plans/2026-07-02-baustein-m-flow-smoke-proofs.md +574 -0
  73. package/docs/superpowers/specs/2026-06-27-forge-cross-agent-harness-design.md +144 -0
  74. package/docs/superpowers/specs/2026-06-28-baustein-e-context-layer-design.md +146 -0
  75. package/docs/superpowers/specs/2026-06-29-baustein-f-routing-design.md +106 -0
  76. package/docs/superpowers/specs/2026-06-29-baustein-g-loop-observability-design.md +186 -0
  77. package/docs/superpowers/specs/2026-06-29-baustein-h-loop-robustness-design.md +113 -0
  78. package/docs/superpowers/specs/2026-06-30-baustein-i-visual-design-verification-design.md +98 -0
  79. package/docs/superpowers/specs/2026-07-02-baustein-j-cross-model-review-gstack-compose-design.md +137 -0
  80. package/docs/superpowers/specs/2026-07-02-baustein-k-zero-to-100-bootstrap-design.md +200 -0
  81. package/docs/superpowers/specs/2026-07-02-baustein-m-flow-smoke-proofs-design.md +155 -0
  82. package/docs/superpowers/specs/2026-07-10-companion-tools-decision.md +33 -0
  83. package/docs/superpowers/specs/2026-07-10-multi-agent-parallel-loop-design.md +99 -0
  84. package/package.json +8 -2
@@ -0,0 +1,6 @@
1
+ canonVersion: 0.1.0
2
+ agents: []
3
+ loop:
4
+ enabled: true
5
+ verify:
6
+ command: node bench-verify.mjs
@@ -0,0 +1,9 @@
1
+
2
+ ## 2026-07-09 — STORY-1: slugify(text): URL-safe slugs
3
+ claude implemented STORY-1
4
+
5
+ ## 2026-07-09 — STORY-2: truncate(text, max): word-boundary truncation with ellipsis
6
+ claude implemented STORY-2
7
+
8
+ ## 2026-07-09 — STORY-3: titleCase(text): English title casing
9
+ claude implemented STORY-3
@@ -0,0 +1,38 @@
1
+ - id: STORY-1
2
+ title: "slugify(text): URL-safe slugs"
3
+ priority: 1
4
+ acceptance:
5
+ - Export `slugify(text)` from src/index.mjs
6
+ - Lowercases input and joins words with single dashes
7
+ - "Strips characters that are not alphanumeric, dash, or space (apostrophes
8
+ vanish: \"What's Up, Doc?\" -> \"whats-up-doc\")"
9
+ - Collapses runs of spaces/dashes into one dash; trims leading/trailing
10
+ dashes
11
+ - Input with no usable characters returns the empty string
12
+ - node bench-verify.mjs exits 0
13
+ passes: true
14
+ - id: STORY-2
15
+ title: "truncate(text, max): word-boundary truncation with ellipsis"
16
+ priority: 2
17
+ acceptance:
18
+ - Export `truncate(text, max)` from src/index.mjs
19
+ - Strings with length <= max are returned unchanged
20
+ - Longer strings are cut at a word boundary and end with the single
21
+ character … (U+2026); result length never exceeds max
22
+ - If even the first word does not fit, hard-cut mid-word so the result
23
+ (incl. …) has exactly max characters
24
+ - max < 1 throws RangeError
25
+ - node bench-verify.mjs exits 0
26
+ passes: true
27
+ - id: STORY-3
28
+ title: "titleCase(text): English title casing"
29
+ priority: 3
30
+ acceptance:
31
+ - Export `titleCase(text)` from src/index.mjs
32
+ - Capitalizes the first letter of each significant word; rest of each word
33
+ lowercased (handles ALL-CAPS input)
34
+ - Small words (a, an, the, and, but, or, for, nor, of, on, in, to, at, by,
35
+ up) stay lowercase mid-title
36
+ - The first and the last word are always capitalized, even if small
37
+ - node bench-verify.mjs exits 0
38
+ passes: true
@@ -0,0 +1,15 @@
1
+ // Cumulative verify: the loop sets YOKE_STORY (e.g. STORY-2); we run the test
2
+ // files for that story AND all earlier ones, so later stories can't break
3
+ // earlier work. Without YOKE_STORY (final quality check), all tests run.
4
+ import { spawnSync } from 'node:child_process'
5
+
6
+ const TOTAL = 3
7
+ const story = process.env.YOKE_STORY
8
+ const n = story ? Number(story.split('-')[1]) : TOTAL
9
+ const upTo = Number.isFinite(n) && n >= 1 && n <= TOTAL ? n : TOTAL
10
+
11
+ const files = []
12
+ for (let i = 1; i <= upTo; i++) files.push(`tests/STORY-${i}.test.mjs`)
13
+
14
+ const r = spawnSync(process.execPath, ['--test', ...files], { stdio: 'inherit' })
15
+ process.exit(r.status ?? 1)
@@ -0,0 +1,9 @@
1
+ {
2
+ "name": "bench-string-kit",
3
+ "private": true,
4
+ "version": "0.0.0",
5
+ "type": "module",
6
+ "scripts": {
7
+ "test": "node bench-verify.mjs"
8
+ }
9
+ }
@@ -0,0 +1,48 @@
1
+ // Implement the functions required by the PRD stories here (or in modules
2
+ // re-exported from here). Tests import from this file.
3
+
4
+ export function slugify(text) {
5
+ return String(text)
6
+ .toLowerCase()
7
+ .replace(/[^a-z0-9\s-]/g, '')
8
+ .replace(/[\s-]+/g, '-')
9
+ .replace(/^-+|-+$/g, '')
10
+ }
11
+
12
+ export function truncate(text, max) {
13
+ if (max < 1) throw new RangeError('max must be >= 1')
14
+
15
+ const str = String(text)
16
+ if (str.length <= max) return str
17
+
18
+ const available = max - 1 // reserve one char for the ellipsis
19
+ const words = str.trim().split(/\s+/)
20
+
21
+ let result = ''
22
+ for (const word of words) {
23
+ const candidate = result ? `${result} ${word}` : word
24
+ if (candidate.length <= available) result = candidate
25
+ else break
26
+ }
27
+
28
+ if (result === '') result = str.slice(0, available) // hard-cut mid-word
29
+ return `${result}…`
30
+ }
31
+
32
+ const SMALL_WORDS = new Set([
33
+ 'a', 'an', 'the', 'and', 'but', 'or', 'for', 'nor',
34
+ 'of', 'on', 'in', 'to', 'at', 'by', 'up',
35
+ ])
36
+
37
+ export function titleCase(text) {
38
+ const words = String(text).trim().split(/\s+/)
39
+
40
+ return words
41
+ .map((word, i) => {
42
+ const lower = word.toLowerCase()
43
+ const isEdge = i === 0 || i === words.length - 1
44
+ if (!isEdge && SMALL_WORDS.has(lower)) return lower
45
+ return lower.charAt(0).toUpperCase() + lower.slice(1)
46
+ })
47
+ .join(' ')
48
+ }
@@ -0,0 +1,24 @@
1
+ import { test } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { slugify } from '../src/index.mjs'
4
+
5
+ test('lowercases and replaces spaces with single dashes', () => {
6
+ assert.equal(slugify('Hello World'), 'hello-world')
7
+ })
8
+
9
+ test('strips characters that are not alphanumeric, dash, or space', () => {
10
+ assert.equal(slugify("What's Up, Doc?"), 'whats-up-doc')
11
+ })
12
+
13
+ test('collapses runs of spaces/dashes into one dash', () => {
14
+ assert.equal(slugify('a -- b'), 'a-b')
15
+ })
16
+
17
+ test('trims leading/trailing dashes', () => {
18
+ assert.equal(slugify(' hello '), 'hello')
19
+ assert.equal(slugify('-hello-'), 'hello')
20
+ })
21
+
22
+ test('returns empty string for input with no usable characters', () => {
23
+ assert.equal(slugify('!!!'), '')
24
+ })
@@ -0,0 +1,28 @@
1
+ import { test } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { truncate } from '../src/index.mjs'
4
+
5
+ test('returns short strings unchanged', () => {
6
+ assert.equal(truncate('hello', 10), 'hello')
7
+ })
8
+
9
+ test('string of exactly max length is unchanged', () => {
10
+ assert.equal(truncate('exact', 5), 'exact')
11
+ })
12
+
13
+ test('truncates at a word boundary and appends a single ellipsis character', () => {
14
+ assert.equal(truncate('the quick brown fox', 12), 'the quick…')
15
+ })
16
+
17
+ test('result length never exceeds max', () => {
18
+ const out = truncate('the quick brown fox jumps', 10)
19
+ assert.ok(out.length <= 10, `expected <= 10, got ${out.length}: "${out}"`)
20
+ })
21
+
22
+ test('falls back to a hard cut when the first word is longer than max', () => {
23
+ assert.equal(truncate('extraordinary', 6), 'extra…')
24
+ })
25
+
26
+ test('throws RangeError for max < 1', () => {
27
+ assert.throws(() => truncate('x', 0), RangeError)
28
+ })
@@ -0,0 +1,25 @@
1
+ import { test } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { titleCase } from '../src/index.mjs'
4
+
5
+ test('capitalizes each significant word', () => {
6
+ assert.equal(titleCase('the quick brown fox'), 'The Quick Brown Fox')
7
+ })
8
+
9
+ test('keeps small words lowercase mid-title', () => {
10
+ assert.equal(titleCase('a tale of two cities'), 'A Tale of Two Cities')
11
+ assert.equal(titleCase('war and peace'), 'War and Peace')
12
+ })
13
+
14
+ test('always capitalizes the first and last word, even small ones', () => {
15
+ assert.equal(titleCase('of mice and men'), 'Of Mice and Men')
16
+ assert.equal(titleCase('what dreams may come to'), 'What Dreams May Come To')
17
+ })
18
+
19
+ test('normalizes ALL-CAPS input', () => {
20
+ assert.equal(titleCase('THE GREAT GATSBY'), 'The Great Gatsby')
21
+ })
22
+
23
+ test('single word is capitalized', () => {
24
+ assert.equal(titleCase('dune'), 'Dune')
25
+ })
@@ -0,0 +1,6 @@
1
+ canonVersion: 0.1.0
2
+ agents: []
3
+ loop:
4
+ enabled: true
5
+ verify:
6
+ command: node bench-verify.mjs
@@ -0,0 +1,32 @@
1
+ - id: STORY-1
2
+ title: "slugify(text): URL-safe slugs"
3
+ priority: 1
4
+ acceptance:
5
+ - "Export `slugify(text)` from src/index.mjs"
6
+ - "Lowercases input and joins words with single dashes"
7
+ - "Strips characters that are not alphanumeric, dash, or space (apostrophes vanish: \"What's Up, Doc?\" -> \"whats-up-doc\")"
8
+ - "Collapses runs of spaces/dashes into one dash; trims leading/trailing dashes"
9
+ - "Input with no usable characters returns the empty string"
10
+ - "node bench-verify.mjs exits 0"
11
+ passes: false
12
+ - id: STORY-2
13
+ title: "truncate(text, max): word-boundary truncation with ellipsis"
14
+ priority: 2
15
+ acceptance:
16
+ - "Export `truncate(text, max)` from src/index.mjs"
17
+ - "Strings with length <= max are returned unchanged"
18
+ - "Longer strings are cut at a word boundary and end with the single character … (U+2026); result length never exceeds max"
19
+ - "If even the first word does not fit, hard-cut mid-word so the result (incl. …) has exactly max characters"
20
+ - "max < 1 throws RangeError"
21
+ - "node bench-verify.mjs exits 0"
22
+ passes: false
23
+ - id: STORY-3
24
+ title: "titleCase(text): English title casing"
25
+ priority: 3
26
+ acceptance:
27
+ - "Export `titleCase(text)` from src/index.mjs"
28
+ - "Capitalizes the first letter of each significant word; rest of each word lowercased (handles ALL-CAPS input)"
29
+ - "Small words (a, an, the, and, but, or, for, nor, of, on, in, to, at, by, up) stay lowercase mid-title"
30
+ - "The first and the last word are always capitalized, even if small"
31
+ - "node bench-verify.mjs exits 0"
32
+ passes: false
@@ -0,0 +1,15 @@
1
+ // Cumulative verify: the loop sets YOKE_STORY (e.g. STORY-2); we run the test
2
+ // files for that story AND all earlier ones, so later stories can't break
3
+ // earlier work. Without YOKE_STORY (final quality check), all tests run.
4
+ import { spawnSync } from 'node:child_process'
5
+
6
+ const TOTAL = 3
7
+ const story = process.env.YOKE_STORY
8
+ const n = story ? Number(story.split('-')[1]) : TOTAL
9
+ const upTo = Number.isFinite(n) && n >= 1 && n <= TOTAL ? n : TOTAL
10
+
11
+ const files = []
12
+ for (let i = 1; i <= upTo; i++) files.push(`tests/STORY-${i}.test.mjs`)
13
+
14
+ const r = spawnSync(process.execPath, ['--test', ...files], { stdio: 'inherit' })
15
+ process.exit(r.status ?? 1)
@@ -0,0 +1,9 @@
1
+ {
2
+ "name": "bench-string-kit",
3
+ "private": true,
4
+ "version": "0.0.0",
5
+ "type": "module",
6
+ "scripts": {
7
+ "test": "node bench-verify.mjs"
8
+ }
9
+ }
@@ -0,0 +1,3 @@
1
+ // Implement the functions required by the PRD stories here (or in modules
2
+ // re-exported from here). Tests import from this file.
3
+ export {}
@@ -0,0 +1,24 @@
1
+ import { test } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { slugify } from '../src/index.mjs'
4
+
5
+ test('lowercases and replaces spaces with single dashes', () => {
6
+ assert.equal(slugify('Hello World'), 'hello-world')
7
+ })
8
+
9
+ test('strips characters that are not alphanumeric, dash, or space', () => {
10
+ assert.equal(slugify("What's Up, Doc?"), 'whats-up-doc')
11
+ })
12
+
13
+ test('collapses runs of spaces/dashes into one dash', () => {
14
+ assert.equal(slugify('a -- b'), 'a-b')
15
+ })
16
+
17
+ test('trims leading/trailing dashes', () => {
18
+ assert.equal(slugify(' hello '), 'hello')
19
+ assert.equal(slugify('-hello-'), 'hello')
20
+ })
21
+
22
+ test('returns empty string for input with no usable characters', () => {
23
+ assert.equal(slugify('!!!'), '')
24
+ })
@@ -0,0 +1,28 @@
1
+ import { test } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { truncate } from '../src/index.mjs'
4
+
5
+ test('returns short strings unchanged', () => {
6
+ assert.equal(truncate('hello', 10), 'hello')
7
+ })
8
+
9
+ test('string of exactly max length is unchanged', () => {
10
+ assert.equal(truncate('exact', 5), 'exact')
11
+ })
12
+
13
+ test('truncates at a word boundary and appends a single ellipsis character', () => {
14
+ assert.equal(truncate('the quick brown fox', 12), 'the quick…')
15
+ })
16
+
17
+ test('result length never exceeds max', () => {
18
+ const out = truncate('the quick brown fox jumps', 10)
19
+ assert.ok(out.length <= 10, `expected <= 10, got ${out.length}: "${out}"`)
20
+ })
21
+
22
+ test('falls back to a hard cut when the first word is longer than max', () => {
23
+ assert.equal(truncate('extraordinary', 6), 'extra…')
24
+ })
25
+
26
+ test('throws RangeError for max < 1', () => {
27
+ assert.throws(() => truncate('x', 0), RangeError)
28
+ })
@@ -0,0 +1,25 @@
1
+ import { test } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { titleCase } from '../src/index.mjs'
4
+
5
+ test('capitalizes each significant word', () => {
6
+ assert.equal(titleCase('the quick brown fox'), 'The Quick Brown Fox')
7
+ })
8
+
9
+ test('keeps small words lowercase mid-title', () => {
10
+ assert.equal(titleCase('a tale of two cities'), 'A Tale of Two Cities')
11
+ assert.equal(titleCase('war and peace'), 'War and Peace')
12
+ })
13
+
14
+ test('always capitalizes the first and last word, even small ones', () => {
15
+ assert.equal(titleCase('of mice and men'), 'Of Mice and Men')
16
+ assert.equal(titleCase('what dreams may come to'), 'What Dreams May Come To')
17
+ })
18
+
19
+ test('normalizes ALL-CAPS input', () => {
20
+ assert.equal(titleCase('THE GREAT GATSBY'), 'The Great Gatsby')
21
+ })
22
+
23
+ test('single word is capitalized', () => {
24
+ assert.equal(titleCase('dune'), 'Dune')
25
+ })
@@ -0,0 +1,42 @@
1
+ # Yoke benchmark — tokens · speed · quality
2
+
3
+ Reproducible cross-runner benchmark for the Yoke loop. One fixed fixture project, the same
4
+ PRD for every runner, three measured dimensions:
5
+
6
+ | Dimension | How it is measured |
7
+ |---|---|
8
+ | **Tokens** | The loop's own token hook (`.yoke/loop-status.json`, claude runner via `--output-format stream-json`; model id included). Gemini/Codex runners do not report usage yet — recorded as `null`, a documented gap. |
9
+ | **Speed** | Wall-clock, measured by the harness from outside: total run + per-story (from `--json` NDJSON event timestamps). The loop itself stores no durations. |
10
+ | **Quality** | Objective, not judged by any model: the fixture ships **pre-written tests** the agent never has to write (only satisfy). After the run, each story's test file is executed against the final tree. `srcLoc` (non-empty lines in `src/`) is a code-economy proxy. |
11
+
12
+ ## The fixture (`fixtures/string-kit`)
13
+
14
+ A dependency-free ESM library with 3 stories (`slugify`, `truncate`, `titleCase`) and 16
15
+ `node:test` assertions total. `bench-verify.mjs` is cumulative: story N runs the tests of
16
+ stories 1…N (the loop exports `YOKE_STORY`), so later stories cannot break earlier work; the
17
+ final quality check runs everything. No npm installs, so results measure the agent — not the
18
+ network.
19
+
20
+ ## Running it
21
+
22
+ ```bash
23
+ npm run build
24
+ node bench/run.mjs --runner=claude # or gemini / codex
25
+ ```
26
+
27
+ Each run copies the fixture to `bench/.runs/<runner>-<stamp>` (git-ignored), git-inits it,
28
+ drives `yoke loop run --json --max=6 --timeout=10`, and writes a result JSON to
29
+ `bench/results/`. Runs are billed against your own accounts for the agent CLIs involved.
30
+
31
+ ## Caveats (read before quoting numbers)
32
+
33
+ - **N=1 per run.** Agent runs are stochastic; treat single runs as indicative, not
34
+ statistically robust. Re-run and compare.
35
+ - Model identity matters more than CLI identity: `tokens.model` records what actually served
36
+ the run. Different default models per CLI make "claude vs gemini" really "model X vs model Y".
37
+ - The fixture is deliberately small (a loop-overhead + basic-competence probe, minutes not
38
+ hours). It does not measure large-context refactoring, UI work, or long-horizon planning.
39
+ - Cumulative verify means a story's duration includes fixing any regressions it caused.
40
+
41
+ Results live in [`results/`](results/) — one JSON per run, summarized in
42
+ [`RESULTS.md`](RESULTS.md).
@@ -0,0 +1,36 @@
1
+ # Benchmark results
2
+
3
+ Fixture `string-kit` (3 stories, 16 pre-written assertions). Methodology and caveats:
4
+ [README.md](README.md). One row per run — raw JSON in [`results/`](results/).
5
+
6
+ ## Runs
7
+
8
+ | Date | Runner | Model (reported) | Result | Wall-clock | Input tok | Output tok | First-pass stories | src LOC |
9
+ |---|---|---|---|---|---|---|---|---|
10
+ | 2026-07-10 | claude | claude-opus-4-8 | ✅ 3/3 complete | 4 m 28 s | 42 957 | 9 817 | 3/3 (1 iteration each) | 39 |
11
+ | 2026-07-10 | gemini | — | ⛔ blocked: CLI not authenticated on the bench machine (headless needs `GEMINI_API_KEY` or configured OAuth) | — | — | — | — | — |
12
+ | — | codex | — | ⛔ not installed on the bench machine | — | — | — | — | — |
13
+
14
+ Per-story wall-clock (claude run): STORY-1 72 s · STORY-2 114 s · STORY-3 81 s. Every story
15
+ passed verify on the first iteration; the final quality check (all 16 assertions on the final
16
+ tree, outside the loop) is green.
17
+
18
+ ## What the harness caught before producing a single number
19
+
20
+ Building an honest benchmark is itself a verification pass. The first runs found two real
21
+ Yoke bugs, both fixed in 0.3.0:
22
+
23
+ 1. **Availability-probe timeout** — `gemini --version` cold-starts in ~5.8 s on Windows; the
24
+ 5 s probe timeout misreported an installed CLI as "not found on PATH". Now 20 s.
25
+ 2. **Gemini invocation** — Gemini CLI 0.33+ requires a value after `-p`; the runner's bare
26
+ `-p --yolo` died with "Not enough arguments following: p". The runner now relies on piped
27
+ stdin (which selects headless mode) and passes only `--yolo`.
28
+
29
+ ## Reading the numbers
30
+
31
+ - Tokens come from the loop's own hook (claude runner only — gemini/codex reporting is an
32
+ open gap, see the multi-agent design doc).
33
+ - N=1: indicative, not statistics. Re-run with `node bench/run.mjs --runner=<agent>` and
34
+ compare rows.
35
+ - ~53 k tokens / ~4.5 minutes for a 3-story micro-backlog is the current price of the full
36
+ gate pipeline (fresh headless session per story + verify + atomic commit) on this fixture.
@@ -0,0 +1,6 @@
1
+ canonVersion: 0.1.0
2
+ agents: []
3
+ loop:
4
+ enabled: true
5
+ verify:
6
+ command: node bench-verify.mjs
@@ -0,0 +1,32 @@
1
+ - id: STORY-1
2
+ title: "slugify(text): URL-safe slugs"
3
+ priority: 1
4
+ acceptance:
5
+ - "Export `slugify(text)` from src/index.mjs"
6
+ - "Lowercases input and joins words with single dashes"
7
+ - "Strips characters that are not alphanumeric, dash, or space (apostrophes vanish: \"What's Up, Doc?\" -> \"whats-up-doc\")"
8
+ - "Collapses runs of spaces/dashes into one dash; trims leading/trailing dashes"
9
+ - "Input with no usable characters returns the empty string"
10
+ - "node bench-verify.mjs exits 0"
11
+ passes: false
12
+ - id: STORY-2
13
+ title: "truncate(text, max): word-boundary truncation with ellipsis"
14
+ priority: 2
15
+ acceptance:
16
+ - "Export `truncate(text, max)` from src/index.mjs"
17
+ - "Strings with length <= max are returned unchanged"
18
+ - "Longer strings are cut at a word boundary and end with the single character … (U+2026); result length never exceeds max"
19
+ - "If even the first word does not fit, hard-cut mid-word so the result (incl. …) has exactly max characters"
20
+ - "max < 1 throws RangeError"
21
+ - "node bench-verify.mjs exits 0"
22
+ passes: false
23
+ - id: STORY-3
24
+ title: "titleCase(text): English title casing"
25
+ priority: 3
26
+ acceptance:
27
+ - "Export `titleCase(text)` from src/index.mjs"
28
+ - "Capitalizes the first letter of each significant word; rest of each word lowercased (handles ALL-CAPS input)"
29
+ - "Small words (a, an, the, and, but, or, for, nor, of, on, in, to, at, by, up) stay lowercase mid-title"
30
+ - "The first and the last word are always capitalized, even if small"
31
+ - "node bench-verify.mjs exits 0"
32
+ passes: false
@@ -0,0 +1,15 @@
1
+ // Cumulative verify: the loop sets YOKE_STORY (e.g. STORY-2); we run the test
2
+ // files for that story AND all earlier ones, so later stories can't break
3
+ // earlier work. Without YOKE_STORY (final quality check), all tests run.
4
+ import { spawnSync } from 'node:child_process'
5
+
6
+ const TOTAL = 3
7
+ const story = process.env.YOKE_STORY
8
+ const n = story ? Number(story.split('-')[1]) : TOTAL
9
+ const upTo = Number.isFinite(n) && n >= 1 && n <= TOTAL ? n : TOTAL
10
+
11
+ const files = []
12
+ for (let i = 1; i <= upTo; i++) files.push(`tests/STORY-${i}.test.mjs`)
13
+
14
+ const r = spawnSync(process.execPath, ['--test', ...files], { stdio: 'inherit' })
15
+ process.exit(r.status ?? 1)
@@ -0,0 +1,9 @@
1
+ {
2
+ "name": "bench-string-kit",
3
+ "private": true,
4
+ "version": "0.0.0",
5
+ "type": "module",
6
+ "scripts": {
7
+ "test": "node bench-verify.mjs"
8
+ }
9
+ }
@@ -0,0 +1,3 @@
1
+ // Implement the functions required by the PRD stories here (or in modules
2
+ // re-exported from here). Tests import from this file.
3
+ export {}
@@ -0,0 +1,24 @@
1
+ import { test } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { slugify } from '../src/index.mjs'
4
+
5
+ test('lowercases and replaces spaces with single dashes', () => {
6
+ assert.equal(slugify('Hello World'), 'hello-world')
7
+ })
8
+
9
+ test('strips characters that are not alphanumeric, dash, or space', () => {
10
+ assert.equal(slugify("What's Up, Doc?"), 'whats-up-doc')
11
+ })
12
+
13
+ test('collapses runs of spaces/dashes into one dash', () => {
14
+ assert.equal(slugify('a -- b'), 'a-b')
15
+ })
16
+
17
+ test('trims leading/trailing dashes', () => {
18
+ assert.equal(slugify(' hello '), 'hello')
19
+ assert.equal(slugify('-hello-'), 'hello')
20
+ })
21
+
22
+ test('returns empty string for input with no usable characters', () => {
23
+ assert.equal(slugify('!!!'), '')
24
+ })
@@ -0,0 +1,28 @@
1
+ import { test } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { truncate } from '../src/index.mjs'
4
+
5
+ test('returns short strings unchanged', () => {
6
+ assert.equal(truncate('hello', 10), 'hello')
7
+ })
8
+
9
+ test('string of exactly max length is unchanged', () => {
10
+ assert.equal(truncate('exact', 5), 'exact')
11
+ })
12
+
13
+ test('truncates at a word boundary and appends a single ellipsis character', () => {
14
+ assert.equal(truncate('the quick brown fox', 12), 'the quick…')
15
+ })
16
+
17
+ test('result length never exceeds max', () => {
18
+ const out = truncate('the quick brown fox jumps', 10)
19
+ assert.ok(out.length <= 10, `expected <= 10, got ${out.length}: "${out}"`)
20
+ })
21
+
22
+ test('falls back to a hard cut when the first word is longer than max', () => {
23
+ assert.equal(truncate('extraordinary', 6), 'extra…')
24
+ })
25
+
26
+ test('throws RangeError for max < 1', () => {
27
+ assert.throws(() => truncate('x', 0), RangeError)
28
+ })
@@ -0,0 +1,25 @@
1
+ import { test } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { titleCase } from '../src/index.mjs'
4
+
5
+ test('capitalizes each significant word', () => {
6
+ assert.equal(titleCase('the quick brown fox'), 'The Quick Brown Fox')
7
+ })
8
+
9
+ test('keeps small words lowercase mid-title', () => {
10
+ assert.equal(titleCase('a tale of two cities'), 'A Tale of Two Cities')
11
+ assert.equal(titleCase('war and peace'), 'War and Peace')
12
+ })
13
+
14
+ test('always capitalizes the first and last word, even small ones', () => {
15
+ assert.equal(titleCase('of mice and men'), 'Of Mice and Men')
16
+ assert.equal(titleCase('what dreams may come to'), 'What Dreams May Come To')
17
+ })
18
+
19
+ test('normalizes ALL-CAPS input', () => {
20
+ assert.equal(titleCase('THE GREAT GATSBY'), 'The Great Gatsby')
21
+ })
22
+
23
+ test('single word is capitalized', () => {
24
+ assert.equal(titleCase('dune'), 'Dune')
25
+ })
@@ -0,0 +1,40 @@
1
+ {
2
+ "runner": "claude",
3
+ "label": null,
4
+ "yokeVersion": "0.3.0",
5
+ "fixture": "string-kit",
6
+ "startedAt": "2026-07-09T22:34:02.042Z",
7
+ "wallClockMs": 268351,
8
+ "exitCode": 0,
9
+ "finalState": "complete",
10
+ "progress": {
11
+ "passed": 3,
12
+ "total": 3
13
+ },
14
+ "tokens": {
15
+ "inputTokens": 42957,
16
+ "outputTokens": 9817,
17
+ "model": "claude-opus-4-8"
18
+ },
19
+ "stories": [
20
+ {
21
+ "id": "STORY-1",
22
+ "durationMs": 72246,
23
+ "iterations": 1,
24
+ "finalTestsPass": true
25
+ },
26
+ {
27
+ "id": "STORY-2",
28
+ "durationMs": 114239,
29
+ "iterations": 1,
30
+ "finalTestsPass": true
31
+ },
32
+ {
33
+ "id": "STORY-3",
34
+ "durationMs": 81328,
35
+ "iterations": 1,
36
+ "finalTestsPass": true
37
+ }
38
+ ],
39
+ "srcLoc": 39
40
+ }