@hecer/yoke 0.8.0 → 1.0.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 (69) hide show
  1. package/.codex-plugin/plugin.json +7 -0
  2. package/CHANGELOG.md +169 -130
  3. package/README.md +61 -21
  4. package/TODOS.md +8 -0
  5. package/agents/docs.toml +6 -0
  6. package/agents/implementer.toml +6 -0
  7. package/agents/reviewer.toml +6 -0
  8. package/agents/security.toml +6 -0
  9. package/bench/README.md +45 -42
  10. package/bench/RESULTS.md +46 -36
  11. package/bench/result-schema.mjs +12 -0
  12. package/bench/results/claude-2026-07-27T18-03-26.json +50 -0
  13. package/bench/results/codex-unavailable-1785175418318.json +15 -0
  14. package/bench/results/gemini-2026-07-27T18-03-44.json +46 -0
  15. package/bench/run-matrix.mjs +26 -0
  16. package/bench/run.mjs +127 -115
  17. package/canon/loop/prd.schema.md +5 -0
  18. package/canon/manifest.yaml +3 -1
  19. package/canon/skills/authoring-prd/SKILL.md +14 -0
  20. package/canon/skills/performance/SKILL.md +48 -0
  21. package/canon/skills/ship/SKILL.md +2 -7
  22. package/canon/tools/codex-rtk-hook.mjs +36 -0
  23. package/dist/agents/providers.js +23 -0
  24. package/dist/agents/telemetry.js +30 -0
  25. package/dist/agents/types.js +1 -0
  26. package/dist/audit/changes.js +6 -0
  27. package/dist/audit/command.js +64 -0
  28. package/dist/audit/dependencies.js +21 -0
  29. package/dist/audit/secrets.js +16 -0
  30. package/dist/audit/types.js +1 -0
  31. package/dist/cli.js +22 -4
  32. package/dist/loop/claims.js +57 -0
  33. package/dist/loop/cleanup.js +10 -4
  34. package/dist/loop/git.js +8 -2
  35. package/dist/loop/identity.js +27 -0
  36. package/dist/loop/loop.js +55 -26
  37. package/dist/loop/merge-queue.js +20 -0
  38. package/dist/loop/parallel.js +39 -0
  39. package/dist/loop/prd.js +48 -2
  40. package/dist/loop/run-command.js +63 -7
  41. package/dist/loop/runner.js +47 -31
  42. package/dist/loop/scheduler.js +8 -0
  43. package/dist/prd/command.js +6 -0
  44. package/dist/retrofit/config.js +15 -0
  45. package/dist/retrofit/planners/codex.js +64 -19
  46. package/dist/review/command.js +52 -12
  47. package/dist/review/verdict.js +45 -0
  48. package/docs/MIGRATING-TO-1.0.md +33 -0
  49. package/docs/superpowers/plans/2026-07-27-yoke-1.0-release.md +205 -0
  50. package/docs/superpowers/specs/2026-07-27-yoke-1.0-hardening-and-codex-parity-design.md +164 -0
  51. package/hooks/hooks.json +19 -0
  52. package/package.json +82 -67
  53. package/bench/.runs/claude-2026-07-09T22-34-01/.yoke/config.yaml +0 -6
  54. package/bench/.runs/claude-2026-07-09T22-34-01/.yoke/context/DECISIONS.md +0 -9
  55. package/bench/.runs/claude-2026-07-09T22-34-01/.yoke/prd.yaml +0 -38
  56. package/bench/.runs/claude-2026-07-09T22-34-01/bench-verify.mjs +0 -15
  57. package/bench/.runs/claude-2026-07-09T22-34-01/package.json +0 -9
  58. package/bench/.runs/claude-2026-07-09T22-34-01/src/index.mjs +0 -48
  59. package/bench/.runs/claude-2026-07-09T22-34-01/tests/STORY-1.test.mjs +0 -24
  60. package/bench/.runs/claude-2026-07-09T22-34-01/tests/STORY-2.test.mjs +0 -28
  61. package/bench/.runs/claude-2026-07-09T22-34-01/tests/STORY-3.test.mjs +0 -25
  62. package/bench/.runs/gemini-2026-07-09T22-34-02/.yoke/config.yaml +0 -6
  63. package/bench/.runs/gemini-2026-07-09T22-34-02/.yoke/prd.yaml +0 -32
  64. package/bench/.runs/gemini-2026-07-09T22-34-02/bench-verify.mjs +0 -15
  65. package/bench/.runs/gemini-2026-07-09T22-34-02/package.json +0 -9
  66. package/bench/.runs/gemini-2026-07-09T22-34-02/src/index.mjs +0 -3
  67. package/bench/.runs/gemini-2026-07-09T22-34-02/tests/STORY-1.test.mjs +0 -24
  68. package/bench/.runs/gemini-2026-07-09T22-34-02/tests/STORY-2.test.mjs +0 -28
  69. package/bench/.runs/gemini-2026-07-09T22-34-02/tests/STORY-3.test.mjs +0 -25
package/package.json CHANGED
@@ -1,67 +1,82 @@
1
- {
2
- "name": "@hecer/yoke",
3
- "version": "0.8.0",
4
- "description": "One harness, three agents, zero trust in \"done\" — cross-agent coding harness for Claude Code, Codex CLI, and Gemini CLI: one skill canon, mechanical safety gates, an autonomous loop with screenshot/video proofs.",
5
- "type": "module",
6
- "bin": {
7
- "yoke": "./dist/cli.js"
8
- },
9
- "files": [
10
- "dist",
11
- "canon",
12
- "bench",
13
- "docs",
14
- "CHANGELOG.md",
15
- "README.md",
16
- "LICENSE"
17
- ],
18
- "engines": {
19
- "node": ">=20"
20
- },
21
- "repository": {
22
- "type": "git",
23
- "url": "git+https://github.com/HECer/yoke.git"
24
- },
25
- "homepage": "https://github.com/HECer/yoke#readme",
26
- "bugs": {
27
- "url": "https://github.com/HECer/yoke/issues"
28
- },
29
- "keywords": [
30
- "claude-code",
31
- "codex",
32
- "gemini-cli",
33
- "agents",
34
- "agentic-coding",
35
- "harness",
36
- "autonomous",
37
- "ralph-loop",
38
- "code-review",
39
- "skills",
40
- "agents-md",
41
- "tdd",
42
- "claude-plugin",
43
- "claude-code-plugin",
44
- "gemini-cli-extension"
45
- ],
46
- "license": "MIT",
47
- "publishConfig": {
48
- "access": "public"
49
- },
50
- "scripts": {
51
- "build": "tsc",
52
- "test": "vitest run",
53
- "yoke": "tsx src/cli.ts",
54
- "prepublishOnly": "npm run build && vitest run"
55
- },
56
- "dependencies": {
57
- "yaml": "^2.5.0",
58
- "zod": "^3.23.8"
59
- },
60
- "devDependencies": {
61
- "@types/node": "^22.7.0",
62
- "smol-toml": "^1.7.0",
63
- "tsx": "^4.19.1",
64
- "typescript": "^5.6.2",
65
- "vitest": "^2.1.1"
66
- }
67
- }
1
+ {
2
+ "name": "@hecer/yoke",
3
+ "version": "1.0.0",
4
+ "description": "One harness, three agents, zero trust in \"done\" — cross-agent coding harness for Claude Code, Codex CLI, and Gemini CLI: one skill canon, mechanical safety gates, an autonomous loop with screenshot/video proofs.",
5
+ "type": "module",
6
+ "bin": {
7
+ "yoke": "./dist/cli.js"
8
+ },
9
+ "files": [
10
+ "dist",
11
+ "canon",
12
+ ".codex-plugin",
13
+ "agents",
14
+ "hooks",
15
+ "bench/README.md",
16
+ "bench/RESULTS.md",
17
+ "bench/result-schema.mjs",
18
+ "bench/run.mjs",
19
+ "bench/run-matrix.mjs",
20
+ "bench/fixtures",
21
+ "bench/results",
22
+ "docs",
23
+ "CHANGELOG.md",
24
+ "TODOS.md",
25
+ "README.md",
26
+ "LICENSE"
27
+ ],
28
+ "engines": {
29
+ "node": ">=20"
30
+ },
31
+ "repository": {
32
+ "type": "git",
33
+ "url": "git+https://github.com/HECer/yoke.git"
34
+ },
35
+ "homepage": "https://github.com/HECer/yoke#readme",
36
+ "bugs": {
37
+ "url": "https://github.com/HECer/yoke/issues"
38
+ },
39
+ "keywords": [
40
+ "claude-code",
41
+ "codex",
42
+ "gemini-cli",
43
+ "agents",
44
+ "agentic-coding",
45
+ "harness",
46
+ "autonomous",
47
+ "ralph-loop",
48
+ "code-review",
49
+ "skills",
50
+ "agents-md",
51
+ "tdd",
52
+ "claude-plugin",
53
+ "claude-code-plugin",
54
+ "gemini-cli-extension"
55
+ ],
56
+ "license": "MIT",
57
+ "publishConfig": {
58
+ "access": "public"
59
+ },
60
+ "scripts": {
61
+ "build": "tsc",
62
+ "lint": "tsc --noEmit",
63
+ "test": "vitest run",
64
+ "docs:check": "node scripts/release-metadata.mjs --check",
65
+ "docs:update": "node scripts/release-metadata.mjs --write",
66
+ "audit:ci": "npm audit --audit-level=high",
67
+ "package:check": "npm pack --dry-run",
68
+ "yoke": "tsx src/cli.ts",
69
+ "prepublishOnly": "npm run lint && npm run build && vitest run && npm run docs:check && npm run package:check"
70
+ },
71
+ "dependencies": {
72
+ "yaml": "^2.5.0",
73
+ "zod": "^3.23.8"
74
+ },
75
+ "devDependencies": {
76
+ "@types/node": "^22.7.0",
77
+ "smol-toml": "^1.7.0",
78
+ "tsx": "^4.19.1",
79
+ "typescript": "^5.6.2",
80
+ "vitest": "^4.1.10"
81
+ }
82
+ }
@@ -1,6 +0,0 @@
1
- canonVersion: 0.1.0
2
- agents: []
3
- loop:
4
- enabled: true
5
- verify:
6
- command: node bench-verify.mjs
@@ -1,9 +0,0 @@
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
@@ -1,38 +0,0 @@
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
@@ -1,15 +0,0 @@
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)
@@ -1,9 +0,0 @@
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
- }
@@ -1,48 +0,0 @@
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
- }
@@ -1,24 +0,0 @@
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
- })
@@ -1,28 +0,0 @@
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
- })
@@ -1,25 +0,0 @@
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
- })
@@ -1,6 +0,0 @@
1
- canonVersion: 0.1.0
2
- agents: []
3
- loop:
4
- enabled: true
5
- verify:
6
- command: node bench-verify.mjs
@@ -1,32 +0,0 @@
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
@@ -1,15 +0,0 @@
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)
@@ -1,9 +0,0 @@
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
- }
@@ -1,3 +0,0 @@
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 {}
@@ -1,24 +0,0 @@
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
- })
@@ -1,28 +0,0 @@
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
- })
@@ -1,25 +0,0 @@
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
- })