Skip to content

Commit c4c616a

Browse files
luohahaclaude
andcommitted
fix(orchestrator): per-attempt human notes via suggestions/NNN.md (no cursor)
Rework the suggestions channel from the single-file mailbox of the previous commit into a per-attempt convention, and move delivery out of the orchestrator entirely. suggestions/ is now a directory parallel to attempts/: the Runner of attempt NNN reads suggestions/NNN.md as part of its Load-context step (alongside goal.md and attempts/). The manager knows the current attempt from status, so it writes a note targeting the round that will read it (suggestions/006.md while 005 runs). The note file stays in place afterward as part of the audit trail. - No cursor, no offsets, no diffing — nothing to drift when notes are edited or deleted (the original bug this PR set out to fix). - The orchestrator no longer injects or consumes anything: _consume_suggestions is gone and _build_brief is back to a static brief. - Delivery is an exact attempt-number match; a note whose round the loop has already passed sits unread until re-pointed — an accepted, low-odds trade for the simplicity of "Runner reads its own NNN.md". Runner prompt (agents/goal-runner.md) gains the read step; manager skill (goal-run), goal-init/goal-flash skills, README, and design.md updated. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CHb1FkoMGfDfwmeJR5hBG6
1 parent 49ffdad commit c4c616a

7 files changed

Lines changed: 60 additions & 91 deletions

File tree

README.md

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -165,9 +165,9 @@ the same session indefinitely.)
165165

166166
You stay in control throughout: read what each attempt did via
167167
`/goal-run` or `goaloop status`; **edit `goal.md`** for a permanent
168-
change or **append to `suggestions.md`** for a transient per-attempt
169-
note — the next attempt picks it up. There's no live conversation into a
170-
running Runner.
168+
change or **drop a `suggestions/NNN.md`** note for a transient per-attempt
169+
nudge — the Runner of that attempt reads it. There's no live conversation into
170+
a running Runner.
171171

172172
### Configuration & modes
173173

@@ -182,12 +182,12 @@ and waits for your approval before the next one; release it with
182182
`goaloop continue <name>`. (`pass`/`blocked`/`error` are terminal, and
183183
`in_progress` resumes automatically — only `advanced` waits.)
184184

185-
`suggestions.md` is an optional async channel — a mailbox: append a one-off
186-
note and the next fresh attempt claims it (exactly once), archiving it to
187-
`.goaloop/suggestions.delivered.md` and clearing the file. Edit or delete
188-
freely before it's claimed; the claim is atomic, so nothing is lost or
189-
double-delivered. Use `goal.md` for permanent/structural changes,
190-
`suggestions.md` for transient nudges (e.g. left while AFK).
185+
`suggestions/` is an optional async channel — a directory parallel to
186+
`attempts/`: the Runner of attempt NNN reads `suggestions/NNN.md` at the start
187+
of that attempt, so the manager drops a note named for the round that will read
188+
it (e.g. `suggestions/006.md` while attempt 005 runs). The file stays in place
189+
afterward as part of the audit trail. Use `goal.md` for permanent/structural
190+
changes, `suggestions/` for transient nudges (e.g. left while AFK).
191191

192192
## Workspace contents
193193

@@ -196,8 +196,8 @@ After running, the workspace looks like:
196196
```
197197
<workspace>/
198198
├── goal.md
199-
├── config.yaml # optional: model / interval / mode
200-
├── suggestions.md # optional: async per-attempt notes
199+
├── config.yaml # optional: model / interval / mode / caps
200+
├── suggestions/ # optional: per-attempt human notes (NNN.md read by attempt NNN)
201201
├── memory/
202202
│ └── learnings.md # ~4KB cap; Runner curates this
203203
└── attempts/

agents/goal-runner.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@ optional Initial Context), `memory/learnings.md` if present, and the last few
1515
on them rather than assumptions. Also read whatever `goal.md`'s Verification
1616
references (rubrics, scripts, baselines).
1717

18+
Also read `suggestions/NNN.md` for THIS attempt's number (your brief states it),
19+
if it exists — a transient human note left for exactly this round. Treat it as
20+
guidance to address this attempt; `goal.md` still wins for the permanent spec.
21+
Leave the file in place (it's part of the audit trail, like `attempts/NNN.md`).
22+
1823
## 2. Verify
1924
Run the Verification procedure from `goal.md` — the **only authoritative check**,
2025
not your sense that it "looks done". It yields **pass** (objective met AND no hard

docs/design.md

Lines changed: 25 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -235,8 +235,9 @@ tokens.
235235
```
236236
<workspace>/
237237
├── goal.md # Objective + Hard Constraints + Verification spec
238-
├── config.yaml # Optional: model / interval / mode (see below)
239-
├── suggestions.md # Optional: async per-attempt human notes (see below)
238+
├── config.yaml # Optional: model / interval / mode / caps (see below)
239+
├── suggestions/ # Optional: per-attempt human notes (see below)
240+
│ └── 006.md # suggestions/NNN.md is read by the Runner of attempt NNN
240241
├── memory/
241242
│ └── learnings.md # Run-level curated knowledge; Runner self-maintains, ~4KB cap
242243
├── attempts/
@@ -247,14 +248,13 @@ tokens.
247248
├── state.json # Checkpoint: active session id + cumulative counters/cost for resume
248249
├── status.txt # Current one-line orchestrator status (read by /goal-run)
249250
├── attempt_complete.json # Last completed attempt's {attempt, status, cost_usd, total_cost_usd}
250-
├── suggestions.delivered.md # Archive of consumed suggestions.md notes, stamped per attempt
251251
├── continue.json # copilot-mode approval token (written by `goaloop continue`)
252252
├── orchestrator.log # Per-attempt log: Runner messages, tool calls, results
253253
└── pipeline.pid # PID of the running orchestrator (for status/stop)
254254
```
255255

256256
The `goal.md` + `memory/` + `attempts/` triple is the goal record GoaLoop
257-
requires; `config.yaml` and `suggestions.md` are optional human inputs, and
257+
requires; `config.yaml` and `suggestions/` are optional human inputs, and
258258
`.goaloop/` is the orchestrator's own bookkeeping and can be deleted
259259
between runs without losing the audit trail. Anything else (scripts the
260260
verification uses, source code being modified, datasets, etc.) lives wherever
@@ -443,8 +443,8 @@ The orchestrator, not the Manager, performs the per-attempt mechanics:
443443
3. **Spawn the Runner.** `claude -p <brief> --append-system-prompt
444444
goal-runner.md --output-format stream-json --session-id <uuid>
445445
--dangerously-skip-permissions`. The brief carries the workspace path,
446-
the attempt number, the read-context + terminator instructions, and —
447-
on a fresh attempt — any NEW `suggestions.md` text since the cursor.
446+
the attempt number, and the read-context + terminator instructions; the
447+
Runner itself reads `suggestions/NNN.md` for that attempt as context.
448448
4. **Parse the terminator** (`{"status": pass|advanced|in_progress|blocked}`)
449449
and branch: `pass`/`blocked` → exit; `advanced` (and `attempts/NNN.md`
450450
exists) → pace (or, in copilot mode, wait for approval), then next
@@ -531,27 +531,28 @@ on the user's confirmation** — not to carry the guidance itself.
531531
This is also why `goal.md` is mutable mid-run: it is the steering wheel, not
532532
just the starting configuration.
533533

534-
**`suggestions.md` — transient / per-attempt.**
534+
**`suggestions/` — transient / per-attempt.**
535535

536536
For a one-off note that does not belong in the goal spec (e.g. something left
537-
while AFK — "try lock granularity next"), the human appends to
538-
`<workspace>/suggestions.md`. The file is a **mailbox, not a log**: whatever it
539-
holds is undelivered. On each FRESH attempt the orchestrator atomically
540-
*claims* its contents (renaming it aside), injects them into the Runner's brief
541-
as a "Human guidance (NEW)" section, archives them to
542-
`.goaloop/suggestions.delivered.md`, and clears the file — so each note reaches
543-
exactly one attempt. The atomic rename is what makes this race-free: a note
544-
appended while a claim is in flight lands in either that batch or a fresh file
545-
the next attempt picks up — never lost, never delivered twice, with no byte
546-
cursor to drift when the human edits or deletes earlier notes. Use `goal.md`
547-
for changes that should persist; use `suggestions.md` for transient nudges.
537+
while AFK — "try lock granularity next"), the manager drops a note file into
538+
`<workspace>/suggestions/` — a directory parallel to `attempts/`. Delivery is
539+
**Runner-side, by attempt number**: the Runner of attempt NNN reads
540+
`suggestions/NNN.md` as part of its Load-context step (just like it reads
541+
`goal.md` and `attempts/`), so the manager — which knows the current attempt
542+
from status — names the note for the round that will read it (`suggestions/006.md`
543+
while 005 runs). The orchestrator never touches it; there is no injection, no
544+
consumption, no cursor. The file stays in place afterward as part of the audit
545+
trail. Because delivery is an exact number match, a note whose round the loop
546+
has already passed simply sits unread until re-pointed — an accepted, low-odds
547+
trade for the simplicity of "Runner reads its own `suggestions/NNN.md`". Use
548+
`goal.md` for changes that should persist; `suggestions/` for transient nudges.
548549

549550
**The Manager distinguishes messages for itself vs. goal edits.**
550551

551552
| User message | Manager's response |
552553
|---|---|
553554
| "How is it going?" | Answer directly by reading `.goaloop/status.txt` + latest `attempts/NNN.md` |
554-
| "Try lock granularity instead next time" | A transient nudge → append to `suggestions.md` (next attempt sees it once); a permanent change → propose editing `goal.md`'s Initial Context |
555+
| "Try lock granularity instead next time" | A transient nudge → write `suggestions/<next-attempt>.md` (that Runner reads it); a permanent change → propose editing `goal.md`'s Initial Context |
555556
| "Stop the orchestrator" | `goaloop stop <name>`; confirm it stopped |
556557
| "Change the target to P99 < 3s" | Propose editing `goal.md`'s Objective; edit on confirmation |
557558
| "Hmm" / "OK" / chit-chat | Ignore |
@@ -633,8 +634,8 @@ Human pacing during a run is achieved by:
633634
- **Reading status** via `/goal-run` (or `goaloop status`, or
634635
`tail -f .goaloop/orchestrator.log`) — the orchestrator writes `status.txt` and
635636
`attempt_complete.json` each attempt.
636-
- **Editing `goal.md`** (permanent) or **appending to `suggestions.md`**
637-
(transient, per-attempt) to steer — the next attempt's Runner picks it up
637+
- **Editing `goal.md`** (permanent) or **dropping a `suggestions/NNN.md`**
638+
(transient, per-attempt) to steer — the Runner of that attempt picks it up
638639
(see Human guidance protocol).
639640
- **`goaloop continue`** to release the next attempt in copilot mode.
640641
- **`goaloop stop`** to halt earlier than `pass`.
@@ -683,9 +684,9 @@ The following were considered and deliberately not built. Each appears here
683684
with the reason so future contributors don't reintroduce them by reflex.
684685

685686
> **Implemented after v0.1 (now included).** Two features once listed here as
686-
> excluded have since been built: `suggestions.md` (a transient per-attempt
687-
> human → agent channel, NEW-since-cursor injection — see Human guidance
688-
> protocol) and copilot mode (the `--mode copilot` human-review pause,
687+
> excluded have since been built: `suggestions/` (a transient per-attempt
688+
> human → agent channel — the Runner of attempt NNN reads `suggestions/NNN.md`;
689+
> see Human guidance protocol) and copilot mode (the `--mode copilot` human-review pause,
689690
> released with `goaloop continue` — see How to run). They are documented
690691
> above and are no longer exclusions.
691692

goaloop/orchestrator.py

Lines changed: 4 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -87,15 +87,10 @@ def __init__(
8787
self.status_path = self.state_dir / "status.txt"
8888
self.complete_path = self.state_dir / "attempt_complete.json"
8989
self.continue_path = self.state_dir / "continue.json"
90-
# Async human feedback channel (optional). suggestions.md is a MAILBOX,
91-
# not a log: whatever it holds is undelivered guidance. Each fresh
92-
# attempt atomically CLAIMS its contents (rename aside), injects them as
93-
# NEW into the brief, archives them, and clears the file — so there is
94-
# no byte cursor to drift when the human edits/deletes (the old model).
95-
self.suggestions_path = self.ws / "suggestions.md"
96-
# Atomic-claim staging + permanent archive of delivered notes.
97-
self.suggestions_inflight = self.state_dir / "suggestions.inflight"
98-
self.suggestions_archive = self.state_dir / "suggestions.delivered.md"
90+
# Human feedback is delivered Runner-side, not here: the manager writes
91+
# a note to suggestions/NNN.md and the Runner of attempt NNN reads it as
92+
# context for that round (see agents/goal-runner.md, "Load context").
93+
# The orchestrator stays out of it — no injection, no consumption.
9994

10095
self.adapter = ClaudeAdapter(
10196
cwd=str(self.ws),
@@ -193,40 +188,6 @@ def _end_error(self, attempt: int, reason: str) -> None:
193188
self._mark_complete(attempt, "error", None)
194189
self._clear_active()
195190

196-
def _consume_suggestions(self, n: int) -> str:
197-
"""Claim any pending human notes from suggestions.md (the mailbox) and
198-
return them as a brief section, or "" if none.
199-
200-
suggestions.md holds undelivered guidance; we CLAIM it by atomically
201-
renaming it aside. That rename is the whole trick: a human append that
202-
races the claim lands either in the batch we just took or in a fresh
203-
file the next attempt picks up — never lost, never delivered twice, and
204-
with no byte cursor to drift when the human edits or deletes earlier
205-
notes. The claimed text is appended to suggestions.delivered.md
206-
(stamped with the attempt) so history survives the cleared live file.
207-
goal.md stays the channel for permanent/structural guidance.
208-
209-
Crash-safety: the inflight file persists until the archive write
210-
succeeds, so a crash mid-claim leaves it for the next call to recover
211-
(at worst delivered twice — harmless — never dropped).
212-
"""
213-
self.state_dir.mkdir(parents=True, exist_ok=True)
214-
if not self.suggestions_inflight.exists():
215-
if not self.suggestions_path.exists():
216-
return ""
217-
try:
218-
self.suggestions_path.rename(self.suggestions_inflight)
219-
except OSError:
220-
return ""
221-
text = self.suggestions_inflight.read_text().strip()
222-
if not text:
223-
self.suggestions_inflight.unlink(missing_ok=True)
224-
return ""
225-
with self.suggestions_archive.open("a") as fh:
226-
fh.write(f"\n## Delivered to attempt {n:03d}\n\n{text}\n")
227-
self.suggestions_inflight.unlink(missing_ok=True)
228-
return f"\n## Human guidance (NEW — address this attempt)\n\n{text}\n"
229-
230191
def _wait_for_continue(self, n: int) -> None:
231192
"""Copilot mode: block until the human approves the next attempt.
232193
@@ -244,10 +205,8 @@ def _wait_for_continue(self, n: int) -> None:
244205
self.log("[orchestrator] approval received — continuing")
245206

246207
def _build_brief(self, n: int) -> str:
247-
guidance = self._consume_suggestions(n)
248208
return f"""Workspace: {self.ws}
249209
This is attempt {n:03d}; write your attempt record to attempts/{n:03d}.md.
250-
{guidance}
251210
Run one attempt per your system-prompt workflow, then end your final message
252211
with a single line that is exactly one of these JSON objects:
253212
{{"status": "pass", "verification": "<one-line summary>"}}

skills/goal-flash/SKILL.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -115,8 +115,9 @@ rather than interviewing for it:
115115
`goaloop status <name>` or `tail -f <workspace>/.goaloop/orchestrator.log`.
116116
- **The inferred goal.md is the steering wheel and is mutable mid-run.** If
117117
the inference was off, edit `<workspace>/goal.md` (esp. Verification) — the
118-
next attempt picks it up. Drop a transient nudge in `suggestions.md`. Halt
119-
with `goaloop stop <name>`.
118+
next attempt picks it up. Drop a transient nudge as a note file in
119+
`suggestions/` (e.g. `suggestions/<next-attempt>.md`). Halt with
120+
`goaloop stop <name>`.
120121

121122
From here, progress relay is identical to `/goal-run` — read
122123
`.goaloop/status.txt`, `.goaloop/attempt_complete.json`, and the latest

skills/goal-init/SKILL.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -131,9 +131,9 @@ After writing, tell the user:
131131
mid-run, edit `goal.md` — the next attempt picks it up.
132132
- (Optional, no action needed at init) the workspace may also hold a
133133
`config.yaml` (flat keys `model` / `interval` / `mode: auto|copilot`)
134-
to set run defaults, and a `suggestions.md` where they can drop async
135-
per-attempt notes mid-run (each note is shown to one fresh attempt);
136-
`goal.md` remains the place for permanent changes.
134+
to set run defaults, and a `suggestions/` directory where the manager can
135+
drop async per-attempt note files mid-run (the Runner of attempt NNN reads
136+
`suggestions/NNN.md`); `goal.md` remains the place for permanent changes.
137137

138138
Encourage them to read and tweak `goal.md` before kicking off — it's
139139
the load-bearing artifact of the whole framework.

skills/goal-run/SKILL.md

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -152,12 +152,15 @@ durable guidance channels, by intent serving different purposes.
152152
(or a file it references, like a rubric). The next attempt's Runner
153153
reads the updated spec naturally — no relay needed. Propose the edit,
154154
make it on the user's confirmation; no restart required.
155-
- **Transient per-attempt note**: append a line to
156-
`~/.goaloop/<name>/suggestions.md` (a mailbox). The next fresh attempt
157-
claims it exactly once — injected into the brief, archived to
158-
`.goaloop/suggestions.delivered.md`, and the file cleared. Use this for
159-
one-off nudges (e.g. dropped while AFK) rather than changes that should
160-
persist — those belong in `goal.md`.
155+
- **Transient per-attempt note**: the Runner of attempt NNN reads
156+
`~/.goaloop/<name>/suggestions/NNN.md` at the start of that attempt. So
157+
write the note there, targeting the round that will read it: check
158+
`goaloop status` for the current attempt and write the NEXT number — if
159+
attempt 005 is running, write `suggestions/006.md`. The file stays in place
160+
afterward as an audit trail (parallel to `attempts/NNN.md`). Use this for
161+
one-off nudges (e.g. dropped while AFK); `goal.md` is for changes that
162+
should persist. (If the loop has already moved past the round you targeted,
163+
the note just sits unread — re-point it at the new next attempt.)
161164
- **Stop the orchestrator**: `goaloop stop <name>` (sends SIGTERM; it
162165
exits after the in-flight attempt's process settles).
163166

0 commit comments

Comments
 (0)