@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.
Files changed (84) hide show
  1. package/CHANGELOG.md +112 -0
  2. package/README.md +557 -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 +32 -6
  38. package/dist/loop/cleanup.js +44 -3
  39. package/dist/loop/loop.js +27 -2
  40. package/dist/loop/reporter.js +21 -3
  41. package/dist/loop/run-command.js +19 -6
  42. package/dist/loop/runner.js +130 -13
  43. package/dist/loop/watchdog.js +67 -17
  44. package/dist/retrofit/apply.js +10 -3
  45. package/dist/retrofit/config.js +2 -0
  46. package/dist/retrofit/gitignore.js +2 -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,364 @@
1
+ # Forge — Baustein B3 (non-destructive settings merge) Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** Make `forge retrofit` MERGE into an existing `.claude/settings.json` (preserving the user's other keys and not duplicating our hook) instead of replacing it.
6
+
7
+ **Architecture:** Add an optional `merge` flag to `Action`. `applyActions` gains a JSON deep-merge path: when `merge` is set and the target exists, it parses both, deep-merges (objects recursive; arrays concatenated + de-duplicated), backs up the original, and writes the merged result (status `merged`). The Claude planner marks only `.claude/settings.json` with `merge: true`. A pure `mergeJson` util holds the merge semantics. Baustein B3 of the Forge spec.
8
+
9
+ **Tech Stack:** Node.js (ESM), TypeScript, vitest. Extends B1/B2 `apply.ts`, `report.ts`, `planners/claude.ts`.
10
+
11
+ **Builds on:** A+B1+B2+C1+C2 on `main`. Modifies: `src/retrofit/apply.ts` (`Action` is defined in `plan.ts`; `AppliedAction` here), `src/retrofit/report.ts`, `src/retrofit/planners/claude.ts`.
12
+
13
+ ---
14
+
15
+ ## File Structure
16
+
17
+ ```
18
+ src/retrofit/
19
+ merge-json.ts # NEW: mergeJson(base, incoming) deep merge (pure)
20
+ plan.ts # MODIFY: Action gains optional `merge?: boolean`
21
+ apply.ts # MODIFY: AppliedAction status gains 'merged'; merge path in applyActions
22
+ report.ts # MODIFY: count/format 'merged'
23
+ planners/claude.ts # MODIFY: mark .claude/settings.json action with merge: true
24
+ tests/retrofit/
25
+ merge-json.test.ts # NEW
26
+ apply.test.ts # MODIFY: merge-into-existing case
27
+ report.test.ts # MODIFY: merged count
28
+ planners-claude.test.ts # MODIFY: settings.json carries merge:true
29
+ ```
30
+
31
+ ---
32
+
33
+ ### Task 1: mergeJson util
34
+
35
+ **Files:**
36
+ - Create: `src/retrofit/merge-json.ts`
37
+ - Test: `tests/retrofit/merge-json.test.ts`
38
+
39
+ - [ ] **Step 1: Write the failing test**
40
+
41
+ Create `tests/retrofit/merge-json.test.ts`:
42
+ ```ts
43
+ import { describe, it, expect } from 'vitest'
44
+ import { mergeJson } from '../../src/retrofit/merge-json.js'
45
+
46
+ describe('mergeJson', () => {
47
+ it('preserves keys only present in base', () => {
48
+ expect(mergeJson({ model: 'opus', x: 1 }, { y: 2 })).toEqual({ model: 'opus', x: 1, y: 2 })
49
+ })
50
+
51
+ it('recursively merges nested objects', () => {
52
+ expect(mergeJson({ a: { p: 1 } }, { a: { q: 2 } })).toEqual({ a: { p: 1, q: 2 } })
53
+ })
54
+
55
+ it('concatenates arrays and de-dupes by structural equality', () => {
56
+ expect(mergeJson({ h: [{ k: 1 }] }, { h: [{ k: 1 }, { k: 2 }] })).toEqual({ h: [{ k: 1 }, { k: 2 }] })
57
+ })
58
+
59
+ it('incoming primitive overrides base on the same key', () => {
60
+ expect(mergeJson({ a: 1 }, { a: 2 })).toEqual({ a: 2 })
61
+ })
62
+
63
+ it('incoming wins when types mismatch', () => {
64
+ expect(mergeJson({ a: { p: 1 } }, { a: 5 })).toEqual({ a: 5 })
65
+ })
66
+ })
67
+ ```
68
+
69
+ - [ ] **Step 2: Run test to verify it fails**
70
+
71
+ Run: `npm test -- merge-json`
72
+ Expected: FAIL — cannot find module `src/retrofit/merge-json.js`.
73
+
74
+ - [ ] **Step 3: Write minimal implementation**
75
+
76
+ Create `src/retrofit/merge-json.ts`:
77
+ ```ts
78
+ type Json = unknown
79
+
80
+ function isPlainObject(v: Json): v is Record<string, Json> {
81
+ return typeof v === 'object' && v !== null && !Array.isArray(v)
82
+ }
83
+
84
+ // Deep-merge two parsed JSON values. Objects merge by key (recursing on shared
85
+ // keys). Arrays concatenate with structural de-duplication. For any other type
86
+ // mismatch or primitive collision, the incoming value wins.
87
+ export function mergeJson(base: Json, incoming: Json): Json {
88
+ if (isPlainObject(base) && isPlainObject(incoming)) {
89
+ const out: Record<string, Json> = { ...base }
90
+ for (const [key, value] of Object.entries(incoming)) {
91
+ out[key] = key in base ? mergeJson(base[key], value) : value
92
+ }
93
+ return out
94
+ }
95
+ if (Array.isArray(base) && Array.isArray(incoming)) {
96
+ const out = [...base]
97
+ for (const item of incoming) {
98
+ if (!out.some(existing => JSON.stringify(existing) === JSON.stringify(item))) {
99
+ out.push(item)
100
+ }
101
+ }
102
+ return out
103
+ }
104
+ return incoming
105
+ }
106
+ ```
107
+
108
+ - [ ] **Step 4: Run test to verify it passes**
109
+
110
+ Run: `npm test -- merge-json`
111
+ Expected: PASS (5 tests).
112
+
113
+ - [ ] **Step 5: Commit**
114
+
115
+ ```bash
116
+ git add src/retrofit/merge-json.ts tests/retrofit/merge-json.test.ts
117
+ git commit -m "feat: add JSON deep-merge utility"
118
+ ```
119
+
120
+ ---
121
+
122
+ ### Task 2: Merge path in applyActions
123
+
124
+ **Files:**
125
+ - Modify: `src/retrofit/plan.ts` (Action interface), `src/retrofit/apply.ts`
126
+ - Test: `tests/retrofit/apply.test.ts` (extend)
127
+
128
+ - [ ] **Step 1: Add failing tests**
129
+
130
+ Append to `tests/retrofit/apply.test.ts` (inside the existing `describe`):
131
+ ```ts
132
+ it('merges a merge-flagged JSON action into an existing file, preserving user keys', () => {
133
+ writeFileSync(join(target, 'settings.json'), JSON.stringify({ model: 'opus', hooks: { A: [1] } }))
134
+ const mergeAction: Action = {
135
+ kind: 'write', target: 'settings.json', merge: true,
136
+ content: JSON.stringify({ hooks: { A: [1], B: [2] } }), reason: 'settings',
137
+ }
138
+ const res = applyActions([mergeAction], target, { backupDir: backupDir() })
139
+ expect(res[0].status).toBe('merged')
140
+ expect(res[0].backedUp).toBeDefined()
141
+ const written = JSON.parse(readFileSync(join(target, 'settings.json'), 'utf8'))
142
+ expect(written.model).toBe('opus') // user key preserved
143
+ expect(written.hooks.A).toEqual([1]) // de-duped, not [1,1]
144
+ expect(written.hooks.B).toEqual([2]) // ours added
145
+ })
146
+
147
+ it('treats a merge action as a plain create when the file does not exist', () => {
148
+ const mergeAction: Action = {
149
+ kind: 'write', target: 'fresh.json', merge: true,
150
+ content: JSON.stringify({ a: 1 }), reason: 'x',
151
+ }
152
+ const res = applyActions([mergeAction], target, { backupDir: backupDir() })
153
+ expect(res[0].status).toBe('created')
154
+ expect(JSON.parse(readFileSync(join(target, 'fresh.json'), 'utf8'))).toEqual({ a: 1 })
155
+ })
156
+ ```
157
+
158
+ - [ ] **Step 2: Run test to verify it fails**
159
+
160
+ Run: `npm test -- retrofit/apply`
161
+ Expected: FAIL — `Action` has no `merge`; `applyActions` produces no `merged` status.
162
+
163
+ - [ ] **Step 3: Add `merge` to the `Action` interface in `src/retrofit/plan.ts`**
164
+
165
+ ```ts
166
+ export interface Action {
167
+ kind: 'write'
168
+ target: string
169
+ content: string
170
+ reason: string
171
+ merge?: boolean
172
+ }
173
+ ```
174
+
175
+ - [ ] **Step 4: Add the merge path to `src/retrofit/apply.ts`**
176
+
177
+ Add the import at the top:
178
+ ```ts
179
+ import { mergeJson } from './merge-json.js'
180
+ ```
181
+
182
+ Extend the `AppliedAction` status union:
183
+ ```ts
184
+ export interface AppliedAction {
185
+ target: string
186
+ status: 'created' | 'overwritten' | 'unchanged' | 'merged'
187
+ backedUp?: string
188
+ reason: string
189
+ }
190
+ ```
191
+
192
+ In `applyActions`, inside the `if (existsSync(dest))` branch, handle merge before the plain overwrite. Replace the existing `if (existsSync(dest)) { ... }` block with:
193
+ ```ts
194
+ if (existsSync(dest)) {
195
+ const current = readFileSync(dest, 'utf8')
196
+
197
+ if (action.merge) {
198
+ const merged = JSON.stringify(mergeJson(JSON.parse(current), JSON.parse(action.content)), null, 2) + '\n'
199
+ if (merged === current) {
200
+ results.push({ target: action.target, status: 'unchanged', reason: action.reason })
201
+ continue
202
+ }
203
+ backedUp = join(opts.backupDir, action.target)
204
+ mkdirSync(dirname(backedUp), { recursive: true })
205
+ copyFileSync(dest, backedUp)
206
+ mkdirSync(dirname(dest), { recursive: true })
207
+ writeFileSync(dest, merged)
208
+ results.push({ target: action.target, status: 'merged', backedUp, reason: action.reason })
209
+ continue
210
+ }
211
+
212
+ if (current === action.content) {
213
+ results.push({ target: action.target, status: 'unchanged', reason: action.reason })
214
+ continue
215
+ }
216
+ backedUp = join(opts.backupDir, action.target)
217
+ mkdirSync(dirname(backedUp), { recursive: true })
218
+ copyFileSync(dest, backedUp)
219
+ status = 'overwritten'
220
+ } else {
221
+ status = 'created'
222
+ }
223
+ ```
224
+ (Keep the rest of the loop — the final `mkdirSync(dirname(dest)...)` + `writeFileSync(dest, action.content)` + `results.push` for the non-merge create/overwrite path — unchanged.)
225
+
226
+ - [ ] **Step 5: Run test to verify it passes**
227
+
228
+ Run: `npm test -- retrofit/apply`
229
+ Expected: PASS (existing + 2 new).
230
+
231
+ - [ ] **Step 6: Commit**
232
+
233
+ ```bash
234
+ git add src/retrofit/plan.ts src/retrofit/apply.ts tests/retrofit/apply.test.ts
235
+ git commit -m "feat: merge JSON actions into existing files non-destructively"
236
+ ```
237
+
238
+ ---
239
+
240
+ ### Task 3: Report counts merged
241
+
242
+ **Files:**
243
+ - Modify: `src/retrofit/report.ts`
244
+ - Test: `tests/retrofit/report.test.ts` (extend)
245
+
246
+ - [ ] **Step 1: Add a failing test**
247
+
248
+ In `tests/retrofit/report.test.ts`, add a `merged` action to the `applied` fixture array and a test:
249
+ ```ts
250
+ it('counts merged actions in the summary', () => {
251
+ const out = formatReport(
252
+ [{ target: '.claude/settings.json', status: 'merged', backedUp: '/b', reason: 'settings' }],
253
+ { loopEnabled: false, detectedAgents: [] },
254
+ )
255
+ expect(out).toContain('1 merged')
256
+ })
257
+ ```
258
+
259
+ - [ ] **Step 2: Run test to verify it fails**
260
+
261
+ Run: `npm test -- retrofit/report`
262
+ Expected: FAIL — summary has no merged count.
263
+
264
+ - [ ] **Step 3: Update `src/retrofit/report.ts`**
265
+
266
+ In `formatReport`, add `merged` to the summary line. Change the summary push to:
267
+ ```ts
268
+ lines.push(`Summary: ${count('created')} created, ${count('overwritten')} overwritten, ${count('merged')} merged, ${count('unchanged')} unchanged`)
269
+ ```
270
+
271
+ - [ ] **Step 4: Run test to verify it passes**
272
+
273
+ Run: `npm test -- retrofit/report`
274
+ Expected: PASS (existing + 1 new).
275
+
276
+ - [ ] **Step 5: Commit**
277
+
278
+ ```bash
279
+ git add src/retrofit/report.ts tests/retrofit/report.test.ts
280
+ git commit -m "feat: report merged-action count"
281
+ ```
282
+
283
+ ---
284
+
285
+ ### Task 4: Claude planner marks settings.json as merge
286
+
287
+ **Files:**
288
+ - Modify: `src/retrofit/planners/claude.ts`
289
+ - Test: `tests/retrofit/planners-claude.test.ts` (extend)
290
+
291
+ - [ ] **Step 1: Add a failing test**
292
+
293
+ In `tests/retrofit/planners-claude.test.ts`, add (the WSL-true path emits settings.json):
294
+ ```ts
295
+ it('marks .claude/settings.json as a merge action', () => {
296
+ const settings = planClaude(canon, '/t', true).find(a => a.target === '.claude/settings.json')!
297
+ expect(settings.merge).toBe(true)
298
+ })
299
+ ```
300
+
301
+ - [ ] **Step 2: Run test to verify it fails**
302
+
303
+ Run: `npm test -- planners-claude`
304
+ Expected: FAIL — the settings action has no `merge: true`.
305
+
306
+ - [ ] **Step 3: Update `src/retrofit/planners/claude.ts`**
307
+
308
+ In the `if (rtkHookable)` block, add `merge: true` to the `.claude/settings.json` action:
309
+ ```ts
310
+ actions.push({
311
+ kind: 'write',
312
+ target: '.claude/settings.json',
313
+ merge: true,
314
+ content: JSON.stringify({
315
+ _forge: 'Generated by forge retrofit. A pre-existing .claude/settings.json (if any) was backed up under .forge/backup/.',
316
+ hooks: {
317
+ PreToolUse: [{ matcher: 'Bash', hooks: [{ type: 'command', command: 'rtk hook' }] }],
318
+ },
319
+ }, null, 2) + '\n',
320
+ reason: 'rtk PreToolUse hook (WSL detected)',
321
+ })
322
+ ```
323
+
324
+ - [ ] **Step 4: Run test to verify it passes**
325
+
326
+ Run: `npm test -- planners-claude`
327
+ Expected: PASS (existing + 1 new).
328
+
329
+ - [ ] **Step 5: Run the full suite + build**
330
+
331
+ Run: `npm test`
332
+ Expected: all pass.
333
+
334
+ Run: `npm run build`
335
+ Expected: tsc 0 errors.
336
+
337
+ Run: `npm run forge -- validate canon`
338
+ Expected: `✓ canon valid (canon)`.
339
+
340
+ - [ ] **Step 6: Commit**
341
+
342
+ ```bash
343
+ git add src/retrofit/planners/claude.ts tests/retrofit/planners-claude.test.ts
344
+ git commit -m "feat: mark .claude/settings.json as a non-destructive merge"
345
+ ```
346
+
347
+ ---
348
+
349
+ ## Self-Review
350
+
351
+ **1. Spec coverage (B3 scope):**
352
+ - Merge into existing `.claude/settings.json` (preserve user keys, no hook duplication) → Tasks 1, 2, 4 ✓
353
+ - Generalizable merge mechanism (`Action.merge` + `mergeJson`) → Tasks 1, 2 ✓
354
+ - Still non-destructive (backup before merge-write) → Task 2 ✓
355
+ - Idempotent (merge equal → unchanged) → Task 2 ✓
356
+ - Report reflects merges → Task 3 ✓
357
+
358
+ **2. Placeholder scan:** No TBD/TODO; every step has complete code.
359
+
360
+ **3. Type consistency:** `mergeJson`, `Action.merge?`, `AppliedAction.status` (now includes `merged`), `formatReport` — consistent. The merge path reuses the existing backup mechanism. `JSON.stringify(..., null, 2) + '\n'` formatting matches how the planner generates the content so idempotency holds. ✓
361
+
362
+ ## Next Plans (not this document)
363
+
364
+ - **Plan C3 — Multi-agent runners + worktree isolation + review-iteration.**