diff --git a/AGENTS.md b/AGENTS.md index d021652b35..8ed1c7c6da 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -121,6 +121,7 @@ state/ runtime records and signals; gitignored ..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-brief.sh b/bin/fm-brief.sh index 45abe357c3..8f347b89b5 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,14 +161,14 @@ 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; } 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 @@ -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 0000000000..efa2affd75 --- /dev/null +++ b/bin/fm-dreamer-grade.sh @@ -0,0 +1,425 @@ +#!/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. +# 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 +# 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 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 + +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" +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 +} + +shell_quote() { + printf "'" + printf '%s' "$1" | sed "s/'/'\\\\''/g" + printf "'" +} + +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 --------------------------------------- +# +# 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 : fill STANDING_KEYWORDS from the old core's +# 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 `` 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 + 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 < <(grep '^[[:space:]]*[-*][[:space:]]' "$old_file" || true) +} + +# contradicts_standing : 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 "$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. + [ "$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