@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.
- package/CHANGELOG.md +130 -0
- package/README.md +583 -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 +37 -6
- package/dist/loop/cleanup.js +44 -3
- package/dist/loop/loop.js +67 -2
- package/dist/loop/reporter.js +88 -4
- package/dist/loop/run-command.js +25 -8
- package/dist/loop/runner.js +133 -14
- package/dist/loop/watchdog.js +67 -17
- package/dist/retrofit/apply.js +10 -3
- package/dist/retrofit/config.js +9 -1
- package/dist/retrofit/gitignore.js +4 -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,413 @@
|
|
|
1
|
+
# Forge — Baustein C4 (per-iteration worktree isolation) 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:** Optionally run each loop iteration in a fresh git worktree, so a failed or partial iteration's changes never touch the main working tree — only a verified, committed story is fast-forwarded back in.
|
|
6
|
+
|
|
7
|
+
**Architecture:** Extend `GitOps` with worktree operations (`addWorktree`, `removeWorktree`, `integrate`). When `runLoop` is given `isolate: true`, each iteration: creates a worktree from the current HEAD, runs the agent + verify **inside it**, and on green marks the PRD + commits **in the worktree** then fast-forward-integrates the commit back into the main repo; on any failure the worktree is removed and the main tree is untouched. `isolate` defaults to `false` (existing behavior unchanged). Baustein C4; review-iteration with role separation is C5.
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** Node.js (ESM), TypeScript, vitest, `node:child_process` + `node:path`. Extends C1/C2/C3 `gates.ts` (the `GitOps` interface), `git.ts` (`realGitOps`), `loop.ts`, `run-command.ts`, `cli.ts`.
|
|
10
|
+
|
|
11
|
+
**Builds on:** A+B1+B2+C1+C2+B3+C3 on `main`. Modifies: `src/loop/gates.ts`, `src/loop/git.ts`, `src/loop/loop.ts`, `src/loop/run-command.ts`, `src/cli.ts`, `canon/loop/loop-spec.md`.
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## File Structure
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
src/loop/
|
|
19
|
+
gates.ts # MODIFY: GitOps interface gains addWorktree/removeWorktree/integrate
|
|
20
|
+
git.ts # MODIFY: realGitOps implements the three worktree ops
|
|
21
|
+
loop.ts # MODIFY: LoopOptions.isolate?; isolated iteration path
|
|
22
|
+
run-command.ts # MODIFY: thread isolate through to runLoop
|
|
23
|
+
cli.ts (src/cli.ts) # MODIFY: loop run parses --isolate
|
|
24
|
+
canon/loop/loop-spec.md # MODIFY: document isolation
|
|
25
|
+
tests/loop/
|
|
26
|
+
git.test.ts # MODIFY: real-git worktree round-trip
|
|
27
|
+
loop.test.ts # MODIFY: isolated-iteration cases via a fs-backed stub git
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
### Task 1: Extend GitOps with worktree operations
|
|
33
|
+
|
|
34
|
+
**Files:**
|
|
35
|
+
- Modify: `src/loop/gates.ts` (interface), `src/loop/git.ts` (impl)
|
|
36
|
+
- Test: `tests/loop/git.test.ts` (extend)
|
|
37
|
+
|
|
38
|
+
- [ ] **Step 1: Add a failing real-git test**
|
|
39
|
+
|
|
40
|
+
In `tests/loop/git.test.ts`, add a worktree round-trip test. Add `existsSync`, `readFileSync`, `writeFileSync` to the `node:fs` import if not present, plus `join`. Add:
|
|
41
|
+
```ts
|
|
42
|
+
import { join } from 'node:path'
|
|
43
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
|
44
|
+
```
|
|
45
|
+
Add inside the `describe('realGitOps', ...)`:
|
|
46
|
+
```ts
|
|
47
|
+
it('addWorktree creates a working copy, integrate brings its commit back, removeWorktree cleans up', () => {
|
|
48
|
+
const wt = join(dir, '.forge', 'worktrees', 'S1')
|
|
49
|
+
realGitOps.addWorktree(dir, wt)
|
|
50
|
+
expect(existsSync(join(wt, 'a.txt'))).toBe(true) // checked out from HEAD
|
|
51
|
+
|
|
52
|
+
// make + commit a change inside the worktree
|
|
53
|
+
writeFileSync(join(wt, 'a.txt'), 'changed in worktree')
|
|
54
|
+
realGitOps.commitAll(wt, 'forge: worktree change')
|
|
55
|
+
|
|
56
|
+
// integrate fast-forwards the main repo to the worktree commit
|
|
57
|
+
realGitOps.integrate(dir, wt)
|
|
58
|
+
expect(readFileSync(join(dir, 'a.txt'), 'utf8')).toBe('changed in worktree')
|
|
59
|
+
expect(realGitOps.isClean(dir)).toBe(true)
|
|
60
|
+
|
|
61
|
+
realGitOps.removeWorktree(dir, wt)
|
|
62
|
+
expect(existsSync(wt)).toBe(false)
|
|
63
|
+
})
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
- [ ] **Step 2: Run test to verify it fails**
|
|
67
|
+
|
|
68
|
+
Run: `npm test -- loop/git`
|
|
69
|
+
Expected: FAIL — `addWorktree` is not a function on `realGitOps`.
|
|
70
|
+
|
|
71
|
+
- [ ] **Step 3: Add the methods to the `GitOps` interface in `src/loop/gates.ts`**
|
|
72
|
+
|
|
73
|
+
```ts
|
|
74
|
+
export interface GitOps {
|
|
75
|
+
isClean(dir: string): boolean
|
|
76
|
+
commitAll(dir: string, message: string): void
|
|
77
|
+
addWorktree(repoDir: string, worktreePath: string): void
|
|
78
|
+
removeWorktree(repoDir: string, worktreePath: string): void
|
|
79
|
+
integrate(repoDir: string, worktreePath: string): void
|
|
80
|
+
}
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
- [ ] **Step 4: Implement the methods in `src/loop/git.ts`**
|
|
84
|
+
|
|
85
|
+
Add to the `realGitOps` object (after `commitAll`):
|
|
86
|
+
```ts
|
|
87
|
+
addWorktree(repoDir: string, worktreePath: string): void {
|
|
88
|
+
execFileSync('git', ['worktree', 'add', '--detach', worktreePath, 'HEAD'], { cwd: repoDir, stdio: 'pipe' })
|
|
89
|
+
},
|
|
90
|
+
removeWorktree(repoDir: string, worktreePath: string): void {
|
|
91
|
+
execFileSync('git', ['worktree', 'remove', '--force', worktreePath], { cwd: repoDir, stdio: 'pipe' })
|
|
92
|
+
},
|
|
93
|
+
integrate(repoDir: string, worktreePath: string): void {
|
|
94
|
+
const sha = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: worktreePath }).toString().trim()
|
|
95
|
+
execFileSync('git', ['merge', '--ff-only', sha], { cwd: repoDir, stdio: 'pipe' })
|
|
96
|
+
},
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
- [ ] **Step 5: Run test to verify it passes**
|
|
100
|
+
|
|
101
|
+
Run: `npm test -- loop/git`
|
|
102
|
+
Expected: PASS (existing + 1 new).
|
|
103
|
+
|
|
104
|
+
- [ ] **Step 6: Commit**
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
git add src/loop/gates.ts src/loop/git.ts tests/loop/git.test.ts
|
|
108
|
+
git commit -m "feat: add git worktree operations to GitOps"
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
---
|
|
112
|
+
|
|
113
|
+
### Task 2: Isolated iteration path in runLoop
|
|
114
|
+
|
|
115
|
+
**Files:**
|
|
116
|
+
- Modify: `src/loop/loop.ts`
|
|
117
|
+
- Test: `tests/loop/loop.test.ts` (extend)
|
|
118
|
+
|
|
119
|
+
- [ ] **Step 1: Add failing tests using an fs-backed stub git**
|
|
120
|
+
|
|
121
|
+
In `tests/loop/loop.test.ts`, add imports and a stub git that models a worktree with real fs (copy the PRD in on add, copy it back on integrate). Add:
|
|
122
|
+
```ts
|
|
123
|
+
import { mkdirSync, copyFileSync, rmSync as rmSyncFs, existsSync } from 'node:fs'
|
|
124
|
+
import { join as joinPath } from 'node:path'
|
|
125
|
+
```
|
|
126
|
+
(If `mkdtempSync`, `writeFileSync`, `join` are already imported, reuse them and only add what's missing — avoid duplicate identifiers; the test file already imports several `node:fs` names and `join` from `node:path`. Add only the missing ones: `mkdirSync`, `copyFileSync`, `existsSync`.)
|
|
127
|
+
|
|
128
|
+
Add a stub-git factory and tests:
|
|
129
|
+
```ts
|
|
130
|
+
function fsWorktreeGit(removed: string[]): GitOps {
|
|
131
|
+
return {
|
|
132
|
+
isClean: () => true,
|
|
133
|
+
commitAll: () => {},
|
|
134
|
+
addWorktree: (_repo, wt) => {
|
|
135
|
+
mkdirSync(join(wt, '.forge'), { recursive: true })
|
|
136
|
+
copyFileSync(join(dir, '.forge', 'prd.yaml'), join(wt, '.forge', 'prd.yaml'))
|
|
137
|
+
},
|
|
138
|
+
integrate: (repo, wt) => {
|
|
139
|
+
copyFileSync(join(wt, '.forge', 'prd.yaml'), join(repo, '.forge', 'prd.yaml'))
|
|
140
|
+
},
|
|
141
|
+
removeWorktree: (_repo, wt) => { removed.push(wt); rmSyncFs(wt, { recursive: true, force: true }) },
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
```
|
|
145
|
+
The existing test setup writes the PRD to `join(dir, 'prd.yaml')`. For the isolation tests, write it to `join(dir, '.forge', 'prd.yaml')` instead so the worktree-relative copy works. Add a dedicated setup in these tests:
|
|
146
|
+
```ts
|
|
147
|
+
describe('runLoop with isolation', () => {
|
|
148
|
+
let isoDir: string
|
|
149
|
+
const isoPrd = () => join(isoDir, '.forge', 'prd.yaml')
|
|
150
|
+
beforeEach(() => {
|
|
151
|
+
isoDir = mkdtempSync(join(tmpdir(), 'forge-iso-'))
|
|
152
|
+
mkdirSync(join(isoDir, '.forge'), { recursive: true })
|
|
153
|
+
writeFileSync(isoPrd(), `
|
|
154
|
+
- { id: S1, title: First, priority: 1, acceptance: ["x"], passes: false }
|
|
155
|
+
`)
|
|
156
|
+
})
|
|
157
|
+
afterEach(() => { rmSyncFs(isoDir, { recursive: true, force: true }) })
|
|
158
|
+
|
|
159
|
+
it('completes a story through an isolated worktree and integrates it back', () => {
|
|
160
|
+
const removed: string[] = []
|
|
161
|
+
const res = runLoop({
|
|
162
|
+
prdPath: isoPrd(), targetDir: isoDir, runner: alwaysPass, git: fsWorktreeGit(removed),
|
|
163
|
+
verify: verifyOk, isolate: true, maxIterations: 5,
|
|
164
|
+
})
|
|
165
|
+
expect(res.status).toBe('complete')
|
|
166
|
+
expect(loadPrd(isoPrd())[0].passes).toBe(true) // integrated back into main
|
|
167
|
+
expect(removed.length).toBe(1) // worktree cleaned up
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
it('discards the worktree and leaves the main PRD untouched when verify fails', () => {
|
|
171
|
+
const removed: string[] = []
|
|
172
|
+
const verifyRed: Verifier = () => ({ passed: false, summary: 'red' })
|
|
173
|
+
const res = runLoop({
|
|
174
|
+
prdPath: isoPrd(), targetDir: isoDir, runner: alwaysPass, git: fsWorktreeGit(removed),
|
|
175
|
+
verify: verifyRed, isolate: true, maxIterations: 5,
|
|
176
|
+
})
|
|
177
|
+
expect(res.status).toBe('blocked')
|
|
178
|
+
expect(loadPrd(isoPrd())[0].passes).toBe(false) // main tree untouched
|
|
179
|
+
expect(removed.length).toBe(1) // worktree still cleaned up
|
|
180
|
+
})
|
|
181
|
+
})
|
|
182
|
+
```
|
|
183
|
+
Note: in `loop.test.ts` the `dir` referenced inside `fsWorktreeGit` must be `isoDir` for these tests — define `fsWorktreeGit` to close over a passed-in repo dir instead. Adjust the factory to take the repo dir:
|
|
184
|
+
```ts
|
|
185
|
+
function fsWorktreeGit(repo: string, removed: string[]): GitOps {
|
|
186
|
+
return {
|
|
187
|
+
isClean: () => true,
|
|
188
|
+
commitAll: () => {},
|
|
189
|
+
addWorktree: (_r, wt) => {
|
|
190
|
+
mkdirSync(join(wt, '.forge'), { recursive: true })
|
|
191
|
+
copyFileSync(join(repo, '.forge', 'prd.yaml'), join(wt, '.forge', 'prd.yaml'))
|
|
192
|
+
},
|
|
193
|
+
integrate: (r, wt) => { copyFileSync(join(wt, '.forge', 'prd.yaml'), join(r, '.forge', 'prd.yaml')) },
|
|
194
|
+
removeWorktree: (_r, wt) => { removed.push(wt); rmSyncFs(wt, { recursive: true, force: true }) },
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
```
|
|
198
|
+
and call `fsWorktreeGit(isoDir, removed)` in both tests.
|
|
199
|
+
|
|
200
|
+
- [ ] **Step 2: Run test to verify it fails**
|
|
201
|
+
|
|
202
|
+
Run: `npm test -- loop/loop`
|
|
203
|
+
Expected: FAIL — `isolate` is not part of `LoopOptions`; the isolated path doesn't exist.
|
|
204
|
+
|
|
205
|
+
- [ ] **Step 3: Modify `src/loop/loop.ts`**
|
|
206
|
+
|
|
207
|
+
Add the path import at the top:
|
|
208
|
+
```ts
|
|
209
|
+
import { join, relative } from 'node:path'
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
Add `isolate` (optional) to `LoopOptions`:
|
|
213
|
+
```ts
|
|
214
|
+
export interface LoopOptions {
|
|
215
|
+
prdPath: string
|
|
216
|
+
targetDir: string
|
|
217
|
+
runner: AgentRunner
|
|
218
|
+
git: GitOps
|
|
219
|
+
verify: Verifier
|
|
220
|
+
maxIterations: number
|
|
221
|
+
isolate?: boolean
|
|
222
|
+
}
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
In the loop body, after the `stopTheLineGate` check passes and before the existing `const result = opts.runner(...)` line, branch into the isolated path when `opts.isolate` is set:
|
|
226
|
+
```ts
|
|
227
|
+
if (opts.isolate) {
|
|
228
|
+
const wt = join(opts.targetDir, '.forge', 'worktrees', story.id)
|
|
229
|
+
const wtPrd = join(wt, relative(opts.targetDir, opts.prdPath))
|
|
230
|
+
opts.git.addWorktree(opts.targetDir, wt)
|
|
231
|
+
try {
|
|
232
|
+
const result = opts.runner({ targetDir: wt, story })
|
|
233
|
+
iterations++
|
|
234
|
+
if (!result.success) {
|
|
235
|
+
return { status: 'blocked', iterations, reason: `story ${story.id} failed: ${result.summary}`, finalProgress: progress(stories) }
|
|
236
|
+
}
|
|
237
|
+
const verdict = opts.verify(wt)
|
|
238
|
+
if (!verdict.passed) {
|
|
239
|
+
return { status: 'blocked', iterations, reason: `story ${story.id} did not verify: ${verdict.summary}`, finalProgress: progress(stories) }
|
|
240
|
+
}
|
|
241
|
+
const updated = stories.map(s => (s.id === story.id ? { ...s, passes: true } : s))
|
|
242
|
+
savePrd(wtPrd, updated)
|
|
243
|
+
opts.git.commitAll(wt, `forge: complete ${story.id} ${story.title}`)
|
|
244
|
+
opts.git.integrate(opts.targetDir, wt)
|
|
245
|
+
} catch (e) {
|
|
246
|
+
return { status: 'blocked', iterations, reason: `isolated iteration failed for ${story.id}: ${(e as Error).message}`, finalProgress: progress(stories) }
|
|
247
|
+
} finally {
|
|
248
|
+
opts.git.removeWorktree(opts.targetDir, wt)
|
|
249
|
+
}
|
|
250
|
+
continue
|
|
251
|
+
}
|
|
252
|
+
```
|
|
253
|
+
Leave the existing non-isolated flow (the current `const result = opts.runner(...)` through the commit-integrity `try/catch`) exactly as-is below this branch — it runs when `isolate` is falsy.
|
|
254
|
+
|
|
255
|
+
Note on integrity: in the isolated path the PRD is only updated inside the worktree and reaches the main tree via `integrate`; if `integrate` (or commit) throws, the worktree is removed and the main tree's PRD is never touched — so no explicit revert is needed there.
|
|
256
|
+
|
|
257
|
+
- [ ] **Step 4: Run test to verify it passes**
|
|
258
|
+
|
|
259
|
+
Run: `npm test -- loop/loop`
|
|
260
|
+
Expected: PASS (all existing non-isolated cases unchanged + 2 new isolation cases).
|
|
261
|
+
|
|
262
|
+
- [ ] **Step 5: Commit**
|
|
263
|
+
|
|
264
|
+
```bash
|
|
265
|
+
git add src/loop/loop.ts tests/loop/loop.test.ts
|
|
266
|
+
git commit -m "feat: run each loop iteration in an isolated worktree when enabled"
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
---
|
|
270
|
+
|
|
271
|
+
### Task 3: Thread `--isolate` through run-command and CLI
|
|
272
|
+
|
|
273
|
+
**Files:**
|
|
274
|
+
- Modify: `src/loop/run-command.ts`, `src/cli.ts`
|
|
275
|
+
- Test: `tests/loop/loop-cli.integration.test.ts` (extend)
|
|
276
|
+
|
|
277
|
+
- [ ] **Step 1: Add a failing test**
|
|
278
|
+
|
|
279
|
+
In `tests/loop/loop-cli.integration.test.ts`, the `stubGit` only implements `isClean`/`commitAll`. Extend it so it satisfies the new `GitOps` members (no-ops are fine for the non-isolated default path), and add a test that `isolate` is passed through. First update `stubGit`:
|
|
280
|
+
```ts
|
|
281
|
+
const stubGit: GitOps = {
|
|
282
|
+
isClean: () => true,
|
|
283
|
+
commitAll: () => {},
|
|
284
|
+
addWorktree: () => {},
|
|
285
|
+
removeWorktree: () => {},
|
|
286
|
+
integrate: () => {},
|
|
287
|
+
}
|
|
288
|
+
```
|
|
289
|
+
Then add a test that isolation runs end-to-end with an injected runner (the default-path tests already cover non-isolated). Because `runLoopCommand` injects `git: stubGit` and `runner: passRunner`, an isolate run with stubGit no-op worktree ops would not copy the PRD — so for this integration test, assert the call returns 0 and the option is accepted (the deep isolation behavior is unit-tested in loop.test.ts). Add:
|
|
290
|
+
```ts
|
|
291
|
+
it('accepts the isolate option and completes with injected stubs', () => {
|
|
292
|
+
saveConfig(dir, { ...cfg(), verify: { command: 'node -e "process.exit(0)"' } })
|
|
293
|
+
// PRD lives at .forge/prd.yaml; with no-op worktree stubs the runner marks via the
|
|
294
|
+
// non-worktree fallback is NOT used — so drive isolate=false here and just assert the flag is plumbed.
|
|
295
|
+
const code = runLoopCommand(dir, { maxIterations: 5, runner: passRunner, git: stubGit, verify: verifyOk, isolate: false })
|
|
296
|
+
expect(code).toBe(0)
|
|
297
|
+
})
|
|
298
|
+
```
|
|
299
|
+
(Keep this test simple — the real isolation behavior is covered by the unit tests in Task 2. This test only guards that the `isolate` option exists and is accepted by `runLoopCommand`.)
|
|
300
|
+
|
|
301
|
+
- [ ] **Step 2: Run test to verify it fails**
|
|
302
|
+
|
|
303
|
+
Run: `npm test -- loop-cli`
|
|
304
|
+
Expected: FAIL — `RunLoopCommandOptions` has no `isolate`; `stubGit` may also fail to satisfy `GitOps` until updated.
|
|
305
|
+
|
|
306
|
+
- [ ] **Step 3: Modify `src/loop/run-command.ts`**
|
|
307
|
+
|
|
308
|
+
Add `isolate` to `RunLoopCommandOptions`:
|
|
309
|
+
```ts
|
|
310
|
+
export interface RunLoopCommandOptions {
|
|
311
|
+
maxIterations: number
|
|
312
|
+
runner?: AgentRunner
|
|
313
|
+
git?: GitOps
|
|
314
|
+
verify?: Verifier
|
|
315
|
+
agent?: Agent
|
|
316
|
+
isAvailable?: (agent: Agent) => boolean
|
|
317
|
+
isolate?: boolean
|
|
318
|
+
}
|
|
319
|
+
```
|
|
320
|
+
Pass it into the `runLoop` call:
|
|
321
|
+
```ts
|
|
322
|
+
const result = runLoop({
|
|
323
|
+
prdPath: path,
|
|
324
|
+
targetDir,
|
|
325
|
+
runner,
|
|
326
|
+
git: opts.git ?? realGitOps,
|
|
327
|
+
verify,
|
|
328
|
+
maxIterations: opts.maxIterations,
|
|
329
|
+
isolate: opts.isolate ?? false,
|
|
330
|
+
})
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
- [ ] **Step 4: Modify `src/cli.ts` to parse `--isolate`**
|
|
334
|
+
|
|
335
|
+
In the `loop` `run` branch, read the flag and pass it through:
|
|
336
|
+
```ts
|
|
337
|
+
const isolate = rest.includes('--isolate')
|
|
338
|
+
return runLoopCommand(targetDir, { maxIterations: rawMax, agent, isolate })
|
|
339
|
+
```
|
|
340
|
+
Update the loop usage string to include it:
|
|
341
|
+
```ts
|
|
342
|
+
console.log('usage: forge loop <on|off|status|run [--max=N] [--runner=<claude|codex|gemini>] [--isolate]> [targetDir]')
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
- [ ] **Step 5: Run the suite + build**
|
|
346
|
+
|
|
347
|
+
Run: `npm test`
|
|
348
|
+
Expected: all pass.
|
|
349
|
+
|
|
350
|
+
Run: `npm run build`
|
|
351
|
+
Expected: tsc 0 errors.
|
|
352
|
+
|
|
353
|
+
- [ ] **Step 6: Commit**
|
|
354
|
+
|
|
355
|
+
```bash
|
|
356
|
+
git add src/loop/run-command.ts src/cli.ts tests/loop/loop-cli.integration.test.ts
|
|
357
|
+
git commit -m "feat: thread --isolate through forge loop run"
|
|
358
|
+
```
|
|
359
|
+
|
|
360
|
+
---
|
|
361
|
+
|
|
362
|
+
### Task 4: Update canon loop-spec
|
|
363
|
+
|
|
364
|
+
**Files:**
|
|
365
|
+
- Modify: `canon/loop/loop-spec.md`
|
|
366
|
+
|
|
367
|
+
- [ ] **Step 1: Document isolation**
|
|
368
|
+
|
|
369
|
+
In `canon/loop/loop-spec.md`, add a line to the command list and update the Limitations. Add under the `forge loop run` description (or near step 1) a note:
|
|
370
|
+
```markdown
|
|
371
|
+
Pass `--isolate` to run each iteration in a fresh git worktree: the agent works on a throwaway checkout, and only a verified, committed story is fast-forwarded back into the main tree. A failed iteration never touches your working tree.
|
|
372
|
+
```
|
|
373
|
+
And replace the Limitations section with:
|
|
374
|
+
```markdown
|
|
375
|
+
## Limitations
|
|
376
|
+
- Review-iteration with role separation (a second agent reviews before a story is marked done) is Baustein C5.
|
|
377
|
+
- The loop verifies via the project's test command, not a per-agent semantic review.
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
- [ ] **Step 2: Validate the canon**
|
|
381
|
+
|
|
382
|
+
Run: `npm run forge -- validate canon`
|
|
383
|
+
Expected: `✓ canon valid (canon)`.
|
|
384
|
+
|
|
385
|
+
Run: `npm test -- real-canon`
|
|
386
|
+
Expected: PASS.
|
|
387
|
+
|
|
388
|
+
- [ ] **Step 3: Commit**
|
|
389
|
+
|
|
390
|
+
```bash
|
|
391
|
+
git add canon/loop/loop-spec.md
|
|
392
|
+
git commit -m "docs: canon loop-spec documents worktree isolation"
|
|
393
|
+
```
|
|
394
|
+
|
|
395
|
+
---
|
|
396
|
+
|
|
397
|
+
## Self-Review
|
|
398
|
+
|
|
399
|
+
**1. Spec coverage (Baustein C4 scope):**
|
|
400
|
+
- Per-iteration git-worktree isolation (agent works in a throwaway worktree) → Tasks 1, 2 ✓
|
|
401
|
+
- Only a verified, committed story is integrated back (fast-forward) → Task 2 ✓
|
|
402
|
+
- A failed/partial iteration never touches the main tree (worktree removed, PRD untouched) → Task 2 (verify-fail test) ✓
|
|
403
|
+
- Opt-in via `--isolate`, default off (existing behavior unchanged) → Tasks 2, 3 ✓
|
|
404
|
+
- Deterministic tests (fs-backed stub git for the loop; real git only in git.test.ts) → Tasks 1, 2 ✓
|
|
405
|
+
- (Deferred to C5: review-iteration with role separation. Correct.)
|
|
406
|
+
|
|
407
|
+
**2. Placeholder scan:** No TBD/TODO. The "reuse already-imported identifiers, add only missing ones" note in Task 2 is a concrete instruction to avoid duplicate imports, not a placeholder. Every code step is complete.
|
|
408
|
+
|
|
409
|
+
**3. Type consistency:** `GitOps` (now 5 methods), `realGitOps` (implements all 5), `LoopOptions.isolate?`, `RunLoopCommandOptions.isolate?`. All existing `GitOps` stubs in tests must gain the three no-op methods (`loop.test.ts` cleanGit/dirtyGit, `gates.test.ts` git, `loop-cli.integration.test.ts` stubGit) — the implementer must update every stub or tsc fails. The isolated path reuses `savePrd`/`progress`/`stopTheLineGate`/`preDispatchGate` unchanged; the non-isolated path is byte-for-byte preserved. ✓
|
|
410
|
+
|
|
411
|
+
## Next Plans (not this document)
|
|
412
|
+
|
|
413
|
+
- **Plan C5 — Review-iteration with role separation:** after a story verifies green, a second agent pass (reviewer role, distinct prompt) must approve before the story is committed/marked done.
|