@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,1006 @@
|
|
|
1
|
+
# Baustein G — Loop Observability 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 the autonomous loop observable (token-free console + `.yoke/loop-status.json` + `.yoke/loop.log`) and self-limiting (an idle-timeout watchdog that kills only truly silent agents), so a blocked or hung run is visible at a glance instead of stalling invisibly.
|
|
6
|
+
|
|
7
|
+
**Architecture:** A new injectable `LoopReporter` narrates each story phase to three harness-side channels (all Node, zero agent tokens). A standalone `watchdog.ts` wraps the agent process and kills it only after N minutes of *no output* (idle, not total runtime), keeping the loop synchronous. `yoke loop status` reads the status file. Retrofit gains a `.gitignore` manager so the runtime files don't break the clean-tree gate.
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** Node.js + TypeScript (ESM, `.js` import specifiers, `moduleResolution: Bundler`), vitest, zod (existing). Run tests: `npx vitest run <path>`; types: `npx tsc --noEmit`.
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## File Structure
|
|
14
|
+
|
|
15
|
+
| File | Responsibility |
|
|
16
|
+
|------|----------------|
|
|
17
|
+
| `src/loop/reporter.ts` (create) | `LoopState`/`LoopPhase`/`LoopStatus` types, `writeStatus`/`readStatus` (atomic), `LoopReporter` interface, `makeReporter` (console+file+log), `noopReporter` |
|
|
18
|
+
| `src/loop/watchdog.ts` (create) | `runWatchdog` core (injectable spawn) + CLI entry; idle-timeout wrapper |
|
|
19
|
+
| `src/loop/runner.ts` (modify) | `Invocation.idleTimeoutMs`; route through watchdog; thread into `makeRunner`/`makeReviewRunner` |
|
|
20
|
+
| `src/loop/loop.ts` (modify) | Drive the reporter at each phase; enrich `blocked` reason with leftover hint |
|
|
21
|
+
| `src/loop/run-command.ts` (modify) | Resolve idle timeout; build reporter + runners; upgrade `loopStatus` output |
|
|
22
|
+
| `src/retrofit/config.ts` (modify) | optional `loop.timeoutMinutes` |
|
|
23
|
+
| `src/retrofit/gitignore.ts` (create) | `ensureGitignore(targetDir, block)` — idempotent managed-block append |
|
|
24
|
+
| `src/retrofit/plan.ts` or `cli.ts` (modify) | call `ensureGitignore` during retrofit |
|
|
25
|
+
| `src/cli.ts` (modify) | parse `--timeout=<minutes>` on `yoke loop run` |
|
|
26
|
+
| `README.md` (modify) | document status/log + `--timeout` |
|
|
27
|
+
|
|
28
|
+
**Constants:** `DEFAULT_IDLE_MINUTES = 20` (in `run-command.ts`).
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
### Task 1: reporter — status types + atomic read/write
|
|
33
|
+
|
|
34
|
+
**Files:** Create `src/loop/reporter.ts`; Test `tests/loop/reporter.test.ts`
|
|
35
|
+
|
|
36
|
+
- [ ] **Step 1: Write the failing test**
|
|
37
|
+
|
|
38
|
+
```typescript
|
|
39
|
+
// tests/loop/reporter.test.ts
|
|
40
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
41
|
+
import { mkdtempSync, writeFileSync, readdirSync, rmSync } from 'node:fs'
|
|
42
|
+
import { join } from 'node:path'
|
|
43
|
+
import { tmpdir } from 'node:os'
|
|
44
|
+
import { writeStatus, readStatus, type LoopStatus } from '../../src/loop/reporter.js'
|
|
45
|
+
|
|
46
|
+
let dir: string
|
|
47
|
+
beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'yoke-rep-')) })
|
|
48
|
+
afterEach(() => { rmSync(dir, { recursive: true, force: true }) })
|
|
49
|
+
|
|
50
|
+
const sample: LoopStatus = {
|
|
51
|
+
state: 'running', phase: 'implementing', story: 'S1', storyTitle: 'First',
|
|
52
|
+
iteration: 1, progress: { passed: 0, total: 2 },
|
|
53
|
+
startedAt: '2026-06-29T10:00:00.000Z', updatedAt: '2026-06-29T10:00:00.000Z',
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
describe('writeStatus / readStatus', () => {
|
|
57
|
+
it('returns null when no status file exists', () => {
|
|
58
|
+
expect(readStatus(dir)).toBeNull()
|
|
59
|
+
})
|
|
60
|
+
it('round-trips a status through .yoke/loop-status.json', () => {
|
|
61
|
+
writeStatus(dir, sample)
|
|
62
|
+
expect(readStatus(dir)).toEqual(sample)
|
|
63
|
+
})
|
|
64
|
+
it('overwrites in place and leaves no temp file behind', () => {
|
|
65
|
+
writeStatus(dir, sample)
|
|
66
|
+
writeStatus(dir, { ...sample, phase: 'verifying' })
|
|
67
|
+
expect(readStatus(dir)?.phase).toBe('verifying')
|
|
68
|
+
const leftovers = readdirSync(join(dir, '.yoke')).filter(f => f.includes('.tmp'))
|
|
69
|
+
expect(leftovers).toEqual([])
|
|
70
|
+
})
|
|
71
|
+
it('returns null on a corrupt status file', () => {
|
|
72
|
+
writeFileSync(join(dir, '.yoke', 'loop-status.json'), '{ not json')
|
|
73
|
+
expect(readStatus(dir)).toBeNull()
|
|
74
|
+
})
|
|
75
|
+
})
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
- [ ] **Step 2: Run test to verify it fails**
|
|
79
|
+
|
|
80
|
+
Run: `npx vitest run tests/loop/reporter.test.ts`
|
|
81
|
+
Expected: FAIL — `Cannot find module '../../src/loop/reporter.js'`
|
|
82
|
+
|
|
83
|
+
- [ ] **Step 3: Write minimal implementation**
|
|
84
|
+
|
|
85
|
+
```typescript
|
|
86
|
+
// src/loop/reporter.ts
|
|
87
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync } from 'node:fs'
|
|
88
|
+
import { join } from 'node:path'
|
|
89
|
+
|
|
90
|
+
export type LoopState = 'running' | 'blocked' | 'complete' | 'cap-reached'
|
|
91
|
+
export type LoopPhase = 'implementing' | 'verifying' | 'reviewing' | 'committing'
|
|
92
|
+
|
|
93
|
+
export interface LoopStatus {
|
|
94
|
+
state: LoopState
|
|
95
|
+
phase?: LoopPhase
|
|
96
|
+
story?: string
|
|
97
|
+
storyTitle?: string
|
|
98
|
+
reason?: string
|
|
99
|
+
iteration: number
|
|
100
|
+
progress: { passed: number; total: number }
|
|
101
|
+
startedAt: string
|
|
102
|
+
updatedAt: string
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function statusPath(dir: string): string {
|
|
106
|
+
return join(dir, '.yoke', 'loop-status.json')
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function writeStatus(dir: string, status: LoopStatus): void {
|
|
110
|
+
const file = statusPath(dir)
|
|
111
|
+
mkdirSync(join(dir, '.yoke'), { recursive: true })
|
|
112
|
+
const tmp = `${file}.${process.pid}.tmp`
|
|
113
|
+
writeFileSync(tmp, JSON.stringify(status, null, 2))
|
|
114
|
+
renameSync(tmp, file) // atomic replace
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function readStatus(dir: string): LoopStatus | null {
|
|
118
|
+
const file = statusPath(dir)
|
|
119
|
+
if (!existsSync(file)) return null
|
|
120
|
+
try {
|
|
121
|
+
return JSON.parse(readFileSync(file, 'utf8')) as LoopStatus
|
|
122
|
+
} catch {
|
|
123
|
+
return null
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
- [ ] **Step 4: Run test to verify it passes**
|
|
129
|
+
|
|
130
|
+
Run: `npx vitest run tests/loop/reporter.test.ts`
|
|
131
|
+
Expected: PASS
|
|
132
|
+
|
|
133
|
+
- [ ] **Step 5: Commit**
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
git add src/loop/reporter.ts tests/loop/reporter.test.ts
|
|
137
|
+
git commit -m "feat(loop): LoopStatus types + atomic writeStatus/readStatus"
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
---
|
|
141
|
+
|
|
142
|
+
### Task 2: reporter — LoopReporter (console + status + log)
|
|
143
|
+
|
|
144
|
+
**Files:** Modify `src/loop/reporter.ts`; Test `tests/loop/reporter.test.ts`
|
|
145
|
+
|
|
146
|
+
- [ ] **Step 1: Write the failing test** (append)
|
|
147
|
+
|
|
148
|
+
```typescript
|
|
149
|
+
import { readFileSync, existsSync } from 'node:fs'
|
|
150
|
+
import { makeReporter, noopReporter, type LoopReporter } from '../../src/loop/reporter.js'
|
|
151
|
+
|
|
152
|
+
const fixedNow = () => new Date('2026-06-29T10:00:00.000Z')
|
|
153
|
+
const prog = { passed: 0, total: 2 }
|
|
154
|
+
|
|
155
|
+
describe('makeReporter', () => {
|
|
156
|
+
it('writes running state + a log line + console line on storyStart', () => {
|
|
157
|
+
const lines: string[] = []
|
|
158
|
+
const r = makeReporter(dir, { log: (s) => lines.push(s) }, fixedNow)
|
|
159
|
+
r.storyStart({ id: 'S1', title: 'First' }, 1, prog)
|
|
160
|
+
const st = readStatus(dir)!
|
|
161
|
+
expect(st.state).toBe('running')
|
|
162
|
+
expect(st.phase).toBe('implementing')
|
|
163
|
+
expect(st.story).toBe('S1')
|
|
164
|
+
expect(lines.join('\n')).toContain('S1')
|
|
165
|
+
const log = readFileSync(join(dir, '.yoke', 'loop.log'), 'utf8')
|
|
166
|
+
expect(log).toContain('implementing')
|
|
167
|
+
expect(log).toContain('S1')
|
|
168
|
+
})
|
|
169
|
+
it('phase() updates phase but keeps story', () => {
|
|
170
|
+
const r = makeReporter(dir, { log: () => {} }, fixedNow)
|
|
171
|
+
r.storyStart({ id: 'S1', title: 'First' }, 1, prog)
|
|
172
|
+
r.phase('verifying')
|
|
173
|
+
expect(readStatus(dir)).toMatchObject({ state: 'running', phase: 'verifying', story: 'S1' })
|
|
174
|
+
})
|
|
175
|
+
it('blocked() records state + reason', () => {
|
|
176
|
+
const r = makeReporter(dir, { log: () => {} }, fixedNow)
|
|
177
|
+
r.storyStart({ id: 'S1', title: 'First' }, 1, prog)
|
|
178
|
+
r.blocked('verify failed')
|
|
179
|
+
expect(readStatus(dir)).toMatchObject({ state: 'blocked', reason: 'verify failed' })
|
|
180
|
+
})
|
|
181
|
+
it('complete() records state + final progress', () => {
|
|
182
|
+
const r = makeReporter(dir, { log: () => {} }, fixedNow)
|
|
183
|
+
r.complete({ passed: 2, total: 2 })
|
|
184
|
+
expect(readStatus(dir)).toMatchObject({ state: 'complete', progress: { passed: 2, total: 2 } })
|
|
185
|
+
})
|
|
186
|
+
it('quiet suppresses the console callback but still writes files', () => {
|
|
187
|
+
const lines: string[] = []
|
|
188
|
+
const r = makeReporter(dir, { log: (s) => lines.push(s), quiet: true }, fixedNow)
|
|
189
|
+
r.storyStart({ id: 'S1', title: 'First' }, 1, prog)
|
|
190
|
+
expect(lines).toEqual([])
|
|
191
|
+
expect(readStatus(dir)?.story).toBe('S1')
|
|
192
|
+
})
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
describe('noopReporter', () => {
|
|
196
|
+
it('does nothing and writes no files', () => {
|
|
197
|
+
noopReporter.storyStart({ id: 'S1', title: 'x' }, 1, prog)
|
|
198
|
+
noopReporter.blocked('x')
|
|
199
|
+
expect(existsSync(join(dir, '.yoke', 'loop-status.json'))).toBe(false)
|
|
200
|
+
})
|
|
201
|
+
})
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
- [ ] **Step 2: Run test to verify it fails**
|
|
205
|
+
|
|
206
|
+
Run: `npx vitest run tests/loop/reporter.test.ts`
|
|
207
|
+
Expected: FAIL — `makeReporter`/`noopReporter` not exported.
|
|
208
|
+
|
|
209
|
+
- [ ] **Step 3: Write minimal implementation** (append to `src/loop/reporter.ts`)
|
|
210
|
+
|
|
211
|
+
```typescript
|
|
212
|
+
import { appendFileSync } from 'node:fs'
|
|
213
|
+
|
|
214
|
+
export interface StoryRef { id: string; title: string }
|
|
215
|
+
export interface Progress { passed: number; total: number }
|
|
216
|
+
|
|
217
|
+
export interface LoopReporter {
|
|
218
|
+
storyStart(story: StoryRef, iteration: number, progress: Progress): void
|
|
219
|
+
phase(phase: LoopPhase): void
|
|
220
|
+
blocked(reason: string): void
|
|
221
|
+
complete(progress: Progress): void
|
|
222
|
+
capReached(progress: Progress): void
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export interface ReporterOpts {
|
|
226
|
+
log?: (line: string) => void // console sink (defaults to process.stdout)
|
|
227
|
+
quiet?: boolean
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export function makeReporter(
|
|
231
|
+
dir: string,
|
|
232
|
+
opts: ReporterOpts = {},
|
|
233
|
+
now: () => Date = () => new Date(),
|
|
234
|
+
): LoopReporter {
|
|
235
|
+
const sink = opts.log ?? ((line: string) => process.stdout.write(line + '\n'))
|
|
236
|
+
const emitConsole = (line: string) => { if (!opts.quiet) sink(line) }
|
|
237
|
+
let current: LoopStatus | null = null
|
|
238
|
+
|
|
239
|
+
const persist = (next: LoopStatus, logLabel: string, consoleLine: string) => {
|
|
240
|
+
current = next
|
|
241
|
+
try {
|
|
242
|
+
writeStatus(dir, next)
|
|
243
|
+
mkdirSync(join(dir, '.yoke'), { recursive: true })
|
|
244
|
+
appendFileSync(
|
|
245
|
+
join(dir, '.yoke', 'loop.log'),
|
|
246
|
+
`${next.updatedAt} ${logLabel} ${next.story ?? '-'} ${next.reason ?? ''}`.trimEnd() + '\n',
|
|
247
|
+
)
|
|
248
|
+
} catch { /* observability must never abort the loop */ }
|
|
249
|
+
emitConsole(consoleLine)
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return {
|
|
253
|
+
storyStart(story, iteration, progress) {
|
|
254
|
+
const ts = now().toISOString()
|
|
255
|
+
persist(
|
|
256
|
+
{ state: 'running', phase: 'implementing', story: story.id, storyTitle: story.title,
|
|
257
|
+
iteration, progress, startedAt: ts, updatedAt: ts },
|
|
258
|
+
'implementing',
|
|
259
|
+
`▶ ${story.id} (${progress.passed}/${progress.total}) — implementing…`,
|
|
260
|
+
)
|
|
261
|
+
},
|
|
262
|
+
phase(phase) {
|
|
263
|
+
if (!current) return
|
|
264
|
+
persist({ ...current, phase, updatedAt: now().toISOString() }, phase, ` · ${phase}…`)
|
|
265
|
+
},
|
|
266
|
+
blocked(reason) {
|
|
267
|
+
const base = current ?? emptyStatus(now().toISOString())
|
|
268
|
+
persist({ ...base, state: 'blocked', reason, updatedAt: now().toISOString() },
|
|
269
|
+
'blocked', `■ blocked on ${base.story ?? '?'}: ${reason}`)
|
|
270
|
+
},
|
|
271
|
+
complete(progress) {
|
|
272
|
+
persist({ ...(current ?? emptyStatus(now().toISOString())), state: 'complete', phase: undefined,
|
|
273
|
+
progress, reason: undefined, updatedAt: now().toISOString() },
|
|
274
|
+
'complete', `✔ loop complete — ${progress.passed}/${progress.total}`)
|
|
275
|
+
},
|
|
276
|
+
capReached(progress) {
|
|
277
|
+
persist({ ...(current ?? emptyStatus(now().toISOString())), state: 'cap-reached', phase: undefined,
|
|
278
|
+
progress, updatedAt: now().toISOString() },
|
|
279
|
+
'cap-reached', `◾ iteration cap reached — ${progress.passed}/${progress.total}`)
|
|
280
|
+
},
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function emptyStatus(ts: string): LoopStatus {
|
|
285
|
+
return { state: 'running', iteration: 0, progress: { passed: 0, total: 0 }, startedAt: ts, updatedAt: ts }
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export const noopReporter: LoopReporter = {
|
|
289
|
+
storyStart() {}, phase() {}, blocked() {}, complete() {}, capReached() {},
|
|
290
|
+
}
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
- [ ] **Step 4: Run test to verify it passes**
|
|
294
|
+
|
|
295
|
+
Run: `npx vitest run tests/loop/reporter.test.ts`
|
|
296
|
+
Expected: PASS
|
|
297
|
+
|
|
298
|
+
- [ ] **Step 5: Commit**
|
|
299
|
+
|
|
300
|
+
```bash
|
|
301
|
+
git add src/loop/reporter.ts tests/loop/reporter.test.ts
|
|
302
|
+
git commit -m "feat(loop): LoopReporter writing console + loop-status.json + loop.log"
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
---
|
|
306
|
+
|
|
307
|
+
### Task 3: watchdog — idle-timeout wrapper
|
|
308
|
+
|
|
309
|
+
**Files:** Create `src/loop/watchdog.ts`; Test `tests/loop/watchdog.test.ts`
|
|
310
|
+
|
|
311
|
+
The core `runWatchdog` takes an injectable spawn so timing logic is unit-tested with fake timers; the CLI entry wires real spawn + process streams.
|
|
312
|
+
|
|
313
|
+
- [ ] **Step 1: Write the failing test**
|
|
314
|
+
|
|
315
|
+
```typescript
|
|
316
|
+
// tests/loop/watchdog.test.ts
|
|
317
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
318
|
+
import { EventEmitter } from 'node:events'
|
|
319
|
+
import { runWatchdog, type SpawnLike } from '../../src/loop/watchdog.js'
|
|
320
|
+
|
|
321
|
+
function fakeChild() {
|
|
322
|
+
const child: any = new EventEmitter()
|
|
323
|
+
child.stdout = new EventEmitter()
|
|
324
|
+
child.stderr = new EventEmitter()
|
|
325
|
+
child.stdin = { }
|
|
326
|
+
child.kill = vi.fn()
|
|
327
|
+
child.pid = 4242
|
|
328
|
+
return child
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
beforeEach(() => { vi.useFakeTimers() })
|
|
332
|
+
afterEach(() => { vi.useRealTimers() })
|
|
333
|
+
|
|
334
|
+
describe('runWatchdog', () => {
|
|
335
|
+
it('does NOT kill while the child keeps emitting output, and passes its exit code', async () => {
|
|
336
|
+
const child = fakeChild()
|
|
337
|
+
const spawnFn: SpawnLike = () => child
|
|
338
|
+
const p = runWatchdog({ command: 'x', args: [], idleMs: 100, spawnFn, stdin: new EventEmitter() as any })
|
|
339
|
+
// emit every 50ms for 300ms — never idle for 100ms
|
|
340
|
+
for (let t = 0; t < 300; t += 50) { vi.advanceTimersByTime(50); child.stdout.emit('data', Buffer.from('x')) }
|
|
341
|
+
child.emit('close', 0)
|
|
342
|
+
await expect(p).resolves.toBe(0)
|
|
343
|
+
expect(child.kill).not.toHaveBeenCalled()
|
|
344
|
+
})
|
|
345
|
+
|
|
346
|
+
it('kills a silent child after the idle window and resolves 124', async () => {
|
|
347
|
+
const child = fakeChild()
|
|
348
|
+
const p = runWatchdog({ command: 'x', args: [], idleMs: 100, spawnFn: () => child, stdin: new EventEmitter() as any })
|
|
349
|
+
vi.advanceTimersByTime(150) // no output for >100ms
|
|
350
|
+
expect(child.kill).toHaveBeenCalled()
|
|
351
|
+
child.emit('close', null) // child dies after kill
|
|
352
|
+
await expect(p).resolves.toBe(124)
|
|
353
|
+
})
|
|
354
|
+
|
|
355
|
+
it('with idleMs=0 never starts a timer and passes the exit code through', async () => {
|
|
356
|
+
const child = fakeChild()
|
|
357
|
+
const p = runWatchdog({ command: 'x', args: [], idleMs: 0, spawnFn: () => child, stdin: new EventEmitter() as any })
|
|
358
|
+
vi.advanceTimersByTime(10_000)
|
|
359
|
+
expect(child.kill).not.toHaveBeenCalled()
|
|
360
|
+
child.emit('close', 3)
|
|
361
|
+
await expect(p).resolves.toBe(3)
|
|
362
|
+
})
|
|
363
|
+
})
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
- [ ] **Step 2: Run test to verify it fails**
|
|
367
|
+
|
|
368
|
+
Run: `npx vitest run tests/loop/watchdog.test.ts`
|
|
369
|
+
Expected: FAIL — module/exports missing.
|
|
370
|
+
|
|
371
|
+
- [ ] **Step 3: Write minimal implementation**
|
|
372
|
+
|
|
373
|
+
```typescript
|
|
374
|
+
// src/loop/watchdog.ts
|
|
375
|
+
import { spawn } from 'node:child_process'
|
|
376
|
+
import { pathToFileURL } from 'node:url'
|
|
377
|
+
import type { Readable } from 'node:stream'
|
|
378
|
+
|
|
379
|
+
export interface SpawnLike {
|
|
380
|
+
(command: string, args: string[], opts: { shell: boolean }): {
|
|
381
|
+
stdout: { on(ev: 'data', cb: (d: unknown) => void): void }
|
|
382
|
+
stderr: { on(ev: 'data', cb: (d: unknown) => void): void }
|
|
383
|
+
stdin: unknown
|
|
384
|
+
kill(signal?: string): void
|
|
385
|
+
pid?: number
|
|
386
|
+
on(ev: 'close', cb: (code: number | null) => void): void
|
|
387
|
+
on(ev: 'error', cb: (e: Error) => void): void
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
export interface WatchdogOpts {
|
|
392
|
+
command: string
|
|
393
|
+
args: string[]
|
|
394
|
+
idleMs: number
|
|
395
|
+
spawnFn?: SpawnLike
|
|
396
|
+
stdin?: Readable
|
|
397
|
+
out?: (d: unknown) => void
|
|
398
|
+
err?: (d: unknown) => void
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// Run `command args`, forwarding stdin in and stdout/stderr out. Reset an idle
|
|
402
|
+
// timer on every output chunk; if no output for idleMs, kill the child and
|
|
403
|
+
// resolve 124. idleMs<=0 disables the timer. Resolves the child's exit code.
|
|
404
|
+
export function runWatchdog(opts: WatchdogOpts): Promise<number> {
|
|
405
|
+
const spawnFn = opts.spawnFn ?? (spawn as unknown as SpawnLike)
|
|
406
|
+
const out = opts.out ?? ((d) => process.stdout.write(d as Buffer))
|
|
407
|
+
const err = opts.err ?? ((d) => process.stderr.write(d as Buffer))
|
|
408
|
+
const child = spawnFn(opts.command, opts.args, { shell: process.platform === 'win32' })
|
|
409
|
+
|
|
410
|
+
if (opts.stdin && (child.stdin as { } )) {
|
|
411
|
+
try { (opts.stdin as Readable).pipe(child.stdin as never) } catch { /* no stdin */ }
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
return new Promise<number>((resolve) => {
|
|
415
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
416
|
+
let killedForIdle = false
|
|
417
|
+
const clear = () => { if (timer) { clearTimeout(timer); timer = undefined } }
|
|
418
|
+
const arm = () => {
|
|
419
|
+
if (opts.idleMs <= 0) return
|
|
420
|
+
clear()
|
|
421
|
+
timer = setTimeout(() => {
|
|
422
|
+
killedForIdle = true
|
|
423
|
+
try { child.kill('SIGTERM') } catch { /* already gone */ }
|
|
424
|
+
}, opts.idleMs)
|
|
425
|
+
}
|
|
426
|
+
child.stdout.on('data', (d) => { out(d); arm() })
|
|
427
|
+
child.stderr.on('data', (d) => { err(d); arm() })
|
|
428
|
+
child.on('error', () => { clear(); resolve(127) })
|
|
429
|
+
child.on('close', (code) => { clear(); resolve(killedForIdle ? 124 : (code ?? 0)) })
|
|
430
|
+
arm() // start the clock so a child that emits nothing is still bounded
|
|
431
|
+
})
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// CLI: node watchdog.js --idle-ms=N -- <command> [args...]
|
|
435
|
+
export function parseWatchdogArgs(argv: string[]): { idleMs: number; command: string; args: string[] } {
|
|
436
|
+
const sep = argv.indexOf('--')
|
|
437
|
+
const flags = sep === -1 ? argv : argv.slice(0, sep)
|
|
438
|
+
const rest = sep === -1 ? [] : argv.slice(sep + 1)
|
|
439
|
+
const idleArg = flags.find((a) => a.startsWith('--idle-ms='))
|
|
440
|
+
const idleMs = idleArg ? Number(idleArg.slice('--idle-ms='.length)) : 0
|
|
441
|
+
const [command, ...args] = rest
|
|
442
|
+
return { idleMs: Number.isFinite(idleMs) ? idleMs : 0, command: command ?? '', args }
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
const isMain = process.argv[1] ? pathToFileURL(process.argv[1]).href === import.meta.url : false
|
|
446
|
+
if (isMain) {
|
|
447
|
+
const { idleMs, command, args } = parseWatchdogArgs(process.argv.slice(2))
|
|
448
|
+
if (!command) { process.stderr.write('watchdog: no command given\n'); process.exit(2) }
|
|
449
|
+
runWatchdog({ command, args, idleMs, stdin: process.stdin }).then((code) => process.exit(code))
|
|
450
|
+
}
|
|
451
|
+
```
|
|
452
|
+
|
|
453
|
+
- [ ] **Step 4: Run test to verify it passes**
|
|
454
|
+
|
|
455
|
+
Run: `npx vitest run tests/loop/watchdog.test.ts`
|
|
456
|
+
Expected: PASS
|
|
457
|
+
|
|
458
|
+
- [ ] **Step 5: Add a parse unit test + commit**
|
|
459
|
+
|
|
460
|
+
```typescript
|
|
461
|
+
// append to tests/loop/watchdog.test.ts
|
|
462
|
+
import { parseWatchdogArgs } from '../../src/loop/watchdog.js'
|
|
463
|
+
describe('parseWatchdogArgs', () => {
|
|
464
|
+
it('splits flags from the command after --', () => {
|
|
465
|
+
expect(parseWatchdogArgs(['--idle-ms=500', '--', 'claude', '-p'])).toEqual({ idleMs: 500, command: 'claude', args: ['-p'] })
|
|
466
|
+
})
|
|
467
|
+
it('defaults idleMs to 0 when absent', () => {
|
|
468
|
+
expect(parseWatchdogArgs(['--', 'node']).idleMs).toBe(0)
|
|
469
|
+
})
|
|
470
|
+
})
|
|
471
|
+
```
|
|
472
|
+
Run: `npx vitest run tests/loop/watchdog.test.ts` → PASS, then:
|
|
473
|
+
```bash
|
|
474
|
+
git add src/loop/watchdog.ts tests/loop/watchdog.test.ts
|
|
475
|
+
git commit -m "feat(loop): idle-timeout watchdog (kills only on no-output, not total runtime)"
|
|
476
|
+
```
|
|
477
|
+
|
|
478
|
+
---
|
|
479
|
+
|
|
480
|
+
### Task 4: runner — route the agent through the watchdog
|
|
481
|
+
|
|
482
|
+
**Files:** Modify `src/loop/runner.ts`; Test `tests/loop/runner.test.ts`
|
|
483
|
+
|
|
484
|
+
- [ ] **Step 1: Write the failing test** (append)
|
|
485
|
+
|
|
486
|
+
```typescript
|
|
487
|
+
import { buildWatchdogInvocation } from '../../src/loop/runner.js'
|
|
488
|
+
|
|
489
|
+
describe('idle-timeout wiring', () => {
|
|
490
|
+
it('wraps the agent command in the watchdog when idleTimeoutMs > 0', () => {
|
|
491
|
+
const inv = buildWatchdogInvocation({ command: 'claude', args: ['-p'], input: 'hi', cwd: '.' }, 1200000)
|
|
492
|
+
expect(inv.command).toBe('node')
|
|
493
|
+
expect(inv.args.join(' ')).toContain('watchdog.js')
|
|
494
|
+
expect(inv.args.join(' ')).toContain('--idle-ms=1200000')
|
|
495
|
+
expect(inv.args.join(' ')).toContain('-- claude -p') // original command after --
|
|
496
|
+
expect(inv.input).toBe('hi')
|
|
497
|
+
})
|
|
498
|
+
it('returns the invocation unchanged when idleTimeoutMs is 0', () => {
|
|
499
|
+
const base = { command: 'claude', args: ['-p'], input: 'hi', cwd: '.' }
|
|
500
|
+
expect(buildWatchdogInvocation(base, 0)).toEqual(base)
|
|
501
|
+
})
|
|
502
|
+
})
|
|
503
|
+
```
|
|
504
|
+
|
|
505
|
+
- [ ] **Step 2: Run test to verify it fails**
|
|
506
|
+
|
|
507
|
+
Run: `npx vitest run tests/loop/runner.test.ts`
|
|
508
|
+
Expected: FAIL — `buildWatchdogInvocation` not exported.
|
|
509
|
+
|
|
510
|
+
- [ ] **Step 3: Write minimal implementation**
|
|
511
|
+
|
|
512
|
+
In `src/loop/runner.ts` add the import and helper (near `agentInvocation`):
|
|
513
|
+
```typescript
|
|
514
|
+
import { fileURLToPath } from 'node:url'
|
|
515
|
+
|
|
516
|
+
function watchdogPath(): string {
|
|
517
|
+
// runner.js and watchdog.js sit side by side in dist/loop/ (and src/loop/ under tsx)
|
|
518
|
+
return fileURLToPath(new URL('./watchdog.js', import.meta.url))
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// When idleTimeoutMs > 0, run the agent THROUGH the watchdog so a silent hang is
|
|
522
|
+
// killed after idleTimeoutMs of no output. The prompt still flows via stdin.
|
|
523
|
+
export function buildWatchdogInvocation(inv: Invocation, idleTimeoutMs: number): Invocation {
|
|
524
|
+
if (idleTimeoutMs <= 0) return inv
|
|
525
|
+
return {
|
|
526
|
+
command: 'node',
|
|
527
|
+
args: [watchdogPath(), `--idle-ms=${idleTimeoutMs}`, '--', inv.command, ...inv.args],
|
|
528
|
+
input: inv.input,
|
|
529
|
+
cwd: inv.cwd,
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
```
|
|
533
|
+
|
|
534
|
+
Thread an optional `idleTimeoutMs` into the runner factories. Change `makeRunner` and `makeReviewRunner` signatures:
|
|
535
|
+
```typescript
|
|
536
|
+
export function makeRunner(agent: Agent, idleTimeoutMs = 0): AgentRunner {
|
|
537
|
+
return (ctx: AgentContext): AgentResult => {
|
|
538
|
+
const base = agentInvocation(agent, buildClaudePrompt(ctx.story, contextBlockFor(ctx.targetDir)), ctx.targetDir)
|
|
539
|
+
const inv = buildWatchdogInvocation(base, idleTimeoutMs)
|
|
540
|
+
try {
|
|
541
|
+
runCli(inv)
|
|
542
|
+
return { success: true, summary: `${agent} implemented ${ctx.story.id}` }
|
|
543
|
+
} catch (e) {
|
|
544
|
+
return { success: false, summary: `${agent} failed on ${ctx.story.id}: ${(e as Error).message}` }
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
export function makeReviewRunner(agent: Agent, idleTimeoutMs = 0): AgentRunner {
|
|
550
|
+
return (ctx: AgentContext): AgentResult => {
|
|
551
|
+
const base = agentInvocation(agent, buildReviewPrompt(ctx.story, contextBlockFor(ctx.targetDir)), ctx.targetDir)
|
|
552
|
+
const inv = buildWatchdogInvocation(base, idleTimeoutMs)
|
|
553
|
+
try {
|
|
554
|
+
runCli(inv)
|
|
555
|
+
return { success: true, summary: `${agent} approved ${ctx.story.id}` }
|
|
556
|
+
} catch (e) {
|
|
557
|
+
return { success: false, summary: `${agent} rejected ${ctx.story.id}: ${(e as Error).message}` }
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
```
|
|
562
|
+
`claudeRunner` stays `makeRunner('claude')` (idle 0 → unchanged behavior).
|
|
563
|
+
|
|
564
|
+
- [ ] **Step 4: Run tests to verify they pass**
|
|
565
|
+
|
|
566
|
+
Run: `npx vitest run tests/loop/runner.test.ts`
|
|
567
|
+
Expected: PASS (new + existing runner tests).
|
|
568
|
+
|
|
569
|
+
- [ ] **Step 5: Commit**
|
|
570
|
+
|
|
571
|
+
```bash
|
|
572
|
+
git add src/loop/runner.ts tests/loop/runner.test.ts
|
|
573
|
+
git commit -m "feat(loop): route agent + reviewer runs through the idle watchdog"
|
|
574
|
+
```
|
|
575
|
+
|
|
576
|
+
---
|
|
577
|
+
|
|
578
|
+
### Task 5: loop — drive the reporter + leftover hint
|
|
579
|
+
|
|
580
|
+
**Files:** Modify `src/loop/loop.ts`; Test `tests/loop/loop.test.ts`
|
|
581
|
+
|
|
582
|
+
- [ ] **Step 1: Write the failing test** (append; reuses existing `dir`, `prd()`, fakes)
|
|
583
|
+
|
|
584
|
+
```typescript
|
|
585
|
+
import { makeReporter, type LoopReporter } from '../../src/loop/reporter.js'
|
|
586
|
+
|
|
587
|
+
function recordingReporter(): { reporter: LoopReporter; events: string[] } {
|
|
588
|
+
const events: string[] = []
|
|
589
|
+
const reporter: LoopReporter = {
|
|
590
|
+
storyStart: (s) => events.push(`start:${s.id}`),
|
|
591
|
+
phase: (p) => events.push(`phase:${p}`),
|
|
592
|
+
blocked: (r) => events.push(`blocked:${r}`),
|
|
593
|
+
complete: () => events.push('complete'),
|
|
594
|
+
capReached: () => events.push('cap'),
|
|
595
|
+
}
|
|
596
|
+
return { reporter, events }
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
it('drives the reporter through phases on success and reports complete', () => {
|
|
600
|
+
const { reporter, events } = recordingReporter()
|
|
601
|
+
runLoop({ prdPath: prd(), targetDir: dir, runner: alwaysPass, git: cleanGit(), verify: verifyOk, maxIterations: 10, reporter })
|
|
602
|
+
expect(events).toContain('start:S1')
|
|
603
|
+
expect(events).toContain('phase:verifying')
|
|
604
|
+
expect(events).toContain('phase:committing')
|
|
605
|
+
expect(events[events.length - 1]).toBe('complete')
|
|
606
|
+
})
|
|
607
|
+
|
|
608
|
+
it('reports blocked with a leftover hint when the tree is dirty after a block', () => {
|
|
609
|
+
const { reporter, events } = recordingReporter()
|
|
610
|
+
const dirtyAfter: GitOps = { isClean: () => false, commitAll: () => { throw new Error('boom') }, addWorktree: () => {}, removeWorktree: () => {}, integrate: () => {} }
|
|
611
|
+
runLoop({ prdPath: prd(), targetDir: dir, runner: alwaysPass, git: dirtyAfter, verify: verifyOk, maxIterations: 10, reporter })
|
|
612
|
+
const blocked = events.find(e => e.startsWith('blocked:'))!
|
|
613
|
+
expect(blocked).toMatch(/uncommitted changes/i)
|
|
614
|
+
})
|
|
615
|
+
```
|
|
616
|
+
Note: the dirty-tree block path — the commit throws, the loop reverts and returns blocked; the reporter's `blocked` reason should carry the leftover hint because `git.isClean` is false. (The pre-dispatch gate runs at the top of the NEXT iteration; for this test the first iteration reaches the commit and fails there.) If `isClean:()=>false` also trips the pre-dispatch gate first, adjust the fake so `isClean` returns true for the gate then false after — simplest: keep a counter. Implement the fake as: first call (pre-dispatch) true, later false. Use a closure counter so the gate passes but the post-block leftover check sees dirty.
|
|
617
|
+
|
|
618
|
+
- [ ] **Step 2: Run test to verify it fails**
|
|
619
|
+
|
|
620
|
+
Run: `npx vitest run tests/loop/loop.test.ts`
|
|
621
|
+
Expected: FAIL — `LoopOptions` has no `reporter`; events empty.
|
|
622
|
+
|
|
623
|
+
- [ ] **Step 3: Write minimal implementation**
|
|
624
|
+
|
|
625
|
+
In `src/loop/loop.ts`:
|
|
626
|
+
- Import: `import { noopReporter, type LoopReporter } from './reporter.js'`
|
|
627
|
+
- Add `reporter?: LoopReporter` to `LoopOptions`. At the top of `runLoop`, `const reporter = opts.reporter ?? noopReporter`.
|
|
628
|
+
- Add a helper for the block path:
|
|
629
|
+
```typescript
|
|
630
|
+
function blockReason(base: string, targetDir: string, git: GitOps): string {
|
|
631
|
+
let dirty = false
|
|
632
|
+
try { dirty = !git.isClean(targetDir) } catch { /* ignore */ }
|
|
633
|
+
return dirty
|
|
634
|
+
? `${base} (working tree has uncommitted changes from the blocked story — review/clean before re-running)`
|
|
635
|
+
: base
|
|
636
|
+
}
|
|
637
|
+
```
|
|
638
|
+
- Call the reporter at each boundary. Wherever the loop currently `return { status: 'blocked', ... reason }`, first call `reporter.blocked(blockReason(reason, opts.targetDir, opts.git))` (use the same string in the return `reason`). Wherever it returns `complete`, call `reporter.complete(progress(stories))`; for `cap-reached`, `reporter.capReached(...)`. After selecting a story (both isolate and non-isolate paths), call `reporter.storyStart({ id: story.id, title: story.title }, iterations + 1, progress(stories))` before dispatch; call `reporter.phase('verifying')` before `opts.verify(...)`, `reporter.phase('reviewing')` before the review call, and `reporter.phase('committing')` before `appendDecision`/`commitAll`.
|
|
639
|
+
|
|
640
|
+
Apply to BOTH the isolate and non-isolate branches. Keep the returned `reason` strings identical to what `reporter.blocked` was given (so console and return value agree). Example for the non-isolate verify-fail:
|
|
641
|
+
```typescript
|
|
642
|
+
const verdict = opts.verify(opts.targetDir)
|
|
643
|
+
if (!verdict.passed) {
|
|
644
|
+
const reason = blockReason(`story ${story.id} did not verify: ${verdict.summary}`, opts.targetDir, opts.git)
|
|
645
|
+
reporter.blocked(reason)
|
|
646
|
+
return { status: 'blocked', iterations, reason, finalProgress: progress(stories) }
|
|
647
|
+
}
|
|
648
|
+
```
|
|
649
|
+
|
|
650
|
+
- [ ] **Step 4: Run tests to verify they pass**
|
|
651
|
+
|
|
652
|
+
Run: `npx vitest run tests/loop/loop.test.ts`
|
|
653
|
+
Expected: PASS (new + all existing loop tests — existing ones use no `reporter`, so `noopReporter` keeps them green).
|
|
654
|
+
|
|
655
|
+
- [ ] **Step 5: Commit**
|
|
656
|
+
|
|
657
|
+
```bash
|
|
658
|
+
git add src/loop/loop.ts tests/loop/loop.test.ts
|
|
659
|
+
git commit -m "feat(loop): narrate phases via LoopReporter + leftover hint on block"
|
|
660
|
+
```
|
|
661
|
+
|
|
662
|
+
---
|
|
663
|
+
|
|
664
|
+
### Task 6: run-command + cli — resolve timeout, wire reporter, upgrade `loop status`
|
|
665
|
+
|
|
666
|
+
**Files:** Modify `src/loop/run-command.ts`, `src/cli.ts`; Test `tests/loop/run-command` coverage via a new `tests/loop/loop-status-render.test.ts`
|
|
667
|
+
|
|
668
|
+
- [ ] **Step 1: Write the failing test**
|
|
669
|
+
|
|
670
|
+
```typescript
|
|
671
|
+
// tests/loop/loop-status-render.test.ts
|
|
672
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
673
|
+
import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from 'node:fs'
|
|
674
|
+
import { join } from 'node:path'
|
|
675
|
+
import { tmpdir } from 'node:os'
|
|
676
|
+
import { writeStatus } from '../../src/loop/reporter.js'
|
|
677
|
+
import { loopStatus } from '../../src/loop/run-command.js'
|
|
678
|
+
|
|
679
|
+
let dir: string
|
|
680
|
+
beforeEach(() => {
|
|
681
|
+
dir = mkdtempSync(join(tmpdir(), 'yoke-ls-'))
|
|
682
|
+
mkdirSync(join(dir, '.yoke'), { recursive: true })
|
|
683
|
+
writeFileSync(join(dir, '.yoke', 'config.yaml'), 'canonVersion: "0.1.0"\nagents: [claude]\nloop:\n enabled: true\n')
|
|
684
|
+
writeFileSync(join(dir, '.yoke', 'prd.yaml'), '- { id: S1, title: t, priority: 1, acceptance: ["x"], passes: true }')
|
|
685
|
+
})
|
|
686
|
+
afterEach(() => { rmSync(dir, { recursive: true, force: true }) })
|
|
687
|
+
|
|
688
|
+
describe('loopStatus with a status file', () => {
|
|
689
|
+
it('renders state, story and reason when blocked', () => {
|
|
690
|
+
writeStatus(dir, { state: 'blocked', story: 'S5', storyTitle: 'Schemas', reason: 'verify failed',
|
|
691
|
+
iteration: 19, progress: { passed: 18, total: 45 },
|
|
692
|
+
startedAt: '2026-06-29T10:00:00.000Z', updatedAt: '2026-06-29T10:00:00.000Z' })
|
|
693
|
+
const out = loopStatus(dir)
|
|
694
|
+
expect(out).toMatch(/blocked/i)
|
|
695
|
+
expect(out).toContain('S5')
|
|
696
|
+
expect(out).toContain('verify failed')
|
|
697
|
+
expect(out).toContain('18/45')
|
|
698
|
+
})
|
|
699
|
+
it('falls back to enabled + PRD progress when no status file exists', () => {
|
|
700
|
+
const out = loopStatus(dir)
|
|
701
|
+
expect(out).toMatch(/enabled/i)
|
|
702
|
+
expect(out).toMatch(/1\/1/)
|
|
703
|
+
})
|
|
704
|
+
})
|
|
705
|
+
```
|
|
706
|
+
|
|
707
|
+
- [ ] **Step 2: Run test to verify it fails**
|
|
708
|
+
|
|
709
|
+
Run: `npx vitest run tests/loop/loop-status-render.test.ts`
|
|
710
|
+
Expected: FAIL — `loopStatus` does not yet read the status file.
|
|
711
|
+
|
|
712
|
+
- [ ] **Step 3: Implement** in `src/loop/run-command.ts`
|
|
713
|
+
|
|
714
|
+
Add imports:
|
|
715
|
+
```typescript
|
|
716
|
+
import { readStatus, makeReporter } from './reporter.js'
|
|
717
|
+
export const DEFAULT_IDLE_MINUTES = 20
|
|
718
|
+
```
|
|
719
|
+
Replace `loopStatus` with a version that prefers the status file:
|
|
720
|
+
```typescript
|
|
721
|
+
export function loopStatus(targetDir: string): string {
|
|
722
|
+
const config = loadConfig(targetDir)
|
|
723
|
+
const enabled = config?.loop.enabled ?? false
|
|
724
|
+
const path = prdPath(targetDir)
|
|
725
|
+
let prog = 'no PRD'
|
|
726
|
+
if (existsSync(path)) {
|
|
727
|
+
const p = progress(loadPrd(path))
|
|
728
|
+
prog = `${p.passed}/${p.total} stories pass`
|
|
729
|
+
}
|
|
730
|
+
const st = readStatus(targetDir)
|
|
731
|
+
if (!st) return `Loop: ${enabled ? 'enabled' : 'disabled'}\nPRD: ${prog}`
|
|
732
|
+
const lines = [
|
|
733
|
+
`Loop: ${st.state.toUpperCase()}${st.story ? ` on ${st.story}${st.storyTitle ? ` "${st.storyTitle}"` : ''}` : ''}`,
|
|
734
|
+
]
|
|
735
|
+
const meta = [st.phase, `iteration ${st.iteration}`, `${st.progress.passed}/${st.progress.total}`,
|
|
736
|
+
`updated ${st.updatedAt}`].filter(Boolean).join(' · ')
|
|
737
|
+
lines.push(` ${meta}`)
|
|
738
|
+
if (st.reason) lines.push(` reason: ${st.reason}`)
|
|
739
|
+
return lines.join('\n')
|
|
740
|
+
}
|
|
741
|
+
```
|
|
742
|
+
Add the timeout resolution + reporter/runner wiring in `runLoopCommand`. Extend `RunLoopCommandOptions` with `timeoutMinutes?: number`. After resolving `runnerAgent` and before building runners:
|
|
743
|
+
```typescript
|
|
744
|
+
const idleMinutes = opts.timeoutMinutes ?? config.loop.timeoutMinutes ?? DEFAULT_IDLE_MINUTES
|
|
745
|
+
const idleMs = idleMinutes > 0 ? idleMinutes * 60_000 : 0
|
|
746
|
+
```
|
|
747
|
+
Build runners with the idle budget (only when not injected for tests):
|
|
748
|
+
```typescript
|
|
749
|
+
runner = makeRunner(runnerAgent, idleMs)
|
|
750
|
+
// ...
|
|
751
|
+
review = makeReviewRunner(reviewerAgent, idleMs)
|
|
752
|
+
```
|
|
753
|
+
Pass a real reporter into `runLoop`:
|
|
754
|
+
```typescript
|
|
755
|
+
const result = runLoop({
|
|
756
|
+
prdPath: path, targetDir, runner, git: opts.git ?? realGitOps, verify,
|
|
757
|
+
maxIterations: opts.maxIterations, isolate: opts.isolate ?? false, review,
|
|
758
|
+
reporter: opts.reporter ?? makeReporter(targetDir),
|
|
759
|
+
})
|
|
760
|
+
```
|
|
761
|
+
Add `reporter?: LoopReporter` (import the type) and `timeoutMinutes?: number` to `RunLoopCommandOptions`.
|
|
762
|
+
|
|
763
|
+
- [ ] **Step 4: Wire the CLI flag** in `src/cli.ts` (inside the `loop run` branch, mirror `--max` parsing):
|
|
764
|
+
```typescript
|
|
765
|
+
const toArg = rest.find(a => a.startsWith('--timeout='))
|
|
766
|
+
let timeoutMinutes: number | undefined
|
|
767
|
+
if (toArg) {
|
|
768
|
+
const v = Number(toArg.slice('--timeout='.length))
|
|
769
|
+
if (!Number.isFinite(v) || v < 0) { console.error(`Invalid --timeout value: ${toArg}`); return 1 }
|
|
770
|
+
timeoutMinutes = v
|
|
771
|
+
}
|
|
772
|
+
```
|
|
773
|
+
and pass `timeoutMinutes` into the `runLoopCommand(targetDir, { ... })` call. Add `--timeout=<minutes>` to the `loop` usage strings.
|
|
774
|
+
|
|
775
|
+
- [ ] **Step 5: Run tests**
|
|
776
|
+
|
|
777
|
+
Run: `npx vitest run tests/loop/ && npx tsc --noEmit`
|
|
778
|
+
Expected: PASS, clean.
|
|
779
|
+
|
|
780
|
+
- [ ] **Step 6: Commit**
|
|
781
|
+
|
|
782
|
+
```bash
|
|
783
|
+
git add src/loop/run-command.ts src/cli.ts tests/loop/loop-status-render.test.ts
|
|
784
|
+
git commit -m "feat(loop): resolve idle timeout, wire reporter, upgrade yoke loop status"
|
|
785
|
+
```
|
|
786
|
+
|
|
787
|
+
---
|
|
788
|
+
|
|
789
|
+
### Task 7: config — optional `loop.timeoutMinutes`
|
|
790
|
+
|
|
791
|
+
**Files:** Modify `src/retrofit/config.ts`; Test `tests/retrofit/config.test.ts`
|
|
792
|
+
|
|
793
|
+
- [ ] **Step 1: Write the failing test** (append)
|
|
794
|
+
|
|
795
|
+
```typescript
|
|
796
|
+
it('accepts an optional loop.timeoutMinutes', () => {
|
|
797
|
+
const cfg = { canonVersion: '0.1.0', agents: ['claude'], loop: { enabled: true, timeoutMinutes: 30 } }
|
|
798
|
+
const parsed = YokeConfigSchema.parse(cfg)
|
|
799
|
+
expect(parsed.loop.timeoutMinutes).toBe(30)
|
|
800
|
+
})
|
|
801
|
+
it('still accepts a loop without timeoutMinutes', () => {
|
|
802
|
+
const parsed = YokeConfigSchema.parse({ canonVersion: '0.1.0', agents: [], loop: { enabled: false } })
|
|
803
|
+
expect(parsed.loop.timeoutMinutes).toBeUndefined()
|
|
804
|
+
})
|
|
805
|
+
```
|
|
806
|
+
(If `YokeConfigSchema` isn't exported, export it, or assert via `loadConfig` against a temp file — match the file's existing idiom.)
|
|
807
|
+
|
|
808
|
+
- [ ] **Step 2: Run test to verify it fails**
|
|
809
|
+
|
|
810
|
+
Run: `npx vitest run tests/retrofit/config.test.ts`
|
|
811
|
+
Expected: FAIL — `timeoutMinutes` rejected/stripped.
|
|
812
|
+
|
|
813
|
+
- [ ] **Step 3: Implement** — update the schema + interface in `src/retrofit/config.ts`:
|
|
814
|
+
```typescript
|
|
815
|
+
const YokeConfigSchema = z.object({
|
|
816
|
+
canonVersion: z.string().min(1),
|
|
817
|
+
agents: z.array(AgentSchema),
|
|
818
|
+
loop: z.object({ enabled: z.boolean(), timeoutMinutes: z.number().nonnegative().optional() }),
|
|
819
|
+
verify: z.object({ command: z.string().min(1) }).optional(),
|
|
820
|
+
codeGraph: CodeGraphSchema.optional(),
|
|
821
|
+
})
|
|
822
|
+
|
|
823
|
+
export interface YokeConfig {
|
|
824
|
+
canonVersion: string
|
|
825
|
+
agents: Agent[]
|
|
826
|
+
loop: { enabled: boolean; timeoutMinutes?: number }
|
|
827
|
+
verify?: { command: string }
|
|
828
|
+
codeGraph?: CodeGraph
|
|
829
|
+
}
|
|
830
|
+
```
|
|
831
|
+
|
|
832
|
+
- [ ] **Step 4: Run tests** — `npx vitest run tests/retrofit/config.test.ts` → PASS.
|
|
833
|
+
|
|
834
|
+
- [ ] **Step 5: Commit**
|
|
835
|
+
```bash
|
|
836
|
+
git add src/retrofit/config.ts tests/retrofit/config.test.ts
|
|
837
|
+
git commit -m "feat(config): optional loop.timeoutMinutes (idle minutes)"
|
|
838
|
+
```
|
|
839
|
+
|
|
840
|
+
---
|
|
841
|
+
|
|
842
|
+
### Task 8: retrofit — `.gitignore` manager for `.yoke/` runtime files
|
|
843
|
+
|
|
844
|
+
**Files:** Create `src/retrofit/gitignore.ts`; Modify `src/cli.ts` (`runRetrofit`); Test `tests/retrofit/gitignore.test.ts`
|
|
845
|
+
|
|
846
|
+
This closes a real gap: retrofit never managed the target `.gitignore`, so `.yoke/loop-status.json` / `.yoke/loop.log` (and `worktrees`/`backup`) would dirty the tree and break the clean-tree gate.
|
|
847
|
+
|
|
848
|
+
- [ ] **Step 1: Write the failing test**
|
|
849
|
+
|
|
850
|
+
```typescript
|
|
851
|
+
// tests/retrofit/gitignore.test.ts
|
|
852
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
853
|
+
import { mkdtempSync, writeFileSync, readFileSync, existsSync, rmSync } from 'node:fs'
|
|
854
|
+
import { join } from 'node:path'
|
|
855
|
+
import { tmpdir } from 'node:os'
|
|
856
|
+
import { ensureGitignore, YOKE_IGNORE_LINES } from '../../src/retrofit/gitignore.js'
|
|
857
|
+
|
|
858
|
+
let dir: string
|
|
859
|
+
beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'yoke-gi-')) })
|
|
860
|
+
afterEach(() => { rmSync(dir, { recursive: true, force: true }) })
|
|
861
|
+
const gi = () => join(dir, '.gitignore')
|
|
862
|
+
|
|
863
|
+
describe('ensureGitignore', () => {
|
|
864
|
+
it('creates .gitignore with the managed block when absent', () => {
|
|
865
|
+
ensureGitignore(dir)
|
|
866
|
+
const text = readFileSync(gi(), 'utf8')
|
|
867
|
+
for (const line of YOKE_IGNORE_LINES) expect(text).toContain(line)
|
|
868
|
+
})
|
|
869
|
+
it('appends the block without disturbing existing content', () => {
|
|
870
|
+
writeFileSync(gi(), 'node_modules/\n')
|
|
871
|
+
ensureGitignore(dir)
|
|
872
|
+
const text = readFileSync(gi(), 'utf8')
|
|
873
|
+
expect(text).toContain('node_modules/')
|
|
874
|
+
expect(text).toContain('.yoke/loop-status.json')
|
|
875
|
+
})
|
|
876
|
+
it('is idempotent — a second run adds nothing', () => {
|
|
877
|
+
ensureGitignore(dir)
|
|
878
|
+
const first = readFileSync(gi(), 'utf8')
|
|
879
|
+
ensureGitignore(dir)
|
|
880
|
+
expect(readFileSync(gi(), 'utf8')).toBe(first)
|
|
881
|
+
})
|
|
882
|
+
it('does not re-add a line that already exists individually', () => {
|
|
883
|
+
writeFileSync(gi(), '.yoke/backup/\n')
|
|
884
|
+
ensureGitignore(dir)
|
|
885
|
+
const text = readFileSync(gi(), 'utf8')
|
|
886
|
+
expect(text.match(/\.yoke\/backup\//g)?.length).toBe(1)
|
|
887
|
+
})
|
|
888
|
+
})
|
|
889
|
+
```
|
|
890
|
+
|
|
891
|
+
- [ ] **Step 2: Run test to verify it fails**
|
|
892
|
+
|
|
893
|
+
Run: `npx vitest run tests/retrofit/gitignore.test.ts`
|
|
894
|
+
Expected: FAIL — module missing.
|
|
895
|
+
|
|
896
|
+
- [ ] **Step 3: Implement**
|
|
897
|
+
|
|
898
|
+
```typescript
|
|
899
|
+
// src/retrofit/gitignore.ts
|
|
900
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
|
901
|
+
import { join } from 'node:path'
|
|
902
|
+
|
|
903
|
+
export const YOKE_IGNORE_LINES = [
|
|
904
|
+
'.yoke/worktrees/',
|
|
905
|
+
'.yoke/backup/',
|
|
906
|
+
'.yoke/loop-status.json',
|
|
907
|
+
'.yoke/loop.log',
|
|
908
|
+
]
|
|
909
|
+
|
|
910
|
+
const HEADER = '# Yoke runtime artifacts (managed by yoke retrofit)'
|
|
911
|
+
|
|
912
|
+
// Idempotently ensure each Yoke runtime path is gitignored. Appends only the
|
|
913
|
+
// lines that are not already present (matched verbatim, line-wise). Preserves
|
|
914
|
+
// all existing content. Returns true if the file changed.
|
|
915
|
+
export function ensureGitignore(targetDir: string): boolean {
|
|
916
|
+
const file = join(targetDir, '.gitignore')
|
|
917
|
+
const current = existsSync(file) ? readFileSync(file, 'utf8') : ''
|
|
918
|
+
const present = new Set(current.split(/\r?\n/).map((l) => l.trim()))
|
|
919
|
+
const missing = YOKE_IGNORE_LINES.filter((l) => !present.has(l))
|
|
920
|
+
if (missing.length === 0) return false
|
|
921
|
+
const prefix = current === '' ? '' : current.endsWith('\n') ? '' : '\n'
|
|
922
|
+
const block = `${prefix}${current === '' ? '' : '\n'}${HEADER}\n${missing.join('\n')}\n`
|
|
923
|
+
writeFileSync(file, current + block)
|
|
924
|
+
return true
|
|
925
|
+
}
|
|
926
|
+
```
|
|
927
|
+
|
|
928
|
+
- [ ] **Step 4: Run tests** — `npx vitest run tests/retrofit/gitignore.test.ts` → PASS.
|
|
929
|
+
|
|
930
|
+
- [ ] **Step 5: Wire into retrofit** — in `src/cli.ts` `runRetrofit`, after `applyActions(...)` and before `saveConfig`/report, add:
|
|
931
|
+
```typescript
|
|
932
|
+
import { ensureGitignore } from './retrofit/gitignore.js'
|
|
933
|
+
// ...
|
|
934
|
+
ensureGitignore(targetDir)
|
|
935
|
+
```
|
|
936
|
+
(Place the import with the other retrofit imports.)
|
|
937
|
+
|
|
938
|
+
- [ ] **Step 6: Run the full suite + commit**
|
|
939
|
+
|
|
940
|
+
Run: `npx vitest run && npx tsc --noEmit`
|
|
941
|
+
Expected: PASS, clean.
|
|
942
|
+
```bash
|
|
943
|
+
git add src/retrofit/gitignore.ts src/cli.ts tests/retrofit/gitignore.test.ts
|
|
944
|
+
git commit -m "feat(retrofit): manage .gitignore for .yoke runtime files (status/log/worktrees/backup)"
|
|
945
|
+
```
|
|
946
|
+
|
|
947
|
+
---
|
|
948
|
+
|
|
949
|
+
### Task 9: docs — README loop observability
|
|
950
|
+
|
|
951
|
+
**Files:** Modify `README.md`
|
|
952
|
+
|
|
953
|
+
- [ ] **Step 1: Update the autonomous-loop section** — add after the `yoke loop ...` example block:
|
|
954
|
+
```markdown
|
|
955
|
+
### Watching a run
|
|
956
|
+
|
|
957
|
+
Every iteration writes token-free, harness-side feedback:
|
|
958
|
+
|
|
959
|
+
- **Live console** — `▶ S6 (19/45) — implementing… · verifying… ✔ committed`.
|
|
960
|
+
- **`.yoke/loop-status.json`** — the current state; read it with `yoke loop status`:
|
|
961
|
+
```
|
|
962
|
+
Loop: BLOCKED on S5 "Segment schemas"
|
|
963
|
+
verifying · iteration 19 · 18/45 · updated 2026-06-29T10:00:00Z
|
|
964
|
+
reason: story did not verify (working tree has uncommitted changes — clean before re-running)
|
|
965
|
+
```
|
|
966
|
+
- **`.yoke/loop.log`** — an append-only timeline of every phase transition.
|
|
967
|
+
|
|
968
|
+
A per-iteration **idle timeout** guards against a genuinely hung agent: if the agent
|
|
969
|
+
produces *no output at all* for `--timeout` minutes (default 20; `0` disables), the loop
|
|
970
|
+
kills it and marks the story blocked. A slow-but-working agent that keeps streaming output
|
|
971
|
+
is never killed — the output stream is the liveness signal. Set a project default with
|
|
972
|
+
`loop.timeoutMinutes` in `.yoke/config.yaml`.
|
|
973
|
+
|
|
974
|
+
`.yoke/loop-status.json` and `.yoke/loop.log` are runtime artifacts; `yoke retrofit`
|
|
975
|
+
gitignores them so they never trip the clean-tree gate.
|
|
976
|
+
```
|
|
977
|
+
|
|
978
|
+
- [ ] **Step 2: Verify nothing references removed symbols** — `npx vitest run` → PASS.
|
|
979
|
+
|
|
980
|
+
- [ ] **Step 3: Commit**
|
|
981
|
+
```bash
|
|
982
|
+
git add README.md
|
|
983
|
+
git commit -m "docs: document loop observability (status, log, idle timeout)"
|
|
984
|
+
```
|
|
985
|
+
|
|
986
|
+
---
|
|
987
|
+
|
|
988
|
+
## Self-Review
|
|
989
|
+
|
|
990
|
+
**Spec coverage:**
|
|
991
|
+
- Status types + atomic read/write → Task 1.
|
|
992
|
+
- LoopReporter (console + status + log), zero-token feedback → Task 2.
|
|
993
|
+
- Idle watchdog (kills only on no-output, not total runtime) → Task 3.
|
|
994
|
+
- Runner routes agent + reviewer through the watchdog; `idleTimeoutMs` resolution → Tasks 4 & 6.
|
|
995
|
+
- Loop drives the reporter at each phase + leftover hint → Task 5.
|
|
996
|
+
- `yoke loop status` upgrade with fallback → Task 6.
|
|
997
|
+
- `--timeout` flag + resolution (flag > config > 20) → Tasks 6 & 7.
|
|
998
|
+
- `loop.timeoutMinutes` config → Task 7.
|
|
999
|
+
- gitignore so runtime files don't break the clean-tree gate → Task 8 (also fixes the pre-existing worktrees/backup gap).
|
|
1000
|
+
- Docs → Task 9.
|
|
1001
|
+
|
|
1002
|
+
**Placeholder scan:** No TBD/TODO. Every code step shows complete code. The Task 5 fake-Git note explains the closure-counter for the dirty-after-block case explicitly rather than hand-waving.
|
|
1003
|
+
|
|
1004
|
+
**Type consistency:** `LoopStatus`/`LoopPhase`/`LoopReporter`/`StoryRef`/`Progress`, `makeReporter(dir, opts, now)`, `noopReporter`, `runWatchdog`/`parseWatchdogArgs`/`SpawnLike`, `buildWatchdogInvocation(inv, idleTimeoutMs)`, `makeRunner(agent, idleTimeoutMs)`, `ensureGitignore`/`YOKE_IGNORE_LINES`, `loop.timeoutMinutes`, `DEFAULT_IDLE_MINUTES` are defined once and referenced consistently. The `Invocation` shape (`{command,args,input,cwd}`) is unchanged; the watchdog wrapping produces a new `Invocation` of the same shape.
|
|
1005
|
+
|
|
1006
|
+
**Integration note:** the clean-tree gate concern is covered by Task 8 (gitignore). Existing downstream projects must re-run `yoke retrofit` (or add the four lines manually) to benefit — documented in Task 9.
|