From 7805684a61d9d4ec2c0680f32526c79a282a8b9b Mon Sep 17 00:00:00 2001 From: PP <121104417+BohnBawerick@users.noreply.github.com> Date: Thu, 20 Aug 2026 10:32:18 +0800 Subject: [PATCH 1/9] feat(memory): add dreamer brief, idle watch, and independent grader 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. --- bin/fm-brief.sh | 100 ++++++++++- bin/fm-dreamer-grade.sh | 320 +++++++++++++++++++++++++++++++++ bin/fm-dreamer-watch.sh | 261 +++++++++++++++++++++++++++ bin/fm-test-run.sh | 4 +- tests/fm-dreamer.test.sh | 369 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 1051 insertions(+), 3 deletions(-) create mode 100755 bin/fm-dreamer-grade.sh create mode 100755 bin/fm-dreamer-watch.sh create mode 100755 tests/fm-dreamer.test.sh diff --git a/bin/fm-brief.sh b/bin/fm-brief.sh index 45abe357c31..9e5b175e857 100755 --- a/bin/fm-brief.sh +++ b/bin/fm-brief.sh @@ -8,9 +8,15 @@ # of shipping a new one). # Usage: fm-brief.sh --mode [--herdr-lab] # fm-brief.sh --scout [--herdr-lab] +# fm-brief.sh --dreamer [--herdr-lab] # fm-brief.sh --secondmate {...|--no-projects} # --scout writes the scout contract instead: the deliverable is a report at # data//report.md (no branch, no push, no PR) and the worktree is scratch. +# --dreamer writes the ephemeral dreamer scout contract: the deliverable is a +# proposed immutable memory generation under data/memory/gen// plus a dream +# receipt report. The dreamer reads the append-only log and the drop tray, +# never writes published memory in place, never takes the session lock, and +# never addresses the captain. It may be combined with --herdr-lab. # --secondmate writes a persistent secondmate charter. The project list # is cloned into the secondmate home, while the natural-language scope # tells the main firstmate when to route work there; routine churn stays in its own home; @@ -125,6 +131,7 @@ for a in "$@"; do fi case "$a" in --scout) KIND=scout ;; + --dreamer) KIND=dreamer ;; --secondmate) KIND=secondmate ;; --herdr-lab) HERDR_LAB=1 ;; --no-projects) NO_PROJECTS=1 ;; @@ -154,7 +161,7 @@ if [ "$KIND" = ship ]; then *) echo "error: --mode must be one of no-mistakes, direct-PR, local-only (got '$MODE')" >&2; exit 1 ;; esac elif [ "$MODE_SET" -eq 1 ]; then - echo "error: --mode applies only to ship briefs; a scout delivers a report and a secondmate charter is not a delivery contract" >&2 + echo "error: --mode applies only to ship briefs; a scout or dreamer delivers a report and a secondmate charter is not a delivery contract" >&2 exit 1 fi [ "${#POS[@]}" -ge 1 ] || { echo "error: task id is required" >&2; exit 1; } @@ -380,6 +387,97 @@ echo "scaffolded: $BRIEF (scout; replace {TASK})" exit 0 fi +if [ "$KIND" = dreamer ]; then +cat > "$BRIEF" <.status\` tails, \`data//report.md\`, + \`data/backlog.md\`, \`data/decisions/*.md\`, and the cold archives + (\`data/done-archive.md\`, \`data/note-archive.md\`, \`data/memory-archive.md\`) when a claim is being corrected. +- The in-band candidate tray: everything under \`data/memory/drop/\`. +- The current published memory: the generation \`data/memory/HEAD\` names, plus the compiled catalog. +Do NOT read firstmate's conversation, worker panes, or anything under \`projects/\`. + +## Synthesize, do not copy +Distillation is the differentiator. A tactical scrap is \`hz-verify-email-37 timed out in chrome-devtools-axi\`. +A durable abstraction generalises to a session that never heard of this task: \`Under multi-lane contention on this +host, chrome-devtools-axi times out; Playwright is the substitute\`, with a citation. Promote a drop claim only when +it becomes standing knowledge or corrects something already standing; reject the rest. + +## Write a new immutable generation +Produce the complete next generation under \`data/memory/gen//\` (where N is the next integer past the highest +existing generation) containing at minimum \`notes/*.md\` (one atomic claim per note, each with a resolvable citation), +\`core.md\` (the standing constitution, a subset or inspect-then-update of the current core, never a silent deletion), +and the source files the catalog is compiled from. Write only under \`data/memory/\`; never touch \`projects/\`. + +## Mechanical verification is mandatory +Before you report done, run \`bin/fm-memory-verify.sh \` on the proposed generation and let its four checks pass +(budget, citations, constitution, diff bounds). If verification fails, revise the generation rather than bypassing it. + +# Hard safety contract +1. NEVER take the session lock. The live primary harness holds it; you must never contend for it. +2. NEVER edit published memory in place. Only a new immutable generation plus an atomic \`data/memory/HEAD\` + pointer may change what a session sees, and firstmate owns that pointer swap after grading. +3. NEVER address the captain. Do not escalate to the captain; report only through your status file and your report. +4. NEVER write under \`projects/\` and never read the captain's conversation or worker panes. +5. A single-flight \`state/.dream.lock\` guarantees one dream at a time; never clear or force it. + +$HERDR_SECTION + +# Setup +You are in a disposable git worktree of $REPO, at a detached HEAD on a clean default branch. +This is an ephemeral DREAMER task: the deliverable is a proposed memory generation under the firstmate home's +\`data/memory/gen/\` plus a dream-receipt report, not a PR and not a chat reply. +The worktree is your laboratory; all scratch work in it is discarded at teardown. Anything worth keeping must +land in the generation or the report. + +# Rules +1. Never push to any remote and never open a PR. +2. Stay inside this worktree; the only files you may write outside it are under the home's \`data/memory/\`, + the report, and the status file below. +3. Use gh-axi for GitHub operations and chrome-devtools-axi for browser operations. +4. Report status by appending one line: + \`echo "{state}: {one short line}" >> $STATUS_FILE\` + States: working, needs-decision, blocked, $PAUSED_VERB, done, failed. + Each append wakes firstmate, so report sparingly: only phase changes a supervisor + would act on and the needs-decision/blocked/paused/done/failed states. No step-by-step + FYI progress lines; firstmate reads your pane for that. + Use \`$PAUSED_VERB: {why}\` - distinct from \`blocked:\` - ONLY when you are deliberately idling on a + known external wait you expect to clear on its own (an upstream release, a rate-limit reset): + firstmate then leaves your idle pane alone and rechecks it on a long cadence instead of + treating it as a possible wedge. Use \`blocked:\` when you are stuck and need help. +5. If you hit the same obstacle twice, append \`blocked: {why}\` and stop; firstmate will help. +6. If a decision belongs above you (product choices, destructive actions, ask-user findings), + append \`needs-decision: {summary of options}\` and stop. Firstmate will apply the configured authority and reply. + A decision or blocker you opened stays open until a \`resolved\` line carrying its exact key lands; a later \`done:\` + or \`working:\` line never closes it, even when the answer is what started that work. + Firstmate's reply normally writes that closing line at answer time; when a blocker or wait clears WITHOUT a firstmate reply, + append \`resolved: {how it cleared}\` yourself (same \`[key=]\` if you opened it with one) as you resume. +7. Never stop, restart, or update the shared \`no-mistakes\` daemon - it is one instance serving + every lane/home, so restarting it kills other lanes' in-flight pipeline runs. On ANY no-mistakes + daemon error, append \`blocked: {the daemon error}\` and stop; only firstmate manages the daemon. + +# Definition of done +Write your dream receipt to \`$DATA/$ID/report.md\`: what you read since the cursor, which drop claims you promoted +or rejected and why, the generation number you produced, the citations you used, and the mechanical verification result. +Do NOT publish \`data/memory/HEAD\` yourself; firstmate runs the grader and performs the atomic pointer swap. +Before reporting done, read and follow \`$FM_ROOT/.agents/skills/decision-hold-lifecycle/SKILL.md\` and pass its +shared completion gate for the report and any visual review. +When the generation is written, verified, and the report is complete, append \`done: {generation number} proposed\` +to the status file and stop. +EOF +echo "scaffolded: $BRIEF (dreamer; replace {TASK})" +exit 0 +fi + # Ship task: shape Setup / Rule 1 / Definition of done by this task's explicit # delivery mode, validated above. The generated DOD opens with the fixed # "Delivery contract: mode=" line that bin/fm-spawn.sh checks against its own diff --git a/bin/fm-dreamer-grade.sh b/bin/fm-dreamer-grade.sh new file mode 100755 index 00000000000..9489df4e5f5 --- /dev/null +++ b/bin/fm-dreamer-grade.sh @@ -0,0 +1,320 @@ +#!/usr/bin/env bash +# fm-dreamer-grade.sh - independent grader rubric for proposed core memory diffs. +# +# Usage: +# fm-dreamer-grade.sh grade [options] +# fm-dreamer-grade.sh scout +# fm-dreamer-grade.sh -h | --help +# +# WHY THIS EXISTS. A dreamer that rewrites the core cannot be the one that says +# the rewrite is safe: a context that did the work cannot grade the work, and +# concise poison is more dangerous than a long messy file. This helper is the +# grader's mechanical rubric. Firstmate runs `grade` directly for the +# deterministic checks, and scaffolds a fresh-context grader scout with `scout` +# when a core diff needs human-grade judgment (whether a claim is a durable +# abstraction rather than a tactical recap). +# +# grade +# Runs the mechanical rubric on the proposed new generation against the old +# one and prints one PASS/FAIL line per check. All checks must pass for a +# PASS overall. The checks: +# 1. Mechanical safety - delegate to bin/fm-memory-verify.sh, which owns +# budget, citations, constitution preservation, and diff bounds. A +# generation that fails the mechanical verifier is rejected outright. +# 2. Tactical scraps - every changed or newly added statement in the new +# core.md (and in changed notes) is inspected for tactical-scrap +# patterns: a bare task id, a dated incident recap, or a claim whose +# whole substance is one task's event. Such a statement is not a durable +# abstraction and is flagged for the grader's judgment. +# 3. Contradiction - every standing bullet rule in the old core must +# still hold in the new core; a new statement that reverses a standing +# rule (a negation of a rule's own wording, or an outright removal) is +# rejected as a contradiction with standing rules. +# The rubric flags (2) and (3) with evidence; the final PASS/FAIL for the +# whole generation requires (1) to pass and no (3) contradictions. Flags of +# type (2) are surfaced for the grader scout to decide, because only a fresh +# context can judge whether a claim generalises. +# +# scout +# Scaffolds an independent grader scout brief at data//brief.md whose +# ONLY input is the old generation, the new generation, and the cited files - +# never the dreamer's chain of thought. The scout applies the judgment half +# of the rubric and writes a pass/fail verdict to its report. It never writes +# memory and never addresses the captain. +# +# OPTIONS (grade): +# --max-diff-ratio passed through to the mechanical verifier (default 50) +# --dry-run verify without publishing (passed to the verifier) +# -h, --help show this help message +set -eu + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" +FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" +DATA="${FM_DATA_OVERRIDE:-$FM_HOME/data}" +MEMORY="$DATA/memory" +STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" + +usage() { + awk 'NR == 1 { next } /^#/ { sub(/^# ?/, ""); print; next } { exit }' "$0" +} + +die() { + printf 'fm-dreamer-grade: %s\n' "$1" >&2 + exit 2 +} + +CMD="" +OLD_TARGET="" +NEW_TARGET="" +MAX_DIFF_RATIO=50 +DRY_RUN=0 + +case "${1:-}" in + grade|scout) CMD=$1; shift ;; + -h|--help) usage; exit 0 ;; + *) usage >&2; exit 2 ;; +esac + +if [ "$CMD" = scout ]; then + [ "$#" -ge 4 ] || { usage >&2; exit 2; } + TASK_ID=$1 + REPO=$2 + OLD_TARGET=$3 + NEW_TARGET=$4 + shift 4 + [ "$#" -eq 0 ] || { usage >&2; exit 2; } +else + while [ "$#" -gt 0 ]; do + case "$1" in + --max-diff-ratio) + [ "$#" -ge 2 ] || { usage >&2; exit 2; } + MAX_DIFF_RATIO="$2"; shift 2 ;; + --dry-run) + DRY_RUN=1; shift ;; + -h|--help) + usage; exit 0 ;; + -*) + usage >&2; exit 2 ;; + *) + if [ -z "$OLD_TARGET" ]; then + OLD_TARGET="$1"; shift + elif [ -z "$NEW_TARGET" ]; then + NEW_TARGET="$1"; shift + else + usage >&2; exit 2 + fi + ;; + esac + done + [ -n "$OLD_TARGET" ] && [ -n "$NEW_TARGET" ] || die "grade requires and generation targets" +fi + +# --- resolve a generation directory ----------------------------------------- +# +# resolve_gen_dir : sets GEN_DIR_RESOLVED to the directory a target +# names, accepting data/memory/gen/, a bare , a data/memory-relative +# path, or an absolute path, mirroring the verifier's resolution. Returns 1 +# when it does not resolve to a real, non-symlinked directory. +GEN_DIR_RESOLVED="" +resolve_gen_dir() { + local target=$1 cand + GEN_DIR_RESOLVED="" + [ -n "$target" ] || return 1 + for cand in \ + "$MEMORY/gen/$target" \ + "$MEMORY/$target" \ + "$target"; do + if [ -d "$cand" ] && [ ! -L "$cand" ]; then + GEN_DIR_RESOLVED="$cand" + return 0 + fi + done + return 1 +} + +# --- tactical scrap heuristic ------------------------------------------------ +# +# is_scrap_statement : returns 0 when the line looks like a tactical +# recap rather than a durable abstraction. Heuristic, and deliberately so: it +# only FLAGS candidates for the grader's judgment; it never rejects alone. +# Patterns: a bare task id like fm-xxxx-123, a dated incident anchor (a +# YYYY-MM-DD inside the statement), or a statement that is entirely one task's +# event with no generalisation signal ("failed", "timed out", "did not run"). +is_scrap_statement() { + local line=$1 + # A dated incident anchor (YYYY-MM-DD inside the statement) is a recap, not + # a durable abstraction. + case "$line" in + *[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]*) + return 0 ;; + esac + # A task-id-shaped token inside the statement is a provenance anchor, not a + # claim: it names one task's event and cannot generalise. Only flag the + # statement when it also carries an event verb, so a citation path that + # happens to contain a task id is not mistaken for a claim. + case "$line" in + *fm-[a-zA-Z0-9-]*) + case "$line" in + *" failed"*|*" timed out"*|*" did not"*|*" was "*|*" is "*) + return 0 ;; + esac + ;; + esac + return 1 +} + +# --- contradiction with standing rules --------------------------------------- +# +# contradicts_standing : returns 0 when (a +# new-core statement) reverses a standing bullet rule in the old core. It +# compares the new statement against each old bullet rule's substantive +# keywords; a new statement that negates a rule is flagged. This is heuristic +# and surfaces evidence for the grader; a true removal is already caught by the +# mechanical constitution check (which requires every standing rule to survive). +contradicts_standing() { + local old_file=$1 line=$2 rule clean keywords kw matched neg + [ -f "$old_file" ] && [ ! -L "$old_file" ] || return 1 + while IFS= read -r rule; do + clean=$(printf '%s\n' "$rule" | sed -e 's/^[[:space:]]*[-*][[:space:]]*//' -e 's/[[:space:]]*$//') + [ -n "$clean" ] || continue + [ "${#clean}" -ge 5 ] || continue + keywords=$(printf '%s\n' "$clean" | LC_ALL=C tr '[:upper:]' '[:lower:]' | tr -cs 'a-z0-9' ' ' | awk '{ for(i=1;i<=NF;i++) if(length($i)>=4) printf "%s ", $i }') + [ -n "$keywords" ] || continue + # Count how many of the old rule's keywords the new statement echoes. + matched=0 + for kw in $keywords; do + case "$(printf '%s\n' "$line" | LC_ALL=C tr '[:upper:]' '[:lower:]')" in + *"$kw"*) matched=$((matched + 1)) ;; + esac + done + # A contradiction is a new statement that shares the rule's substance but + # reverses it: an explicit "never" / "do not" / "no longer" against a + # standing "always" / "do" rule. + if [ "$matched" -ge 2 ]; then + for neg in 'never' 'do not' 'no longer' 'must not' 'refuse to'; do + case "$line" in + *"$neg"*) return 0 ;; + esac + done + fi + done < "$old_file" + return 1 +} + +if [ "$CMD" = scout ]; then + # --- grader scout scaffold ------------------------------------------------- + if [ -e "$DATA/$TASK_ID" ]; then + die "task id '$TASK_ID' already exists at $DATA/$TASK_ID; choose a distinct id" + fi + BRIEF="$DATA/$TASK_ID/brief.md" + mkdir -p "$DATA/$TASK_ID" + STATUS_FILE="$STATE/$TASK_ID.status" + cat > "$BRIEF" <> $STATUS_FILE\` + States: working, needs-decision, blocked, paused, done, failed. + Each append wakes firstmate, so report sparingly: only phase changes a supervisor would act on. +5. If you hit the same obstacle twice, append \`blocked: {why}\` and stop. +6. If a decision belongs above you, append \`needs-decision: {summary}\` and stop. +7. Never stop, restart, or update the shared \`no-mistakes\` daemon. + +# Definition of done +Append \`done: APPROVE\` or \`done: REJECT\` to the status file and stop. +EOF + echo "scaffolded: $BRIEF (grader scout; APPROVE/REJECT)" + exit 0 +fi + +# --- grade ------------------------------------------------------------------- + +resolve_gen_dir "$OLD_TARGET" || die "old generation target does not resolve: '$OLD_TARGET'" +OLD_DIR=$GEN_DIR_RESOLVED +resolve_gen_dir "$NEW_TARGET" || die "new generation target does not resolve: '$NEW_TARGET'" +NEW_DIR=$GEN_DIR_RESOLVED + +ERRORS=0 +GRADE_WARNINGS=0 + +# 1. Mechanical safety via the verifier. Pass through max-diff-ratio and dry-run. +VERIFY_ARGS=("$SCRIPT_DIR/fm-memory-verify.sh" verify "$NEW_DIR" --max-diff-ratio "$MAX_DIFF_RATIO") +if [ "$DRY_RUN" -eq 1 ]; then + VERIFY_ARGS+=("--dry-run") +fi +if ! "${VERIFY_ARGS[@]}" >/dev/null 2>&1; then + printf 'FAIL grade: proposed generation fails the mechanical verifier\n' >&2 + ERRORS=$((ERRORS + 1)) +else + printf 'PASS grade: proposed generation passes the mechanical verifier\n' +fi + +# 2/3. Rubric over the core diff. Resolve both core files by the compiler rule: +# a generation's core.md when present, else data/captain.md. +OLD_CORE="" +NEW_CORE="" +[ -f "$OLD_DIR/core.md" ] && [ ! -L "$OLD_DIR/core.md" ] && OLD_CORE="$OLD_DIR/core.md" +[ -z "$OLD_CORE" ] && [ -f "$DATA/captain.md" ] && [ ! -L "$DATA/captain.md" ] && OLD_CORE="$DATA/captain.md" +[ -f "$NEW_DIR/core.md" ] && [ ! -L "$NEW_DIR/core.md" ] && NEW_CORE="$NEW_DIR/core.md" +[ -z "$NEW_CORE" ] && [ -f "$DATA/captain.md" ] && [ ! -L "$DATA/captain.md" ] && NEW_CORE="$DATA/captain.md" + +if [ -n "$OLD_CORE" ] && [ -n "$NEW_CORE" ] && [ "$OLD_CORE" != "$NEW_CORE" ]; then + # Core changed: inspect every new statement for scraps and contradictions. + while IFS= read -r line || [ -n "$line" ]; do + [ -n "$line" ] || continue + case "$line" in + '#'*|'---'*|''|' '*|$'\t'*) continue ;; + esac + if is_scrap_statement "$line"; then + printf 'WARN grade: possible tactical scrap in new core: %s\n' "$line" >&2 + GRADE_WARNINGS=$((GRADE_WARNINGS + 1)) + fi + if contradicts_standing "$OLD_CORE" "$line"; then + printf 'FAIL grade: new core contradicts a standing rule: %s\n' "$line" >&2 + ERRORS=$((ERRORS + 1)) + fi + done < "$NEW_CORE" +else + printf 'INFO grade: no core change to rubric (new core resolves to %s)\n' \ + "${NEW_CORE:-ABSENT}" >&2 +fi + +if [ "$ERRORS" -gt 0 ]; then + printf 'GRADE REJECTED: %s rubric violation(s) found\n' "$ERRORS" >&2 + exit 1 +fi + +printf 'GRADE APPROVED: no contradiction with standing rules%s\n' \ + "$([ "$GRADE_WARNINGS" -gt 0 ] && printf ' (%s tactical-scrap candidate(s) surfaced for grader judgment)' "$GRADE_WARNINGS" || printf '')" +exit 0 diff --git a/bin/fm-dreamer-watch.sh b/bin/fm-dreamer-watch.sh new file mode 100755 index 00000000000..ec0c4860492 --- /dev/null +++ b/bin/fm-dreamer-watch.sh @@ -0,0 +1,261 @@ +#!/usr/bin/env bash +# fm-dreamer-watch.sh - evaluate whether an offline dream (memory consolidation) +# pass is due, and arm the idle-notification watch for it. +# +# Usage: +# fm-dreamer-watch.sh check [options] +# fm-dreamer-watch.sh arm [options] +# fm-dreamer-watch.sh mark-due +# fm-dreamer-watch.sh -h | --help +# +# check +# Evaluate the dream-due condition for the home selected by FM_HOME and print +# one machine-readable verdict line: +# DREAM_DUE: due reason= +# DREAM_DUE: not-due reason= +# The exit code is the when-adapter contract: 0 for due (true), 1 for a clean +# not-due (false), and 2 for a usage error. The condition is true exactly when +# the fleet has no live non-dreamer worker AND either the drop tray holds an +# unconsumed candidate file OR data/memory/HEAD is older than the threshold. +# +# arm +# Register the deterministic condition->action watch that wakes firstmate when +# a dream pass is due, via bin/fm-procevent-when.sh. The condition is this +# script's `check`; the action is this script's `mark-due`, which writes a +# durable marker so a later dispatch decision has evidence. Arm only from a +# firstmate turn; the watch fires at most once and firstmate re-arms it after +# handling the `done:` of a dream scout. The action only marks due and wakes +# firstmate; it never spawns an agent, because dispatch needs judgment (quota, +# whether the core changed and must be graded). +# +# mark-due +# The safe, deterministic action the armed watch runs. It atomically writes a +# durable marker file state/.dream-due with fixed content and exits 0. It is +# idempotent and reversible (removing the marker file), so it is a legal when +# action. The runner captures its output and wakes firstmate. +# +# OPTIONS (check and arm): +# --head-age HEAD age threshold (default: FM_DREAM_HEAD_AGE_HOURS +# or 12) +# --interval arm only: when poll cadence (default 3600) +# --stable arm only: consecutive true polls before firing +# (default 2) +# --dry-run arm only: print the when registration argv without +# registering (default off) +# +# A worker is live when its state/.meta endpoint exists via +# bin/fm-backend.sh's fm_backend_target_exists - the same liveness read the +# session-start fleet digest uses. An unsupported backend or an unresolvable +# endpoint treats that worker as live (block), which is fail-safe for "never +# dream while a worker may be active". Dream tasks are identified by the +# conventional fm-dream- prefix on the task id and are excluded from the +# live-worker count. A missing FM_HOME or a home with no state/ or data/memory/ +# reports not-due with a reason rather than a hard error, so the watch can sit +# on a not-yet-initialized home without alarming. +set -eu + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" +FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" +STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" +DATA="${FM_DATA_OVERRIDE:-$FM_HOME/data}" +MEMORY="$DATA/memory" + +# shellcheck source=bin/fm-backend.sh +. "$SCRIPT_DIR/fm-backend.sh" + +usage() { + awk 'NR == 1 { next } /^#/ { sub(/^# ?/, ""); print; next } { exit }' "$0" +} + +die() { + printf 'fm-dreamer-watch: %s\n' "$1" >&2 + exit 2 +} + +HEAD_AGE_HOURS=${FM_DREAM_HEAD_AGE_HOURS:-12} +INTERVAL=${FM_DREAM_WATCH_INTERVAL:-3600} +STABLE=2 +DRY_RUN=0 +SOURCE_ID="" +CMD="" + +case "${1:-}" in + check|arm|mark-due) CMD=$1; shift ;; + -h|--help) usage; exit 0 ;; + *) usage >&2; exit 2 ;; +esac + +while [ "$#" -gt 0 ]; do + case "$1" in + --head-age) + [ "$#" -ge 2 ] || { usage >&2; exit 2; } + HEAD_AGE_HOURS="$2"; shift 2 ;; + --head-age=*) + HEAD_AGE_HOURS=${1#*=}; shift ;; + --interval) + [ "$#" -ge 2 ] || { usage >&2; exit 2; } + INTERVAL="$2"; shift 2 ;; + --interval=*) + INTERVAL=${1#*=}; shift ;; + --stable) + [ "$#" -ge 2 ] || { usage >&2; exit 2; } + STABLE="$2"; shift 2 ;; + --stable=*) + STABLE=${1#*=}; shift ;; + --dry-run) + DRY_RUN=1; shift ;; + -h|--help) + usage; exit 0 ;; + -*) + usage >&2; exit 2 ;; + *) + if [ "$CMD" = mark-due ] && [ -z "$SOURCE_ID" ]; then + SOURCE_ID="$1"; shift + else + usage >&2; exit 2 + fi + ;; + esac +done + +case "$HEAD_AGE_HOURS" in + ''|*[!0-9.]*) die "invalid --head-age: '$HEAD_AGE_HOURS' (expected hours)" ;; +esac +case "$INTERVAL" in + ''|*[!0-9.]*) die "invalid --interval: '$INTERVAL' (expected seconds)" ;; +esac +case "$STABLE" in + ''|*[!0-9]*) die "invalid --stable: '$STABLE' (expected an integer)" ;; +esac +[ "$STABLE" -ge 1 ] || die "--stable must be at least 1" + +# --- mark-due ---------------------------------------------------------------- + +if [ "$CMD" = mark-due ]; then + [ -n "${SOURCE_ID:-}" ] || die "mark-due requires a argument" + [ -d "$STATE" ] || mkdir -p "$STATE" || die "could not create $STATE" + printf 'dream due: %s\n' "$SOURCE_ID" > "$STATE/.dream-due.tmp" + mv -f "$STATE/.dream-due.tmp" "$STATE/.dream-due" || die "could not write $STATE/.dream-due" + printf 'dream due marker written for %s\n' "$SOURCE_ID" + exit 0 +fi + +# --- shared condition helpers ------------------------------------------------ + +# drop_has_candidates: 0 when data/memory/drop/ holds at least one candidate +# file. A symlinked tray is refused (no verdict), consistent with the memory +# guards, so a link cannot silently hide candidates or fabricate them. +drop_has_candidates() { + [ -d "$DROP_DIR" ] && [ ! -L "$DROP_DIR" ] || return 1 + local f found=0 + for f in "$DROP_DIR"/*.md; do + [ -f "$f" ] && [ ! -L "$f" ] || continue + found=1 + break + done + [ "$found" -eq 1 ] +} + +# head_is_stale: 0 when data/memory/HEAD exists and is older than HEAD_AGE_HOURS. +# A missing HEAD (home not yet dreamed) is NOT stale here: the compiler falls +# back to data/memory and the home still works, so an uninitialized home must +# not alarm on age. Only a HEAD that exists and aged is stale. +head_is_stale() { + [ -f "$MEMORY/HEAD" ] && [ ! -L "$MEMORY/HEAD" ] || return 1 + local mtime now age + mtime=$(stat -c %Y "$MEMORY/HEAD" 2>/dev/null || stat -f %m "$MEMORY/HEAD" 2>/dev/null) || return 1 + now=$(date +%s) || return 1 + age=$((now - mtime)) + [ "$age" -ge 0 ] || return 1 + # Compare in seconds to avoid floating point in the shell. + local max_seconds + max_seconds=$(awk -v h="$HEAD_AGE_HOURS" 'BEGIN { printf "%.0f", h * 3600 }') + [ "$age" -gt "$max_seconds" ] +} + +# live_workers: prints the task id of every live NON-dreamer worker, one per +# line, and returns 0 when at least one was found. A dream task is one whose id +# starts with the conventional fm-dream- prefix. A worker whose endpoint cannot +# be positively confirmed gone is treated as live (fail-safe block): only a +# supported backend whose target does not exist counts as dead, because an +# unsupported backend returning "no" is not proof the worker is idle. +live_workers() { + local meta id backend target live=0 + [ -d "$STATE" ] || return 0 + for meta in "$STATE"/*.meta; do + [ -f "$meta" ] || continue + id=$(basename "$meta" .meta) + case "$id" in + fm-dream-*) continue ;; + esac + backend=$(fm_backend_of_meta "$meta") + target=$(fm_backend_target_of_meta "$meta") + case "$backend" in + tmux|herdr|zellij|orca|cmux) ;; + *) + # Unsupported or unknown backend: cannot prove the worker is idle. + printf '%s\n' "$id" + live=1 + continue + ;; + esac + if [ -n "$target" ] && fm_backend_target_exists "$backend" "$target" "fm-$id" >/dev/null 2>&1; then + printf '%s\n' "$id" + live=1 + fi + done + [ "$live" -eq 1 ] +} + +DROP_DIR="$MEMORY/drop" + +# --- check ------------------------------------------------------------------- + +if [ "$CMD" = check ]; then + if [ ! -d "$MEMORY" ]; then + printf 'DREAM_DUE: not-due reason=no data/memory directory\n' + exit 1 + fi + if live_out=$(live_workers) && [ -n "$live_out" ]; then + printf 'DREAM_DUE: not-due reason=live non-dreamer worker(s): %s\n' "$(printf '%s' "$live_out" | tr '\n' ' ')" + exit 1 + fi + if drop_has_candidates; then + printf 'DREAM_DUE: due reason=unconsumed candidate files in data/memory/drop\n' + exit 0 + fi + if head_is_stale; then + printf 'DREAM_DUE: due reason=data/memory/HEAD older than %s hours\n' "$HEAD_AGE_HOURS" + exit 0 + fi + printf 'DREAM_DUE: not-due reason=no unconsumed drops and HEAD not stale\n' + exit 1 +fi + +# --- arm --------------------------------------------------------------------- + +# The when condition argv must be exact and deterministic: run this script's +# `check` with the resolved home and threshold. The action argv is `mark-due` +# with the resolved source id. Both argv vectors are executed directly by the +# runner with no shell, so each token is passed as its own argument. +WHEN_NAME="dream-due" +CONDITION_ARGV=("$SCRIPT_DIR/fm-dreamer-watch.sh" check --head-age "$HEAD_AGE_HOURS") +SOURCE_ID="when-dream-due" +ACTION_ARGV=("$SCRIPT_DIR/fm-dreamer-watch.sh" mark-due "$SOURCE_ID") + +if [ "$DRY_RUN" -eq 1 ]; then + printf 'would arm: bin/fm-procevent-when.sh arm %s --interval %s --stable %s --condition' \ + "$WHEN_NAME" "$INTERVAL" "$STABLE" + printf ' %q' "${CONDITION_ARGV[@]}" + printf ' --action' + printf ' %q' "${ACTION_ARGV[@]}" + printf '\n' + exit 0 +fi + +exec "$SCRIPT_DIR/fm-procevent-when.sh" arm "$WHEN_NAME" \ + --interval "$INTERVAL" \ + --stable "$STABLE" \ + --condition "${CONDITION_ARGV[@]}" \ + --action "${ACTION_ARGV[@]}" diff --git a/bin/fm-test-run.sh b/bin/fm-test-run.sh index a4e8de1ef57..179967329f2 100755 --- a/bin/fm-test-run.sh +++ b/bin/fm-test-run.sh @@ -408,7 +408,7 @@ family_for_basename() { printf '%s\n' secondmate ;; fm-bootstrap.test.sh|fm-fleet-sync.test.sh|fm-gate-refuse.test.sh|fm-gotmp.test.sh|\ - fm-memory-compile.test.sh|fm-memory-verify.test.sh|\ + fm-dreamer.test.sh|fm-memory-compile.test.sh|fm-memory-verify.test.sh|\ fm-session-start.test.sh|fm-sessionstart-nudge.test.sh|fm-startup-network.test.sh|\ fm-tangle-guard.test.sh|fm-update.test.sh) printf '%s\n' session-bootstrap @@ -1139,7 +1139,7 @@ families_for_changed_path() { printf '%s\n' secondmate ;; bin/fm-session-start.sh|bin/fm-bootstrap.sh|bin/fm-fleet-sync.sh|\ - bin/fm-memory*|\ + bin/fm-memory*|bin/fm-dreamer*|\ bin/fm-sessionstart-nudge.sh|bin/fm-startup-network.sh|bin/fm-tangle*|bin/fm-update.sh|\ bin/fm-gate-refuse*|bin/fm-lock*|bin/fm-quota-axi-lib.sh) printf '%s\n' session-bootstrap diff --git a/tests/fm-dreamer.test.sh b/tests/fm-dreamer.test.sh new file mode 100755 index 00000000000..ed59107354c --- /dev/null +++ b/tests/fm-dreamer.test.sh @@ -0,0 +1,369 @@ +#!/usr/bin/env bash +# Behavioral coverage for Memory Slice 3: the Dreamer scout brief, the idle +# dream trigger / evaluation helper, and the independent grader rubric. +# +# Every assertion here runs the real helper scripts on disk and inspects their +# real outputs, files, and exit codes. +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +TMP_ROOT=$(fm_test_tmproot fm-dreamer) +BRIEF="$ROOT/bin/fm-brief.sh" +WATCH="$ROOT/bin/fm-dreamer-watch.sh" +GRADE="$ROOT/bin/fm-dreamer-grade.sh" +VERIFY="$ROOT/bin/fm-memory-verify.sh" +DROP="$ROOT/bin/fm-memory-drop.sh" +PUBLISH="$ROOT/bin/fm-memory-publish.sh" +COMPILE="$ROOT/bin/fm-memory-compile.sh" + +# new_home [budget]: a clean test home with budget and standard dirs. +new_home() { + local budget=${2:-7500} home="$TMP_ROOT/$1" + mkdir -p "$home/config" "$home/data" "$home/state" "$home/data/memory/drop" + printf '%s\n' "$budget" > "$home/config/startup-memory-budget" + printf '%s\n' "$home" +} + +# write_note <triggers> <updated> <source> [body] +write_note() { + local notes_dir=$1 slug=$2 title=$3 triggers=$4 updated=$5 source=$6 body=${7:-""} + mkdir -p "$notes_dir" + { + printf -- '---\n' + printf 'title: %s\n' "$title" + printf 'triggers: %s\n' "$triggers" + printf 'updated: %s\n' "$updated" + printf 'source: %s\n' "$source" + printf -- '---\n\n' + printf '# %s\n\n' "$title" + if [ -n "$body" ]; then + printf '%s\n' "$body" + else + printf 'Body of note %s citing %s.\n' "$slug" "$source" + fi + } > "$notes_dir/$slug.md" +} + +# --- 1. Dreamer brief scaffolding ------------------------------------------- + +test_dreamer_brief_scaffolds_contract() { + local home out brief content + home=$(new_home dream-brief-basic) + out=$(FM_HOME="$home" "$BRIEF" fm-dream-1 firstmate --dreamer) + assert_contains "$out" 'dreamer' 'scaffold did not identify a dreamer brief' + + brief="$home/data/fm-dream-1/brief.md" + assert_present "$brief" 'dreamer brief was not written' + content=$(cat "$brief") + + # The dreamer contract rules must all be present. + assert_contains "$content" 'DREAMER' 'brief did not name the dreamer role' + assert_contains "$content" 'NEVER take the session lock' \ + 'dreamer brief does not forbid taking the session lock' + assert_contains "$content" 'NEVER edit published memory in place' \ + 'dreamer brief does not forbid editing published memory in place' + assert_contains "$content" 'NEVER address the captain' \ + 'dreamer brief does not forbid addressing the captain' + assert_contains "$content" 'bin/fm-memory-verify.sh' \ + 'dreamer brief does not require mechanical verification' + assert_contains "$content" 'data/memory/gen/' \ + 'dreamer brief does not direct writing an immutable generation' + assert_contains "$content" 'data/memory/drop/' \ + 'dreamer brief does not name the drop tray as an input' + assert_contains "$content" 'state/.dream.lock' \ + 'dreamer brief does not mention the single-flight dream lock' + # The deliverable is a generation + receipt, never a PR. + assert_contains "$content" 'never open a PR' \ + 'dreamer brief does not forbid opening a PR' + + pass 'dreamer brief scaffolds the full offline-consolidation contract' +} + +test_dreamer_brief_refuses_ship_mode() { + local home out + home=$(new_home dream-brief-mode) + if out=$(FM_HOME="$home" "$BRIEF" fm-dream-2 firstmate --dreamer --mode no-mistakes 2>&1); then + fail "dreamer brief accepted a ship --mode: $out" + fi + assert_contains "$out" '--mode applies only to ship briefs' \ + 'dreamer brief did not explain the mode refusal' + assert_absent "$home/data/fm-dream-2" 'a refused brief left data behind' + + pass 'dreamer brief refuses the ship-only --mode flag' +} + +test_dreamer_brief_accepts_herdr_lab() { + local home out content + home=$(new_home dream-brief-herdr) + out=$(FM_HOME="$home" "$BRIEF" fm-dream-3 firstmate --dreamer --herdr-lab) + content=$(cat "$home/data/fm-dream-3/brief.md") + assert_contains "$content" 'Herdr isolation - HARD SAFETY CONTRACT' \ + 'dreamer brief with --herdr-lab lacks the isolation contract' + pass 'dreamer brief composes with --herdr-lab' +} + +# --- 2. Idle dream evaluation (fm-dreamer-watch check) ----------------------- + +test_watch_not_due_on_empty_home() { + local home out rc + home=$(new_home watch-empty) + out=$(FM_HOME="$home" "$WATCH" check 2>&1); rc=$? + [ "$rc" -eq 1 ] || fail "empty home was not clean not-due (exit $rc): $out" + assert_contains "$out" 'DREAM_DUE: not-due' 'empty home did not report not-due' + pass 'watch reports not-due when there is nothing to consolidate' +} + +test_watch_due_on_unconsumed_drop() { + local home out rc + home=$(new_home watch-drop) + printf -- '- candidate claim\n' > "$home/data/memory/drop/cand.md" + out=$(FM_HOME="$home" "$WATCH" check 2>&1); rc=$? + [ "$rc" -eq 0 ] || fail "unconsumed drop did not make the dream due (exit $rc): $out" + assert_contains "$out" 'DREAM_DUE: due' 'unconsumed drop did not report due' + assert_contains "$out" 'data/memory/drop' 'due reason did not name the drop tray' + pass 'watch reports due when the drop tray holds an unconsumed candidate' +} + +test_watch_due_on_stale_head() { + local home out rc + home=$(new_home watch-stale-head) + printf 'gen/0\n' > "$home/data/memory/HEAD" + touch -d '30 hours ago' "$home/data/memory/HEAD" + out=$(FM_HOME="$home" "$WATCH" check --head-age 12 2>&1); rc=$? + [ "$rc" -eq 0 ] || fail "stale HEAD did not make the dream due (exit $rc): $out" + assert_contains "$out" 'older than 12 hours' 'stale HEAD reason did not name the age threshold' + pass 'watch reports due when HEAD is older than the threshold' +} + +test_watch_not_due_on_fresh_head() { + local home out rc + home=$(new_home watch-fresh-head) + printf 'gen/0\n' > "$home/data/memory/HEAD" + out=$(FM_HOME="$home" "$WATCH" check --head-age 12 2>&1); rc=$? + [ "$rc" -eq 1 ] || fail "fresh HEAD was treated as due (exit $rc): $out" + assert_contains "$out" 'DREAM_DUE: not-due' 'fresh HEAD did not report not-due' + pass 'watch reports not-due when HEAD is fresh' +} + +test_watch_blocked_by_live_non_dreamer_worker() { + local home out rc + home=$(new_home watch-live-worker) + printf -- '- candidate\n' > "$home/data/memory/drop/cand.md" + # An unsupported backend cannot prove the worker is idle, so it must block. + printf 'window=foo:0.1\nbackend=none\n' > "$home/state/fm-real-task.meta" + out=$(FM_HOME="$home" "$WATCH" check 2>&1); rc=$? + [ "$rc" -eq 1 ] || fail "a live non-dreamer worker did not block the dream (exit $rc): $out" + assert_contains "$out" 'live non-dreamer worker' \ + 'blocked reason did not name the live worker' + assert_contains "$out" 'fm-real-task' 'blocked reason did not name the blocking worker' + pass 'watch blocks dreaming while a live non-dreamer worker exists' +} + +test_watch_ignores_dreamer_prefixed_worker() { + local home out rc + home=$(new_home watch-dream-worker) + printf -- '- candidate\n' > "$home/data/memory/drop/cand.md" + printf 'window=foo:0.1\nbackend=none\n' > "$home/state/fm-dream-99.meta" + out=$(FM_HOME="$home" "$WATCH" check 2>&1); rc=$? + [ "$rc" -eq 0 ] || fail "a dreamer-prefixed worker wrongly blocked the dream (exit $rc): $out" + assert_contains "$out" 'DREAM_DUE: due' 'dreamer-prefixed worker prevented a due verdict' + pass 'watch does not count a dreamer task as a blocking live worker' +} + +test_watch_mark_due_writes_durable_marker() { + local home out marker + home=$(new_home watch-marker) + out=$(FM_HOME="$home" "$WATCH" mark-due when-dream-due) + assert_contains "$out" 'dream due marker written' 'mark-due did not confirm the marker' + marker=$(cat "$home/state/.dream-due") + assert_contains "$marker" 'when-dream-due' 'marker did not record the source id' + pass 'mark-due writes the durable dream-due marker' +} + +test_watch_arm_dry_run_prints_argv() { + local home out + home=$(new_home watch-arm) + out=$(FM_HOME="$home" "$WATCH" arm --dry-run) + assert_contains "$out" 'would arm: bin/fm-procevent-when.sh arm dream-due' \ + 'arm dry-run did not name the when watch' + assert_contains "$out" 'fm-dreamer-watch.sh check --head-age 12' \ + 'arm dry-run condition argv is missing or wrong' + assert_contains "$out" 'fm-dreamer-watch.sh mark-due when-dream-due' \ + 'arm dry-run action argv is missing or wrong' + assert_absent "$home/state/.dream-due" 'arm dry-run must not write the marker' + pass 'arm --dry-run prints the exact when registration argv without registering' +} + +# --- 3. Grader rubric -------------------------------------------------------- + +# valid_gen0: a home whose published HEAD is a real, verifying generation so a +# later proposed generation can be graded against it. +build_passing_home() { + local home=$1 + printf 'SOURCE\n' > "$home/data/source.md" + printf '# Captain\n- Rule One: always test changes\n' > "$home/data/captain.md" + local gen0="$home/data/memory/gen/0" + mkdir -p "$gen0/notes" + printf '# Core\n<!-- source: data/captain.md -->\n- Rule One: always test changes\n' > "$gen0/core.md" + write_note "$gen0/notes" n1 'Standing Note' 'test' 2026-08-20 'data/source.md' + printf 'gen/0\n' > "$home/data/memory/HEAD" +} + +test_grade_approves_no_core_change() { + local home out rc + home=$(new_home grade-no-change) + build_passing_home "$home" + local gen1="$home/data/memory/gen/1" + mkdir -p "$gen1/notes" + printf '# Core\n<!-- source: data/captain.md -->\n- Rule One: always test changes\n' > "$gen1/core.md" + write_note "$gen1/notes" n1 'Standing Note' 'test' 2026-08-20 'data/source.md' + out=$(FM_HOME="$home" "$GRADE" grade gen/0 gen/1 2>&1); rc=$? + [ "$rc" -eq 0 ] || fail "grade rejected a safe no-op generation (exit $rc): $out" + assert_contains "$out" 'GRADE APPROVED' 'grade did not approve a no-op generation' + pass 'grade approves a proposed generation with no core change' +} + +test_grade_rejects_failing_mechanical_verify() { + local home out rc + home=$(new_home grade-verify-fail) + build_passing_home "$home" + # A proposed generation that deletes every baseline note fails diff-bounds. + local gen1="$home/data/memory/gen/1" + mkdir -p "$gen1/notes" + printf '# Core\n<!-- source: data/captain.md -->\n- Rule One: always test changes\n' > "$gen1/core.md" + out=$(FM_HOME="$home" "$GRADE" grade gen/0 gen/1 2>&1); rc=$? + [ "$rc" -eq 1 ] || fail "grade did not reject a verifier-failing generation (exit $rc): $out" + assert_contains "$out" 'FAIL grade' 'grade did not report the mechanical failure' + pass 'grade rejects a proposed generation that fails the mechanical verifier' +} + +test_grade_flags_tactical_scrap_as_warning() { + local home out rc + home=$(new_home grade-scrap) + build_passing_home "$home" + # New core passes verify (keeps the standing rule) but adds a dated recap + # line, which the rubric surfaces as a possible tactical scrap. + local gen1="$home/data/memory/gen/1" + mkdir -p "$gen1/notes" + { + printf '# Core\n<!-- source: data/captain.md -->\n- Rule One: always test changes\n' + printf -- '- on 2026-08-19 fm-abc-12 failed with a timeout\n' + } > "$gen1/core.md" + write_note "$gen1/notes" n1 'Standing Note' 'test' 2026-08-20 'data/source.md' + out=$(FM_HOME="$home" "$GRADE" grade gen/0 gen/1 2>&1); rc=$? + assert_contains "$out" 'possible tactical scrap' \ + 'grade did not surface the dated recap as a tactical scrap' + pass 'grade flags a dated incident recap as a possible tactical scrap' +} + +test_grade_rejects_contradiction_of_standing_rule() { + local home out rc + home=$(new_home grade-contradiction) + build_passing_home "$home" + # New core preserves the standing rule (so verify passes) but also adds a + # negation that reverses it, which the rubric rejects as a contradiction. + local gen1="$home/data/memory/gen/1" + mkdir -p "$gen1/notes" + { + printf '# Core\n<!-- source: data/captain.md -->\n- Rule One: always test changes\n' + printf -- '- never test changes under any circumstance\n' + } > "$gen1/core.md" + write_note "$gen1/notes" n1 'Standing Note' 'test' 2026-08-20 'data/source.md' + out=$(FM_HOME="$home" "$GRADE" grade gen/0 gen/1 2>&1); rc=$? + [ "$rc" -eq 1 ] || fail "grade did not reject a contradiction of a standing rule (exit $rc): $out" + assert_contains "$out" 'contradicts a standing rule' \ + 'grade did not report the standing-rule contradiction' + pass 'grade rejects a new statement that contradicts a standing rule' +} + +test_grade_scout_scaffolds_independent_grader_brief() { + local home out brief content + home=$(new_home grade-scout-brief) + out=$(FM_HOME="$home" "$GRADE" scout grade-1 firstmate gen/0 gen/1) + assert_contains "$out" 'grader scout' 'grade scout did not identify the deliverable' + brief="$home/data/grade-1/brief.md" + assert_present "$brief" 'grader scout brief was not written' + content=$(cat "$brief") + assert_contains "$content" 'INDEPENDENT GRADER' 'grader brief did not name the role' + assert_contains "$content" 'gen/0' 'grader brief did not name the old generation' + assert_contains "$content" 'gen/1' 'grader brief did not name the new generation' + assert_contains "$content" 'APPROVE' 'grader brief lacks the APPROVE verdict' + assert_contains "$content" 'REJECT' 'grader brief lacks the REJECT verdict' + assert_contains "$content" 'never open a PR' 'grader brief does not forbid a PR' + assert_contains "$content" 'Do NOT address the captain' \ + 'grader brief does not forbid addressing the captain' + pass 'grade scout scaffolds a fresh-context independent grader brief' +} + +# --- 4. Full integration: dream -> verify -> grade -> publish ---------------- + +test_full_dream_loop_integration() { + local home out + home=$(new_home dream-loop) + build_passing_home "$home" + + # Simulate a completed task depositing a drop, which the dreamer would read. + mkdir -p "$home/data/fm-task-9" + printf '# Report\nHealthlog needs xvfb for playwright.\n' > "$home/data/fm-task-9/report.md" + printf 'project=healthlog\nreport=data/fm-task-9/report.md\n' > "$home/state/fm-task-9.meta" + FM_HOME="$home" "$DROP" fm-task-9 --claim "Healthlog requires xvfb for playwright" >/dev/null + + # The watch sees the unconsumed drop and reports due. + out=$(FM_HOME="$home" "$WATCH" check 2>&1) + assert_contains "$out" 'DREAM_DUE: due' 'watch did not report due with a drop present' + + # A dreamer proposes generation 1 from the drop: a new abstraction with a + # resolvable citation, preserving the standing rule AND the baseline note. + local gen1="$home/data/memory/gen/1" + mkdir -p "$gen1/notes" + { + printf '# Core\n<!-- source: data/captain.md -->\n- Rule One: always test changes\n' + } > "$gen1/core.md" + write_note "$gen1/notes" n1 'Standing Note' 'test' 2026-08-20 'data/source.md' + write_note "$gen1/notes" healthlog 'Healthlog needs xvfb for playwright' 'healthlog' 2026-08-20 'data/fm-task-9/report.md' 'BODY-OF-HEALTHLOG-NOTE' + + # The mechanical verifier passes. + out=$(FM_HOME="$home" "$VERIFY" 1) + assert_contains "$out" 'VERIFICATION PASSED' 'verifier did not pass the proposed generation' + + # The grader approves (no core change, no contradictions). + out=$(FM_HOME="$home" "$GRADE" grade gen/0 gen/1 2>&1) + assert_contains "$out" 'GRADE APPROVED' 'grader did not approve the valid generation' + + # Firstmate publishes HEAD to the new generation. + out=$(FM_HOME="$home" "$PUBLISH" 1) + assert_contains "$out" 'published generation gen/1' 'publish did not update HEAD' + [ "$(head -n 1 "$home/data/memory/HEAD")" = "gen/1" ] \ + || fail "HEAD does not point at gen/1 after publish" + + # The compiler now injects from the published generation. + out=$(FM_HOME="$home" "$COMPILE" compile) + assert_contains "$out" 'COMPILED WORKING MEMORY (data/memory/gen/1)' \ + 'compiler did not compile from the published generation' + assert_contains "$out" 'BODY-OF-HEALTHLOG-NOTE' \ + 'compiler did not inject the promoted note from the published generation' + + pass 'dreamer loop composes: drop -> due -> verify -> grade -> publish -> compile' +} + +# --- runner ------------------------------------------------------------------ + +test_dreamer_brief_scaffolds_contract +test_dreamer_brief_refuses_ship_mode +test_dreamer_brief_accepts_herdr_lab +test_watch_not_due_on_empty_home +test_watch_due_on_unconsumed_drop +test_watch_due_on_stale_head +test_watch_not_due_on_fresh_head +test_watch_blocked_by_live_non_dreamer_worker +test_watch_ignores_dreamer_prefixed_worker +test_watch_mark_due_writes_durable_marker +test_watch_arm_dry_run_prints_argv +test_grade_approves_no_core_change +test_grade_rejects_failing_mechanical_verify +test_grade_flags_tactical_scrap_as_warning +test_grade_rejects_contradiction_of_standing_rule +test_grade_scout_scaffolds_independent_grader_brief +test_full_dream_loop_integration From ab592c2fd8fa44744924a9d93c3170525bd108ce Mon Sep 17 00:00:00 2001 From: PP <121104417+BohnBawerick@users.noreply.github.com> Date: Thu, 20 Aug 2026 10:53:42 +0800 Subject: [PATCH 2/9] no-mistakes(review): fix grader diff boundary, negation case, cost, and watch liveness --- bin/fm-dreamer-grade.sh | 143 ++++++++++++++++++++++++++++--------- bin/fm-dreamer-watch.sh | 51 ++++++++++--- docs/scripts.md | 2 + tests/fm-dreamer.test.sh | 150 ++++++++++++++++++++++++++++++++++++++- 4 files changed, 301 insertions(+), 45 deletions(-) diff --git a/bin/fm-dreamer-grade.sh b/bin/fm-dreamer-grade.sh index 9489df4e5f5..273fb1376f9 100755 --- a/bin/fm-dreamer-grade.sh +++ b/bin/fm-dreamer-grade.sh @@ -30,6 +30,10 @@ # still hold in the new core; a new statement that reverses a standing # rule (a negation of a rule's own wording, or an outright removal) is # rejected as a contradiction with standing rules. +# A statement the old generation already carries verbatim is not a proposed +# statement and is not inspected by (2) or (3): the mechanical verifier +# requires every standing rule to survive, so a preserved rule must not be +# read as a new claim about itself. # The rubric flags (2) and (3) with evidence; the final PASS/FAIL for the # whole generation requires (1) to pass and no (3) contradictions. Flags of # type (2) are surfaced for the grader scout to decide, because only a fresh @@ -166,42 +170,110 @@ is_scrap_statement() { # --- contradiction with standing rules --------------------------------------- # -# contradicts_standing <old-core> <new-core> <line>: returns 0 when <line> (a -# new-core statement) reverses a standing bullet rule in the old core. It -# compares the new statement against each old bullet rule's substantive -# keywords; a new statement that negates a rule is flagged. This is heuristic -# and surfaces evidence for the grader; a true removal is already caught by the -# mechanical constitution check (which requires every standing rule to survive). -contradicts_standing() { - local old_file=$1 line=$2 rule clean keywords kw matched neg - [ -f "$old_file" ] && [ ! -L "$old_file" ] || return 1 - while IFS= read -r rule; do +# The old core's statements are parsed once into STANDING_KEYWORDS, one +# space-separated keyword list per rule, so the per-statement check below costs +# no subprocess at all. Re-parsing per statement is quadratic in core size and +# makes `grade` look hung on a realistic constitution. +STANDING_KEYWORDS=() + +# load_standing_rules <old-core>: fill STANDING_KEYWORDS from the old core's +# statements. A statement contributes its substantive words (four characters or +# more), lowercased, with bullet markers and punctuation stripped. +load_standing_rules() { + local old_file=$1 rule clean keywords + [ -n "$old_file" ] && [ -f "$old_file" ] && [ ! -L "$old_file" ] || return 0 + while IFS= read -r rule || [ -n "$rule" ]; do clean=$(printf '%s\n' "$rule" | sed -e 's/^[[:space:]]*[-*][[:space:]]*//' -e 's/[[:space:]]*$//') [ -n "$clean" ] || continue [ "${#clean}" -ge 5 ] || continue keywords=$(printf '%s\n' "$clean" | LC_ALL=C tr '[:upper:]' '[:lower:]' | tr -cs 'a-z0-9' ' ' | awk '{ for(i=1;i<=NF;i++) if(length($i)>=4) printf "%s ", $i }') [ -n "$keywords" ] || continue + STANDING_KEYWORDS+=("$keywords") + done < "$old_file" +} + +# contradicts_standing <lowercased-statement>: returns 0 when the statement +# reverses a standing rule loaded by load_standing_rules: it shares the rule's +# substance (two or more of the rule's keywords) and negates it. The statement +# is matched lowercased throughout, so an ordinary sentence-case "Never ..." is +# caught exactly like a lowercase one. This is heuristic and surfaces evidence +# for the grader; a true removal is already caught by the mechanical +# constitution check (which requires every standing rule to survive). +contradicts_standing() { + local line_lc=$1 keywords kw matched neg i=0 + while [ "$i" -lt "${#STANDING_KEYWORDS[@]}" ]; do + keywords=${STANDING_KEYWORDS[$i]} + i=$((i + 1)) # Count how many of the old rule's keywords the new statement echoes. matched=0 for kw in $keywords; do - case "$(printf '%s\n' "$line" | LC_ALL=C tr '[:upper:]' '[:lower:]')" in + case "$line_lc" in *"$kw"*) matched=$((matched + 1)) ;; esac done # A contradiction is a new statement that shares the rule's substance but # reverses it: an explicit "never" / "do not" / "no longer" against a # standing "always" / "do" rule. - if [ "$matched" -ge 2 ]; then - for neg in 'never' 'do not' 'no longer' 'must not' 'refuse to'; do - case "$line" in - *"$neg"*) return 0 ;; - esac - done - fi - done < "$old_file" + [ "$matched" -ge 2 ] || continue + for neg in 'never' 'do not' 'no longer' 'must not' 'refuse to'; do + case "$line_lc" in + *"$neg"*) return 0 ;; + esac + done + done return 1 } +# --- statement inspection ---------------------------------------------------- +# +# inspect_statements <old-file> <new-file> <label>: run the scrap and +# contradiction checks over the statements the new file adds or changes. A line +# the old file already carries verbatim is not a proposed statement and is +# skipped: the mechanical verifier REQUIRES every standing rule to survive into +# the new generation, so inspecting a preserved rule would make the grader +# reject exactly the input the verifier demands. Headings, source markers, and +# note frontmatter are metadata rather than claims and are skipped too. An empty +# <old-file> means every statement in <new-file> is new. +inspect_statements() { + local old_file=$1 new_file=$2 label=$3 + local line line_lc haystack="" in_frontmatter=0 first=1 + [ -f "$new_file" ] && [ ! -L "$new_file" ] || return 0 + if [ -n "$old_file" ] && [ -f "$old_file" ] && [ ! -L "$old_file" ]; then + haystack=$'\n'"$(cat "$old_file")"$'\n' + fi + while IFS= read -r line || [ -n "$line" ]; do + if [ "$first" -eq 1 ]; then + first=0 + if [ "$line" = '---' ]; then + in_frontmatter=1 + continue + fi + fi + if [ "$in_frontmatter" -eq 1 ]; then + [ "$line" = '---' ] && in_frontmatter=0 + continue + fi + [ -n "$line" ] || continue + case "$line" in + '#'*|'---'*|'<!--'*|' '*|$'\t'*) continue ;; + esac + if [ -n "$haystack" ]; then + case "$haystack" in + *$'\n'"$line"$'\n'*) continue ;; + esac + fi + if is_scrap_statement "$line"; then + printf 'WARN grade: possible tactical scrap in %s: %s\n' "$label" "$line" >&2 + GRADE_WARNINGS=$((GRADE_WARNINGS + 1)) + fi + line_lc=$(printf '%s\n' "$line" | LC_ALL=C tr '[:upper:]' '[:lower:]') + if contradicts_standing "$line_lc"; then + printf 'FAIL grade: %s contradicts a standing rule: %s\n' "$label" "$line" >&2 + ERRORS=$((ERRORS + 1)) + fi + done < "$new_file" +} + if [ "$CMD" = scout ]; then # --- grader scout scaffold ------------------------------------------------- if [ -e "$DATA/$TASK_ID" ]; then @@ -289,27 +361,28 @@ NEW_CORE="" [ -f "$NEW_DIR/core.md" ] && [ ! -L "$NEW_DIR/core.md" ] && NEW_CORE="$NEW_DIR/core.md" [ -z "$NEW_CORE" ] && [ -f "$DATA/captain.md" ] && [ ! -L "$DATA/captain.md" ] && NEW_CORE="$DATA/captain.md" -if [ -n "$OLD_CORE" ] && [ -n "$NEW_CORE" ] && [ "$OLD_CORE" != "$NEW_CORE" ]; then - # Core changed: inspect every new statement for scraps and contradictions. - while IFS= read -r line || [ -n "$line" ]; do - [ -n "$line" ] || continue - case "$line" in - '#'*|'---'*|''|' '*|$'\t'*) continue ;; - esac - if is_scrap_statement "$line"; then - printf 'WARN grade: possible tactical scrap in new core: %s\n' "$line" >&2 - GRADE_WARNINGS=$((GRADE_WARNINGS + 1)) - fi - if contradicts_standing "$OLD_CORE" "$line"; then - printf 'FAIL grade: new core contradicts a standing rule: %s\n' "$line" >&2 - ERRORS=$((ERRORS + 1)) - fi - done < "$NEW_CORE" +load_standing_rules "$OLD_CORE" + +if [ -n "$NEW_CORE" ] && [ "$OLD_CORE" != "$NEW_CORE" ]; then + inspect_statements "$OLD_CORE" "$NEW_CORE" "new core" else printf 'INFO grade: no core change to rubric (new core resolves to %s)\n' \ "${NEW_CORE:-ABSENT}" >&2 fi +# The same rubric runs over the generation's notes, because a claim smuggled +# into a note is published to every session exactly as a core statement is. +# A note the old generation already carries verbatim contributes no statement. +if [ -d "$NEW_DIR/notes" ] && [ ! -L "$NEW_DIR/notes" ]; then + for note in "$NEW_DIR"/notes/*.md; do + [ -f "$note" ] && [ ! -L "$note" ] || continue + note_name=$(basename "$note") + old_note="$OLD_DIR/notes/$note_name" + [ -f "$old_note" ] && [ ! -L "$old_note" ] || old_note="" + inspect_statements "$old_note" "$note" "changed note notes/$note_name" + done +fi + if [ "$ERRORS" -gt 0 ]; then printf 'GRADE REJECTED: %s rubric violation(s) found\n' "$ERRORS" >&2 exit 1 diff --git a/bin/fm-dreamer-watch.sh b/bin/fm-dreamer-watch.sh index ec0c4860492..cc8effe9a60 100755 --- a/bin/fm-dreamer-watch.sh +++ b/bin/fm-dreamer-watch.sh @@ -5,11 +5,12 @@ # Usage: # fm-dreamer-watch.sh check [options] # fm-dreamer-watch.sh arm [options] -# fm-dreamer-watch.sh mark-due <source-id> +# fm-dreamer-watch.sh mark-due <source-id> [options] # fm-dreamer-watch.sh -h | --help # # check -# Evaluate the dream-due condition for the home selected by FM_HOME and print +# Evaluate the dream-due condition for the home selected by --home, or by +# FM_HOME when no --home is given, and print # one machine-readable verdict line: # DREAM_DUE: due reason=<one-line reason> # DREAM_DUE: not-due reason=<one-line reason> @@ -34,7 +35,11 @@ # idempotent and reversible (removing the marker file), so it is a legal when # action. The runner captures its output and wakes firstmate. # -# OPTIONS (check and arm): +# OPTIONS (all commands accept --home; the rest apply to check and arm): +# --home <path> the firstmate home to evaluate, pinned explicitly +# instead of inherited from FM_HOME. `arm` places its +# own resolved home in both registered argv vectors so +# the watch does not depend on the runner environment. # --head-age <hours> HEAD age threshold (default: FM_DREAM_HEAD_AGE_HOURS # or 12) # --interval <secs> arm only: when poll cadence (default 3600) @@ -73,6 +78,7 @@ die() { exit 2 } +HOME_OPT="" HEAD_AGE_HOURS=${FM_DREAM_HEAD_AGE_HOURS:-12} INTERVAL=${FM_DREAM_WATCH_INTERVAL:-3600} STABLE=2 @@ -88,6 +94,11 @@ esac while [ "$#" -gt 0 ]; do case "$1" in + --home) + [ "$#" -ge 2 ] || { usage >&2; exit 2; } + HOME_OPT="$2"; shift 2 ;; + --home=*) + HOME_OPT=${1#*=}; shift ;; --head-age) [ "$#" -ge 2 ] || { usage >&2; exit 2; } HEAD_AGE_HOURS="$2"; shift 2 ;; @@ -130,6 +141,23 @@ case "$STABLE" in esac [ "$STABLE" -ge 1 ] || die "--stable must be at least 1" +# An explicit --home is authoritative for every path this script reads or +# writes, so a registered watch evaluates the home it was armed for no matter +# what environment the runner happens to carry. +if [ -n "$HOME_OPT" ]; then + case "$HOME_OPT" in + /*) ;; + *) HOME_OPT=$(CDPATH='' cd -- "$HOME_OPT" 2>/dev/null && pwd -P) \ + || die "--home is not a reachable directory" ;; + esac + [ -d "$HOME_OPT" ] && [ ! -L "$HOME_OPT" ] \ + || die "--home must name an existing directory, got '$HOME_OPT'" + FM_HOME=$HOME_OPT + STATE="$FM_HOME/state" + DATA="$FM_HOME/data" + MEMORY="$DATA/memory" +fi + # --- mark-due ---------------------------------------------------------------- if [ "$CMD" = mark-due ]; then @@ -200,7 +228,10 @@ live_workers() { continue ;; esac - if [ -n "$target" ] && fm_backend_target_exists "$backend" "$target" "fm-$id" >/dev/null 2>&1; then + # An empty target is a meta with no resolvable endpoint (for example a + # partially written meta with no window= key). That is not proof the worker + # is gone, so it counts as live, like an unsupported backend. + if [ -z "$target" ] || fm_backend_target_exists "$backend" "$target" "fm-$id" >/dev/null 2>&1; then printf '%s\n' "$id" live=1 fi @@ -236,13 +267,15 @@ fi # --- arm --------------------------------------------------------------------- # The when condition argv must be exact and deterministic: run this script's -# `check` with the resolved home and threshold. The action argv is `mark-due` -# with the resolved source id. Both argv vectors are executed directly by the -# runner with no shell, so each token is passed as its own argument. +# `check` with the resolved home and threshold, both pinned as explicit tokens +# so the registered spec is self-contained. The action argv is `mark-due` with +# the resolved source id and the same pinned home. Both argv vectors are +# executed directly by the runner with no shell, so each token is passed as its +# own argument. WHEN_NAME="dream-due" -CONDITION_ARGV=("$SCRIPT_DIR/fm-dreamer-watch.sh" check --head-age "$HEAD_AGE_HOURS") +CONDITION_ARGV=("$SCRIPT_DIR/fm-dreamer-watch.sh" check --head-age "$HEAD_AGE_HOURS" --home "$FM_HOME") SOURCE_ID="when-dream-due" -ACTION_ARGV=("$SCRIPT_DIR/fm-dreamer-watch.sh" mark-due "$SOURCE_ID") +ACTION_ARGV=("$SCRIPT_DIR/fm-dreamer-watch.sh" mark-due "$SOURCE_ID" --home "$FM_HOME") if [ "$DRY_RUN" -eq 1 ]; then printf 'would arm: bin/fm-procevent-when.sh arm %s --interval %s --stable %s --condition' \ diff --git a/docs/scripts.md b/docs/scripts.md index 9a579f5dccf..881def19247 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -126,3 +126,5 @@ The shared no-mistakes gate refusal for fleet lifecycle entrypoints is summarize | `fm-memory-drop.sh` | Deposit candidate claims from completed tasks into the drop tray `data/memory/drop/` | | `fm-memory-verify.sh` | Mechanical safety verifier and generation publication guard for `data/memory/` | | `fm-memory-publish.sh` | Verify and atomically update `data/memory/HEAD` to point to a proposed generation | +| `fm-dreamer-watch.sh` | Evaluate whether an offline dream pass is due and arm its idle-notification watch | +| `fm-dreamer-grade.sh` | Grade a proposed memory generation against the rubric and scaffold the grader scout | diff --git a/tests/fm-dreamer.test.sh b/tests/fm-dreamer.test.sh index ed59107354c..513bad16e7b 100755 --- a/tests/fm-dreamer.test.sh +++ b/tests/fm-dreamer.test.sh @@ -172,6 +172,44 @@ test_watch_ignores_dreamer_prefixed_worker() { pass 'watch does not count a dreamer task as a blocking live worker' } +test_watch_blocked_by_worker_with_unresolvable_endpoint() { + local home out rc + home=$(new_home watch-empty-target) + printf -- '- candidate\n' > "$home/data/memory/drop/cand.md" + # A supported backend with no recorded endpoint is a partially written meta. + # That is not proof the worker is gone, so it must block the dream. + printf 'backend=tmux\n' > "$home/state/fm-real-task.meta" + out=$(FM_HOME="$home" "$WATCH" check 2>&1); rc=$? + [ "$rc" -eq 1 ] || fail "a worker with no resolvable endpoint did not block (exit $rc): $out" + assert_contains "$out" 'fm-real-task' 'blocked reason did not name the unresolvable worker' + pass 'watch treats a worker with no resolvable endpoint as live' +} + +test_watch_home_flag_pins_the_evaluated_home() { + local pinned ambient out rc + pinned=$(new_home watch-home-pinned) + ambient=$(new_home watch-home-ambient) + printf -- '- candidate\n' > "$pinned/data/memory/drop/cand.md" + # FM_HOME points at a home with nothing to consolidate; --home must win. + out=$(FM_HOME="$ambient" "$WATCH" check --home "$pinned" 2>&1); rc=$? + [ "$rc" -eq 0 ] || fail "--home did not select the pinned home (exit $rc): $out" + assert_contains "$out" 'DREAM_DUE: due' '--home did not evaluate the pinned home' + + out=$(FM_HOME="$pinned" "$WATCH" check --home "$ambient" 2>&1); rc=$? + [ "$rc" -eq 1 ] || fail "--home did not override an ambient due home (exit $rc): $out" + pass 'check --home evaluates the pinned home, not the ambient FM_HOME' +} + +test_watch_mark_due_home_flag_writes_into_the_pinned_home() { + local pinned ambient + pinned=$(new_home watch-mark-pinned) + ambient=$(new_home watch-mark-ambient) + FM_HOME="$ambient" "$WATCH" mark-due when-dream-due --home "$pinned" >/dev/null + assert_present "$pinned/state/.dream-due" 'mark-due did not write into the pinned home' + assert_absent "$ambient/state/.dream-due" 'mark-due wrote into the ambient home' + pass 'mark-due --home writes the marker into the pinned home' +} + test_watch_mark_due_writes_durable_marker() { local home out marker home=$(new_home watch-marker) @@ -192,6 +230,12 @@ test_watch_arm_dry_run_prints_argv() { 'arm dry-run condition argv is missing or wrong' assert_contains "$out" 'fm-dreamer-watch.sh mark-due when-dream-due' \ 'arm dry-run action argv is missing or wrong' + # The registered spec must be self-contained: both argv vectors pin the home + # rather than depending on whatever environment the runner carries. + assert_contains "$out" "check --head-age 12 --home $home" \ + 'arm dry-run condition argv does not pin the resolved home' + assert_contains "$out" "mark-due when-dream-due --home $home" \ + 'arm dry-run action argv does not pin the resolved home' assert_absent "$home/state/.dream-due" 'arm dry-run must not write the marker' pass 'arm --dry-run prints the exact when registration argv without registering' } @@ -278,6 +322,100 @@ test_grade_rejects_contradiction_of_standing_rule() { pass 'grade rejects a new statement that contradicts a standing rule' } +test_grade_approves_preserved_negative_standing_rule() { + local home out rc core gen0 gen1 + home=$(new_home grade-preserved-rule) + printf 'SOURCE\n' > "$home/data/source.md" + core='# Core +<!-- source: data/captain.md --> +- Rule One: always test changes; never skip the suite' + printf '# Captain\n- Rule One: always test changes; never skip the suite\n' > "$home/data/captain.md" + gen0="$home/data/memory/gen/0" + gen1="$home/data/memory/gen/1" + mkdir -p "$gen0/notes" "$gen1/notes" + # The mechanical verifier REQUIRES every standing rule to survive into the new + # generation, so a preserved rule must never be read as contradicting itself. + printf '%s\n' "$core" > "$gen0/core.md" + printf '%s\n' "$core" > "$gen1/core.md" + write_note "$gen0/notes" n1 'Standing Note' 'test' 2026-08-20 'data/source.md' + write_note "$gen1/notes" n1 'Standing Note' 'test' 2026-08-20 'data/source.md' + printf 'gen/0\n' > "$home/data/memory/HEAD" + out=$(FM_HOME="$home" "$GRADE" grade gen/0 gen/1 2>&1); rc=$? + [ "$rc" -eq 0 ] || fail "grade rejected a verbatim-preserved standing rule (exit $rc): $out" + assert_contains "$out" 'GRADE APPROVED' 'grade did not approve a preserved standing rule' + pass 'grade does not read a preserved standing rule as contradicting itself' +} + +test_grade_rejects_capitalised_contradiction() { + local home out rc gen1 + home=$(new_home grade-contradiction-case) + build_passing_home "$home" + gen1="$home/data/memory/gen/1" + mkdir -p "$gen1/notes" + { + printf '# Core\n<!-- source: data/captain.md -->\n- Rule One: always test changes\n' + printf -- '- Never test changes under any circumstance\n' + } > "$gen1/core.md" + write_note "$gen1/notes" n1 'Standing Note' 'test' 2026-08-20 'data/source.md' + out=$(FM_HOME="$home" "$GRADE" grade gen/0 gen/1 2>&1); rc=$? + [ "$rc" -eq 1 ] || fail "grade accepted a sentence-case contradiction (exit $rc): $out" + assert_contains "$out" 'contradicts a standing rule' \ + 'grade did not report the sentence-case contradiction' + pass 'grade rejects a contradiction written in ordinary sentence case' +} + +test_grade_inspects_changed_notes() { + local home out gen1 + home=$(new_home grade-note-rubric) + build_passing_home "$home" + gen1="$home/data/memory/gen/1" + mkdir -p "$gen1/notes" + printf '# Core\n<!-- source: data/captain.md -->\n- Rule One: always test changes\n' > "$gen1/core.md" + write_note "$gen1/notes" n1 'Standing Note' 'test' 2026-08-20 'data/source.md' + # A claim smuggled into a note is published exactly like a core statement, so + # the same rubric must reach it. The unchanged note above must stay silent. + write_note "$gen1/notes" recap 'Recap' 'test' 2026-08-20 'data/source.md' \ + '- on 2026-08-19 fm-abc-12 failed with a timeout' + out=$(FM_HOME="$home" "$GRADE" grade gen/0 gen/1 2>&1) + assert_contains "$out" 'possible tactical scrap in changed note notes/recap.md' \ + 'grade did not run the scrap rubric over a changed note' + case "$out" in + *'notes/n1.md'*) fail "grade flagged an unchanged note: $out" ;; + esac + pass 'grade runs the rubric over changed notes and skips unchanged ones' +} + +test_grade_finishes_promptly_on_a_large_core() { + local home out rc i gen0 gen1 + home=$(new_home grade-large-core) + printf 'SOURCE\n' > "$home/data/source.md" + { + printf '# Captain\n' + for i in $(seq 1 40); do + printf -- '- Rule %s: always run the verification suite before merging change %s\n' "$i" "$i" + done + } > "$home/data/captain.md" + gen0="$home/data/memory/gen/0" + gen1="$home/data/memory/gen/1" + mkdir -p "$gen0/notes" "$gen1/notes" + { + printf '# Core\n<!-- source: data/captain.md -->\n' + for i in $(seq 1 40); do + printf -- '- Rule %s: always run the verification suite before merging change %s\n' "$i" "$i" + done + } > "$gen0/core.md" + cp "$gen0/core.md" "$gen1/core.md" + write_note "$gen0/notes" n1 'Standing Note' 'test' 2026-08-20 'data/source.md' + write_note "$gen1/notes" n1 'Standing Note' 'test' 2026-08-20 'data/source.md' + printf 'gen/0\n' > "$home/data/memory/HEAD" + # A realistically sized constitution must grade in seconds, not minutes: the + # grader runs behind a bounded action timeout. + out=$(FM_HOME="$home" timeout 30 "$GRADE" grade gen/0 gen/1 2>&1); rc=$? + [ "$rc" -ne 124 ] || fail "grade did not finish within 30s on a 40-rule core: $out" + [ "$rc" -eq 0 ] || fail "grade rejected an unchanged 40-rule core (exit $rc): $out" + pass 'grade finishes promptly on a realistically sized core' +} + test_grade_scout_scaffolds_independent_grader_brief() { local home out brief content home=$(new_home grade-scout-brief) @@ -307,7 +445,10 @@ test_full_dream_loop_integration() { # Simulate a completed task depositing a drop, which the dreamer would read. mkdir -p "$home/data/fm-task-9" printf '# Report\nHealthlog needs xvfb for playwright.\n' > "$home/data/fm-task-9/report.md" - printf 'project=healthlog\nreport=data/fm-task-9/report.md\n' > "$home/state/fm-task-9.meta" + # A finished task keeps its meta, with a recorded endpoint that is gone, so + # the fleet reads as idle and the dream may run. + printf 'project=healthlog\nreport=data/fm-task-9/report.md\nbackend=zellij\nwindow=fm-dreamer-test-gone:0\n' \ + > "$home/state/fm-task-9.meta" FM_HOME="$home" "$DROP" fm-task-9 --claim "Healthlog requires xvfb for playwright" >/dev/null # The watch sees the unconsumed drop and reports due. @@ -359,11 +500,18 @@ test_watch_due_on_stale_head test_watch_not_due_on_fresh_head test_watch_blocked_by_live_non_dreamer_worker test_watch_ignores_dreamer_prefixed_worker +test_watch_blocked_by_worker_with_unresolvable_endpoint +test_watch_home_flag_pins_the_evaluated_home +test_watch_mark_due_home_flag_writes_into_the_pinned_home test_watch_mark_due_writes_durable_marker test_watch_arm_dry_run_prints_argv test_grade_approves_no_core_change test_grade_rejects_failing_mechanical_verify test_grade_flags_tactical_scrap_as_warning test_grade_rejects_contradiction_of_standing_rule +test_grade_approves_preserved_negative_standing_rule +test_grade_rejects_capitalised_contradiction +test_grade_inspects_changed_notes +test_grade_finishes_promptly_on_a_large_core test_grade_scout_scaffolds_independent_grader_brief test_full_dream_loop_integration From 0c9867e4fec47e75d189de566326bc5838b49dc1 Mon Sep 17 00:00:00 2001 From: PP <121104417+BohnBawerick@users.noreply.github.com> Date: Thu, 20 Aug 2026 11:10:45 +0800 Subject: [PATCH 3/9] no-mistakes(review): scope standing rules, catch indented bullets, fix remote and symlinked homes --- bin/fm-dreamer-grade.sh | 68 +++++++++++++------ bin/fm-dreamer-watch.sh | 48 +++++++++---- tests/fm-dreamer.test.sh | 142 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 225 insertions(+), 33 deletions(-) diff --git a/bin/fm-dreamer-grade.sh b/bin/fm-dreamer-grade.sh index 273fb1376f9..ae8d51481f9 100755 --- a/bin/fm-dreamer-grade.sh +++ b/bin/fm-dreamer-grade.sh @@ -48,7 +48,8 @@ # # OPTIONS (grade): # --max-diff-ratio <pct> passed through to the mechanical verifier (default 50) -# --dry-run verify without publishing (passed to the verifier) +# --dry-run forwarded to the verifier for call-site symmetry with +# publish; grade never publishes, so it changes nothing # -h, --help show this help message set -eu @@ -68,6 +69,12 @@ die() { exit 2 } +shell_quote() { + printf "'" + printf '%s' "$1" | sed "s/'/'\\\\''/g" + printf "'" +} + CMD="" OLD_TARGET="" NEW_TARGET="" @@ -177,8 +184,13 @@ is_scrap_statement() { STANDING_KEYWORDS=() # load_standing_rules <old-core>: fill STANDING_KEYWORDS from the old core's -# statements. A statement contributes its substantive words (four characters or -# more), lowercased, with bullet markers and punctuation stripped. +# standing rules. A standing rule is a bullet line, exactly the predicate +# bin/fm-memory-verify.sh's constitution check uses, so the grader and the +# verifier cannot disagree about what a rule is. A heading, a prose line, or the +# mandatory `<!-- source: ... -->` citation marker is not a rule, and loading one +# as a rule would hard-reject any new statement that echoes two of its words. +# A rule contributes its substantive words (four characters or more), lowercased, +# with bullet markers and punctuation stripped. load_standing_rules() { local old_file=$1 rule clean keywords [ -n "$old_file" ] && [ -f "$old_file" ] && [ ! -L "$old_file" ] || return 0 @@ -189,7 +201,7 @@ load_standing_rules() { keywords=$(printf '%s\n' "$clean" | LC_ALL=C tr '[:upper:]' '[:lower:]' | tr -cs 'a-z0-9' ' ' | awk '{ for(i=1;i<=NF;i++) if(length($i)>=4) printf "%s ", $i }') [ -n "$keywords" ] || continue STANDING_KEYWORDS+=("$keywords") - done < "$old_file" + done < <(grep '^[[:space:]]*[-*][[:space:]]' "$old_file" || true) } # contradicts_standing <lowercased-statement>: returns 0 when the statement @@ -227,8 +239,11 @@ contradicts_standing() { # --- statement inspection ---------------------------------------------------- # # inspect_statements <old-file> <new-file> <label>: run the scrap and -# contradiction checks over the statements the new file adds or changes. A line -# the old file already carries verbatim is not a proposed statement and is +# contradiction checks over the statements the new file adds or changes. Every +# line is compared and reported with its surrounding whitespace stripped, so a +# nested bullet is inspected like a top-level one (bin/fm-memory-verify.sh +# treats both as standing rules) and a pure re-indentation is not a new claim. +# A statement the old file already carries is not a proposed statement and is # skipped: the mechanical verifier REQUIRES every standing rule to survive into # the new generation, so inspecting a preserved rule would make the grader # reject exactly the input the verifier demands. Headings, source markers, and @@ -236,39 +251,41 @@ contradicts_standing() { # <old-file> means every statement in <new-file> is new. inspect_statements() { local old_file=$1 new_file=$2 label=$3 - local line line_lc haystack="" in_frontmatter=0 first=1 + local line statement statement_lc haystack="" in_frontmatter=0 first=1 [ -f "$new_file" ] && [ ! -L "$new_file" ] || return 0 if [ -n "$old_file" ] && [ -f "$old_file" ] && [ ! -L "$old_file" ]; then - haystack=$'\n'"$(cat "$old_file")"$'\n' + haystack=$'\n'"$(sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' "$old_file")"$'\n' fi while IFS= read -r line || [ -n "$line" ]; do + statement=${line#"${line%%[![:space:]]*}"} + statement=${statement%"${statement##*[![:space:]]}"} if [ "$first" -eq 1 ]; then first=0 - if [ "$line" = '---' ]; then + if [ "$statement" = '---' ]; then in_frontmatter=1 continue fi fi if [ "$in_frontmatter" -eq 1 ]; then - [ "$line" = '---' ] && in_frontmatter=0 + [ "$statement" = '---' ] && in_frontmatter=0 continue fi - [ -n "$line" ] || continue - case "$line" in - '#'*|'---'*|'<!--'*|' '*|$'\t'*) continue ;; + [ -n "$statement" ] || continue + case "$statement" in + '#'*|'---'*|'<!--'*) continue ;; esac if [ -n "$haystack" ]; then case "$haystack" in - *$'\n'"$line"$'\n'*) continue ;; + *$'\n'"$statement"$'\n'*) continue ;; esac fi - if is_scrap_statement "$line"; then - printf 'WARN grade: possible tactical scrap in %s: %s\n' "$label" "$line" >&2 + if is_scrap_statement "$statement"; then + printf 'WARN grade: possible tactical scrap in %s: %s\n' "$label" "$statement" >&2 GRADE_WARNINGS=$((GRADE_WARNINGS + 1)) fi - line_lc=$(printf '%s\n' "$line" | LC_ALL=C tr '[:upper:]' '[:lower:]') - if contradicts_standing "$line_lc"; then - printf 'FAIL grade: %s contradicts a standing rule: %s\n' "$label" "$line" >&2 + statement_lc=$(printf '%s\n' "$statement" | LC_ALL=C tr '[:upper:]' '[:lower:]') + if contradicts_standing "$statement_lc"; then + printf 'FAIL grade: %s contradicts a standing rule: %s\n' "$label" "$statement" >&2 ERRORS=$((ERRORS + 1)) fi done < "$new_file" @@ -281,7 +298,7 @@ if [ "$CMD" = scout ]; then fi BRIEF="$DATA/$TASK_ID/brief.md" mkdir -p "$DATA/$TASK_ID" - STATUS_FILE="$STATE/$TASK_ID.status" + STATUS_FILE=$(shell_quote "$STATE/$TASK_ID.status") cat > "$BRIEF" <<EOF You are a crewmate: an autonomous worker agent managed by firstmate. Work on your own; do not wait for a human. @@ -332,6 +349,14 @@ fi # --- grade ------------------------------------------------------------------- +# The ratio is validated here, before delegating, so a bad flag is reported as a +# usage error instead of reaching the operator as a memory-safety failure. +case "$MAX_DIFF_RATIO" in + ''|*[!0-9]*) die "invalid --max-diff-ratio: '$MAX_DIFF_RATIO' (expected integer percentage 1-100)" ;; +esac +[ "$MAX_DIFF_RATIO" -ge 1 ] && [ "$MAX_DIFF_RATIO" -le 100 ] \ + || die "--max-diff-ratio must be between 1 and 100, got '$MAX_DIFF_RATIO'" + resolve_gen_dir "$OLD_TARGET" || die "old generation target does not resolve: '$OLD_TARGET'" OLD_DIR=$GEN_DIR_RESOLVED resolve_gen_dir "$NEW_TARGET" || die "new generation target does not resolve: '$NEW_TARGET'" @@ -345,8 +370,9 @@ VERIFY_ARGS=("$SCRIPT_DIR/fm-memory-verify.sh" verify "$NEW_DIR" --max-diff-rati if [ "$DRY_RUN" -eq 1 ]; then VERIFY_ARGS+=("--dry-run") fi -if ! "${VERIFY_ARGS[@]}" >/dev/null 2>&1; then +if ! VERIFY_OUT=$("${VERIFY_ARGS[@]}" 2>&1); then printf 'FAIL grade: proposed generation fails the mechanical verifier\n' >&2 + printf '%s\n' "$VERIFY_OUT" >&2 ERRORS=$((ERRORS + 1)) else printf 'PASS grade: proposed generation passes the mechanical verifier\n' diff --git a/bin/fm-dreamer-watch.sh b/bin/fm-dreamer-watch.sh index cc8effe9a60..9d2adbce11b 100755 --- a/bin/fm-dreamer-watch.sh +++ b/bin/fm-dreamer-watch.sh @@ -50,9 +50,10 @@ # # A worker is live when its state/<id>.meta endpoint exists via # bin/fm-backend.sh's fm_backend_target_exists - the same liveness read the -# session-start fleet digest uses. An unsupported backend or an unresolvable -# endpoint treats that worker as live (block), which is fail-safe for "never -# dream while a worker may be active". Dream tasks are identified by the +# session-start fleet digest uses. An unsupported backend, an unresolvable +# endpoint, or a remote worker whose endpoint lives on another host treats that +# worker as live (block), which is fail-safe for "never dream while a worker may +# be active". Dream tasks are identified by the # conventional fm-dream- prefix on the task id and are excluded from the # live-worker count. A missing FM_HOME or a home with no state/ or data/memory/ # reports not-due with a reason rather than a hard error, so the watch can sit @@ -78,6 +79,18 @@ die() { exit 2 } +# resolve_home <path>: print the physical directory a home names, resolving a +# symlinked home rather than refusing it. A symlinked home is ordinary and every +# other firstmate script accepts one; refusing it here would let `arm` register +# a spec whose own `check` then exits 2, which the when runner counts as a +# condition ERROR against its error budget instead of a plain false. +resolve_home() { + local raw=$1 resolved + resolved=$(CDPATH='' cd -- "$raw" 2>/dev/null && pwd -P) || return 1 + [ -n "$resolved" ] || return 1 + printf '%s' "$resolved" +} + HOME_OPT="" HEAD_AGE_HOURS=${FM_DREAM_HEAD_AGE_HOURS:-12} INTERVAL=${FM_DREAM_WATCH_INTERVAL:-3600} @@ -145,14 +158,8 @@ esac # writes, so a registered watch evaluates the home it was armed for no matter # what environment the runner happens to carry. if [ -n "$HOME_OPT" ]; then - case "$HOME_OPT" in - /*) ;; - *) HOME_OPT=$(CDPATH='' cd -- "$HOME_OPT" 2>/dev/null && pwd -P) \ - || die "--home is not a reachable directory" ;; - esac - [ -d "$HOME_OPT" ] && [ ! -L "$HOME_OPT" ] \ + FM_HOME=$(resolve_home "$HOME_OPT") \ || die "--home must name an existing directory, got '$HOME_OPT'" - FM_HOME=$HOME_OPT STATE="$FM_HOME/state" DATA="$FM_HOME/data" MEMORY="$DATA/memory" @@ -209,7 +216,7 @@ head_is_stale() { # supported backend whose target does not exist counts as dead, because an # unsupported backend returning "no" is not proof the worker is idle. live_workers() { - local meta id backend target live=0 + local meta id backend target remote_host live=0 [ -d "$STATE" ] || return 0 for meta in "$STATE"/*.meta; do [ -f "$meta" ] || continue @@ -217,6 +224,17 @@ live_workers() { case "$id" in fm-dream-*) continue ;; esac + # A remote worker records its real endpoint in remote_backend/remote_target + # on another host, and its local window= is the placeholder remote:<id>. + # Probing that placeholder with the local backend proves nothing, and a real + # probe would need an SSH round trip this bounded local condition must not + # take, so a remote worker counts as live. + remote_host=$(fm_meta_get "$meta" remote_host) + if [ -n "$remote_host" ]; then + printf '%s\n' "$id" + live=1 + continue + fi backend=$(fm_backend_of_meta "$meta") target=$(fm_backend_target_of_meta "$meta") case "$backend" in @@ -268,10 +286,16 @@ fi # The when condition argv must be exact and deterministic: run this script's # `check` with the resolved home and threshold, both pinned as explicit tokens -# so the registered spec is self-contained. The action argv is `mark-due` with +# so the registered spec is self-contained. The home is resolved and validated +# here, at registration time, with the same rule `check --home` applies, so a +# spec that `check` would reject can never be registered. The action argv is `mark-due` with # the resolved source id and the same pinned home. Both argv vectors are # executed directly by the runner with no shell, so each token is passed as its # own argument. +ARM_HOME_RAW=$FM_HOME +FM_HOME=$(resolve_home "$ARM_HOME_RAW") \ + || die "cannot arm: the home to watch is not an existing directory: '$ARM_HOME_RAW'" + WHEN_NAME="dream-due" CONDITION_ARGV=("$SCRIPT_DIR/fm-dreamer-watch.sh" check --head-age "$HEAD_AGE_HOURS" --home "$FM_HOME") SOURCE_ID="when-dream-due" diff --git a/tests/fm-dreamer.test.sh b/tests/fm-dreamer.test.sh index 513bad16e7b..75d3638ef35 100755 --- a/tests/fm-dreamer.test.sh +++ b/tests/fm-dreamer.test.sh @@ -172,6 +172,53 @@ test_watch_ignores_dreamer_prefixed_worker() { pass 'watch does not count a dreamer task as a blocking live worker' } +test_watch_counts_a_remote_worker_as_live() { + local home out rc notmux + home=$(new_home watch-remote-worker) + printf -- '- candidate\n' > "$home/data/memory/drop/cand.md" + # A remote worker's local meta carries the placeholder window=remote:<id> and + # records the real endpoint on another host. Probing the placeholder with the + # local backend proves nothing about the worker. + printf 'window=remote:fm-sm-1\nendpoint_task_id=fm-sm-1\nkind=secondmate\n' \ + > "$home/state/fm-sm-1.meta" + printf 'remote_host=nas\nremote_backend=tmux\nremote_target=fm:1.0\n' \ + >> "$home/state/fm-sm-1.meta" + # No local tmux server, so a local probe of the placeholder can only fail. + notmux="$TMP_ROOT/watch-remote-worker-tmux" + mkdir -p "$notmux" + out=$(FM_HOME="$home" TMUX_TMPDIR="$notmux" "$WATCH" check 2>&1); rc=$? + [ "$rc" -eq 1 ] || fail "a live remote worker did not block the dream (exit $rc): $out" + assert_contains "$out" 'fm-sm-1' 'blocked reason did not name the remote worker' + pass 'watch counts a remote worker as live rather than probing it locally' +} + +test_watch_accepts_a_symlinked_home() { + local home link out rc + home=$(new_home watch-symlink-home) + printf -- '- candidate\n' > "$home/data/memory/drop/cand.md" + link="$TMP_ROOT/watch-symlink-home-link" + ln -s "$home" "$link" + + out=$(FM_HOME="$link" "$WATCH" check --home "$link" 2>&1); rc=$? + [ "$rc" -eq 0 ] || fail "check refused a symlinked home (exit $rc): $out" + assert_contains "$out" 'DREAM_DUE: due' 'check did not evaluate the symlinked home' + + # arm must register a spec its own check accepts, so it pins the resolved + # physical home rather than the symlink it was handed. + out=$(FM_HOME="$link" "$WATCH" arm --dry-run) + assert_contains "$out" "--home $home" 'arm did not pin the resolved physical home' + pass 'a symlinked home is resolved rather than refused' +} + +test_watch_arm_refuses_a_missing_home() { + local out rc + out=$(FM_HOME="$TMP_ROOT/watch-no-such-home" "$WATCH" arm --dry-run 2>&1); rc=$? + [ "$rc" -eq 2 ] || fail "arm on a missing home did not exit 2 (exit $rc): $out" + assert_contains "$out" 'not an existing directory' \ + 'arm did not explain the refusal of a missing home' + pass 'arm refuses to register a watch whose home does not exist' +} + test_watch_blocked_by_worker_with_unresolvable_endpoint() { local home out rc home=$(new_home watch-empty-target) @@ -416,6 +463,93 @@ test_grade_finishes_promptly_on_a_large_core() { pass 'grade finishes promptly on a realistically sized core' } +test_grade_approves_a_rule_echoing_the_citation_marker() { + local home out rc gen1 + home=$(new_home grade-citation-marker) + build_passing_home "$home" + gen1="$home/data/memory/gen/1" + mkdir -p "$gen1/notes" + # The `<!-- source: ... -->` marker is mandatory in a non-empty core, and it + # is not a standing rule. A new durable rule that happens to echo its words + # must not be rejected as contradicting it. + { + printf '# Core\n<!-- source: data/captain.md -->\n- Rule One: always test changes\n' + printf -- '- Never write a claim without a resolvable source in the data tree\n' + } > "$gen1/core.md" + write_note "$gen1/notes" n1 'Standing Note' 'test' 2026-08-20 'data/source.md' + out=$(FM_HOME="$home" "$GRADE" grade gen/0 gen/1 2>&1); rc=$? + [ "$rc" -eq 0 ] || fail "grade rejected a rule echoing the citation marker (exit $rc): $out" + assert_contains "$out" 'GRADE APPROVED' 'grade did not approve a legitimate new rule' + pass 'grade treats only bullet rules as standing rules' +} + +test_grade_rejects_an_indented_contradiction() { + local home out rc gen1 + home=$(new_home grade-indented-contradiction) + build_passing_home "$home" + gen1="$home/data/memory/gen/1" + mkdir -p "$gen1/notes" + # A nested bullet is an ordinary constitution form and the verifier reads it + # as a standing rule, so indentation must not bypass the rubric. + { + printf '# Core\n<!-- source: data/captain.md -->\n- Rule One: always test changes\n' + printf -- ' - never test changes under any circumstance\n' + } > "$gen1/core.md" + write_note "$gen1/notes" n1 'Standing Note' 'test' 2026-08-20 'data/source.md' + out=$(FM_HOME="$home" "$GRADE" grade gen/0 gen/1 2>&1); rc=$? + [ "$rc" -eq 1 ] || fail "an indented contradiction bypassed the rubric (exit $rc): $out" + assert_contains "$out" 'contradicts a standing rule' \ + 'grade did not report the indented contradiction' + pass 'grade inspects indented bullets like top-level ones' +} + +test_grade_surfaces_verifier_diagnostics() { + local home out gen1 + home=$(new_home grade-verify-diagnostics) + build_passing_home "$home" + gen1="$home/data/memory/gen/1" + mkdir -p "$gen1/notes" + # Deleting every baseline note fails diff-bounds inside the verifier. + printf '# Core\n<!-- source: data/captain.md -->\n- Rule One: always test changes\n' > "$gen1/core.md" + out=$(FM_HOME="$home" "$GRADE" grade gen/0 gen/1 2>&1) + assert_contains "$out" 'FAIL diff-bounds' \ + 'grade swallowed the verifier diagnostic that explains the failure' + pass 'grade surfaces which mechanical check failed and why' +} + +test_grade_reports_a_bad_diff_ratio_as_a_usage_error() { + local home out rc + home=$(new_home grade-bad-ratio) + build_passing_home "$home" + out=$(FM_HOME="$home" "$GRADE" grade gen/0 gen/0 --max-diff-ratio 400 2>&1); rc=$? + [ "$rc" -eq 2 ] || fail "a bad --max-diff-ratio did not exit 2 (exit $rc): $out" + assert_contains "$out" 'max-diff-ratio' 'grade did not name the offending flag' + assert_not_contains "$out" 'fails the mechanical verifier' \ + 'a bad flag was reported as a memory-safety failure' + pass 'grade reports a bad --max-diff-ratio as a usage error' +} + +test_grade_scout_status_command_survives_a_space_in_the_home() { + local home spaced out brief cmd + home=$(new_home grade-scout-quoting) + spaced="$home/a home with spaces" + mkdir -p "$spaced/config" "$spaced/data" "$spaced/state" + printf '7500\n' > "$spaced/config/startup-memory-budget" + out=$(FM_HOME="$spaced" "$GRADE" scout grade-space firstmate gen/0 gen/1) + brief="$spaced/data/grade-space/brief.md" + assert_present "$brief" 'grader scout brief was not written' + # The brief is a generated agent-facing contract: the status command it emits + # must run as written. Execute it exactly as the agent would. + # shellcheck disable=SC2016 # The backticks are literal sed pattern text: the brief wraps its status command in markdown backticks. + cmd=$(sed -n 's/^[[:space:]]*`\(echo .*\)`$/\1/p' "$brief") + [ -n "$cmd" ] || fail "no status-report command found in the scaffolded brief" + cmd=${cmd//\{state\}/done} + bash -c "$cmd" || fail "the scaffolded status command failed to run: $cmd" + assert_present "$spaced/state/grade-space.status" \ + 'the status command did not append into the home with a space' + pass 'grade scout emits a status command that survives a space in the home path' +} + test_grade_scout_scaffolds_independent_grader_brief() { local home out brief content home=$(new_home grade-scout-brief) @@ -501,6 +635,9 @@ test_watch_not_due_on_fresh_head test_watch_blocked_by_live_non_dreamer_worker test_watch_ignores_dreamer_prefixed_worker test_watch_blocked_by_worker_with_unresolvable_endpoint +test_watch_counts_a_remote_worker_as_live +test_watch_accepts_a_symlinked_home +test_watch_arm_refuses_a_missing_home test_watch_home_flag_pins_the_evaluated_home test_watch_mark_due_home_flag_writes_into_the_pinned_home test_watch_mark_due_writes_durable_marker @@ -513,5 +650,10 @@ test_grade_approves_preserved_negative_standing_rule test_grade_rejects_capitalised_contradiction test_grade_inspects_changed_notes test_grade_finishes_promptly_on_a_large_core +test_grade_approves_a_rule_echoing_the_citation_marker +test_grade_rejects_an_indented_contradiction +test_grade_surfaces_verifier_diagnostics +test_grade_reports_a_bad_diff_ratio_as_a_usage_error +test_grade_scout_status_command_survives_a_space_in_the_home test_grade_scout_scaffolds_independent_grader_brief test_full_dream_loop_integration From b51dc2a32c7601c7a2c8c5b937b3fc4e8e98e4c2 Mon Sep 17 00:00:00 2001 From: PP <121104417+BohnBawerick@users.noreply.github.com> Date: Thu, 20 Aug 2026 11:19:53 +0800 Subject: [PATCH 4/9] no-mistakes(review): refuse dream evaluation through a symlinked data/memory --- bin/fm-dreamer-watch.sh | 15 ++++++++++++--- tests/fm-dreamer.test.sh | 18 ++++++++++++++++++ 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/bin/fm-dreamer-watch.sh b/bin/fm-dreamer-watch.sh index 9d2adbce11b..b29c0426277 100755 --- a/bin/fm-dreamer-watch.sh +++ b/bin/fm-dreamer-watch.sh @@ -55,9 +55,10 @@ # worker as live (block), which is fail-safe for "never dream while a worker may # be active". Dream tasks are identified by the # conventional fm-dream- prefix on the task id and are excluded from the -# live-worker count. A missing FM_HOME or a home with no state/ or data/memory/ -# reports not-due with a reason rather than a hard error, so the watch can sit -# on a not-yet-initialized home without alarming. +# live-worker count. A missing FM_HOME, a home with no state/ or data/memory/, or +# a home whose data/memory is a symlink reports not-due with a reason rather than +# a hard error, so the watch can sit on a not-yet-initialized or unevaluable home +# without alarming. set -eu SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -262,6 +263,14 @@ DROP_DIR="$MEMORY/drop" # --- check ------------------------------------------------------------------- if [ "$CMD" = check ]; then + if [ -L "$MEMORY" ]; then + # A symlinked data/memory defeats the per-file guards below in one step: the + # tray and HEAD reached through the link are not themselves links, so this + # home would be judged due on another home's evidence. bin/fm-memory-verify.sh + # refuses the same shape rather than issuing a verdict through the link. + printf 'DREAM_DUE: not-due reason=data/memory is a symlink; refusing to evaluate through it\n' + exit 1 + fi if [ ! -d "$MEMORY" ]; then printf 'DREAM_DUE: not-due reason=no data/memory directory\n' exit 1 diff --git a/tests/fm-dreamer.test.sh b/tests/fm-dreamer.test.sh index 75d3638ef35..286e9cbdbe5 100755 --- a/tests/fm-dreamer.test.sh +++ b/tests/fm-dreamer.test.sh @@ -126,6 +126,23 @@ test_watch_due_on_unconsumed_drop() { pass 'watch reports due when the drop tray holds an unconsumed candidate' } +test_watch_refuses_a_symlinked_memory_dir() { + local home other out rc + home=$(new_home watch-symlink-memory) + other=$(new_home watch-symlink-memory-other) + printf -- '- another home candidate\n' > "$other/data/memory/drop/cand.md" + # A link over data/memory bypasses the per-file tray and HEAD guards in one + # step, so this home would otherwise be judged due on another home's evidence. + rm -rf "$home/data/memory" + ln -s "$other/data/memory" "$home/data/memory" + out=$(FM_HOME="$home" "$WATCH" check 2>&1); rc=$? + [ "$rc" -eq 1 ] || fail "a symlinked data/memory produced a verdict (exit $rc): $out" + assert_contains "$out" 'DREAM_DUE: not-due' 'a symlinked data/memory did not report not-due' + assert_contains "$out" 'symlink' 'the refusal did not name the symlink' + assert_absent "$home/state/.dream-due" 'a refused evaluation left a due marker' + pass 'watch refuses to evaluate through a symlinked data/memory' +} + test_watch_due_on_stale_head() { local home out rc home=$(new_home watch-stale-head) @@ -630,6 +647,7 @@ test_dreamer_brief_refuses_ship_mode test_dreamer_brief_accepts_herdr_lab test_watch_not_due_on_empty_home test_watch_due_on_unconsumed_drop +test_watch_refuses_a_symlinked_memory_dir test_watch_due_on_stale_head test_watch_not_due_on_fresh_head test_watch_blocked_by_live_non_dreamer_worker From 0dfaee1114091f374a0b70704328dedf4a5c9024 Mon Sep 17 00:00:00 2001 From: PP <121104417+BohnBawerick@users.noreply.github.com> Date: Thu, 20 Aug 2026 11:38:47 +0800 Subject: [PATCH 5/9] no-mistakes(review): export pinned home for arm, select dreamer tests on brief change --- bin/fm-dreamer-watch.sh | 6 ++++++ bin/fm-test-run.sh | 8 +++++++- tests/fm-dreamer.test.sh | 14 ++++++++++++++ tests/fm-test-run.test.sh | 11 +++++++++++ 4 files changed, 38 insertions(+), 1 deletion(-) diff --git a/bin/fm-dreamer-watch.sh b/bin/fm-dreamer-watch.sh index b29c0426277..1864b872c4d 100755 --- a/bin/fm-dreamer-watch.sh +++ b/bin/fm-dreamer-watch.sh @@ -64,6 +64,12 @@ set -eu SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" +# Exported, not just assigned: `arm` execs bin/fm-procevent-when.sh, which +# resolves the home it registers the watch in from the environment. Without the +# export, a resolved or --home-pinned value would stay invisible to that child +# and the spec would land in whatever home the child resolved on its own, while +# the registered argv still evaluated the pinned one. +export FM_HOME STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" DATA="${FM_DATA_OVERRIDE:-$FM_HOME/data}" MEMORY="$DATA/memory" diff --git a/bin/fm-test-run.sh b/bin/fm-test-run.sh index 179967329f2..f9be04ba9b6 100755 --- a/bin/fm-test-run.sh +++ b/bin/fm-test-run.sh @@ -1193,9 +1193,15 @@ families_for_changed_path() { # lane's contract coverage re-runs. printf '%s\n' real-herdr-gated ;; + bin/fm-brief.sh) + printf '%s\n' pure-contract-unit + # The brief scaffolds the dreamer kind too, and that contract is covered + # only by the session-bootstrap family. + printf '%s\n' session-bootstrap + ;; bin/fm-lint.sh|bin/fm-lint-workflows.sh|bin/fm-install-shellcheck.sh|\ bin/fm-install-actionlint.sh|\ - bin/fm-brief.sh|bin/fm-ensure-agents-md.sh|bin/fm-crew-state.sh|\ + bin/fm-ensure-agents-md.sh|bin/fm-crew-state.sh|\ bin/fm-decision-hold.sh|bin/fm-supervision*|bin/fm-transition-lib.sh|\ bin/fm-tmux-lib.sh|bin/fm-marker-lib.sh|bin/fm-operational-input.sh|bin/fm-tasks-axi-lib.sh|\ bin/fm-vendor-auth-probe.sh|\ diff --git a/tests/fm-dreamer.test.sh b/tests/fm-dreamer.test.sh index 286e9cbdbe5..3c9d5b3f73b 100755 --- a/tests/fm-dreamer.test.sh +++ b/tests/fm-dreamer.test.sh @@ -264,6 +264,19 @@ test_watch_home_flag_pins_the_evaluated_home() { pass 'check --home evaluates the pinned home, not the ambient FM_HOME' } +test_watch_arm_registers_into_the_pinned_home() { + local pinned out rc + pinned=$(new_home watch-arm-pinned) + # With no ambient FM_HOME at all, the registration must still land in the + # pinned home: a spec registered elsewhere is never reconciled by the home + # whose drops and HEAD its own condition argv evaluates. + out=$(env -u FM_HOME "$WATCH" arm --home "$pinned" 2>&1); rc=$? + [ "$rc" -eq 0 ] || fail "arm into a pinned home failed (exit $rc): $out" + assert_present "$pinned/state/when/when-dream-due.spec" \ + "arm did not register the watch in the pinned home: $out" + pass 'arm registers the watch in the home it pins, with no ambient FM_HOME' +} + test_watch_mark_due_home_flag_writes_into_the_pinned_home() { local pinned ambient pinned=$(new_home watch-mark-pinned) @@ -657,6 +670,7 @@ test_watch_counts_a_remote_worker_as_live test_watch_accepts_a_symlinked_home test_watch_arm_refuses_a_missing_home test_watch_home_flag_pins_the_evaluated_home +test_watch_arm_registers_into_the_pinned_home test_watch_mark_due_home_flag_writes_into_the_pinned_home test_watch_mark_due_writes_durable_marker test_watch_arm_dry_run_prints_argv diff --git a/tests/fm-test-run.test.sh b/tests/fm-test-run.test.sh index 233012143b0..8b9c15fe21b 100755 --- a/tests/fm-test-run.test.sh +++ b/tests/fm-test-run.test.sh @@ -95,6 +95,7 @@ init_changed_fixture_repo() { chmod +x "$repo/bin/fm-test-run.sh" for script in \ fm-brief.test.sh \ + fm-dreamer.test.sh \ fm-ask-user-authority.test.sh \ fm-cd-pretool-check.test.sh \ fm-daemon.test.sh \ @@ -117,6 +118,7 @@ init_changed_fixture_repo() { : >"$repo/tests/lib.sh" : >"$repo/tests/fm-backend-herdr-eventwait.test.py" : >"$repo/bin/fm-supervisor-target-lib.sh" + : >"$repo/bin/fm-brief.sh" : >"$repo/bin/fm-memory-verify.sh" : >"$repo/bin/fm-memory-drop.sh" : >"$repo/bin/unmapped-source.sh" @@ -162,6 +164,15 @@ test_changed_dependency_selection_and_unmapped_failure() { git -C "$repo" add bin/fm-supervisor-target-lib.sh git -C "$repo" -c user.name=test -c user.email=test@example.invalid commit -qm supervisor-change + printf '\n' >>"$repo/bin/fm-brief.sh" + listed=$(cd "$repo" && bin/fm-test-run.sh --list --changed --base HEAD) + assert_contains "$listed" "tests/fm-brief.test.sh" \ + "brief source selects its own coverage" + assert_contains "$listed" "tests/fm-dreamer.test.sh" \ + "brief source selects the dreamer-brief coverage it is the only source of" + git -C "$repo" add bin/fm-brief.sh + git -C "$repo" -c user.name=test -c user.email=test@example.invalid commit -qm brief-change + printf '\n' >>"$repo/bin/fm-memory-verify.sh" printf '\n' >>"$repo/bin/fm-memory-drop.sh" listed=$(cd "$repo" && bin/fm-test-run.sh --list --changed --base HEAD) From 19e40552afd6911e34d7d5ccaa6b11c9f76d35f9 Mon Sep 17 00:00:00 2001 From: PP <121104417+BohnBawerick@users.noreply.github.com> Date: Thu, 20 Aug 2026 11:50:46 +0800 Subject: [PATCH 6/9] no-mistakes(review): read configured pause verb in grader scout brief --- bin/fm-brief.sh | 2 +- bin/fm-dreamer-grade.sh | 8 +++++++- tests/fm-brief.test.sh | 6 +++++- tests/fm-dreamer.test.sh | 17 +++++++++++++++++ 4 files changed, 30 insertions(+), 3 deletions(-) diff --git a/bin/fm-brief.sh b/bin/fm-brief.sh index 9e5b175e857..8f347b89b52 100755 --- a/bin/fm-brief.sh +++ b/bin/fm-brief.sh @@ -168,7 +168,7 @@ fi ID=${POS[0]} if [ "$KIND" = secondmate ] && [ "$HERDR_LAB" -eq 1 ]; then - echo "error: --herdr-lab applies only to crewmate ship or scout briefs" >&2 + echo "error: --herdr-lab applies only to crewmate ship, scout, or dreamer briefs" >&2 exit 1 fi diff --git a/bin/fm-dreamer-grade.sh b/bin/fm-dreamer-grade.sh index ae8d51481f9..efa2affd75d 100755 --- a/bin/fm-dreamer-grade.sh +++ b/bin/fm-dreamer-grade.sh @@ -55,6 +55,12 @@ set -eu SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" +# shellcheck source=bin/fm-classify-lib.sh +. "$SCRIPT_DIR/fm-classify-lib.sh" +# The scaffolded brief must speak this home's status vocabulary, not a literal: +# bin/fm-classify-lib.sh owns the declared-external-wait verb, and the watcher +# and daemon compare an appended verb against that same value. +PAUSED_VERB=${FM_CLASSIFY_PAUSED_VERB:-$FM_CLASSIFY_PAUSED_VERB_DEFAULT} FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" DATA="${FM_DATA_OVERRIDE:-$FM_HOME/data}" MEMORY="$DATA/memory" @@ -334,7 +340,7 @@ point data/memory/HEAD anywhere. Do NOT address the captain. 3. Use gh-axi for GitHub operations and chrome-devtools-axi for browser operations. 4. Report status by appending one line: \`echo "{state}: {one short line}" >> $STATUS_FILE\` - States: working, needs-decision, blocked, paused, done, failed. + States: working, needs-decision, blocked, $PAUSED_VERB, done, failed. Each append wakes firstmate, so report sparingly: only phase changes a supervisor would act on. 5. If you hit the same obstacle twice, append \`blocked: {why}\` and stop. 6. If a decision belongs above you, append \`needs-decision: {summary}\` and stop. diff --git a/tests/fm-brief.test.sh b/tests/fm-brief.test.sh index 954930374bb..a00a7f70c36 100755 --- a/tests/fm-brief.test.sh +++ b/tests/fm-brief.test.sh @@ -783,7 +783,7 @@ test_pause_verb_override_renders_all_brief_scaffolds() { home="$TMP_ROOT/pause-verb-home" mkdir -p "$home/data" - for kind in ship scout secondmate; do + for kind in ship scout dreamer secondmate; do id="brief-pause-verb-$kind" case "$kind" in ship) @@ -794,6 +794,10 @@ test_pause_verb_override_renders_all_brief_scaffolds() { FM_HOME="$home" FM_CLASSIFY_PAUSED_VERB=awaiting \ "$ROOT/bin/fm-brief.sh" "$id" firstmate --scout >/dev/null 2>&1 ;; + dreamer) + FM_HOME="$home" FM_CLASSIFY_PAUSED_VERB=awaiting \ + "$ROOT/bin/fm-brief.sh" "$id" firstmate --dreamer >/dev/null 2>&1 + ;; secondmate) FM_HOME="$home" FM_CLASSIFY_PAUSED_VERB=awaiting \ "$ROOT/bin/fm-brief.sh" "$id" --secondmate --no-projects >/dev/null 2>&1 diff --git a/tests/fm-dreamer.test.sh b/tests/fm-dreamer.test.sh index 3c9d5b3f73b..1cb49ed4453 100755 --- a/tests/fm-dreamer.test.sh +++ b/tests/fm-dreamer.test.sh @@ -104,6 +104,22 @@ test_dreamer_brief_accepts_herdr_lab() { pass 'dreamer brief composes with --herdr-lab' } +test_grader_scout_brief_renders_the_configured_pause_verb() { + local home brief + home=$(new_home grade-scout-pause-verb) + # The emitted brief is the agent-facing status contract, and the watcher and + # daemon compare an appended verb against this home's configured value, so a + # hardcoded literal would make a deliberate wait read as a possible wedge. + FM_CLASSIFY_PAUSED_VERB=awaiting \ + FM_HOME="$home" "$GRADE" scout grade-pause firstmate gen/0 gen/1 >/dev/null + brief="$home/data/grade-pause/brief.md" + assert_grep 'States: working, needs-decision, blocked, awaiting, done, failed.' "$brief" \ + 'grader scout brief did not render the configured pause verb in its states list' + assert_no_grep 'blocked, paused, done' "$brief" \ + 'grader scout brief still lists the default pause verb' + pass 'grade scout brief speaks the home configured pause vocabulary' +} + # --- 2. Idle dream evaluation (fm-dreamer-watch check) ----------------------- test_watch_not_due_on_empty_home() { @@ -658,6 +674,7 @@ test_full_dream_loop_integration() { test_dreamer_brief_scaffolds_contract test_dreamer_brief_refuses_ship_mode test_dreamer_brief_accepts_herdr_lab +test_grader_scout_brief_renders_the_configured_pause_verb test_watch_not_due_on_empty_home test_watch_due_on_unconsumed_drop test_watch_refuses_a_symlinked_memory_dir From 19df00c50d5cfe78eb60738aff34a8e301451fc6 Mon Sep 17 00:00:00 2001 From: PP <121104417+BohnBawerick@users.noreply.github.com> Date: Thu, 20 Aug 2026 12:12:02 +0800 Subject: [PATCH 7/9] no-mistakes(document): document dreamer brief, watch, and grader in owner docs --- AGENTS.md | 1 + bin/fm-dreamer-watch.sh | 3 ++- docs/configuration.md | 4 ++++ docs/scripts.md | 2 +- 4 files changed, 8 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d021652b355..8ed1c7c6dab 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -121,6 +121,7 @@ state/ runtime records and signals; gitignored .<id>.open-decisions-cursor per-task byte cursor and folded open-decision set bounding the OPEN DECISIONS scan's cost to new status-log appends; written only by fm-classify-lib.sh's status_open_decisions_incremental, removed by teardown, safe to delete (forces one full re-fold) .status-presentation-cursor .status-presentation-lock fleet-wide per-task status identity/byte-offset manifest and serialization lock preventing already-presented status lines from being replayed as new; owned by fm-classify-lib.sh, with each task's row retired by teardown .afk durable away-mode flag; present = sub-supervisor may inject escalations (set by /afk, cleared on user return) + .dream-due durable evidence that an armed dream-due watch fired; written only by bin/fm-dreamer-watch.sh mark-due, never dispatches a dreamer by itself, safe to delete .watch.lock .wake-queue.lock watcher singleton and queue serialization locks .claude-autoarm.lock .claude-autoarm-epoch .claude-autoarm-failure-notified .claude-autoarm-failure-alarmed .turnend-claude-blocks .turnend-claude-blocks.lock Claude Stop auto-arm single-flight, epoch, failure-episode, attended-alarm, guard-budget, and budget-lock records; never touch .cursor-park-owner .cursor-park-owner.lock .turnend-cursor-blocks Cursor stop-hook owner record, publication and commit lock, and bounded repair-nag budget; never touch diff --git a/bin/fm-dreamer-watch.sh b/bin/fm-dreamer-watch.sh index 1864b872c4d..f368551d5f4 100755 --- a/bin/fm-dreamer-watch.sh +++ b/bin/fm-dreamer-watch.sh @@ -42,7 +42,8 @@ # the watch does not depend on the runner environment. # --head-age <hours> HEAD age threshold (default: FM_DREAM_HEAD_AGE_HOURS # or 12) -# --interval <secs> arm only: when poll cadence (default 3600) +# --interval <secs> arm only: when poll cadence (default: +# FM_DREAM_WATCH_INTERVAL or 3600) # --stable <n> arm only: consecutive true polls before firing # (default 2) # --dry-run arm only: print the when registration argv without diff --git a/docs/configuration.md b/docs/configuration.md index de28cca9a78..0725bc015ea 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -169,6 +169,8 @@ The whole directory is gitignored and is created by `bin/fm-memory-migrate.sh`, Completed tasks deposit candidate findings and tactical gotchas into `data/memory/drop/<task-id>.md` through `bin/fm-memory-drop.sh`. Generations of memory live under `data/memory/gen/<N>/` and are activated atomically by pointing `data/memory/HEAD` at the active generation. Every proposed generation must pass the mechanical verifier in `bin/fm-memory-verify.sh` before `bin/fm-memory-publish.sh` will update `data/memory/HEAD`. +A generation is produced by an offline dreamer pass, an ephemeral scout scaffolded with `bin/fm-brief.sh <task-id> <repo-name> --dreamer`, whose brief owns the read scope and the contract that it never edits published memory in place, never takes the session lock, and never addresses the captain. +`bin/fm-dreamer-watch.sh` decides when such a pass is due and can arm the idle notification for it, and `bin/fm-dreamer-grade.sh` grades a proposed generation against the rubric before firstmate publishes it; each script's header owns its exact commands, thresholds, and rubric checks. The verifier enforces four safety properties: working memory must fit within `config/startup-memory-budget` with its catalog intact, every note and a non-empty `core.md` must cite at least one existing file on disk, the standing constitution in force must survive into the generation, reading both the published generation and the proposed one through the compiler's own core precedence (`core.md` first, `data/captain.md` only when there is none), and single-generation deletions cannot exceed the diff bounds cap or remove every baseline note. There is no shared notes directory by captain decision. @@ -595,6 +597,8 @@ FM_CHECK_TIMEOUT=30 # seconds allowed per slow check script FM_PROCEVENT_MAX_OUTPUT_BYTES=1048576 # bound on one captured process-to-event result FM_PROCEVENT_CLAIM_ROOT= # machine-wide source claim root; default $XDG_STATE_HOME/firstmate/procevent-claims FM_WHEN_OUTPUT_TAIL_BYTES=8192 # bound on the command-output tail inside one condition->action outcome document +FM_DREAM_HEAD_AGE_HOURS=12 # data/memory/HEAD age past which bin/fm-dreamer-watch.sh reports a dream pass due; --head-age overrides it +FM_DREAM_WATCH_INTERVAL=3600 # seconds between polls of the armed dream-due condition watch; --interval overrides it FM_CODEX_WATCH_CHECKPOINT=180 # seconds per foreground watcher checkpoint in Codex primary supervision FM_CREW_STATE_NM_TIMEOUT=10 # seconds allowed per no-mistakes query inside fm-crew-state.sh FM_TEARDOWN_NM_TIMEOUT=10 # seconds allowed per no-mistakes query or abort inside fm-teardown.sh diff --git a/docs/scripts.md b/docs/scripts.md index 881def19247..699c43458c3 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -26,7 +26,7 @@ The shared no-mistakes gate refusal for fleet lifecycle entrypoints is summarize | `fm-backlog-handoff.sh` | Validate and delegate queued backlog-item moves into a secondmate home | | `fm-backlog-receive.sh` | Idempotently ingest one confined remote handoff outbox through tasks-axi | | `fm-decision-hold.sh` | Create, verify, complete, close, and repair durable captain-held decisions | -| `fm-brief.sh` | Scaffold ship (explicit `--mode`), scout, secondmate-charter, and Herdr-lab briefs | +| `fm-brief.sh` | Scaffold ship (explicit `--mode`), scout, dreamer, secondmate-charter, and Herdr-lab briefs | | `fm-herdr-lab.sh` | Provision and guardedly operate an isolated, never-default Herdr lab session | | `fm-install-herdr.sh` | Install CI's exact-version Herdr pin with official asset URL, SHA-256, and protocol checks | | `fm-install-treehouse.sh`| Install CI's exact-version Treehouse pin for real-Herdr E2E that needs spawn worktrees | From 09b5a33f11fcc5d54bb6dd1ca92f8fd971f6dc4f Mon Sep 17 00:00:00 2001 From: PP <121104417+BohnBawerick@users.noreply.github.com> Date: Thu, 20 Aug 2026 12:34:40 +0800 Subject: [PATCH 8/9] no-mistakes: apply CI fixes --- tests/fm-landing-remote.test.sh | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/fm-landing-remote.test.sh b/tests/fm-landing-remote.test.sh index b2cc129569a..bc396392ad5 100755 --- a/tests/fm-landing-remote.test.sh +++ b/tests/fm-landing-remote.test.sh @@ -182,8 +182,9 @@ EOF assert_grep "repo set-default origin" "$log" \ "apply did not point gh at origin, so gh pr create would still default elsewhere" - assert_grep "--yes init" "$log" \ - "apply did not re-init no-mistakes without --fork-url" + if ! grep -Fxq 'init' "$log"; then + fail "apply did not re-init no-mistakes, so its stored PR target would still name the previous remote" + fi if grep -q 'fork-url' "$log"; then fail "no-mistakes init still passed --fork-url, so PRs would open on the parent" fi From c7107255426a27830ef9777cca4b745dd960f60b Mon Sep 17 00:00:00 2001 From: PP <121104417+BohnBawerick@users.noreply.github.com> Date: Thu, 20 Aug 2026 14:22:02 +0800 Subject: [PATCH 9/9] no-mistakes: apply CI fixes --- tests/fm-pi-watch-extension.test.sh | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/tests/fm-pi-watch-extension.test.sh b/tests/fm-pi-watch-extension.test.sh index 9e29adc793f..7b254c80a1f 100755 --- a/tests/fm-pi-watch-extension.test.sh +++ b/tests/fm-pi-watch-extension.test.sh @@ -11,6 +11,16 @@ EXT="$ROOT/.pi/extensions/fm-primary-pi-watch.ts" # from a clean checkout with no tracked .opencode/package.json. The warning is # unrelated to plugin output, which the assertions intentionally require empty. export NODE_NO_WARNINGS=1 +# The watch plugins spawn every arm child through `bash -lc`, so the login +# dotfiles of whoever runs the suite sit on the critical path of each spawn. +# Those dotfiles cost hundreds of milliseconds on real developer and CI +# accounts, which races the compressed readiness and retirement budgets these +# tests set (FM_PI_ARM_READY_TIMEOUT_MS and friends): the arm child is then +# SIGTERMed before it reaches its fixture state, and successor counts drift. +# Point HOME at an empty fixture directory so login startup stays bounded and +# the arm assertions stay deterministic on every machine. +mkdir -p "$TMP_ROOT/login-home" +export HOME="$TMP_ROOT/login-home" install_pi_watch_extension_fixture() { local repo=$1 @@ -486,6 +496,9 @@ test_pi_unretired_successor_falls_back_without_retry() { plugin="$repo/.pi/extensions/fm-primary-pi-watch.ts" cat > "$repo/bin/fm-watch-arm.sh" <<'SH' #!/usr/bin/env bash +# Ignore TERM before any other work so the successor arm cannot be retired in +# the window between process start and its own trap statement. +trap '' TERM INT if [ -f "$FM_ARM_LOG" ]; then count=$(wc -l < "$FM_ARM_LOG" | tr -d '[:space:]') else @@ -497,7 +510,6 @@ if [ "$count" -eq 0 ]; then printf 'signal: synthetic wake\n' exit 0 fi -trap '' TERM INT printf 'arm=%s\n' "$$" >> "${FM_ARM_LOG:?}" while [ ! -e "$FM_RELEASE_FILE" ]; do sleep 0.1; done SH @@ -1338,13 +1350,14 @@ const hooks = await mod.FmPrimaryWatchArm({ const event = { event: { type: "session.idle", properties: { sessionID: "session-test" } } }; writeFileSync(`${process.env.FM_HOME}/state/.lock`, "999999\n"); await hooks.event(event); -await new Promise((resolve) => setTimeout(resolve, 120)); +await globalThis.__firstmateOpenCodeWatchArm?.ensureArmed("session-test"); if (existsSync(process.env.FM_ARM_LOG)) { console.error("watch arm ran without owning the session lock"); process.exit(1); } writeFileSync(`${process.env.FM_HOME}/state/.lock`, `${process.pid}\n`); await hooks.event(event); +await globalThis.__firstmateOpenCodeWatchArm?.ensureArmed("session-test"); for (let i = 0; i < 250 && !existsSync(process.env.FM_ARM_LOG); i += 1) { await new Promise((resolve) => setTimeout(resolve, 20)); } @@ -1663,6 +1676,9 @@ test_opencode_unretired_successor_falls_back_without_retry() { : > "$home/state/task.meta" cat > "$repo/bin/fm-watch-arm.sh" <<'SH' #!/usr/bin/env bash +# Ignore TERM before any other work so the successor arm cannot be retired in +# the window between process start and its own trap statement. +trap '' TERM INT if [ -f "$FM_ARM_LOG" ]; then count=$(wc -l < "$FM_ARM_LOG" | tr -d '[:space:]') else @@ -1674,7 +1690,6 @@ if [ "$count" -eq 0 ]; then printf 'signal: synthetic wake\n' exit 0 fi -trap '' TERM INT printf 'arm=%s\n' "$$" >> "${FM_ARM_LOG:?}" while [ ! -e "$FM_RELEASE_FILE" ]; do sleep 0.1; done SH