feat(bin): add dreamer scout brief, idle watch, and memory grader - #8
Open
BohnBawerick wants to merge 9 commits into
Open
feat(bin): add dreamer scout brief, idle watch, and memory grader#8BohnBawerick wants to merge 9 commits into
BohnBawerick wants to merge 9 commits into
Conversation
Slice 3 of the adopted memory architecture. Adds the ephemeral dreamer scout brief (fm-brief.sh --dreamer), the idle dream evaluation/arm helper (fm-dreamer-watch.sh), and the independent grader rubric helper (fm-dreamer-grade.sh) plus a comprehensive behavioral test suite. The dreamer brief enforces the hard safety contract: never take the session lock, never edit published memory in place, never address the captain, and always pass the mechanical verifier before reporting done. The idle watch reports due when the fleet has no live non-dreamer worker and either the drop tray holds an unconsumed candidate or data/memory/HEAD is stale, and can arm the deterministic when watch that wakes firstmate. The grader runs the mechanical verifier plus a rubric that surfaces tactical scraps and rejects contradictions of standing rules, and scaffolds a fresh-context grader scout for the judgment half.
…nd watch liveness
…x remote and symlinked homes
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Intent
Build Memory Slice 3 of the adopted Firstmate memory architecture (data/fm-memory-design/report.md sections 16-21): the Dreamer Scout Brief, Idle Trigger, and Independent Grader.
Slice 1 established bin/fm-memory-compile.sh and bin/fm-memory-migrate.sh. Slice 2 established bin/fm-memory-drop.sh, bin/fm-memory-verify.sh, and bin/fm-memory-publish.sh. Slice 3 completes the dreamer loop with three deliverables plus tests.
Dreamer Scout Brief: scaffold the ephemeral dreamer scout brief via a --dreamer flag on bin/fm-brief.sh. The brief instructs the dreamer agent to: read recent status logs, completed task reports, data/memory/drop/, and current published memory; synthesize durable abstractions from tactical drop notes; produce a new immutable generation in data/memory/gen//; run bin/fm-memory-verify.sh on the proposed generation; and never edit published memory in place, never take the session lock, and never address the captain.
Idle Dreamer Watch / Evaluation Helper: bin/fm-dreamer-watch.sh checks whether a dream pass is due (data/memory/drop/ has unconsumed drop files, or data/memory/HEAD age exceeds a threshold, and the fleet has no live non-dreamer workers). It can check or arm the condition watch for firstmate idle notification.
Independent Grader Rubric & Helper: bin/fm-dreamer-grade.sh evaluates proposed core memory diffs against a rubric that rejects tactical scraps, requires valid citations, and rejects contradictions with standing rules, and approves or rejects publication of proposed core modifications.
Automated Tests: a comprehensive suite tests/fm-dreamer.test.sh covering dreamer brief scaffolding and contract rules, idle evaluation logic, grader rubric pass/fail conditions, and full integration with the verify and publish scripts. All scripts must pass bin/fm-lint.sh.
Follow firstmate-coding-guidelines strictly before editing tracked files. Never use em dashes; use plain hyphens. Never add agent co-author lines. The local main branch is authoritative; origin is https://github.com/BohnBawerick/firstmate.git.
What Changed
bin/fm-brief.shaccepts a new--dreamerkind that scaffolds the ephemeral dreamer scout contract: read-only scope over status logs, task reports,data/memory/drop/, and published memory; write a new immutable generation underdata/memory/gen/<N>/; runbin/fm-memory-verify.shon it; and never edit published memory in place, take the session lock, or address the captain. The--herdr-laband--modeargument checks now cover the dreamer kind.bin/fm-dreamer-watch.shwithcheck,arm, andmark-due: it reports a dream pass due only when no live non-dreamer worker exists and either the drop tray holds an unconsumed file ordata/memory/HEADis older thanFM_DREAM_HEAD_AGE_HOURS(default 12), arms the condition watch throughbin/fm-procevent-when.shatFM_DREAM_WATCH_INTERVAL(default 3600), and writes the durablestate/.dream-duemarker without spawning any agent. A missing home, a missingstate/, or a symlinkeddata/memoryreports not-due.bin/fm-dreamer-grade.shwithgradeandscout:gradedelegates mechanical safety tobin/fm-memory-verify.sh, flags tactical scraps in changed core and note statements, and rejects negations or removals of standing core rules;scoutscaffolds a fresh-context grader brief limited to the two generations and their cited files. Newtests/fm-dreamer.test.shcovers brief scaffolding, watch evaluation, grader rubric, and verify/publish integration, andbin/fm-test-run.shmapsbin/fm-dreamer*andbin/fm-brief.shonto thesession-bootstrapfamily so those tests get selected.AGENTS.md,docs/configuration.md, anddocs/scripts.mddocument the new marker, env vars, and scripts.Risk Assessment
✅ Low: The change is purely additive (two new scripts, a new brief kind, and a test-selection map entry), it satisfies every source-verifiable acceptance criterion in the intent, bin/fm-lint.sh is clean, I confirmed each of the five prior fix rounds by running the real scripts, and the only remaining findings are two informational validation/documentation gaps in optional flags.
Testing
I ran the new tests/fm-dreamer.test.sh (35 behavioral checks, all pass) plus the three suites this change touches (fm-brief, fm-test-run, fm-memory-verify), then went beyond unit coverage and drove the whole feature by hand in a disposable firstmate home, recording a CLI transcript as the reviewer-visible artifact. The transcript shows the idle trigger flipping not-due/due on an unconsumed drop and on a stale HEAD, a genuinely live tmux worker blocking the dream, the armed watch argv and the durable state/.dream-due marker, the emitted dreamer brief carrying the never-lock / never-edit-published-memory-in-place / never-address-the-captain contract and the mandatory bin/fm-memory-verify.sh step, and the full happy path where a proposed generation verifies, the grader approves, publish swaps HEAD and the compiler serves the promoted note to a new session. It also shows both reject paths (a core that reverses a standing rule, flagged alongside a tactical-scrap warning, and an uncited claim caught by the mechanical half) with HEAD left untouched, plus the fresh-context grader scout brief. This change is shell CLI only, with no rendered UI surface, so the CLI transcript is the actual end-user experience and no screenshot applies. No test failures, no flaky or setup problems, and the worktree is clean with all temporary homes removed; per this phase's rules I did not run bin/fm-lint.sh.
Evidence: Dreamer loop end-to-end CLI transcript
Source: Dreamer loop end-to-end CLI transcript
Evidence: Idle trigger and grader verdicts (excerpt)
Pipeline
Updates from git push no-mistakes
✅ **intent** - passed
✅ No issues found.
✅ **Rebase** - passed
✅ No issues found.
bin/fm-dreamer-grade.sh:294- The rubric loop inspects EVERY line of the new core, not only changed or added statements (the gate at line 292 compares core file PATHS, which always differ between two generation dirs). Combined with contradicts_standing, a standing rule that the new generation preserved verbatim is matched against itself: keyword overlap is total and the line's own negation word ('never', 'do not', 'must not') trips the reversal test, so the rule is reported as contradicting itself. Reproduced: old core and new core both containing only '- always test changes; never skip the suite' -> 'PASS grade: proposed generation passes the mechanical verifier' followed by 'FAIL grade: new core contradicts a standing rule: - always test changes; never skip the suite' and 'GRADE REJECTED: 1 rubric violation(s)'. This is the normal case: bin/fm-memory-verify.sh check_constitution REQUIRES every standing bullet rule to survive into the new core, so the verifier mandates exactly the input the grader rejects. Fix at the loop boundary: skip any new-core statement that is present verbatim in the old core before running the scrap and contradiction checks, which also makes the code match its documented contract of inspecting 'every changed or newly added statement'.bin/fm-dreamer-grade.sh:196- contradicts_standing lowercases the statement for keyword matching (line 187) but matches the negation words against the RAW $line (line 196). Every negation token in the list is lowercase, so a contradiction written in ordinary sentence case is never detected. Reproduced against a standing rule '- Rule One: always test changes': new core line '- Never test changes under any circumstance' -> 'GRADE APPROVED', while the identical line as '- never test changes under any circumstance' -> 'GRADE REJECTED'. The intent requires the rubric to reject contradictions with standing rules; a bullet starting with a capitalised 'Never' or 'Do not' is the most likely real form and it passes. Fix: compute the lowercased line once and match both the keywords and the negation tokens against it.bin/fm-dreamer-grade.sh:187- contradicts_standing re-lowercases the same new-core statement inside the per-keyword loop, spawning two processes (printf | tr) for every (new-core line x old-core rule x keyword) triple, on top of a sed|tr|tr|awk pipeline per (line x rule) pair. The cost is quadratic in core size with a large process constant. Measured on this worktree: a core with 20 bullet rules took 19.9s real; a core with 80 bullet rules did not complete within a 120s timeout. A realistic constitution therefore makesgradeappear hung, and it would also blow the when adapter's default --action-timeout if it were ever wired behind one. Fix: hoist the lowercased $line out of both loops (compute it once per statement) and reuse it; that alone removes the dominant term.bin/fm-dreamer-watch.sh:149- drop_has_candidates treats 'any *.md in data/memory/drop/' as 'unconsumed', but nothing in the repo ever removes, moves, or marks a drop file consumed: bin/fm-memory-drop.sh only writes into the tray, and the dreamer brief (bin/fm-brief.sh:405) tells the dreamer to 'Read, and only read' it. Concrete sequence: a task drops a claim -> check reports due -> a dream pass consumes it into gen/N -> firstmate publishes and re-arms the watch -> check immediately reports due again on the same already-consumed file -> mark-due fires and another dream is scheduled over nothing new. The stale state/.dream-due marker is likewise never cleared by any code path. Decide who clears the tray (the dreamer, firstmate after publish, or a dream cursor as the brief already alludes to) before this loop can be armed in production.bin/fm-brief.sh:431- The dreamer brief asserts to the agent as fact: 'A single-flightstate/.dream.lockguarantees one dream at a time; never clear or force it.' No code in bin/, tests/, .agents/, or docs/ creates, acquires, checks, or releases state/.dream.lock - grep finds only this brief line and the test that asserts the line is present. The single-flight invariant the brief promises is therefore unenforced, which matters because the drop tray is never consumed (see drop-tray-never-consumed), so two dream passes over the same tray are reachable. Either implement the lock in this slice or reword the brief so it does not state a guarantee that does not exist.bin/fm-dreamer-watch.sh:203- The documented contract at lines 46-51 and 180-182 is fail-safe: 'A worker whose endpoint cannot be positively confirmed gone is treated as live', and 'only a supported backend whose target does not exist counts as dead'. The code violates that for an empty target:[ -n "$target" ] && fm_backend_target_exists ...short-circuits to false, so a worker with a supported backend but no resolvable endpoint is silently counted as NOT live. fm_backend_target_of_meta (bin/fm-backend.sh) returns empty whenever the meta has nowindow=key (and, for orca, noterminal=either), which is what a partially written meta looks like - bin/fm-spawn.sh:777 writes a meta with a plain redirect rather than the tmp+mv used elsewhere. Failure: such a worker is running, check reports due, and a dream is dispatched while a real non-dreamer worker is live. Fix: treat an empty target as live, matching the stated invariant.bin/fm-dreamer-grade.sh:318- The intent requires 'a rubric that rejects tactical scraps, requires valid citations, and rejects contradictions with standing rules, and approves or rejects publication of proposed core modifications.' The implementation deliberately downgrades tactical scraps to a non-blocking WARN (lines 299-302) and still prints 'GRADE APPROVED' with exit 0 (line 318), delegating rejection to the human-judgment grader scout. That is a defensible design and is documented at lines 33-36, but as written a caller that gates publication ongrade's exit code will publish a core containing a dated incident recap. Confirm whether the mechanicalgradeshould hard-fail on scraps, or whether firstmate is required to also run thescoutverdict before any pointer swap.bin/fm-dreamer-grade.sh:25- The header states the rubric inspects 'every changed or newly added statement in the new core.md (and in changed notes)', and the scaffolded grader-scout brief repeats it at line 231. The grade implementation only ever reads NEW_CORE (line 307); no note file is opened. Either drop the '(and in changed notes)' claim from both places or extend the loop over the generation's notes/*.md.bin/fm-dreamer-watch.sh:243- The comment at lines 238-241 says the condition argv runs 'this script'scheckwith the resolved home and threshold', but only --head-age is placed in CONDITION_ARGV; the home is not pinned andcheckfalls back to ambient FM_HOME at line 59. In practice the runner inherits FM_HOME from the reconcile that owns the claim, so this works today, but it makes the registered spec less self-contained than the comment (and fm-procevent-when.sh's 'exact and deterministic argv' policy) claims. Either add an explicit home flag tocheckand pass it, or soften the comment to say the home comes from the runner's environment.docs/scripts.md:128- docs/scripts.md lists every bin script, including all five scripts added by memory slices 1 and 2 (fm-memory-compile/migrate/drop/verify/publish at lines 124-128). The two scripts added by this slice, bin/fm-dreamer-watch.sh and bin/fm-dreamer-grade.sh, are absent from that table, and no doc anywhere mentions fm-dreamer. Nothing enforces this mechanically, but it breaks the convention the sibling slices established.🔧 Fix: fix grader diff boundary, negation case, cost, and watch liveness
9 issues (1 error, 6 warnings, 2 infos) still open:
bin/fm-dreamer-grade.sh:182- load_standing_rules loads EVERY line of the old core as a standing rule. It only strips a leading bullet marker; it never filters to bullet rules, so the mandatory<!-- source: ... -->citation comment (and any heading or prose line) becomes a pseudo-rule whose keywords hard-reject new statements. Reproduced on a real home: gen/0 core =# Core/<!-- source: data/captain.md -->/- Rule One: always test changes, gen/1 adds the legitimate durable rule- Never write a claim without a resolvable source in the data tree->PASS grade: proposed generation passes the mechanical verifierthenFAIL grade: new core contradicts a standing rule: ...andGRADE REJECTED: 1 rubric violation(s) found(exit 1). Deleting ONLY the<!-- source: ... -->line from gen/0 and re-running the identical input ->GRADE APPROVED(exit 0). The comment marker matched keywordssourceanddata, and the new line'snevertripped the reversal test. This is not an edge case: bin/fm-memory-verify.sh check_citations requires a non-empty core.md to cite a source, and<!-- source: ... -->is the convention used throughout tests/fm-memory-verify.test.sh, so essentially every real core carries it, and any new negative rule mentioning two of its tokens is falsely rejected. The script's own header (line 29) says the check is over 'every standing bullet rule in the old core', and bin/fm-memory-verify.sh:625 reads standing rules only fromgrep '^[[:space:]]*[-*][[:space:]]'. Fix at the load boundary: restrict load_standing_rules to that same bullet predicate so the grader and the verifier agree on what a standing rule is.bin/fm-dreamer-grade.sh:258- inspect_statements skips any line starting with a space or tab (' '*|$'\t'*), so an indented / nested markdown bullet is never inspected by the scrap check or the contradiction check. Reproduced against the same old core used by tests/fm-dreamer.test.sh:305: new core line- never test changes under any circumstance->GRADE REJECTED, but the identical line written as a nested bullet- never test changes under any circumstance->GRADE APPROVED(exit 0). Nested bullets are ordinary in a constitution, and bin/fm-memory-verify.sh treats them as standing rules (^[[:space:]]*[-*][[:space:]]), so this is a one-space bypass of the gate. The skip list is documented as covering 'headings, source markers, and note frontmatter'; indentation is not metadata. Fix: skip only headings, HTML comments, and frontmatter, and inspect indented bullets after stripping leading whitespace.bin/fm-dreamer-watch.sh:220- live_workers resolves every meta with fm_backend_of_meta / fm_backend_target_of_meta, which read the LOCALbackend=andwindow=keys. bin/fm-spawn.sh:627-645 writes a remote worker's meta withwindow=remote:$id, nobackend=key, and the real endpoint inremote_host=/remote_backend=/remote_target=. fm_backend_of_meta therefore defaults totmuxand the target becomes the fabricated stringremote:<id>, so the liveness probe is a local tmux lookup of a target that never exists locally. On a host where firstmate does not run a local tmux server that probe fails, the live remote secondmate is counted dead, andcheckreports due -> a dream is dispatched while a non-dreamer worker is live. That violates the fail-safe contract this file states at lines 46-51 and 205-210. bin/fm-fleet-snapshot.sh:424-431 - the fleet read this header claims to mirror - branches onremote_hostfirst and usesremote_backend/remote_targetfor exactly this reason. Fix: apply the sameremote_hostbranch here, and treat a remote meta whose endpoint cannot be positively confirmed gone as live.bin/fm-dreamer-watch.sh:276-armpins--home "$FM_HOME"using the raw environment value, without applying the validationcheck --homeenforces at lines 147-159. An absolute FM_HOME whose leaf is a symlink is accepted by every other firstmate script (bin/fm-brief.sh resolve_directory_input returns an absolute path unchanged; only a relative path getspwd -P), but--home's[ ! -L "$HOME_OPT" ]guard rejects it. Reproduced:FM_HOME=<symlink-to-home> fm-dreamer-watch.sh arm --dry-runexits 0 and registers the spec, then the registered conditionfm-dreamer-watch.sh check --head-age 12 --home <symlink>prints--home must name an existing directoryand exits 2. Per bin/fm-procevent-when.sh, exit 2 is a condition ERROR, not a false, so the watch consumes its --error-budget and wakes firstmate with condition-error instead of ever evaluating the dream condition. Fix: resolve the home physically (cd -- "$HOME_OPT" && pwd -P) for absolute inputs too rather than refusing a symlinked home, and run the same resolution/validation inarmso a bad home fails at registration time.bin/fm-dreamer-watch.sh:211- live_workers counts any state/*.meta whose endpoint exists, filtered only by thefm-dream-task-id prefix. A secondmate is persistent by design: its meta stays in state/ and its pane endpoint stays alive for the life of the home. So in any home running a secondmate,checkalways returnsDREAM_DUE: not-due reason=live non-dreamer worker(s): <secondmate-id>and the idle trigger can never fire, no matter how many drops accumulate or how old HEAD gets. The intent's wording ('the fleet has no live non-dreamer workers') is satisfied literally, but the feature is silently disabled for those homes. bin/fm-fleet-snapshot.sh distinguisheskind=secondmateand readsfm_backend_agent_alivefor it rather than treating endpoint existence as activity. Decide whether a persistent secondmate should be excluded like a dreamer, or gated on an actual busy read instead of endpoint existence, before this watch is armed in production.bin/fm-dreamer-grade.sh:348- The mechanical verify runs as"${VERIFY_ARGS[@]}" >/dev/null 2>&1, so on failure the operator sees onlyFAIL grade: proposed generation fails the mechanical verifierwith no indication of which of the four checks (budget, citations, constitution, diff bounds) failed or why. The same redirect also swallows the verifier's usage error, so an invalid--max-diff-ratio(which grade never validates itself, unlike bin/fm-memory-verify.sh:101-104) is reported to the user as a memory-safety failure rather than a bad flag. Fix: capture the verifier output and echo it to stderr on failure, and validate --max-diff-ratio locally before delegating.bin/fm-brief.sh:422- The dreamer brief instructsrun \bin/fm-memory-verify.sh <N>` on the proposed generationas a bare relative path with no home pinned. The dreamer runs inside a disposable git worktree of the firstmate repo, and bin/fm-spawn.sh only injectsFM_HOME=into the launched pane whenKIND = secondmate(bin/fm-spawn.sh:2951-2968); a scout/ship pane is created by a long-lived tmux/herdr daemon that does not inherit firstmate's environment. So the invocation as written resolves FM_HOME to FM_ROOT, i.e. the worktree, and looks for<worktree>/data/memory/gen/<N>instead of the home's generation - the mandatory verification step in the contract cannot succeed as instructed. The same brief already interpolates absolute paths elsewhere ($DATA/$ID/report.mdat the Definition of done,$FM_ROOT/.agents/skills/...), so this is an internal inconsistency rather than a deliberate shorthand. Suggested wording:FM_HOME=$FM_HOME $FM_ROOT/bin/fm-memory-verify.sh <N>`.bin/fm-dreamer-grade.sh:51---dry-runis documented as 'verify without publishing (passed to the verifier)', butgradenever publishes anything, and bin/fm-memory-verify.sh accepts --dry-run only to suppress the HEAD swap inpublishmode - inverifymode it is parsed and ignored (bin/fm-memory-verify.sh:83). The flag therefore has no observable effect on any code path. Either drop the option and its passthrough at lines 345-347, or reword the help so it does not imply grade would otherwise publish.bin/fm-dreamer-grade.sh:284- The grader scout brief embedsSTATUS_FILE="$STATE/$TASK_ID.status"raw into the status-reporting command (echo "{state}: ..." >> $STATUS_FILEat line 319). bin/fm-brief.sh:218 deliberately passes the same path throughshell_quotebefore embedding it for exactly this reason, so a home path containing a space produces a broken command in the scaffolded brief. Apply the same quoting here.🔧 Fix: scope standing rules, catch indented bullets, fix remote and symlinked homes
3 issues (1 warning, 2 infos) still open:
bin/fm-dreamer-grade.sh:229- contradicts_standing still reads a standing rule as contradicting itself whenever the dreamer AMENDS its wording, because the round-2 fix only skips statements the old core carries VERBATIM (inspect_statements haystack, line 279). Reproduced in this worktree: gen/0 core = '- Rule One: never skip the test suite'; gen/1 core = '- Rule One: never skip the test suite before merging'. Output: 'PASS grade: proposed generation passes the mechanical verifier' then 'FAIL grade: new core contradicts a standing rule: - Rule One: never skip the test suite before merging' and 'GRADE REJECTED: 1 rubric violation(s) found' (exit 1). The amended line is not verbatim, so it is inspected; it shares >=2 keywords with its own predecessor and carries 'never', so the reversal test at lines 229-234 fires. bin/fm-memory-verify.sh check_constitution ACCEPTS the amendment (it needs only >=50% keyword overlap with some core block, bin/fm-memory-verify.sh:604-620), so the mechanical verifier mandates exactly the input the grader rejects - the same invariant round 1's grader-self-contradiction fix was meant to establish, patched only at the exact-equality boundary. This is the normal path, not an edge case: the dreamer brief at bin/fm-brief.sh:415 explicitly authorises 'a subset or inspect-then-update of the current core'. Second reproduction, same root cause with no amendment involved: gen/0 '- Rule One: always test changes' plus a new line '- Never merge changes that skip the test suite' (a rule that REINFORCES the standing one) -> 'contradicts a standing rule', exit 1. Fix at the contradicts_standing boundary rather than at the caller: skip a standing rule when the statement is that rule's successor (reusing the verifier's best-matching-block computation, which already owns 'is this the same rule'), or when the negation token the reversal test found is already present in the rule that was matched.bin/fm-dreamer-grade.sh:167- is_scrap_statement gates on*fm-[a-zA-Z0-9-]*AND one of ' failed' / ' timed out' / ' did not' / ' was ' / ' is '. ' is ' matches almost any declarative sentence, and 'fm-' matches every firstmate script name, so ordinary durable rules that name a script are reported as tactical scraps. Reproduced: new core line '- A ship brief is scaffolded with bin/fm-brief.sh before dispatch' -> 'WARN grade: possible tactical scrap in new core: ...' and the approval line reports '1 tactical-scrap candidate(s) surfaced for grader judgment'. This is non-blocking (exit 0), so it is not a merge risk, but this repo's own constitution names fm-*.sh scripts in most rules, so the warning stream handed to the grader scout would be mostly false positives and lose its signal. Narrowing the token test to a task-id shape (fm-<slug>-<digits>, which is what the comment at lines 163-166 actually describes) would keep the intended catch without the noise.bin/fm-dreamer-watch.sh:265- check guards a symlinked drop tray (line 185) and a symlinked HEAD (line 200), and the comment at lines 182-183 claims consistency with 'the memory guards', but nothing guards a symlinked data/memory itself.[ ! -d "$MEMORY" ]at line 265 follows the link, and neither $MEMORY/drop nor $MEMORY/HEAD is then a symlink, so both per-file guards are bypassed in one step: check reports 'DREAM_DUE: due' from whatever tray the link resolves to, and mark-due writes state/.dream-due for this home on another home's evidence. bin/fm-memory-verify.sh:108 refuses this exact shape ('data/memory is a symlink; refusing to verify through it'). Add the same[ ! -L "$MEMORY" ]refusal before evaluating, reporting not-due with a reason so the watch still does not hard-error.🔧 Fix: refuse dream evaluation through a symlinked data/memory
2 warnings still open:
bin/fm-dreamer-watch.sh:162-armpins the resolved home into both registered argv vectors, but the watch REGISTRATION itself still lands in whatever home the exec'd bin/fm-procevent-when.sh resolves from the environment. Line 162 (and line 305) assignFM_HOME=$(resolve_home ...)as a plain shell variable; the assignment only stays visible to theexecat line 323 when FM_HOME was already exported into this process. Reproduced in this worktree:env -u FM_HOME bin/fm-dreamer-watch.sh arm --home /tmp/.../pinnedprintedarmed: when-dream-due, left/tmp/.../pinned/stateempty, and wrotestate/when/when-dream-due.spec,state/when/when-dream-due.trust, andstate/procevent/when-dream-due.sourceinto FM_ROOT instead. The same command withFM_HOME=/tmp/.../ambientexported and--home /tmp/.../pinnedcorrectly registered under the pinned home, so the destination silently depends on whether FM_HOME happened to be exported. The consequence is a watch whose condition/action argv evaluate home X while the spec, trust, fired, and procevent source records live in home Y:fm-procevent.sh reconcilein X never runs it, and Y's reconcile owns a watch for a home it does not manage. The header at lines 39-42 presents --home as the way to pin the home the watch belongs to, andmark-due --homealready writes into the pinned home, so this is an internal inconsistency rather than a documented limitation. Fix:export FM_HOMEafter resolution (both at line 162 and at line 305) so the exec'd registrar inherits the pinned home.bin/fm-test-run.sh:1198- This change adds a--dreamercode path to bin/fm-brief.sh whose ONLY behavioral coverage is tests/fm-dreamer.test.sh, and it registers that test in thesession-bootstrapfamily (line 411) plusbin/fm-dreamer*in the session-bootstrap changed-path arm (line 1142). It does not touch thebin/fm-brief.sharm at line 1198, which still emits onlypure-contract-unit. Verified against the real runner:bin/fm-test-run.sh --list --family pure-contract-unitdoes not list tests/fm-dreamer.test.sh, while--list --family session-bootstrapdoes; andfamilies_for_changed_pathmatches bin/fm-brief.sh on the exclusive case arm at 1198, so thefamilies_for_test_referencefallback at line 1256 (which would have found the reference) is never reached. Net effect:bin/fm-test-run.sh --changedafter any future edit to bin/fm-brief.sh - the KIND dispatch, the --mode refusal, the shared HERDR_SECTION, the heredoc - runs none of the tests that cover the dreamer brief contract. The map's own comment at line 1072 states the invariant this breaks: 'Conservative path -> family map. Over-selects rather than under-selects.' Full CI lanes still run everything, so this is a selection gap, not a CI hole. Fix: addprintf '%s\n' session-bootstrapto the bin/fm-brief.sh case arm, as other multi-family arms already do.🔧 Fix: export pinned home for arm, select dreamer tests on brief change
3 issues (1 warning, 2 infos) still open:
bin/fm-dreamer-grade.sh:337- The grader scout brief hardcodes the pause state verb as the literalpaused, while every scaffold in bin/fm-brief.sh interpolates $PAUSED_VERB (bin/fm-brief.sh:87, used at lines 278, 361, 449, 571). bin/fm-classify-lib.sh:55-62 states the invariant explicitly: FM_CLASSIFY_PAUSED_VERB_DEFAULT is "the ONE definition of the verb; both the watcher and the daemon read it here (status_is_paused) rather than hardcoding the literal, so the vocabulary cannot drift between the two consumers", and FM_CLASSIFY_PAUSED_VERB overrides it. Reproduced with the real scripts: with FM_CLASSIFY_PAUSED_VERB=waiting,bin/fm-brief.sh s1 firstmate --scoutemitsStates: working, needs-decision, blocked, waiting, done, failed.whilebin/fm-dreamer-grade.sh scout g1 firstmate gen/0 gen/1emitsStates: working, needs-decision, blocked, paused, done, failed.. Failure path: in a home that configures a different verb, an idle grader scout follows its brief and appendspaused: <reason>; bin/fm-classify-lib.sh:131 status_is_paused compares the verb against the configured value and returns false, so the deliberate external wait is not absorbed by the stale path and the idle pane is escalated as a possible wedge instead. Fix: read the same value the brief scaffolder does (FM_CLASSIFY_PAUSED_VERB with FM_CLASSIFY_PAUSED_VERB_DEFAULT from bin/fm-classify-lib.sh) and interpolate it, rather than embedding the literal.bin/fm-dreamer-grade.sh:340- The grader scout brief's rule 6 tells the agent to appendneeds-decision: {summary}and stop, but unlike every scaffold in bin/fm-brief.sh it omits the paragraph that follows there: "A decision or blocker you opened stays open until aresolvedline carrying its exact key lands; a laterdone:orworking:line never closes it" plus the instruction to appendresolved:itself when a wait clears without a firstmate reply (bin/fm-brief.sh:369-373 for the scout kind, and the same text in the new dreamer kind at bin/fm-brief.sh:463-467). It also omits the decision-hold-lifecycle completion gate that the ship, scout, and dreamer briefs all carry (bin/fm-brief.sh:382, 472, and the new dreamer block), which .agents/skills/firstmate-orca/SKILL.md:78 names as the precondition for tearing scout work down. Consequence: a grader scout that opens a hold and later appendsdone: APPROVEper its own Definition of done leaves a hold that noresolvedline ever closes, and its teardown skips the gate the rest of the fleet applies. Confirm whether the independent grader is deliberately exempt from the shared hold lifecycle, or whether the two paragraphs should be carried over.bin/fm-brief.sh:171- This change makes--dreameraccept--herdr-lab(the dreamer block interpolates $HERDR_SECTION, and the usage header added at line 17 says "It may be combined with --herdr-lab"), and tests/fm-dreamer.test.sh:97-105 asserts that combination works. The refusal message for a secondmate charter still reads "--herdr-lab applies only to crewmate ship or scout briefs", so the one place that enumerates the valid kinds now omits dreamer. The neighbouring --mode refusal at line 164 was updated in this same change to mention the dreamer; this one was not. Fix: extend the message to "ship, scout, or dreamer briefs".🔧 Fix: read configured pause verb in grader scout brief
2 infos still open:
bin/fm-dreamer-watch.sh:153- The--head-ageand--intervalguards use the predicate''|*[!0-9.]*, which accepts any arrangement of dots:.,.., and1.2.3all pass. HEAD_AGE_HOURS is then fed toawk -v h="$HEAD_AGE_HOURS" 'BEGIN { printf "%.0f", h * 3600 }'at line 215, where awk silently coerces.to 0 and1.2.3to 1.2, so a malformed threshold degrades fail-open to "almost always stale" instead of being reported as a usage error. Reproduced in this worktree against a home whose data/memory/HEAD was aged 30 hours:check --head-age .->DREAM_DUE: due reason=data/memory/HEAD older than . hours(exit 0), andcheck --head-age 1.2.3->DREAM_DUE: due reason=data/memory/HEAD older than 1.2.3 hours(exit 0), whilecheck --head-age 999correctly reports not-due. That matters becausearmcopies the value verbatim into the registered condition argv (line 315): a typo at registration time silently arms a watch whose condition is true on every poll, so it fires, writes state/.dream-due, and wakes firstmate regardless of real HEAD age. The same predicate lets--interval .pass this script's validation and then die at theexecwith the registrar's genericerror: --interval needs a positive number of secondsand exit 1, rather than this script's own exit-2 usage error. Reproduced:arm --interval . --dry-runexits 0 and prints the spec, whilearm --interval .exits 1 with the registrar's message. Every other numeric flag in this change validates tightly (--stableis integer-checked at line 159, and bin/fm-dreamer-grade.sh:360-364 range-checks --max-diff-ratio). Fix: require a single optional dot with at least one digit (for example reject any value that does not match^[0-9]+(\.[0-9]+)?$) for both flags, and mirror the registrar's positive-number rule soarmrefuses a bad interval itself.bin/fm-dreamer-watch.sh:103-INTERVAL=${FM_DREAM_WATCH_INTERVAL:-3600}introduces an environment override that no header line or--helpoutput mentions. The sibling override on the previous line is documented: the OPTIONS block at lines 43-44 reads--head-age <hours> HEAD age threshold (default: FM_DREAM_HEAD_AGE_HOURS or 12), while line 45 reads only--interval <secs> arm only: when poll cadence (default 3600). Sinceusage()at line 80 renders the header verbatim as--help, the poll-cadence override is undiscoverable from the script's own documented interface, and firstmate-coding-guidelines tier 7 makes the script header plus its--helpoutput the single owner of exact flags and knobs. Fix: extend the--intervalline to(default: FM_DREAM_WATCH_INTERVAL or 3600), matching the--head-ageentry directly above it.✅ **Test** - passed
✅ No issues found.
bin/fm-test-run.sh tests/fm-dreamer.test.sh(35/35 ok, exit=0)bin/fm-test-run.sh tests/fm-brief.test.sh tests/fm-test-run.test.sh tests/fm-memory-verify.test.sh(3/3 scripts pass)Manual:bin/fm-dreamer-watch.sh checkon an empty home (not-due), afterbin/fm-memory-drop.sh fm-task-9 --claim ...(due), with a real live tmux worker registered in state/*.meta (blocked), and with--head-age 12against a 30-hour-old data/memory/HEAD (due)Manual:bin/fm-dreamer-watch.sh arm --dry-runandbin/fm-dreamer-watch.sh mark-due drop-tray(state/.dream-due written)Manual:bin/fm-brief.sh fm-dream-1 firstmate --dreamerand inspection of the emitted data/fm-dream-1/brief.md contract promptManual:bin/fm-memory-verify.sh 1->bin/fm-dreamer-grade.sh grade gen/0 gen/1(GRADE APPROVED) ->bin/fm-memory-publish.sh 1->bin/fm-memory-compile.sh compileshowing the promoted note in the compiled startup memoryManual reject paths:bin/fm-dreamer-grade.sh grade gen/1 gen/2(contradiction FAIL + tactical-scrap WARN, exit=1, HEAD unchanged) andbin/fm-dreamer-grade.sh grade gen/1 gen/3(mechanical citation failure, exit=1)Manual:bin/fm-dreamer-grade.sh scout fm-grade-2 firstmate gen/1 gen/2and inspection of the emitted fresh-context grader briefdocs/architecture.md:311- docs/architecture.md "Operational memory routing" covers /stow routing only and does not describe the dream loop's architectural boundary (why the dreamer cannot grade its own core rewrite). I left that safety rationale in bin/fm-dreamer-grade.sh's "WHY THIS EXISTS" header and routed the operator-facing lifecycle to docs/configuration.md's "Compiled working memory" section, which slices 1 and 2 already established as the owner. Adding an architecture-level narrative for the whole memory-generation mechanism is a separate consolidation worth a follow-up once the design's remaining slices land, not part of this change.✅ **Lint** - passed
✅ No issues found.
✅ **Push** - passed
✅ No issues found.