@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,891 @@
|
|
|
1
|
+
# Forge — Baustein C1 (Loop-Engine) 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:** Add the optional autonomous Ralph loop: a PRD-driven driver that implements one story per iteration through an **injectable** agent runner, guarded by mechanical gates (Stop-the-Line + clean-worktree), plus `forge loop on|off|status|run`.
|
|
6
|
+
|
|
7
|
+
**Architecture:** The loop driver (`runLoop`) depends on two injected seams — an `AgentRunner` (real impl shells out to `claude -p`; tests pass a mock) and `GitOps` (real impl calls `git`; tests pass a real temp repo or a stub). This keeps the orchestration + gates **deterministic and free to test** with no real LLM calls. PRD parsing, story selection, and gates are pure. State lives outside any model context: the PRD file (`.forge/prd.yaml`) + git. This is Baustein C1 of the Forge spec; full per-iteration git-worktree isolation is C2.
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** Node.js (ESM), TypeScript, vitest, `yaml`, `zod`, `node:child_process`. Reuses `loadConfig`/`saveConfig`/`ForgeConfig` from B1. Loop is OFF by default (set in B1's `.forge/config.yaml`).
|
|
10
|
+
|
|
11
|
+
**Builds on:** Baustein A + B1 (on `main`): `src/retrofit/config.ts` (`loadConfig`, `saveConfig`, `ForgeConfig`, `defaultConfig`), `src/cli.ts` (`main` dispatch with `validate` + `retrofit`, `isMain` guard).
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## File Structure
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
src/
|
|
19
|
+
loop/
|
|
20
|
+
prd.ts # Story type + loadPrd/savePrd/selectNextStory/allPass/progress
|
|
21
|
+
gates.ts # GateResult, stopTheLineGate, GitOps interface, preDispatchGate
|
|
22
|
+
runner.ts # AgentRunner type, buildClaudePrompt, claudeRunner (shells out)
|
|
23
|
+
git.ts # realGitOps — isClean/commitAll via `git` CLI
|
|
24
|
+
loop.ts # runLoop(opts) — the Ralph driver
|
|
25
|
+
run-command.ts # runLoopCommand(targetDir, opts) — wires config+PRD+real seams (injectable)
|
|
26
|
+
cli.ts # MODIFY: add `loop` subcommand (on|off|status|run)
|
|
27
|
+
tests/
|
|
28
|
+
loop/
|
|
29
|
+
prd.test.ts
|
|
30
|
+
gates.test.ts
|
|
31
|
+
runner.test.ts
|
|
32
|
+
git.test.ts
|
|
33
|
+
loop.test.ts
|
|
34
|
+
loop-cli.integration.test.ts
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Each module has one responsibility. The pure parts (`prd`, `gates`) test without a filesystem or git; `git.ts` tests against a real temp repo; `loop.ts` tests with a mock runner + stub git; the CLI integration test drives `runLoopCommand` with an injected mock runner so it stays deterministic.
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
### Task 1: PRD module
|
|
42
|
+
|
|
43
|
+
**Files:**
|
|
44
|
+
- Create: `src/loop/prd.ts`
|
|
45
|
+
- Test: `tests/loop/prd.test.ts`
|
|
46
|
+
|
|
47
|
+
- [ ] **Step 1: Write the failing test**
|
|
48
|
+
|
|
49
|
+
Create `tests/loop/prd.test.ts`:
|
|
50
|
+
```ts
|
|
51
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
52
|
+
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'
|
|
53
|
+
import { join } from 'node:path'
|
|
54
|
+
import { tmpdir } from 'node:os'
|
|
55
|
+
import { loadPrd, savePrd, selectNextStory, allPass, progress } from '../../src/loop/prd.js'
|
|
56
|
+
|
|
57
|
+
let dir: string
|
|
58
|
+
const prd = () => join(dir, 'prd.yaml')
|
|
59
|
+
beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'forge-prd-')) })
|
|
60
|
+
afterEach(() => { rmSync(dir, { recursive: true, force: true }) })
|
|
61
|
+
|
|
62
|
+
const sample = `
|
|
63
|
+
- { id: S1, title: First, priority: 2, acceptance: ["does X"], passes: false }
|
|
64
|
+
- { id: S2, title: Second, priority: 1, acceptance: ["does Y"], passes: false }
|
|
65
|
+
- { id: S3, title: Third, priority: 3, acceptance: ["does Z"], passes: true }
|
|
66
|
+
`
|
|
67
|
+
|
|
68
|
+
describe('prd', () => {
|
|
69
|
+
it('loads stories from yaml', () => {
|
|
70
|
+
writeFileSync(prd(), sample)
|
|
71
|
+
const stories = loadPrd(prd())
|
|
72
|
+
expect(stories).toHaveLength(3)
|
|
73
|
+
expect(stories[0]).toMatchObject({ id: 'S1', priority: 2, passes: false })
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('selects the highest-priority (lowest number) unfinished story', () => {
|
|
77
|
+
writeFileSync(prd(), sample)
|
|
78
|
+
const next = selectNextStory(loadPrd(prd()))
|
|
79
|
+
expect(next?.id).toBe('S2')
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('selectNextStory returns null when all pass', () => {
|
|
83
|
+
expect(selectNextStory([{ id: 'A', title: 't', priority: 1, acceptance: ['x'], passes: true }])).toBeNull()
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('allPass and progress report completion', () => {
|
|
87
|
+
writeFileSync(prd(), sample)
|
|
88
|
+
const stories = loadPrd(prd())
|
|
89
|
+
expect(allPass(stories)).toBe(false)
|
|
90
|
+
expect(progress(stories)).toEqual({ passed: 1, total: 3 })
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('saves stories back to yaml round-trip', () => {
|
|
94
|
+
writeFileSync(prd(), sample)
|
|
95
|
+
const stories = loadPrd(prd())
|
|
96
|
+
stories[0].passes = true
|
|
97
|
+
savePrd(prd(), stories)
|
|
98
|
+
expect(progress(loadPrd(prd()))).toEqual({ passed: 2, total: 3 })
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('rejects a malformed story (missing acceptance)', () => {
|
|
102
|
+
writeFileSync(prd(), `- { id: X, title: t, priority: 1, passes: false }`)
|
|
103
|
+
expect(() => loadPrd(prd())).toThrow()
|
|
104
|
+
})
|
|
105
|
+
})
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
- [ ] **Step 2: Run test to verify it fails**
|
|
109
|
+
|
|
110
|
+
Run: `npm test -- loop/prd`
|
|
111
|
+
Expected: FAIL — cannot find module `src/loop/prd.js`.
|
|
112
|
+
|
|
113
|
+
- [ ] **Step 3: Write minimal implementation**
|
|
114
|
+
|
|
115
|
+
Create `src/loop/prd.ts`:
|
|
116
|
+
```ts
|
|
117
|
+
import { readFileSync, writeFileSync } from 'node:fs'
|
|
118
|
+
import { parse, stringify } from 'yaml'
|
|
119
|
+
import { z } from 'zod'
|
|
120
|
+
|
|
121
|
+
export const StorySchema = z.object({
|
|
122
|
+
id: z.string().min(1),
|
|
123
|
+
title: z.string().min(1),
|
|
124
|
+
priority: z.number(),
|
|
125
|
+
acceptance: z.array(z.string().min(1)),
|
|
126
|
+
passes: z.boolean(),
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
export type Story = z.infer<typeof StorySchema>
|
|
130
|
+
|
|
131
|
+
const PrdSchema = z.array(StorySchema)
|
|
132
|
+
|
|
133
|
+
export function loadPrd(file: string): Story[] {
|
|
134
|
+
return PrdSchema.parse(parse(readFileSync(file, 'utf8')))
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function savePrd(file: string, stories: Story[]): void {
|
|
138
|
+
writeFileSync(file, stringify(stories))
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function selectNextStory(stories: Story[]): Story | null {
|
|
142
|
+
const open = stories.filter(s => !s.passes)
|
|
143
|
+
if (open.length === 0) return null
|
|
144
|
+
return open.reduce((best, s) => (s.priority < best.priority ? s : best))
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function allPass(stories: Story[]): boolean {
|
|
148
|
+
return stories.length > 0 && stories.every(s => s.passes)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function progress(stories: Story[]): { passed: number; total: number } {
|
|
152
|
+
return { passed: stories.filter(s => s.passes).length, total: stories.length }
|
|
153
|
+
}
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
- [ ] **Step 4: Run test to verify it passes**
|
|
157
|
+
|
|
158
|
+
Run: `npm test -- loop/prd`
|
|
159
|
+
Expected: PASS (6 tests).
|
|
160
|
+
|
|
161
|
+
- [ ] **Step 5: Commit**
|
|
162
|
+
|
|
163
|
+
```bash
|
|
164
|
+
git add src/loop/prd.ts tests/loop/prd.test.ts
|
|
165
|
+
git commit -m "feat: add PRD module (load/save/select/progress)"
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
---
|
|
169
|
+
|
|
170
|
+
### Task 2: Gates module
|
|
171
|
+
|
|
172
|
+
**Files:**
|
|
173
|
+
- Create: `src/loop/gates.ts`
|
|
174
|
+
- Test: `tests/loop/gates.test.ts`
|
|
175
|
+
|
|
176
|
+
- [ ] **Step 1: Write the failing test**
|
|
177
|
+
|
|
178
|
+
Create `tests/loop/gates.test.ts`:
|
|
179
|
+
```ts
|
|
180
|
+
import { describe, it, expect } from 'vitest'
|
|
181
|
+
import { stopTheLineGate, preDispatchGate } from '../../src/loop/gates.js'
|
|
182
|
+
import type { GitOps } from '../../src/loop/gates.js'
|
|
183
|
+
import type { Story } from '../../src/loop/prd.js'
|
|
184
|
+
|
|
185
|
+
const story = (over: Partial<Story> = {}): Story => ({
|
|
186
|
+
id: 'S1', title: 't', priority: 1, acceptance: ['does X'], passes: false, ...over,
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
const git = (clean: boolean): GitOps => ({
|
|
190
|
+
isClean: () => clean,
|
|
191
|
+
commitAll: () => {},
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
describe('gates', () => {
|
|
195
|
+
it('stopTheLineGate passes when acceptance criteria exist', () => {
|
|
196
|
+
expect(stopTheLineGate(story()).ok).toBe(true)
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
it('stopTheLineGate blocks when acceptance is empty', () => {
|
|
200
|
+
const r = stopTheLineGate(story({ acceptance: [] }))
|
|
201
|
+
expect(r.ok).toBe(false)
|
|
202
|
+
expect(r.reason).toMatch(/acceptance/i)
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
it('preDispatchGate passes on a clean worktree', () => {
|
|
206
|
+
expect(preDispatchGate('/x', git(true)).ok).toBe(true)
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
it('preDispatchGate blocks on a dirty worktree', () => {
|
|
210
|
+
const r = preDispatchGate('/x', git(false))
|
|
211
|
+
expect(r.ok).toBe(false)
|
|
212
|
+
expect(r.reason).toMatch(/worktree/i)
|
|
213
|
+
})
|
|
214
|
+
})
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
- [ ] **Step 2: Run test to verify it fails**
|
|
218
|
+
|
|
219
|
+
Run: `npm test -- loop/gates`
|
|
220
|
+
Expected: FAIL — cannot find module `src/loop/gates.js`.
|
|
221
|
+
|
|
222
|
+
- [ ] **Step 3: Write minimal implementation**
|
|
223
|
+
|
|
224
|
+
Create `src/loop/gates.ts`:
|
|
225
|
+
```ts
|
|
226
|
+
import type { Story } from './prd.js'
|
|
227
|
+
|
|
228
|
+
export interface GateResult {
|
|
229
|
+
ok: boolean
|
|
230
|
+
reason?: string
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export interface GitOps {
|
|
234
|
+
isClean(dir: string): boolean
|
|
235
|
+
commitAll(dir: string, message: string): void
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export function stopTheLineGate(story: Story): GateResult {
|
|
239
|
+
if (story.acceptance.length === 0) {
|
|
240
|
+
return { ok: false, reason: `story ${story.id} has no acceptance criteria (Stop-the-Line)` }
|
|
241
|
+
}
|
|
242
|
+
return { ok: true }
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export function preDispatchGate(targetDir: string, git: GitOps): GateResult {
|
|
246
|
+
if (!git.isClean(targetDir)) {
|
|
247
|
+
return { ok: false, reason: 'git worktree is dirty — commit or stash before running the loop' }
|
|
248
|
+
}
|
|
249
|
+
return { ok: true }
|
|
250
|
+
}
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
- [ ] **Step 4: Run test to verify it passes**
|
|
254
|
+
|
|
255
|
+
Run: `npm test -- loop/gates`
|
|
256
|
+
Expected: PASS (4 tests).
|
|
257
|
+
|
|
258
|
+
- [ ] **Step 5: Commit**
|
|
259
|
+
|
|
260
|
+
```bash
|
|
261
|
+
git add src/loop/gates.ts tests/loop/gates.test.ts
|
|
262
|
+
git commit -m "feat: add loop gates (stop-the-line, pre-dispatch)"
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
---
|
|
266
|
+
|
|
267
|
+
### Task 3: Loop driver
|
|
268
|
+
|
|
269
|
+
**Files:**
|
|
270
|
+
- Create: `src/loop/loop.ts`
|
|
271
|
+
- Test: `tests/loop/loop.test.ts`
|
|
272
|
+
|
|
273
|
+
- [ ] **Step 1: Write the failing test**
|
|
274
|
+
|
|
275
|
+
Create `tests/loop/loop.test.ts`:
|
|
276
|
+
```ts
|
|
277
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
278
|
+
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'
|
|
279
|
+
import { join } from 'node:path'
|
|
280
|
+
import { tmpdir } from 'node:os'
|
|
281
|
+
import { runLoop } from '../../src/loop/loop.js'
|
|
282
|
+
import { loadPrd } from '../../src/loop/prd.js'
|
|
283
|
+
import type { GitOps } from '../../src/loop/gates.js'
|
|
284
|
+
import type { AgentRunner } from '../../src/loop/runner.js'
|
|
285
|
+
|
|
286
|
+
let dir: string
|
|
287
|
+
const prd = () => join(dir, 'prd.yaml')
|
|
288
|
+
const cleanGit = (): GitOps => ({ isClean: () => true, commitAll: () => {} })
|
|
289
|
+
|
|
290
|
+
beforeEach(() => {
|
|
291
|
+
dir = mkdtempSync(join(tmpdir(), 'forge-loop-'))
|
|
292
|
+
writeFileSync(prd(), `
|
|
293
|
+
- { id: S1, title: First, priority: 1, acceptance: ["x"], passes: false }
|
|
294
|
+
- { id: S2, title: Second, priority: 2, acceptance: ["y"], passes: false }
|
|
295
|
+
`)
|
|
296
|
+
})
|
|
297
|
+
afterEach(() => { rmSync(dir, { recursive: true, force: true }) })
|
|
298
|
+
|
|
299
|
+
const alwaysPass: AgentRunner = () => ({ success: true, summary: 'done' })
|
|
300
|
+
|
|
301
|
+
describe('runLoop', () => {
|
|
302
|
+
it('completes all stories with a passing runner', () => {
|
|
303
|
+
const commits: string[] = []
|
|
304
|
+
const git: GitOps = { isClean: () => true, commitAll: (_d, m) => commits.push(m) }
|
|
305
|
+
const res = runLoop({ prdPath: prd(), targetDir: dir, runner: alwaysPass, git, maxIterations: 10 })
|
|
306
|
+
expect(res.status).toBe('complete')
|
|
307
|
+
expect(res.iterations).toBe(2)
|
|
308
|
+
expect(res.finalProgress).toEqual({ passed: 2, total: 2 })
|
|
309
|
+
expect(loadPrd(prd()).every(s => s.passes)).toBe(true)
|
|
310
|
+
expect(commits).toHaveLength(2)
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
it('blocks when the runner fails a story', () => {
|
|
314
|
+
const failS1: AgentRunner = ({ story }) => ({ success: story.id !== 'S1', summary: 'boom' })
|
|
315
|
+
const res = runLoop({ prdPath: prd(), targetDir: dir, runner: failS1, git: cleanGit(), maxIterations: 10 })
|
|
316
|
+
expect(res.status).toBe('blocked')
|
|
317
|
+
expect(res.reason).toMatch(/S1/)
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
it('stops at the iteration cap', () => {
|
|
321
|
+
const res = runLoop({ prdPath: prd(), targetDir: dir, runner: alwaysPass, git: cleanGit(), maxIterations: 1 })
|
|
322
|
+
expect(res.status).toBe('cap-reached')
|
|
323
|
+
expect(res.iterations).toBe(1)
|
|
324
|
+
expect(res.finalProgress).toEqual({ passed: 1, total: 2 })
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
it('blocks via pre-dispatch gate on a dirty worktree', () => {
|
|
328
|
+
const dirtyGit: GitOps = { isClean: () => false, commitAll: () => {} }
|
|
329
|
+
const res = runLoop({ prdPath: prd(), targetDir: dir, runner: alwaysPass, git: dirtyGit, maxIterations: 10 })
|
|
330
|
+
expect(res.status).toBe('blocked')
|
|
331
|
+
expect(res.reason).toMatch(/worktree/i)
|
|
332
|
+
expect(res.iterations).toBe(0)
|
|
333
|
+
})
|
|
334
|
+
|
|
335
|
+
it('blocks via stop-the-line when the next story lacks acceptance', () => {
|
|
336
|
+
writeFileSync(prd(), `- { id: S1, title: t, priority: 1, acceptance: [], passes: false }`)
|
|
337
|
+
const res = runLoop({ prdPath: prd(), targetDir: dir, runner: alwaysPass, git: cleanGit(), maxIterations: 10 })
|
|
338
|
+
expect(res.status).toBe('blocked')
|
|
339
|
+
expect(res.reason).toMatch(/acceptance/i)
|
|
340
|
+
})
|
|
341
|
+
})
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
- [ ] **Step 2: Run test to verify it fails**
|
|
345
|
+
|
|
346
|
+
Run: `npm test -- loop/loop`
|
|
347
|
+
Expected: FAIL — cannot find module `src/loop/loop.js` (and `runner.js` not yet present; Task 4 adds the type, but this test only imports the `AgentRunner` type — create `runner.ts` in Task 4. To unblock this task, the loop imports the type from `./runner.js`, so do Step 3 here and if the type import fails, add a minimal `runner.ts` exporting just the types as part of this task, then flesh it out in Task 4).
|
|
348
|
+
|
|
349
|
+
Note: implement `src/loop/runner.ts` type exports now (the `AgentRunner`/`AgentResult`/`AgentContext` interfaces only — no `claudeRunner` yet) so this task compiles; Task 4 adds the real runner + its tests.
|
|
350
|
+
|
|
351
|
+
- [ ] **Step 3: Create the runner type stub, then the loop**
|
|
352
|
+
|
|
353
|
+
Create `src/loop/runner.ts` (types only for now):
|
|
354
|
+
```ts
|
|
355
|
+
import type { Story } from './prd.js'
|
|
356
|
+
|
|
357
|
+
export interface AgentContext {
|
|
358
|
+
targetDir: string
|
|
359
|
+
story: Story
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
export interface AgentResult {
|
|
363
|
+
success: boolean
|
|
364
|
+
summary: string
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
export type AgentRunner = (ctx: AgentContext) => AgentResult
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
Create `src/loop/loop.ts`:
|
|
371
|
+
```ts
|
|
372
|
+
import { loadPrd, savePrd, selectNextStory, allPass, progress } from './prd.js'
|
|
373
|
+
import { stopTheLineGate, preDispatchGate, type GitOps } from './gates.js'
|
|
374
|
+
import type { AgentRunner } from './runner.js'
|
|
375
|
+
|
|
376
|
+
export interface LoopOptions {
|
|
377
|
+
prdPath: string
|
|
378
|
+
targetDir: string
|
|
379
|
+
runner: AgentRunner
|
|
380
|
+
git: GitOps
|
|
381
|
+
maxIterations: number
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
export interface LoopResult {
|
|
385
|
+
status: 'complete' | 'blocked' | 'cap-reached'
|
|
386
|
+
iterations: number
|
|
387
|
+
reason?: string
|
|
388
|
+
finalProgress: { passed: number; total: number }
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
export function runLoop(opts: LoopOptions): LoopResult {
|
|
392
|
+
let iterations = 0
|
|
393
|
+
|
|
394
|
+
for (;;) {
|
|
395
|
+
const stories = loadPrd(opts.prdPath)
|
|
396
|
+
|
|
397
|
+
if (allPass(stories)) {
|
|
398
|
+
return { status: 'complete', iterations, finalProgress: progress(stories) }
|
|
399
|
+
}
|
|
400
|
+
if (iterations >= opts.maxIterations) {
|
|
401
|
+
return { status: 'cap-reached', iterations, finalProgress: progress(stories) }
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
const pre = preDispatchGate(opts.targetDir, opts.git)
|
|
405
|
+
if (!pre.ok) {
|
|
406
|
+
return { status: 'blocked', iterations, reason: pre.reason, finalProgress: progress(stories) }
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const story = selectNextStory(stories)
|
|
410
|
+
if (!story) {
|
|
411
|
+
return { status: 'complete', iterations, finalProgress: progress(stories) }
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const stl = stopTheLineGate(story)
|
|
415
|
+
if (!stl.ok) {
|
|
416
|
+
return { status: 'blocked', iterations, reason: stl.reason, finalProgress: progress(stories) }
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const result = opts.runner({ targetDir: opts.targetDir, story })
|
|
420
|
+
iterations++
|
|
421
|
+
|
|
422
|
+
if (!result.success) {
|
|
423
|
+
return {
|
|
424
|
+
status: 'blocked',
|
|
425
|
+
iterations,
|
|
426
|
+
reason: `story ${story.id} failed: ${result.summary}`,
|
|
427
|
+
finalProgress: progress(stories),
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
const updated = stories.map(s => (s.id === story.id ? { ...s, passes: true } : s))
|
|
432
|
+
savePrd(opts.prdPath, updated)
|
|
433
|
+
opts.git.commitAll(opts.targetDir, `forge: complete ${story.id} ${story.title}`)
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
```
|
|
437
|
+
|
|
438
|
+
- [ ] **Step 4: Run test to verify it passes**
|
|
439
|
+
|
|
440
|
+
Run: `npm test -- loop/loop`
|
|
441
|
+
Expected: PASS (5 tests).
|
|
442
|
+
|
|
443
|
+
- [ ] **Step 5: Commit**
|
|
444
|
+
|
|
445
|
+
```bash
|
|
446
|
+
git add src/loop/runner.ts src/loop/loop.ts tests/loop/loop.test.ts
|
|
447
|
+
git commit -m "feat: add Ralph loop driver with gates and injectable runner"
|
|
448
|
+
```
|
|
449
|
+
|
|
450
|
+
---
|
|
451
|
+
|
|
452
|
+
### Task 4: Claude agent runner
|
|
453
|
+
|
|
454
|
+
**Files:**
|
|
455
|
+
- Modify: `src/loop/runner.ts` (add `buildClaudePrompt` + `claudeRunner`)
|
|
456
|
+
- Test: `tests/loop/runner.test.ts`
|
|
457
|
+
|
|
458
|
+
- [ ] **Step 1: Write the failing test**
|
|
459
|
+
|
|
460
|
+
Create `tests/loop/runner.test.ts`:
|
|
461
|
+
```ts
|
|
462
|
+
import { describe, it, expect } from 'vitest'
|
|
463
|
+
import { buildClaudePrompt } from '../../src/loop/runner.js'
|
|
464
|
+
import type { Story } from '../../src/loop/prd.js'
|
|
465
|
+
|
|
466
|
+
const story: Story = {
|
|
467
|
+
id: 'S1', title: 'Add login', priority: 1,
|
|
468
|
+
acceptance: ['returns 200 for valid creds', 'rejects bad creds'], passes: false,
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
describe('buildClaudePrompt', () => {
|
|
472
|
+
it('includes the story id, title, and every acceptance criterion', () => {
|
|
473
|
+
const p = buildClaudePrompt(story)
|
|
474
|
+
expect(p).toContain('S1')
|
|
475
|
+
expect(p).toContain('Add login')
|
|
476
|
+
expect(p).toContain('returns 200 for valid creds')
|
|
477
|
+
expect(p).toContain('rejects bad creds')
|
|
478
|
+
})
|
|
479
|
+
|
|
480
|
+
it('instructs the agent to implement only this story and not commit', () => {
|
|
481
|
+
const p = buildClaudePrompt(story)
|
|
482
|
+
expect(p).toMatch(/only this story/i)
|
|
483
|
+
expect(p).toMatch(/not commit/i)
|
|
484
|
+
})
|
|
485
|
+
})
|
|
486
|
+
```
|
|
487
|
+
|
|
488
|
+
- [ ] **Step 2: Run test to verify it fails**
|
|
489
|
+
|
|
490
|
+
Run: `npm test -- loop/runner`
|
|
491
|
+
Expected: FAIL — `buildClaudePrompt` is not exported.
|
|
492
|
+
|
|
493
|
+
- [ ] **Step 3: Add the implementation to `src/loop/runner.ts`**
|
|
494
|
+
|
|
495
|
+
Append to `src/loop/runner.ts` (keep the existing type exports):
|
|
496
|
+
```ts
|
|
497
|
+
import { execFileSync } from 'node:child_process'
|
|
498
|
+
|
|
499
|
+
export function buildClaudePrompt(story: Story): string {
|
|
500
|
+
const criteria = story.acceptance.map(a => `- ${a}`).join('\n')
|
|
501
|
+
return [
|
|
502
|
+
'You are an autonomous coding agent running inside the Forge loop.',
|
|
503
|
+
'Implement ONLY this story and nothing else. Follow test-driven development.',
|
|
504
|
+
'',
|
|
505
|
+
`Story ${story.id}: ${story.title}`,
|
|
506
|
+
'Acceptance criteria (Definition of Done):',
|
|
507
|
+
criteria,
|
|
508
|
+
'',
|
|
509
|
+
"When done, ensure the project's full test suite passes.",
|
|
510
|
+
'Do NOT commit — the loop commits on your behalf after verifying.',
|
|
511
|
+
].join('\n')
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
export function claudeRunner(ctx: AgentContext): AgentResult {
|
|
515
|
+
const prompt = buildClaudePrompt(ctx.story)
|
|
516
|
+
try {
|
|
517
|
+
execFileSync('claude', ['-p', prompt], { cwd: ctx.targetDir, stdio: 'inherit' })
|
|
518
|
+
return { success: true, summary: `claude implemented ${ctx.story.id}` }
|
|
519
|
+
} catch (e) {
|
|
520
|
+
return { success: false, summary: `claude failed on ${ctx.story.id}: ${(e as Error).message}` }
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
```
|
|
524
|
+
|
|
525
|
+
- [ ] **Step 4: Run test to verify it passes**
|
|
526
|
+
|
|
527
|
+
Run: `npm test -- loop/runner`
|
|
528
|
+
Expected: PASS (2 tests). `claudeRunner` itself is not invoked in tests (it would shell out to a real `claude`); only prompt construction is tested.
|
|
529
|
+
|
|
530
|
+
- [ ] **Step 5: Commit**
|
|
531
|
+
|
|
532
|
+
```bash
|
|
533
|
+
git add src/loop/runner.ts tests/loop/runner.test.ts
|
|
534
|
+
git commit -m "feat: add claude agent runner and prompt builder"
|
|
535
|
+
```
|
|
536
|
+
|
|
537
|
+
---
|
|
538
|
+
|
|
539
|
+
### Task 5: Real GitOps
|
|
540
|
+
|
|
541
|
+
**Files:**
|
|
542
|
+
- Create: `src/loop/git.ts`
|
|
543
|
+
- Test: `tests/loop/git.test.ts`
|
|
544
|
+
|
|
545
|
+
- [ ] **Step 1: Write the failing test**
|
|
546
|
+
|
|
547
|
+
Create `tests/loop/git.test.ts`:
|
|
548
|
+
```ts
|
|
549
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
550
|
+
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'
|
|
551
|
+
import { join } from 'node:path'
|
|
552
|
+
import { tmpdir } from 'node:os'
|
|
553
|
+
import { execFileSync } from 'node:child_process'
|
|
554
|
+
import { realGitOps } from '../../src/loop/git.js'
|
|
555
|
+
|
|
556
|
+
let dir: string
|
|
557
|
+
function git(...args: string[]) { execFileSync('git', args, { cwd: dir, stdio: 'pipe' }) }
|
|
558
|
+
|
|
559
|
+
beforeEach(() => {
|
|
560
|
+
dir = mkdtempSync(join(tmpdir(), 'forge-git-'))
|
|
561
|
+
git('init', '-q')
|
|
562
|
+
git('config', 'user.email', 'test@forge.local')
|
|
563
|
+
git('config', 'user.name', 'Forge Test')
|
|
564
|
+
writeFileSync(join(dir, 'a.txt'), 'hello')
|
|
565
|
+
git('add', '-A'); git('-c', 'commit.gpgsign=false', 'commit', '-q', '-m', 'init')
|
|
566
|
+
})
|
|
567
|
+
afterEach(() => { rmSync(dir, { recursive: true, force: true }) })
|
|
568
|
+
|
|
569
|
+
describe('realGitOps', () => {
|
|
570
|
+
it('isClean is true on a committed tree', () => {
|
|
571
|
+
expect(realGitOps.isClean(dir)).toBe(true)
|
|
572
|
+
})
|
|
573
|
+
|
|
574
|
+
it('isClean is false with uncommitted changes', () => {
|
|
575
|
+
writeFileSync(join(dir, 'b.txt'), 'new')
|
|
576
|
+
expect(realGitOps.isClean(dir)).toBe(false)
|
|
577
|
+
})
|
|
578
|
+
|
|
579
|
+
it('commitAll stages and commits, leaving a clean tree', () => {
|
|
580
|
+
writeFileSync(join(dir, 'b.txt'), 'new')
|
|
581
|
+
realGitOps.commitAll(dir, 'forge: test commit')
|
|
582
|
+
expect(realGitOps.isClean(dir)).toBe(true)
|
|
583
|
+
const log = execFileSync('git', ['log', '--oneline', '-1'], { cwd: dir }).toString()
|
|
584
|
+
expect(log).toContain('forge: test commit')
|
|
585
|
+
})
|
|
586
|
+
})
|
|
587
|
+
```
|
|
588
|
+
|
|
589
|
+
- [ ] **Step 2: Run test to verify it fails**
|
|
590
|
+
|
|
591
|
+
Run: `npm test -- loop/git`
|
|
592
|
+
Expected: FAIL — cannot find module `src/loop/git.js`.
|
|
593
|
+
|
|
594
|
+
- [ ] **Step 3: Write minimal implementation**
|
|
595
|
+
|
|
596
|
+
Create `src/loop/git.ts`:
|
|
597
|
+
```ts
|
|
598
|
+
import { execFileSync } from 'node:child_process'
|
|
599
|
+
import type { GitOps } from './gates.js'
|
|
600
|
+
|
|
601
|
+
export const realGitOps: GitOps = {
|
|
602
|
+
isClean(dir: string): boolean {
|
|
603
|
+
const out = execFileSync('git', ['status', '--porcelain'], { cwd: dir }).toString()
|
|
604
|
+
return out.trim() === ''
|
|
605
|
+
},
|
|
606
|
+
commitAll(dir: string, message: string): void {
|
|
607
|
+
execFileSync('git', ['add', '-A'], { cwd: dir, stdio: 'pipe' })
|
|
608
|
+
execFileSync('git', ['-c', 'commit.gpgsign=false', 'commit', '-m', message], { cwd: dir, stdio: 'pipe' })
|
|
609
|
+
},
|
|
610
|
+
}
|
|
611
|
+
```
|
|
612
|
+
|
|
613
|
+
- [ ] **Step 4: Run test to verify it passes**
|
|
614
|
+
|
|
615
|
+
Run: `npm test -- loop/git`
|
|
616
|
+
Expected: PASS (3 tests).
|
|
617
|
+
|
|
618
|
+
- [ ] **Step 5: Commit**
|
|
619
|
+
|
|
620
|
+
```bash
|
|
621
|
+
git add src/loop/git.ts tests/loop/git.test.ts
|
|
622
|
+
git commit -m "feat: add real GitOps (isClean, commitAll)"
|
|
623
|
+
```
|
|
624
|
+
|
|
625
|
+
---
|
|
626
|
+
|
|
627
|
+
### Task 6: Run-command wiring + `forge loop` CLI
|
|
628
|
+
|
|
629
|
+
**Files:**
|
|
630
|
+
- Create: `src/loop/run-command.ts`
|
|
631
|
+
- Modify: `src/cli.ts`
|
|
632
|
+
- Test: `tests/loop/loop-cli.integration.test.ts`
|
|
633
|
+
|
|
634
|
+
- [ ] **Step 1: Write the failing integration test**
|
|
635
|
+
|
|
636
|
+
Create `tests/loop/loop-cli.integration.test.ts`:
|
|
637
|
+
```ts
|
|
638
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
639
|
+
import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'node:fs'
|
|
640
|
+
import { join } from 'node:path'
|
|
641
|
+
import { tmpdir } from 'node:os'
|
|
642
|
+
import { runLoopCommand } from '../../src/loop/run-command.js'
|
|
643
|
+
import { setLoopEnabled, loopStatus } from '../../src/loop/run-command.js'
|
|
644
|
+
import { saveConfig } from '../../src/retrofit/config.js'
|
|
645
|
+
import { loadConfig } from '../../src/retrofit/config.js'
|
|
646
|
+
import { loadPrd } from '../../src/loop/prd.js'
|
|
647
|
+
import type { GitOps } from '../../src/loop/gates.js'
|
|
648
|
+
import type { AgentRunner } from '../../src/loop/runner.js'
|
|
649
|
+
|
|
650
|
+
let dir: string
|
|
651
|
+
const cfg = () => ({ canonVersion: '0.1.0', agents: ['claude'] as const, loop: { enabled: true } })
|
|
652
|
+
const stubGit: GitOps = { isClean: () => true, commitAll: () => {} }
|
|
653
|
+
const passRunner: AgentRunner = () => ({ success: true, summary: 'ok' })
|
|
654
|
+
|
|
655
|
+
beforeEach(() => {
|
|
656
|
+
dir = mkdtempSync(join(tmpdir(), 'forge-loopcli-'))
|
|
657
|
+
mkdirSync(join(dir, '.forge'), { recursive: true })
|
|
658
|
+
writeFileSync(join(dir, '.forge', 'prd.yaml'),
|
|
659
|
+
`- { id: S1, title: First, priority: 1, acceptance: ["x"], passes: false }`)
|
|
660
|
+
})
|
|
661
|
+
afterEach(() => { rmSync(dir, { recursive: true, force: true }) })
|
|
662
|
+
|
|
663
|
+
describe('forge loop CLI', () => {
|
|
664
|
+
it('setLoopEnabled on/off updates the config', () => {
|
|
665
|
+
saveConfig(dir, cfg())
|
|
666
|
+
setLoopEnabled(dir, false)
|
|
667
|
+
expect(loadConfig(dir)!.loop.enabled).toBe(false)
|
|
668
|
+
setLoopEnabled(dir, true)
|
|
669
|
+
expect(loadConfig(dir)!.loop.enabled).toBe(true)
|
|
670
|
+
})
|
|
671
|
+
|
|
672
|
+
it('loopStatus reports enabled state and progress', () => {
|
|
673
|
+
saveConfig(dir, cfg())
|
|
674
|
+
const out = loopStatus(dir)
|
|
675
|
+
expect(out).toMatch(/enabled/i)
|
|
676
|
+
expect(out).toContain('0/1')
|
|
677
|
+
})
|
|
678
|
+
|
|
679
|
+
it('run refuses when the loop is disabled', () => {
|
|
680
|
+
saveConfig(dir, { ...cfg(), loop: { enabled: false } })
|
|
681
|
+
const code = runLoopCommand(dir, { maxIterations: 5, runner: passRunner, git: stubGit })
|
|
682
|
+
expect(code).toBe(2)
|
|
683
|
+
expect(loadPrd(join(dir, '.forge', 'prd.yaml'))[0].passes).toBe(false)
|
|
684
|
+
})
|
|
685
|
+
|
|
686
|
+
it('run completes the PRD with an injected passing runner', () => {
|
|
687
|
+
saveConfig(dir, cfg())
|
|
688
|
+
const code = runLoopCommand(dir, { maxIterations: 5, runner: passRunner, git: stubGit })
|
|
689
|
+
expect(code).toBe(0)
|
|
690
|
+
expect(loadPrd(join(dir, '.forge', 'prd.yaml'))[0].passes).toBe(true)
|
|
691
|
+
})
|
|
692
|
+
})
|
|
693
|
+
```
|
|
694
|
+
|
|
695
|
+
- [ ] **Step 2: Run test to verify it fails**
|
|
696
|
+
|
|
697
|
+
Run: `npm test -- loop-cli`
|
|
698
|
+
Expected: FAIL — cannot find module `src/loop/run-command.js`.
|
|
699
|
+
|
|
700
|
+
- [ ] **Step 3: Create `src/loop/run-command.ts`**
|
|
701
|
+
|
|
702
|
+
```ts
|
|
703
|
+
import { join } from 'node:path'
|
|
704
|
+
import { existsSync } from 'node:fs'
|
|
705
|
+
import { loadConfig, saveConfig, defaultConfig } from '../retrofit/config.js'
|
|
706
|
+
import { loadPrd, progress } from './prd.js'
|
|
707
|
+
import { runLoop } from './loop.js'
|
|
708
|
+
import { realGitOps } from './git.js'
|
|
709
|
+
import { claudeRunner, type AgentRunner } from './runner.js'
|
|
710
|
+
import type { GitOps } from './gates.js'
|
|
711
|
+
|
|
712
|
+
export function prdPath(targetDir: string): string {
|
|
713
|
+
return join(targetDir, '.forge', 'prd.yaml')
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
export function setLoopEnabled(targetDir: string, enabled: boolean): void {
|
|
717
|
+
const config = loadConfig(targetDir) ?? defaultConfig('0.0.0')
|
|
718
|
+
config.loop = { enabled }
|
|
719
|
+
saveConfig(targetDir, config)
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
export function loopStatus(targetDir: string): string {
|
|
723
|
+
const config = loadConfig(targetDir)
|
|
724
|
+
const enabled = config?.loop.enabled ?? false
|
|
725
|
+
const path = prdPath(targetDir)
|
|
726
|
+
let prog = 'no PRD'
|
|
727
|
+
if (existsSync(path)) {
|
|
728
|
+
const p = progress(loadPrd(path))
|
|
729
|
+
prog = `${p.passed}/${p.total} stories pass`
|
|
730
|
+
}
|
|
731
|
+
return `Loop: ${enabled ? 'enabled' : 'disabled'}\nPRD: ${prog}`
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
export interface RunLoopCommandOptions {
|
|
735
|
+
maxIterations: number
|
|
736
|
+
runner?: AgentRunner
|
|
737
|
+
git?: GitOps
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
export function runLoopCommand(targetDir: string, opts: RunLoopCommandOptions): number {
|
|
741
|
+
const config = loadConfig(targetDir)
|
|
742
|
+
if (!config?.loop.enabled) {
|
|
743
|
+
console.error('Loop is disabled. Enable it with: forge loop on')
|
|
744
|
+
return 2
|
|
745
|
+
}
|
|
746
|
+
const path = prdPath(targetDir)
|
|
747
|
+
if (!existsSync(path)) {
|
|
748
|
+
console.error(`No PRD found at ${path}. Create one (see canon loop/prd.schema.md).`)
|
|
749
|
+
return 2
|
|
750
|
+
}
|
|
751
|
+
const result = runLoop({
|
|
752
|
+
prdPath: path,
|
|
753
|
+
targetDir,
|
|
754
|
+
runner: opts.runner ?? claudeRunner,
|
|
755
|
+
git: opts.git ?? realGitOps,
|
|
756
|
+
maxIterations: opts.maxIterations,
|
|
757
|
+
})
|
|
758
|
+
console.log(`Loop ${result.status} after ${result.iterations} iteration(s): ${result.finalProgress.passed}/${result.finalProgress.total} stories pass`)
|
|
759
|
+
if (result.reason) console.log(`Reason: ${result.reason}`)
|
|
760
|
+
return result.status === 'complete' ? 0 : 1
|
|
761
|
+
}
|
|
762
|
+
```
|
|
763
|
+
|
|
764
|
+
- [ ] **Step 4: Modify `src/cli.ts` to add the `loop` subcommand**
|
|
765
|
+
|
|
766
|
+
Add imports near the other retrofit imports in `src/cli.ts`:
|
|
767
|
+
```ts
|
|
768
|
+
import { setLoopEnabled, loopStatus, runLoopCommand } from './loop/run-command.js'
|
|
769
|
+
```
|
|
770
|
+
|
|
771
|
+
Add a `loop` case to the `switch` in `main` (before `default`):
|
|
772
|
+
```ts
|
|
773
|
+
case 'loop': {
|
|
774
|
+
const sub = rest[0]
|
|
775
|
+
const targetDir = rest.slice(1).find(a => !a.startsWith('-')) ?? '.'
|
|
776
|
+
if (sub === 'on') { setLoopEnabled(targetDir, true); console.log('Loop enabled.'); return 0 }
|
|
777
|
+
if (sub === 'off') { setLoopEnabled(targetDir, false); console.log('Loop disabled.'); return 0 }
|
|
778
|
+
if (sub === 'status') { console.log(loopStatus(targetDir)); return 0 }
|
|
779
|
+
if (sub === 'run') {
|
|
780
|
+
const maxArg = rest.find(a => a.startsWith('--max='))
|
|
781
|
+
const maxIterations = maxArg ? Number(maxArg.slice('--max='.length)) : 25
|
|
782
|
+
return runLoopCommand(targetDir, { maxIterations })
|
|
783
|
+
}
|
|
784
|
+
console.log('usage: forge loop <on|off|status|run [--max=N]> [targetDir]')
|
|
785
|
+
return 1
|
|
786
|
+
}
|
|
787
|
+
```
|
|
788
|
+
|
|
789
|
+
Update the `default` usage line:
|
|
790
|
+
```ts
|
|
791
|
+
default:
|
|
792
|
+
console.log('usage: forge <validate [canonDir] | retrofit [targetDir] [--loop] | loop <on|off|status|run>>')
|
|
793
|
+
return cmd ? 1 : 0
|
|
794
|
+
```
|
|
795
|
+
|
|
796
|
+
- [ ] **Step 5: Run test to verify it passes**
|
|
797
|
+
|
|
798
|
+
Run: `npm test -- loop-cli`
|
|
799
|
+
Expected: PASS (4 tests).
|
|
800
|
+
|
|
801
|
+
- [ ] **Step 6: Run the full suite**
|
|
802
|
+
|
|
803
|
+
Run: `npm test`
|
|
804
|
+
Expected: all tests pass (A's 17 + B1's 39-17=22 + C1's new tests).
|
|
805
|
+
|
|
806
|
+
Run (PowerShell) smoke for the toggle:
|
|
807
|
+
```
|
|
808
|
+
npm run forge -- loop status .
|
|
809
|
+
```
|
|
810
|
+
Expected: prints `Loop: disabled` (or enabled) and PRD progress for the repo (likely `no PRD`).
|
|
811
|
+
|
|
812
|
+
- [ ] **Step 7: Commit**
|
|
813
|
+
|
|
814
|
+
```bash
|
|
815
|
+
git add src/loop/run-command.ts src/cli.ts tests/loop/loop-cli.integration.test.ts
|
|
816
|
+
git commit -m "feat: wire forge loop on|off|status|run command"
|
|
817
|
+
```
|
|
818
|
+
|
|
819
|
+
---
|
|
820
|
+
|
|
821
|
+
### Task 7: Document the loop in the canon
|
|
822
|
+
|
|
823
|
+
**Files:**
|
|
824
|
+
- Modify: `canon/loop/loop-spec.md`
|
|
825
|
+
|
|
826
|
+
- [ ] **Step 1: Update `canon/loop/loop-spec.md` to match the implemented commands**
|
|
827
|
+
|
|
828
|
+
Replace the file content with:
|
|
829
|
+
```markdown
|
|
830
|
+
# Loop Specification (Ralph + GSD)
|
|
831
|
+
|
|
832
|
+
The autonomous loop is OPTIONAL and toggle-able:
|
|
833
|
+
|
|
834
|
+
- `forge loop on` / `forge loop off` — enable/disable (recorded in `.forge/config.yaml`, default off).
|
|
835
|
+
- `forge loop status` — show enabled state + PRD progress.
|
|
836
|
+
- `forge loop run [--max=N]` — run the loop (default cap 25 iterations).
|
|
837
|
+
|
|
838
|
+
When enabled and run, each iteration:
|
|
839
|
+
|
|
840
|
+
1. Pre-dispatch gate: the git worktree must be clean, else `blocked`.
|
|
841
|
+
2. Pick the highest-priority unfinished PRD story (`.forge/prd.yaml`).
|
|
842
|
+
3. Stop-the-Line gate: the story must have acceptance criteria, else `blocked`.
|
|
843
|
+
4. Run a fresh agent (default `claude -p`) to implement ONE story.
|
|
844
|
+
5. On success: mark the story `passes: true`, save the PRD, and commit atomically.
|
|
845
|
+
On failure: `blocked`.
|
|
846
|
+
6. Stop when all stories `passes: true` (`complete`), or the iteration cap is reached (`cap-reached`).
|
|
847
|
+
|
|
848
|
+
State lives outside the model context: the PRD file + git. The agent runner is
|
|
849
|
+
pluggable; Codex/Gemini runners and full per-iteration worktree isolation are Baustein C2.
|
|
850
|
+
```
|
|
851
|
+
|
|
852
|
+
- [ ] **Step 2: Validate the canon still passes**
|
|
853
|
+
|
|
854
|
+
Run: `npm run forge -- validate canon`
|
|
855
|
+
Expected: `✓ canon valid (canon)`.
|
|
856
|
+
|
|
857
|
+
Run: `npm test -- real-canon`
|
|
858
|
+
Expected: PASS.
|
|
859
|
+
|
|
860
|
+
- [ ] **Step 3: Commit**
|
|
861
|
+
|
|
862
|
+
```bash
|
|
863
|
+
git add canon/loop/loop-spec.md
|
|
864
|
+
git commit -m "docs: align canon loop-spec with implemented forge loop commands"
|
|
865
|
+
```
|
|
866
|
+
|
|
867
|
+
---
|
|
868
|
+
|
|
869
|
+
## Self-Review
|
|
870
|
+
|
|
871
|
+
**1. Spec coverage (Baustein C1 scope):**
|
|
872
|
+
- Aggressive-autonomous loop, hard mechanical gates → Tasks 2, 3 ✓
|
|
873
|
+
- PRD-driven, stop when all `passes:true` → Tasks 1, 3 ✓
|
|
874
|
+
- Stop-the-Line gate (acceptance required) → Task 2 ✓
|
|
875
|
+
- Pre-dispatch gate (clean worktree) → Tasks 2, 5 ✓
|
|
876
|
+
- Fresh agent per iteration, state outside context (PRD + git) → Tasks 3, 4 ✓
|
|
877
|
+
- `forge loop on|off|status|run`, default off → Task 6 ✓
|
|
878
|
+
- Toggle is non-destructive (config-only) → Task 6 ✓
|
|
879
|
+
- Iteration cap → Tasks 3, 6 ✓
|
|
880
|
+
- (Deferred to C2: Codex/Gemini runners, full per-iteration git-worktree isolation, tool-readiness gate. Correct — C1 is the loop core + claude runner.)
|
|
881
|
+
|
|
882
|
+
**2. Placeholder scan:** No TBD/TODO; every step has complete code/content. The Task 3 note about creating `runner.ts` types before `loop.ts` is an ordering instruction, not a placeholder. ✓
|
|
883
|
+
|
|
884
|
+
**3. Type consistency:** `Story`, `GateResult`, `GitOps{isClean,commitAll}`, `AgentContext{targetDir,story}`, `AgentResult{success,summary}`, `AgentRunner`, `LoopOptions`, `LoopResult{status,iterations,reason?,finalProgress}`, `runLoop`, `buildClaudePrompt`, `claudeRunner`, `realGitOps`, `runLoopCommand`, `setLoopEnabled`, `loopStatus`, `prdPath` — consistent across tasks. `loadConfig/saveConfig/defaultConfig/ForgeConfig` reused from B1 unchanged. `progress` returns `{passed,total}` used consistently. ✓
|
|
885
|
+
|
|
886
|
+
---
|
|
887
|
+
|
|
888
|
+
## Next Plans (not this document)
|
|
889
|
+
|
|
890
|
+
- **Plan B2 — Retrofit for Codex + Gemini + tool wiring** (rtk/graphify/playwright MCP, rtk WSL detection).
|
|
891
|
+
- **Plan C2 — Loop hardening:** Codex/Gemini runners, per-iteration git-worktree isolation, review-iteration with role separation, tool-surface-readiness gate.
|