@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
|
@@ -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,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,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,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
|
+
})
|
package/bench/README.md
ADDED
|
@@ -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).
|
package/bench/RESULTS.md
ADDED
|
@@ -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,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,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
|
+
}
|