From 48fa6766a9f580a82e7b24e33f1525739f7cbb39 Mon Sep 17 00:00:00 2001 From: Trillium Smith Date: Sun, 12 Jul 2026 15:07:25 -0700 Subject: [PATCH 01/23] fix: hash_pane() md5 fallback chain for restricted PATH on macOS --- bin/fm-watch.sh | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/bin/fm-watch.sh b/bin/fm-watch.sh index b7006e6362..71bef79776 100755 --- a/bin/fm-watch.sh +++ b/bin/fm-watch.sh @@ -154,7 +154,11 @@ _event_cap_fails=0 afk_present() { [ -e "$STATE/.afk" ]; } hash_pane() { - if command -v md5 >/dev/null 2>&1; then md5 -q; else md5sum | cut -d' ' -f1; fi + if command -v md5sum >/dev/null 2>&1; then md5sum | cut -d' ' -f1 + elif command -v md5 >/dev/null 2>&1; then md5 -q + elif [ -x /sbin/md5 ]; then /sbin/md5 -q + else shasum | cut -d' ' -f1 + fi } # window_is_busy: 0 (busy) iff the task's harness is actively working. Prefers From 8b7b3cdda7218826095a51487c4533c9a72a0b58 Mon Sep 17 00:00:00 2001 From: Trillium Smith Date: Wed, 15 Jul 2026 02:40:30 -0700 Subject: [PATCH 02/23] feat: herdr->firstmate spur bridge prototype fm-herdr-spur.sh: detached daemon that watches herdr agent status via the native pane.agent_status_changed event stream (reusing herdr-eventwait.py) and, on a working->idle/done edge for a tracked EXTERNAL agent, enqueues a check wake into state/.wake-queue so fm-wake-drain surfaces it. Fills the gap where parlay-spawned herdr agents have no firstmate status file or turn-end hook. Poll fallback via herdr agent list when events incapable. Debounced, keyed by agent name, configurable via --agent / config/herdr-spur.agents / all-agents. Read-only against herdr; bash 3.2 safe. --- bin/fm-herdr-spur.sh | 353 ++++++++++++++++++++++++++++++++ docs/examples/herdr-spur.agents | 10 + 2 files changed, 363 insertions(+) create mode 100755 bin/fm-herdr-spur.sh create mode 100644 docs/examples/herdr-spur.agents diff --git a/bin/fm-herdr-spur.sh b/bin/fm-herdr-spur.sh new file mode 100755 index 0000000000..493cac04c6 --- /dev/null +++ b/bin/fm-herdr-spur.sh @@ -0,0 +1,353 @@ +#!/usr/bin/env bash +# fm-herdr-spur.sh - bridge that SPURS firstmate when an EXTERNAL herdr agent +# finishes (working -> idle | working -> done), by enqueuing a durable wake into +# firstmate's own state/.wake-queue so fm-wake-drain surfaces it and firstmate +# reacts. This closes the reactive gap where a herdr agent NOT spawned by +# firstmate (e.g. a parlay-spawned agent) has no state/.status file and no +# turn-end hook, so firstmate never learns it finished. +# +# WHY A SEPARATE BRIDGE (not the built-in herdr backend push). +# firstmate ALREADY has a native herdr push path +# (bin/backends/herdr.sh fm_backend_herdr_wait_transition, wire reader +# bin/backends/herdr-eventwait.py). That path only watches panes that firstmate +# itself spawned (recorded in state/.meta window=), and its policy +# (bin/fm-transition-lib.sh) deliberately DEFERS idle/done because a +# firstmate-spawned crew's completion is already caught by its status file and +# turn-end hook. An EXTERNAL agent has neither, so for it idle/done IS the only +# completion signal. This bridge reuses the SAME wire reader and the SAME +# normalized-record shape, but applies a spur-specific edge policy: a +# working->idle or working->done transition on a TRACKED external agent is the +# actionable edge. +# +# WHAT IT ENQUEUES. +# The durable wake queue (state/.wake-queue) is written only through +# fm-wake-lib.sh's fm_wake_append, whose kind is validated against +# signal|stale|check|heartbeat. An external-agent completion is not a +# firstmate-task status signal or a stale-pane read, so it rides the `check` +# kind - the "per-task slow poll fired, always actionable" lane the watcher +# already treats as unconditionally surfaced. The record written is: +# \t\tcheck\therdr-spur:\t +# where is a human-readable "herdr agent went (was +# working)" line. fm-wake-drain prints it verbatim as a `check:` wake and +# firstmate acts on it exactly like any other actionable wake. +# +# NATIVE PUSH vs POLL. +# When the herdr server is events-capable (protocol >= 16, events.subscribe + +# pane.agent_status_changed in `herdr api schema`), the bridge BLOCKS on the +# native pane.agent_status_changed stream via herdr-eventwait.py and reacts +# sub-second - no polling. When it is not capable (or the socket/reader is +# unusable), it falls back to a lightweight periodic `herdr agent list` poll. +# Both paths share one normalize + edge-detect + enqueue core. +# +# DEBOUNCE. +# Edges are computed against a remembered last-status per agent (in-memory for +# the process). Only working->{idle,done} fires. A subsequent working edge +# re-arms the agent, so a real finish after a resumed turn spurs again, but +# idle/done flapping without an intervening working edge fires at most once. +# +# CONFIGURABLE. +# Which agents to watch is resolved, in order: +# 1. --agent flags (repeatable) on the command line; +# 2. config/herdr-spur.agents (one agent name per line, # comments allowed); +# 3. otherwise ALL agents herdr currently reports (auto-track every agent). +# --session selects the herdr session (default: "default"). +# --once runs a single poll pass and exits (used by the smoke test). +# --self-detach re-execs the process detached (setsid + nohup) so it survives +# the launching shell / firstmate compaction; the captain's rule: processes +# survive, context does not. +# +# SAFETY. This bridge is READ-ONLY against herdr: it only ever runs +# `herdr agent list`, `herdr api schema`, `herdr session list`, and the +# read-only event subscription. It never sends, focuses, renames, or tears down +# anything. It only WRITES to firstmate's own state/.wake-queue via the +# sanctioned fm_wake_append helper. +set -u + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=bin/fm-wake-lib.sh +. "$SCRIPT_DIR/fm-wake-lib.sh" +# shellcheck source=bin/fm-transition-lib.sh +. "$SCRIPT_DIR/fm-transition-lib.sh" + +FM_HERDR_SPUR_SESSION="${FM_HERDR_SPUR_SESSION:-default}" +FM_HERDR_SPUR_POLL_INTERVAL="${FM_HERDR_SPUR_POLL_INTERVAL:-5}" # seconds between poll passes (poll fallback) +FM_HERDR_SPUR_EVENT_BUDGET="${FM_HERDR_SPUR_EVENT_BUDGET:-3600}" # seconds per event-wait block before reconcile +FM_HERDR_SPUR_AGENTS_FILE="${FM_HERDR_SPUR_AGENTS_FILE:-$FM_HOME/config/herdr-spur.agents}" +FM_HERDR_SPUR_LOG="${FM_HERDR_SPUR_LOG:-$STATE/.herdr-spur.log}" + +# --- CLI --------------------------------------------------------------------- +MODE=loop # loop | once +DO_DETACH=false +declare -a WATCH_AGENTS=() + +usage() { + cat >&2 <<'EOF' +Usage: fm-herdr-spur.sh [options] + --agent watch this agent (repeatable; overrides config file) + --session herdr session (default: "default") + --once run one poll pass and exit (no event-wait, no loop) + --self-detach re-exec detached so the daemon survives its parent + --interval poll-fallback interval (default: 5) + -h, --help show this help +EOF +} + +while [ $# -gt 0 ]; do + case "$1" in + --agent) WATCH_AGENTS+=("$2"); shift 2 ;; + --session) FM_HERDR_SPUR_SESSION="$2"; shift 2 ;; + --once) MODE=once; shift ;; + --self-detach) DO_DETACH=true; shift ;; + --interval) FM_HERDR_SPUR_POLL_INTERVAL="$2"; shift 2 ;; + -h|--help) usage; exit 0 ;; + *) printf 'fm-herdr-spur: unknown arg: %s\n' "$1" >&2; usage; exit 2 ;; + esac +done + +log() { printf '%s %s\n' "$(date +%FT%T%z)" "$*" >> "$FM_HERDR_SPUR_LOG" 2>/dev/null || true; } + +# --- self-detach: robust survival across shell/context death ----------------- +# Re-exec via setsid (Linux) or nohup+disown fallback, dropping --self-detach so +# the child runs the real loop. All state truth lives in files, so a restart is +# a non-event. +if [ "$DO_DETACH" = true ]; then + args=() + [ "$MODE" = once ] && args+=(--once) + args+=(--session "$FM_HERDR_SPUR_SESSION" --interval "$FM_HERDR_SPUR_POLL_INTERVAL") + for a in "${WATCH_AGENTS[@]:-}"; do [ -n "$a" ] && args+=(--agent "$a"); done + if command -v setsid >/dev/null 2>&1; then + setsid nohup "$SCRIPT_DIR/fm-herdr-spur.sh" "${args[@]}" >/dev/null 2>&1 & + else + nohup "$SCRIPT_DIR/fm-herdr-spur.sh" "${args[@]}" >/dev/null 2>&1 & + disown 2>/dev/null || true + fi + printf 'fm-herdr-spur: detached daemon started (session=%s, pid=%s)\n' \ + "$FM_HERDR_SPUR_SESSION" "$!" + log "detached daemon started session=$FM_HERDR_SPUR_SESSION pid=$!" + exit 0 +fi + +# --- herdr reads (read-only) ------------------------------------------------- +# fm_herdr_spur_snapshot: emit one TAB line per agent for the session: +# \t\t\t +# agent-name falls back to the pane_id when herdr reports no explicit name. +fm_herdr_spur_snapshot() { + herdr agent list --session "$FM_HERDR_SPUR_SESSION" 2>/dev/null \ + | jq -r '.result.agents[]? + | [ (.name // .pane_id), .pane_id, .workspace_id, .agent_status ] + | @tsv' 2>/dev/null +} + +# Non-session-scoped fallback (default session may reject --session on some +# builds); try the plain form when the scoped form yields nothing. +fm_herdr_spur_snapshot_any() { + local out + out=$(fm_herdr_spur_snapshot) + if [ -z "$out" ]; then + out=$(herdr agent list 2>/dev/null \ + | jq -r '.result.agents[]? + | [ (.name // .pane_id), .pane_id, .workspace_id, .agent_status ] + | @tsv' 2>/dev/null) + fi + # Emit with a trailing newline so `read` in the consuming while-loop does not + # drop the final agent (command substitution strips trailing newlines). + [ -n "$out" ] && printf '%s\n' "$out" +} + +# --- watch-set resolution ---------------------------------------------------- +# Prints the newline-separated set of agent names to watch. Empty output means +# "watch every agent herdr currently reports". +fm_herdr_spur_watch_set() { + if [ "${#WATCH_AGENTS[@]}" -gt 0 ]; then + printf '%s\n' "${WATCH_AGENTS[@]}" + return 0 + fi + if [ -f "$FM_HERDR_SPUR_AGENTS_FILE" ]; then + grep -vE '^[[:space:]]*(#|$)' "$FM_HERDR_SPUR_AGENTS_FILE" 2>/dev/null \ + | sed 's/[[:space:]]*$//' + return 0 + fi + return 0 # empty = all +} + +fm_herdr_spur_in_watch_set() { # + local agent=$1 set=$2 + [ -z "$set" ] && return 0 # empty set = all + printf '%s\n' "$set" | grep -Fxq -- "$agent" +} + +# --- edge detection + enqueue ------------------------------------------------ +# LAST_STATUS_ holds the previously-seen status per agent for debounce. +# A working->{idle,done} edge on a watched agent enqueues one `check` wake. +fm_herdr_spur_safekey() { printf '%s' "$1" | tr -c 'A-Za-z0-9_' '_'; } + +# fm_herdr_spur_edge_policy: the spur-specific edge policy for EXTERNAL agents. +# Returns 0 (spur) only for a fresh working->{idle,done} finish edge; 1 +# otherwise. Deliberately different from fm_transition_policy, whose `defer` on +# idle/done is correct for firstmate-OWNED tasks (caught by status/turn-end) but +# would swallow an external agent's only completion signal. +fm_herdr_spur_edge_policy() { # + local from=$1 to=$2 + [ "$from" = "working" ] || return 1 + case "$to" in + idle|done) return 0 ;; + *) return 1 ;; + esac +} + +# Enqueue one spur wake for reaching . +fm_herdr_spur_enqueue() { # + local agent=$1 status=$2 pane=$3 reason + reason="herdr agent ${agent} went ${status} (was working; external agent, no firstmate status file) pane=${pane}" + if fm_wake_append check "herdr-spur:${agent}" "$reason"; then + log "SPUR agent=$agent status=$status pane=$pane" + printf 'fm-herdr-spur: spurred firstmate: %s\n' "$reason" >&2 + return 0 + fi + log "SPUR-FAILED agent=$agent status=$status pane=$pane" + return 1 +} + +# fm_herdr_spur_reconcile: read the current snapshot, and for every watched +# agent whose status transitioned working->{idle,done} since last seen, enqueue +# a spur. Updates the in-memory last-status. This is the shared core used by +# BOTH the poll fallback and the post-event level-reconcile. +fm_herdr_spur_reconcile() { # + local watch_set=$1 line agent pane ws status key prev prev_var + while IFS=$'\t' read -r agent pane ws status; do + [ -n "$agent" ] || continue + fm_herdr_spur_in_watch_set "$agent" "$watch_set" || continue + key=$(fm_herdr_spur_safekey "$agent") + prev_var="LAST_STATUS_${key}" + prev="${!prev_var:-}" + if [ -n "$prev" ] && fm_herdr_spur_edge_policy "$prev" "$status"; then + fm_herdr_spur_enqueue "$agent" "$status" "$pane" + fi + printf -v "$prev_var" '%s' "$status" + done < <(fm_herdr_spur_snapshot_any) +} + +# --- capability probe (mirror fm_backend_herdr_events_capable, lightweight) --- +fm_herdr_spur_events_capable() { + command -v jq >/dev/null 2>&1 || return 1 + command -v python3 >/dev/null 2>&1 || return 1 + local protocol schema + protocol=$(herdr status --json 2>/dev/null | jq -r '.client.protocol // empty' 2>/dev/null) + case "$protocol" in ''|*[!0-9]*) return 1 ;; esac + [ "$protocol" -ge 16 ] || return 1 + schema=$(herdr api schema --json 2>/dev/null) || return 1 + printf '%s' "$schema" | grep -Fq 'events.subscribe' || return 1 + printf '%s' "$schema" | grep -Fq 'pane.agent_status_changed' || return 1 + return 0 +} + +fm_herdr_spur_socket_path() { + herdr session list --json 2>/dev/null \ + | jq -r --arg name "$FM_HERDR_SPUR_SESSION" \ + '.sessions[]? | select(.name == $name) | .socket_path // empty' 2>/dev/null \ + | head -1 +} + +# --- the event-wait block: reuse herdr-eventwait.py --------------------------- +# Subscribe to pane.agent_status_changed for every currently-watched pane, and +# for each streamed edge run the shared edge policy + enqueue. Returns when the +# reader exits (budget elapsed or stream closed); the loop then reconciles +# levels and re-subscribes (panes may have appeared/disappeared). +# Note: this machine's bash is 3.2 (no associative arrays), matching the house +# rule in bin/backends/herdr.sh. pane->agent mapping is stored in per-pane +# dynamic variables PANE_AGENT_, read via indirection. +fm_herdr_spur_event_block() { # + local watch_set=$1 sock reader_py agent pane ws status key + local pane_ids="" + sock=$(fm_herdr_spur_socket_path) + [ -n "$sock" ] || return 2 + reader_py="$SCRIPT_DIR/backends/herdr-eventwait.py" + [ -f "$reader_py" ] || return 2 + + # Build pane list for watched agents, seed last-status so the first streamed + # edge has a `from`, and record each pane's agent name for the reverse lookup. + while IFS=$'\t' read -r agent pane ws status; do + [ -n "$agent" ] || continue + fm_herdr_spur_in_watch_set "$agent" "$watch_set" || continue + pane_ids="$pane_ids $pane" + key=$(fm_herdr_spur_safekey "$agent") + printf -v "LAST_STATUS_${key}" '%s' "$status" + printf -v "PANE_AGENT_$(fm_herdr_spur_safekey "$pane")" '%s' "$agent" + done < <(fm_herdr_spur_snapshot_any) + # shellcheck disable=SC2086 + set -- $pane_ids + [ "$#" -gt 0 ] || return 2 + + # Stream. herdr-eventwait.py prints "@subscribed" then TAB lines: + # \t\t\t + local pane_id ev_ws ev_status ev_agent name prev prev_var pa_var + while IFS=$'\t' read -r pane_id ev_ws ev_status ev_agent; do + [ "$pane_id" = "@subscribed" ] && { log "subscribed panes=$#"; continue; } + [ -n "$pane_id" ] || continue + pa_var="PANE_AGENT_$(fm_herdr_spur_safekey "$pane_id")" + name="${!pa_var:-$ev_agent}" + [ -n "$name" ] || name="$pane_id" + fm_herdr_spur_in_watch_set "$name" "$watch_set" || continue + key=$(fm_herdr_spur_safekey "$name") + prev_var="LAST_STATUS_${key}" + prev="${!prev_var:-}" + if [ -n "$prev" ] && fm_herdr_spur_edge_policy "$prev" "$ev_status"; then + fm_herdr_spur_enqueue "$name" "$ev_status" "$pane_id" + fi + printf -v "$prev_var" '%s' "$ev_status" + done < <(python3 "$reader_py" "$sock" "$FM_HERDR_SPUR_EVENT_BUDGET" "$@" 2>/dev/null) + return 0 +} + +# --- main -------------------------------------------------------------------- +main() { + local watch_set + watch_set=$(fm_herdr_spur_watch_set) + log "start mode=$MODE session=$FM_HERDR_SPUR_SESSION watch=[$(printf '%s' "$watch_set" | tr '\n' ' ')]" + + if [ "$MODE" = once ]; then + # One reconcile pass. With no prior in-memory status this seeds levels and + # cannot detect an edge by itself; --once exists for the smoke test, which + # seeds a working level then flips it. To let a single invocation observe an + # edge, honor FM_HERDR_SPUR_SEED (agent=status,agent=status) as the assumed + # prior status. + if [ -n "${FM_HERDR_SPUR_SEED:-}" ]; then + local pair a s key + IFS=',' read -ra pairs <<<"$FM_HERDR_SPUR_SEED" + for pair in "${pairs[@]}"; do + a="${pair%%=*}"; s="${pair#*=}" + [ -n "$a" ] || continue + key=$(fm_herdr_spur_safekey "$a") + printf -v "LAST_STATUS_${key}" '%s' "$s" + done + fi + fm_herdr_spur_reconcile "$watch_set" + return 0 + fi + + local capable=false + if fm_herdr_spur_events_capable; then + capable=true + log "events-capable: using native pane.agent_status_changed push" + else + log "events-incapable: using herdr agent list poll fallback" + fi + + while :; do + watch_set=$(fm_herdr_spur_watch_set) + if [ "$capable" = true ]; then + # Native push: block on the stream. On return (budget/close), reconcile + # levels to catch any edge missed across the re-subscribe seam, then loop. + fm_herdr_spur_event_block "$watch_set" || { + log "event block unusable this cycle; poll reconcile" + fm_herdr_spur_reconcile "$watch_set" + sleep "$FM_HERDR_SPUR_POLL_INTERVAL" + } + else + fm_herdr_spur_reconcile "$watch_set" + sleep "$FM_HERDR_SPUR_POLL_INTERVAL" + fi + done +} + +main diff --git a/docs/examples/herdr-spur.agents b/docs/examples/herdr-spur.agents new file mode 100644 index 0000000000..ea60ca46ca --- /dev/null +++ b/docs/examples/herdr-spur.agents @@ -0,0 +1,10 @@ +# fm-herdr-spur.sh watch list (copy to config/herdr-spur.agents to use). +# +# One herdr agent NAME per line. Blank lines and # comments are ignored. +# When this file is absent AND no --agent flags are passed, fm-herdr-spur.sh +# watches EVERY agent herdr reports (auto-track). List names here to restrict +# the spur to specific externally-spawned agents (e.g. parlay-spawned ones). +# +# Example: only spur firstmate for these two external agents. +muse +parlay-dev From a8b99f2005d618302d4b8310d96bc1556e639e4e Mon Sep 17 00:00:00 2001 From: Trillium Smith Date: Wed, 15 Jul 2026 02:52:23 -0700 Subject: [PATCH 03/23] feat: fm-groom proactive ideas-store work generator Grooms ready ideas into dispatched work without the captain writing a brief. Per idea: formulate a concrete runnable brief via PAI Inference (plain prompts to dodge PromptGuard), classify safe (research/design/prototype) vs escalate (merge/deploy/production), then dispatch safe via fm-spawn scout or file unsafe to the review store. Safety rails: OFF by default (dry-run unless FM_GROOM_ENABLED=1), rate-limited (FM_GROOM_MAX_IN_FLIGHT), bounded per run (FM_GROOM_MAX_PER_RUN), idempotent (groom:* state label), fail-safe classify (any error escalates). 7 hermetic tests cover the rails; shellcheck-clean. --- bin/fm-groom-json-field.sh | 29 ++++ bin/fm-groom-lib.sh | 129 ++++++++++++++++ bin/fm-groom.sh | 303 +++++++++++++++++++++++++++++++++++++ tests/fm-groom.test.sh | 212 ++++++++++++++++++++++++++ 4 files changed, 673 insertions(+) create mode 100755 bin/fm-groom-json-field.sh create mode 100755 bin/fm-groom-lib.sh create mode 100755 bin/fm-groom.sh create mode 100755 tests/fm-groom.test.sh diff --git a/bin/fm-groom-json-field.sh b/bin/fm-groom-json-field.sh new file mode 100755 index 0000000000..d0c38a52ca --- /dev/null +++ b/bin/fm-groom-json-field.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# fm-groom-json-field.sh - extract one string field from a JSON document on stdin. +# +# `ideas show --json` emits an array of one issue object whose `description` can +# contain quotes, newlines, and unicode -- none of which a grep/sed pipeline reads +# reliably. This helper does a real JSON parse and prints the requested field's +# string value (empty string if absent), so the groom pipeline never mangles a +# spark's text before formulating a brief from it. +# +# Usage: FM_KEY= fm-groom-json-field.sh (JSON on stdin, value on stdout) +# The field is read from FM_KEY (not argv) so no shell quoting can corrupt it. +set -eu + +exec bun -e ' +const key = process.env.FM_KEY; +if (!key) { process.stderr.write("FM_KEY unset\n"); process.exit(2); } +let raw = ""; +process.stdin.setEncoding("utf-8"); +process.stdin.on("data", (c) => { raw += c; }); +process.stdin.on("end", () => { + let doc; + try { doc = JSON.parse(raw); } catch { process.exit(0); } + const obj = Array.isArray(doc) ? doc[0] : doc; + if (obj && typeof obj === "object" && typeof obj[key] === "string") { + process.stdout.write(obj[key]); + } + process.exit(0); +}); +' diff --git a/bin/fm-groom-lib.sh b/bin/fm-groom-lib.sh new file mode 100755 index 0000000000..5a18c435d3 --- /dev/null +++ b/bin/fm-groom-lib.sh @@ -0,0 +1,129 @@ +#!/usr/bin/env bash +# fm-groom-lib.sh - brief-FORMULATION and SAFETY-CLASSIFICATION for fm-groom.sh. +# +# This is the value core split out from the orchestrator (fm-groom.sh): given a +# rough idea (title + description), MANUFACTURE a concrete, runnable brief string +# and DECIDE whether that work is safe to auto-dispatch. Both decisions go through +# an LLM (PAI Inference.ts, subscription-billed) with PLAIN, non-instructional +# prompts so PAI's PromptGuard injection inspector does not block the nested claude +# (see data/learnings.md: instruction-style briefs -- "You are an agent...", +# "guardrails", "do NOT..." -- are classified as injection and blocked at 0 turns). +# +# Sourced by fm-groom.sh; no side effects at source time. Every function prints to +# stdout and returns non-zero on failure so the orchestrator can decide fallback. +# +# Dependencies: bun + PAI Inference.ts on the resolving path (fm_groom_inference_bin). +set -u + +# Strip leading and trailing blank lines from a string (portable: awk, not the +# GNU-only multiline sed forms that BSD/macOS sed rejects). Interior blank lines +# are preserved so a formulated brief keeps its paragraph structure. +fm_groom_trim_blank_lines() { + printf '%s\n' "$1" | awk ' + { lines[NR] = $0 } + END { + first = 0; last = 0 + for (i = 1; i <= NR; i++) if (lines[i] ~ /[^[:space:]]/) { if (!first) first = i; last = i } + if (!first) exit + for (i = first; i <= last; i++) print lines[i] + }' +} + +# Resolve the PAI Inference.ts tool. Overridable via FM_GROOM_INFERENCE for tests +# (a mock script that reads the same argv shape). Default is the PAI install path. +fm_groom_inference_bin() { + if [ -n "${FM_GROOM_INFERENCE:-}" ]; then + printf '%s\n' "$FM_GROOM_INFERENCE" + return 0 + fi + printf '%s\n' "${HOME}/.claude/PAI/TOOLS/Inference.ts" +} + +# Run one inference turn. Args: . +# When FM_GROOM_INFERENCE is set it is invoked directly as an executable with the +# same three positional args (level system user) plus a leading --level pair, so a +# mock can stub the LLM without bun. Otherwise `bun --level ...`. +# Prints the model's stdout; returns the child's exit status. +fm_groom_infer() { + local level=$1 system=$2 user=$3 bin + bin=$(fm_groom_inference_bin) + if [ -n "${FM_GROOM_INFERENCE:-}" ]; then + "$bin" --level "$level" "$system" "$user" + else + bun "$bin" --level "$level" "$system" "$user" + fi +} + +# Formulate a concrete runnable brief from a rough idea. +# Args: <description>. Prints the formulated brief to stdout. +# The prompts are deliberately descriptive ("Write a task description...") +# rather than imperative role-play, both to dodge PromptGuard AND because the +# OUTPUT is what a downstream agent will act on -- we want a plain task spec, not +# a persona. Returns non-zero if inference failed or produced empty output. +fm_groom_formulate_brief() { + local title=$1 desc=$2 system user out + system=$(cat <<'SYS' +The assistant is a technical planning helper. Given a short product idea, it +writes a single concrete, self-contained task description that another engineer +could pick up and run without further clarification. The description names the +goal in one sentence, lists the concrete first steps, names the files or systems +likely involved when they are inferable, and states what "done" looks like. It +writes plain prose and short lists. It never adds preamble, never asks questions, +and never role-plays. It outputs only the task description, nothing else. +SYS +) + user=$(printf 'Idea title: %s\n\nIdea notes: %s\n\nWrite the concrete task description now.' "$title" "$desc") + out=$(fm_groom_infer standard "$system" "$user") || return 1 + out=$(fm_groom_trim_blank_lines "$out") + [ -n "$out" ] || return 1 + printf '%s\n' "$out" +} + +# Classify a formulated brief as safe-to-auto-dispatch or escalate. +# Args: <title> <brief>. Prints exactly one word on the first line: +# safe research / design / prototype / investigation - reversible, no +# production or shared-state mutation; OK to auto-dispatch. +# escalate merges, deploys, credential/security changes, anything touching +# production or irreversible shared state - firstmate must NOT run it; +# file a review item for the captain instead. +# A second line carries a one-sentence rationale. Any parse failure or ambiguous +# answer is treated by the caller as `escalate` (fail-safe). Returns non-zero on +# inference failure so the caller can fail-safe. +fm_groom_classify() { + local title=$1 brief=$2 system user out verdict + system=$(cat <<'SYS' +The assistant is a risk triage helper for an autonomous work dispatcher. It reads +a task description and answers whether the task is safe for an automated system to +start on its own, unattended, without a human first approving it. + +It answers with exactly one word on the first line: "safe" or "escalate". + +A task is "safe" when it is research, investigation, design, a scoped prototype, +a local experiment, documentation, or analysis -- work that is reversible and does +not merge code, deploy, publish, rotate credentials, change security or auth +settings, delete data, or otherwise mutate production or shared state. + +A task is "escalate" when it merges or pushes to a shared branch, deploys or +releases, publishes anything externally, touches credentials, keys, auth, or +security posture, deletes or migrates data, or makes any irreversible or +production-affecting change. When uncertain, it answers "escalate". + +The second line is one short sentence of rationale. It outputs nothing else. +SYS +) + user=$(printf 'Task title: %s\n\nTask description: %s\n\nAnswer now.' "$title" "$brief") + # standard tier (sonnet, 30s): the fast/haiku 15s ceiling is too tight for the + # safety reasoning, and a timeout would fail-safe to escalate -- costing the + # captain a needless review item. Classification reliability is worth one tier up. + out=$(fm_groom_infer standard "$system" "$user") || return 1 + # ASCII-only on purpose: the verdict vocabulary is "safe"/"escalate". + # shellcheck disable=SC2018,SC2019 + verdict=$(printf '%s\n' "$out" | sed -e '/./,$!d' | head -1 | tr 'A-Z' 'a-z' | tr -cd 'a-z') + case "$verdict" in + safe|escalate) : ;; + *) verdict=escalate ;; # fail-safe: anything unparseable escalates + esac + # Emit verdict then the rationale line (best-effort; may be empty). + printf '%s\n' "$verdict" + printf '%s\n' "$out" | sed -e '/./,$!d' | sed -n '2p' +} diff --git a/bin/fm-groom.sh b/bin/fm-groom.sh new file mode 100755 index 0000000000..926f3fa966 --- /dev/null +++ b/bin/fm-groom.sh @@ -0,0 +1,303 @@ +#!/usr/bin/env bash +# fm-groom.sh - PROACTIVE idea->brief->dispatch generator for firstmate. +# +# The missing engine behind data/captain.md's "Formulate the brief from his sparks +# -- never make him articulate the exact task" doctrine. The captain drops rough +# ideas into the `ideas` store; today firstmate only launches work REACTIVELY, so +# those ideas sit ungroomed. This tool grooms them: for each READY idea it +# FORMULATES a concrete runnable brief with an LLM (the core value -- turning a +# spark into the clear string gnhf/a crewmate needs), CLASSIFIES it safe vs unsafe, +# and either dispatches the safe ones or files the unsafe ones to the `review` +# store for the captain. +# +# SAFETY (this is an autonomous LAUNCHER -- it must not run wild): +# * OFF BY DEFAULT. Real dispatch requires FM_GROOM_ENABLED=1. Without it every +# run is a DRY-RUN: it formulates + classifies + prints the intended action, +# and spawns/files/marks NOTHING. This is the primary rail. +# * RATE-LIMITED. FM_GROOM_MAX_IN_FLIGHT (default 2) caps concurrently-dispatched +# groom tasks; when the fleet already holds that many groom-dispatched tasks, +# no new safe idea is dispatched this run (it is reported as deferred). +# * BOUNDED PER RUN. FM_GROOM_MAX_PER_RUN (default 3) caps how many ideas this +# single invocation will act on (dispatch OR file), so one run can never +# stampede the whole 25-idea backlog. Cost-aware: fewer LLM calls, fewer spawns. +# * IDEMPOTENT. Every acted-on idea gets a durable `groom:<state>` label via +# `ideas set-state`; a labeled idea is skipped forever after, so re-runs never +# re-dispatch or re-file the same idea. +# * DURABLE DISPATCH. Safe work is launched through fm-spawn.sh as a Herder-backed +# crewmate (survives this session's compaction; the captain can watch it), never +# a detached headless process. +# +# The LLM prompts live in fm-groom-lib.sh and are deliberately non-instructional so +# PAI's PromptGuard injection inspector does not block the nested claude (see +# data/learnings.md). +# +# Usage: +# fm-groom.sh dry-run over ready ideas (default, safe) +# fm-groom.sh --limit N override FM_GROOM_MAX_PER_RUN for this run +# fm-groom.sh --idea <id> groom exactly one idea by id (still dry-run unless enabled) +# fm-groom.sh --json machine-readable per-idea result records (one JSON object per line) +# FM_GROOM_ENABLED=1 fm-groom.sh ARM real dispatch/file/mark (still bounded + rate-limited) +# +# Environment: +# FM_GROOM_ENABLED 1 arms real actions; anything else = dry-run (default off) +# FM_GROOM_MAX_PER_RUN max ideas acted on per invocation (default 3) +# FM_GROOM_MAX_IN_FLIGHT max concurrently groom-dispatched tasks (default 2) +# FM_GROOM_INFERENCE override Inference.ts path (tests); see fm-groom-lib.sh +# FM_GROOM_IDEAS_BIN override the `ideas` CLI (tests); default `ideas` on PATH +# FM_GROOM_REVIEW_BIN override the `review` CLI (tests); default `review` on PATH +# FM_GROOM_SPAWN override the dispatch command (tests); default bin/fm-spawn.sh +# +# Exit status: 0 when the run completed (even a dry-run finding escalations); 1 on +# a hard setup error (missing dependency, unreadable store). +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}" +STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" +# shellcheck source=bin/fm-groom-lib.sh +. "$SCRIPT_DIR/fm-groom-lib.sh" + +IDEAS_BIN=${FM_GROOM_IDEAS_BIN:-ideas} +REVIEW_BIN=${FM_GROOM_REVIEW_BIN:-review} +SPAWN_BIN=${FM_GROOM_SPAWN:-$SCRIPT_DIR/fm-spawn.sh} + +# The durable groom state-label dimension. `ideas set-state <id> groom=<value>` +# writes an event bead + a `groom:<value>` label; the presence of ANY groom:* +# label is the idempotency marker. +GROOM_DIM=groom + +ENABLED=0 +[ "${FM_GROOM_ENABLED:-}" = "1" ] && ENABLED=1 +MAX_PER_RUN=${FM_GROOM_MAX_PER_RUN:-3} +MAX_IN_FLIGHT=${FM_GROOM_MAX_IN_FLIGHT:-2} +JSON=0 +ONLY_IDEA= + +usage() { sed -n '2,52p' "$0" | sed 's/^# \{0,1\}//'; } + +while [ $# -gt 0 ]; do + case "$1" in + -h|--help) usage; exit 0 ;; + --json) JSON=1 ;; + --limit) shift; [ $# -gt 0 ] || { echo "error: --limit requires a value" >&2; exit 1; }; MAX_PER_RUN=$1 ;; + --limit=*) MAX_PER_RUN=${1#--limit=} ;; + --idea) shift; [ $# -gt 0 ] || { echo "error: --idea requires a value" >&2; exit 1; }; ONLY_IDEA=$1 ;; + --idea=*) ONLY_IDEA=${1#--idea=} ;; + *) echo "error: unknown argument '$1'" >&2; usage >&2; exit 1 ;; + esac + shift +done + +case "$MAX_PER_RUN" in ''|*[!0-9]*) echo "error: --limit / FM_GROOM_MAX_PER_RUN must be a non-negative integer" >&2; exit 1 ;; esac +case "$MAX_IN_FLIGHT" in ''|*[!0-9]*) echo "error: FM_GROOM_MAX_IN_FLIGHT must be a non-negative integer" >&2; exit 1 ;; esac + +command -v "$IDEAS_BIN" >/dev/null 2>&1 || { echo "error: ideas CLI not found ('$IDEAS_BIN')" >&2; exit 1; } + +# --- helpers ------------------------------------------------------------------- + +# Count groom-dispatched tasks currently in flight. A groom dispatch writes a +# state/<task-id>.meta whose ID is prefixed `groom-`; teardown removes the meta. +# So the live in-flight count is the number of groom-*.meta files present. This is +# the same "meta = live task" convention fm-spawn/fm-teardown already use. +fm_groom_in_flight_count() { + local n=0 f + [ -d "$STATE" ] || { printf '0\n'; return 0; } + for f in "$STATE"/groom-*.meta; do + [ -e "$f" ] || continue + n=$((n + 1)) + done + printf '%s\n' "$n" +} + +# Has this idea already been groomed? True when it carries any groom:* label. +fm_groom_already_done() { + local id=$1 labels + labels=$("$IDEAS_BIN" label list "$id" 2>/dev/null || true) + printf '%s\n' "$labels" | grep -qE "(^|[[:space:]])${GROOM_DIM}:" && return 0 + return 1 +} + +# Emit a JSON record for one idea's outcome (when --json). Fields are pre-escaped +# by the caller via fm_groom_json_escape. +fm_groom_json_escape() { + printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' -e ':a' -e 'N' -e '$!ba' -e 's/\n/\\n/g' -e 's/\t/\\t/g' +} + +emit_json() { # <id> <title> <verdict> <action> <brief> <note> + printf '{"id":"%s","title":"%s","verdict":"%s","action":"%s","brief":"%s","note":"%s"}\n' \ + "$(fm_groom_json_escape "$1")" "$(fm_groom_json_escape "$2")" \ + "$(fm_groom_json_escape "$3")" "$(fm_groom_json_escape "$4")" \ + "$(fm_groom_json_escape "$5")" "$(fm_groom_json_escape "$6")" +} + +# Human-readable per-idea report block. +emit_human() { # <id> <title> <verdict> <action> <brief> <note> + printf '\n──────────────────────────────────────────────────────────────\n' + printf 'IDEA %s %s\n' "$1" "$2" + printf 'VERDICT %s' "$3" + [ -n "$6" ] && printf ' (%s)' "$6" + printf '\n' + printf 'ACTION %s\n' "$4" + printf 'BRIEF ↓\n%s\n' "$5" +} + +# --- ready-idea gathering ------------------------------------------------------ + +# Pull ready ideas as a stable id list, then read each idea's title+description. +# We shell out per-idea (ideas show --json) rather than parse the ready array so +# we get the full description reliably regardless of ready's truncation. +ready_json=$("$IDEAS_BIN" ready --json 2>/dev/null || printf '[]') +ids=$(printf '%s' "$ready_json" | grep -oE '"id"[[:space:]]*:[[:space:]]*"[^"]+"' | sed -E 's/.*"([^"]+)"$/\1/') +if [ -n "$ONLY_IDEA" ]; then + ids=$ONLY_IDEA +fi + +if [ -z "$ids" ]; then + [ "$JSON" -eq 1 ] || echo "fm-groom: no ready ideas to groom." + exit 0 +fi + +in_flight=$(fm_groom_in_flight_count) + +if [ "$JSON" -eq 0 ]; then + mode_label="DRY-RUN (no dispatch, no file, no mark)" + [ "$ENABLED" -eq 1 ] && mode_label="ARMED (real dispatch/file/mark)" + printf '════════════════════════════════════════════════════════════════\n' + printf 'fm-groom mode=%s\n' "$mode_label" + printf ' max-per-run=%s max-in-flight=%s currently-in-flight=%s\n' \ + "$MAX_PER_RUN" "$MAX_IN_FLIGHT" "$in_flight" + printf '════════════════════════════════════════════════════════════════\n' +fi + +acted=0 +skipped=0 +dispatched_this_run=0 + +for id in $ids; do + # Bounded per-run: stop acting once we hit the cap. In dry-run we still WANT to + # show the cap taking effect, so we report remaining ideas as capped and stop. + if [ "$acted" -ge "$MAX_PER_RUN" ]; then + if [ "$JSON" -eq 1 ]; then + title=$("$IDEAS_BIN" show "$id" --json 2>/dev/null | grep -oE '"title"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed -E 's/.*"([^"]*)"$/\1/') + emit_json "$id" "${title:-}" "-" "capped" "" "per-run limit ($MAX_PER_RUN) reached" + else + printf '\n(… %s and remaining ideas skipped: per-run limit %s reached)\n' "$id" "$MAX_PER_RUN" + fi + break + fi + + # Idempotency: skip already-groomed ideas. + if fm_groom_already_done "$id"; then + skipped=$((skipped + 1)) + [ "$JSON" -eq 1 ] && emit_json "$id" "" "-" "skipped" "" "already groomed (groom:* label present)" + continue + fi + + show_json=$("$IDEAS_BIN" show "$id" --json 2>/dev/null || true) + title=$(printf '%s' "$show_json" | grep -oE '"title"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed -E 's/.*"([^"]*)"$/\1/') + # Description can contain escaped quotes/newlines; pull it with a JSON-aware read. + desc=$(printf '%s' "$show_json" | FM_KEY=description "$SCRIPT_DIR/fm-groom-json-field.sh" 2>/dev/null || true) + [ -n "$title" ] || title="(untitled $id)" + [ -n "$desc" ] || desc="$title" + + # (1)+(2) FORMULATE the runnable brief. This is the core value. + if ! brief=$(fm_groom_formulate_brief "$title" "$desc"); then + note="brief formulation failed (inference error)" + if [ "$JSON" -eq 1 ]; then emit_json "$id" "$title" "-" "error" "" "$note" + else emit_human "$id" "$title" "-" "SKIP: $note" "" ""; fi + acted=$((acted + 1)) + continue + fi + + # (3) CLASSIFY safe vs escalate. Fail-safe: any failure => escalate. + if classify_out=$(fm_groom_classify "$title" "$brief"); then + verdict=$(printf '%s\n' "$classify_out" | head -1) + rationale=$(printf '%s\n' "$classify_out" | sed -n '2p') + else + verdict=escalate + rationale="classification inference failed; escalating by fail-safe policy" + fi + + if [ "$verdict" = "safe" ]; then + # (4) SAFE -> dispatch (rate-limited). If we're at the in-flight cap, defer. + if [ "$dispatched_this_run" -ge 1 ] && [ $((in_flight + dispatched_this_run)) -ge "$MAX_IN_FLIGHT" ]; then + action="DEFER dispatch (in-flight cap $MAX_IN_FLIGHT reached)" + if [ "$JSON" -eq 1 ]; then emit_json "$id" "$title" "$verdict" "deferred" "$brief" "$rationale" + else emit_human "$id" "$title" "$verdict" "$action" "$brief" "$rationale"; fi + acted=$((acted + 1)) + continue + fi + if [ $((in_flight + dispatched_this_run)) -ge "$MAX_IN_FLIGHT" ]; then + action="DEFER dispatch (in-flight cap $MAX_IN_FLIGHT reached)" + if [ "$JSON" -eq 1 ]; then emit_json "$id" "$title" "$verdict" "deferred" "$brief" "$rationale" + else emit_human "$id" "$title" "$verdict" "$action" "$brief" "$rationale"; fi + acted=$((acted + 1)) + continue + fi + + task_id="groom-$(printf '%s' "$id" | tr -cd 'a-z0-9')" + if [ "$ENABLED" -eq 1 ]; then + # Real dispatch: write the formulated brief to data/<task-id>/brief.md and + # spawn a scout crewmate (report deliverable, scratch worktree). We mark the + # idea in-progress via groom=dispatched BEFORE spawn so a crash mid-spawn + # still leaves the idempotency marker (never double-dispatch). + mkdir -p "$DATA/$task_id" + { + printf '# Groomed from idea %s: %s\n\n' "$id" "$title" + printf '%s\n\n' "$brief" + # shellcheck disable=SC2016 # literal backticks/braces are the brief text, not shell expansions + printf 'When done, document your findings to brain via `brain create --type=knowledge "<title>"`.\n' + printf 'Report your result to data/%s/report.md.\n' "$task_id" + } > "$DATA/$task_id/brief.md" + "$IDEAS_BIN" set-state "$id" "$GROOM_DIM=dispatched" \ + --reason "fm-groom auto-dispatched as $task_id" >/dev/null 2>&1 || true + "$IDEAS_BIN" note "$id" "fm-groom dispatched as task $task_id (result -> data/$task_id/report.md)" >/dev/null 2>&1 || true + if "$SPAWN_BIN" "$task_id" "$FM_ROOT" --scout >/dev/null 2>&1; then + action="DISPATCHED as $task_id (scout)" + dispatched_this_run=$((dispatched_this_run + 1)) + else + action="DISPATCH FAILED for $task_id (idea marked; retry manually)" + fi + else + action="WOULD DISPATCH as $task_id (scout via fm-spawn)" + fi + else + # ESCALATE -> file a review item (never dispatch). + if [ "$ENABLED" -eq 1 ]; then + review_title="Groomed idea needs your call: $title" + if command -v "$REVIEW_BIN" >/dev/null 2>&1; then + rid=$(printf '%s' "$brief" | "$REVIEW_BIN" q "$review_title" \ + --description "Auto-formulated from idea $id. Classified NOT safe to auto-dispatch: ${rationale:-no rationale}. Review the brief below and either enable it or drop it." \ + --body-file - 2>/dev/null || true) + "$IDEAS_BIN" set-state "$id" "$GROOM_DIM=escalated" \ + --reason "fm-groom filed review item ${rid:-?}" >/dev/null 2>&1 || true + "$IDEAS_BIN" note "$id" "fm-groom escalated to review item ${rid:-(unknown)}: $rationale" >/dev/null 2>&1 || true + action="FILED review item ${rid:-(id unknown)}" + else + action="REVIEW CLI MISSING; idea left unmarked for retry" + fi + else + action="WOULD FILE review item (escalate)" + fi + fi + + if [ "$JSON" -eq 1 ]; then + emit_json "$id" "$title" "$verdict" "$action" "$brief" "$rationale" + else + emit_human "$id" "$title" "$verdict" "$action" "$brief" "$rationale" + fi + acted=$((acted + 1)) +done + +if [ "$JSON" -eq 0 ]; then + printf '\n════════════════════════════════════════════════════════════════\n' + printf 'fm-groom done: acted=%s skipped(already-groomed)=%s dispatched-this-run=%s\n' \ + "$acted" "$skipped" "$dispatched_this_run" + if [ "$ENABLED" -eq 0 ]; then + printf 'This was a DRY-RUN. To arm real dispatch/file/mark:\n' + printf ' FM_GROOM_ENABLED=1 %s\n' "$SCRIPT_DIR/fm-groom.sh" + fi + printf '════════════════════════════════════════════════════════════════\n' +fi diff --git a/tests/fm-groom.test.sh b/tests/fm-groom.test.sh new file mode 100755 index 0000000000..2822581d68 --- /dev/null +++ b/tests/fm-groom.test.sh @@ -0,0 +1,212 @@ +#!/usr/bin/env bash +# fm-groom.test.sh - hermetic behavior tests for bin/fm-groom.sh. +# +# fm-groom is an autonomous LAUNCHER; its whole reason to exist safely is a set of +# rails that must never regress. This suite pins the rails that matter WITHOUT any +# network or the real ideas/review/inference/spawn tools, using fm-groom's own test +# override env vars (FM_GROOM_IDEAS_BIN / FM_GROOM_REVIEW_BIN / FM_GROOM_SPAWN / +# FM_GROOM_INFERENCE), so it runs on CI's tool-less ubuntu image. +# +# Rails asserted: +# 1. DRY-RUN (default, unset FM_GROOM_ENABLED) dispatches/files/marks NOTHING. +# 2. FM_GROOM_MAX_PER_RUN bounds how many ideas one run acts on. +# 3. A classifier that returns garbage FAILS SAFE to escalate (never auto-dispatch). +# 4. Idempotency: an idea already carrying a groom:* label is SKIPPED. +# 5. ARMED safe idea -> dispatch happens via fm-spawn AND the idea is marked. +# 6. ARMED unsafe idea -> a review item is filed AND the idea is marked, no spawn. +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +GROOM="$ROOT/bin/fm-groom.sh" + +TMP=$(fm_test_tmproot fm-groom) +HOME_DIR="$TMP/home" +mkdir -p "$HOME_DIR/state" "$HOME_DIR/data" + +# --- mock CLIs -------------------------------------------------------------- +# The `ideas` mock is a tiny record-and-replay store backed by files in $TMP. +# ideas ready --json -> emits the fixture id list +# ideas show <id> --json -> emits {id,title,description} for a fixture +# ideas label list <id> -> prints any label lines recorded for <id> +# ideas set-state <id> k=v ... -> records a "k:v" label line for <id> (idempotency marker) +# ideas note <id> ... -> no-op success +MOCK_STORE="$TMP/mockstore" +mkdir -p "$MOCK_STORE" + +cat > "$TMP/ideas" <<MOCK +#!/usr/bin/env bash +set -u +store="$MOCK_STORE" +cmd=\${1:-} +case "\$cmd" in + ready) + cat "\$store/ready.json" + ;; + show) + id=\$2 + cat "\$store/show-\$id.json" 2>/dev/null || printf '[]' + ;; + label) + sub=\${2:-} + if [ "\$sub" = list ]; then + id=\$3 + cat "\$store/labels-\$id" 2>/dev/null || true + fi + ;; + set-state) + id=\$2 + kv=\$3 # dimension=value + dim=\${kv%%=*} + val=\${kv#*=} + printf '%s:%s\n' "\$dim" "\$val" >> "\$store/labels-\$id" + ;; + note) + : ;; + *) + : ;; +esac +exit 0 +MOCK +chmod +x "$TMP/ideas" + +# review mock: `review q <title> ...` prints a fake id and records the call. +cat > "$TMP/review" <<MOCK +#!/usr/bin/env bash +set -u +store="$MOCK_STORE" +if [ "\${1:-}" = q ]; then + cat >/dev/null 2>&1 || true # drain --body-file - stdin + echo "filed \$*" >> "\$store/review.log" + printf 'rev-fake-1\n' +fi +exit 0 +MOCK +chmod +x "$TMP/review" + +# spawn mock: records each dispatch and succeeds. +cat > "$TMP/spawn" <<MOCK +#!/usr/bin/env bash +set -u +store="$MOCK_STORE" +echo "spawn \$*" >> "\$store/spawn.log" +exit 0 +MOCK +chmod +x "$TMP/spawn" + +# inference mock: argv is "--level <lvl> <system> <user>". The SYSTEM prompt tells +# us which call this is: the formulator's system contains "task description", the +# classifier's contains 'one word on the first line: "safe" or "escalate"'. We key +# the verdict off a marker word placed in the fixture idea title/desc: +# contains UNSAFEWORD -> classifier returns "escalate" +# contains GARBAGEWORD -> classifier returns junk (tests fail-safe) +# otherwise -> classifier returns "safe" +cat > "$TMP/inference" <<'MOCK' +#!/usr/bin/env bash +set -u +# args: --level <lvl> <system> <user> +system=$3 +user=$4 +if printf '%s' "$system" | grep -q 'safe.*escalate'; then + # classifier call + if printf '%s' "$user" | grep -q GARBAGEWORD; then + printf 'purple monkey dishwasher\n(no verdict here)\n' + elif printf '%s' "$user" | grep -q UNSAFEWORD; then + printf 'escalate\nThis touches production.\n' + else + printf 'safe\nLocal reversible work.\n' + fi +else + # formulator call + printf 'FORMULATED BRIEF for: %s\n' "$user" | head -1 +fi +exit 0 +MOCK +chmod +x "$TMP/inference" + +# --- fixtures --------------------------------------------------------------- +# Three ready ideas: one safe, one unsafe (UNSAFEWORD), one garbage (GARBAGEWORD). +cat > "$MOCK_STORE/ready.json" <<'JSON' +[ + {"id":"idea-safe","title":"add a local search helper"}, + {"id":"idea-danger","title":"UNSAFEWORD deploy to prod"}, + {"id":"idea-junk","title":"GARBAGEWORD unclassifiable"} +] +JSON +printf '[{"id":"idea-safe","title":"add a local search helper","description":"read local files and rank them"}]\n' > "$MOCK_STORE/show-idea-safe.json" +printf '[{"id":"idea-danger","title":"UNSAFEWORD deploy to prod","description":"merge and deploy"}]\n' > "$MOCK_STORE/show-idea-danger.json" +printf '[{"id":"idea-junk","title":"GARBAGEWORD unclassifiable","description":"who knows"}]\n' > "$MOCK_STORE/show-idea-junk.json" + +run_groom() { # extra args... + FM_HOME="$HOME_DIR" \ + FM_GROOM_IDEAS_BIN="$TMP/ideas" \ + FM_GROOM_REVIEW_BIN="$TMP/review" \ + FM_GROOM_SPAWN="$TMP/spawn" \ + FM_GROOM_INFERENCE="$TMP/inference" \ + bash "$GROOM" "$@" +} + +reset_mock_state() { + rm -f "$MOCK_STORE"/labels-* "$MOCK_STORE"/review.log "$MOCK_STORE"/spawn.log + rm -f "$HOME_DIR"/state/groom-*.meta 2>/dev/null || true + rm -rf "$HOME_DIR"/data/groom-* 2>/dev/null || true +} + +# --- 1. DRY-RUN mutates nothing -------------------------------------------- +reset_mock_state +out=$(run_groom 2>&1) || fail "dry-run exited non-zero"$'\n'"$out" +assert_contains "$out" "DRY-RUN" "dry-run banner present" +assert_contains "$out" "WOULD DISPATCH" "dry-run shows would-dispatch for safe idea" +assert_contains "$out" "WOULD FILE" "dry-run shows would-file for unsafe idea" +[ ! -e "$MOCK_STORE/spawn.log" ] || fail "dry-run must not dispatch (spawn.log exists)" +[ ! -e "$MOCK_STORE/review.log" ] || fail "dry-run must not file a review (review.log exists)" +[ -z "$(ls "$MOCK_STORE"/labels-* 2>/dev/null || true)" ] || fail "dry-run must not mark any idea" +pass "dry-run dispatches/files/marks nothing" + +# --- 2. FM_GROOM_MAX_PER_RUN bounds the run -------------------------------- +reset_mock_state +out=$(run_groom --limit 1 2>&1) || fail "limited dry-run exited non-zero" +# Exactly one IDEA block acted; the report notes the per-run limit stop. +n_ideas=$(printf '%s\n' "$out" | grep -c '^IDEA ' || true) +[ "$n_ideas" -eq 1 ] || fail "expected exactly 1 acted idea with --limit 1, got $n_ideas"$'\n'"$out" +assert_contains "$out" "per-run limit" "limit note surfaced" +pass "max-per-run bounds the run to N ideas" + +# --- 3. garbage classifier fails safe to escalate -------------------------- +reset_mock_state +out=$(run_groom --idea idea-junk 2>&1) || fail "junk-idea dry-run exited non-zero" +assert_contains "$out" "VERDICT escalate" "unparseable classifier output escalates (fail-safe)" +assert_contains "$out" "WOULD FILE" "fail-safe routes to review, never dispatch" +case "$out" in *"WOULD DISPATCH"*) fail "fail-safe idea must not be dispatched" ;; esac +pass "garbage classifier output fails safe to escalate" + +# --- 4. idempotency: already-groomed idea is skipped ----------------------- +reset_mock_state +printf 'groom:dispatched\n' > "$MOCK_STORE/labels-idea-safe" # pre-mark it +out=$(run_groom --idea idea-safe 2>&1) || fail "idempotency dry-run exited non-zero" +# Acted count is zero: the only idea was skipped. +assert_contains "$out" "acted=0" "already-groomed idea is skipped (acted=0)" +case "$out" in *"WOULD DISPATCH"*) fail "already-groomed idea must not be re-acted" ;; esac +pass "already-groomed idea is skipped (idempotent)" + +# --- 5. ARMED safe idea dispatches AND marks ------------------------------- +reset_mock_state +out=$(FM_GROOM_ENABLED=1 run_groom --idea idea-safe 2>&1) || fail "armed safe run exited non-zero"$'\n'"$out" +assert_contains "$out" "DISPATCHED as groom-ideasafe" "armed safe idea is dispatched" +[ -e "$MOCK_STORE/spawn.log" ] || fail "armed safe idea must call fm-spawn" +grep -q 'scout' "$MOCK_STORE/spawn.log" || fail "dispatch must be a scout" +grep -q 'groom:dispatched' "$MOCK_STORE/labels-idea-safe" || fail "armed dispatch must mark the idea groom:dispatched" +[ -f "$HOME_DIR/data/groom-ideasafe/brief.md" ] || fail "armed dispatch must write the formulated brief file" +pass "armed safe idea dispatches via fm-spawn and marks the idea" + +# --- 6. ARMED unsafe idea files a review AND marks, no spawn --------------- +reset_mock_state +out=$(FM_GROOM_ENABLED=1 run_groom --idea idea-danger 2>&1) || fail "armed unsafe run exited non-zero"$'\n'"$out" +assert_contains "$out" "FILED review item" "armed unsafe idea files a review item" +[ -e "$MOCK_STORE/review.log" ] || fail "armed unsafe idea must file a review item" +[ ! -e "$MOCK_STORE/spawn.log" ] || fail "armed unsafe idea must NOT dispatch" +grep -q 'groom:escalated' "$MOCK_STORE/labels-idea-danger" || fail "armed escalate must mark the idea groom:escalated" +pass "armed unsafe idea files a review item and marks the idea, no dispatch" + +pass "fm-groom: all rails hold" From 1e917ec09768aaa2565b8bba1cb61ad392899b07 Mon Sep 17 00:00:00 2001 From: Trillium Smith <trillium@trilliumsmith.com> Date: Wed, 15 Jul 2026 03:09:31 -0700 Subject: [PATCH 04/23] feat(review-page): render review-store items to visitable Pulse pages bin/fm-review-page.ts: for each open review item, write a self-contained, phone-readable HTML page under ~/pulse-pages/review/<id>/ served by Pulse, plus an index at the review root. --all sweeps every open item; positional ids render specific ones. Own dependency-free markdown renderer (headings, fenced code, lists, blockquote, links, bold/italic) in the GitHub-dark house style matching existing pulse-pages. Wires each page URL back onto the item (page_url metadata + a Page: note), idempotently. Read-only on item content except that safe append; parameterized by env for scheduling. --- bin/fm-review-page.ts | 810 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 810 insertions(+) create mode 100755 bin/fm-review-page.ts diff --git a/bin/fm-review-page.ts b/bin/fm-review-page.ts new file mode 100755 index 0000000000..2b7387d118 --- /dev/null +++ b/bin/fm-review-page.ts @@ -0,0 +1,810 @@ +#!/usr/bin/env bun +/** + * fm-review-page.ts — give every review-store item a visitable page. + * + * The captain's morning review should be *visiting pages*, not reading terse + * chat. Anything filed into the `review` federated store (the "requires the + * captain's eyes" queue) gets a self-contained, phone-readable HTML page under + * ~/pulse-pages/review/<id>/index.html, served by Pulse at + * http://localhost:31337/review/<id>/ (and 100.74.138.74:31337/review/<id>/ + * over Tailscale). An index at ~/pulse-pages/review/index.html lists every open + * item, reachable one click from /status/ (one-URL rule). + * + * SURFACE CHOICE — Pulse static pages, not lavish: + * lavish (~/.local/bin/lavish -> parlay) is a live, interactive review + * SESSION tool: it opens a browser, expects `lavish-axi poll` for feedback, + * and its only "shareable URL" path publishes to a third-party public host + * (ht-ml.app). It cannot be driven headlessly to emit a stable, persistent, + * Pulse-served, Tailscale-reachable URL. Pulse static pages are the robust, + * one-URL-rule-compliant path and match the existing /brain, /done, /brief + * pulse-page pattern. So this tool writes static HTML. + * + * WIRE-BACK: each rendered item is updated with `page_url` metadata AND a + * `Page: <url>` note, so the URL travels with the item and shows in + * `review show` / `review ready`. Both are idempotent (metadata is a set; + * the note is only appended when the page_url is new or changed). + * + * READ-ONLY on item CONTENT: the only writes to the store are the page_url + * metadata + note wire-back (a safe append). Everything else is `--json` + * reads. The intended file output lives entirely under the pulse-pages + * review root. + * + * IDEMPOTENT + SCHEDULABLE: `--all` re-renders every open item; re-running + * overwrites pages in place (no duplicates) and only re-notes changed URLs. + * Parameterized entirely by env, so it can run on a cadence or right after a + * review filing (e.g. after fm-idea-mine files its item). + * + * Example cron (NOT installed by this tool — documented only): + * # every 15 min, render open review items to visitable pages + * *\/15 * * * * /opt/homebrew/bin/bun \ + * ~/code/firstmate/bin/fm-review-page.ts --all >/dev/null 2>&1 + * + * ENV OVERRIDES (all optional; defaults are the live surfaces): + * FM_REVIEW_PAGE_OUT output root (default ~/pulse-pages/review) + * FM_REVIEW_PAGE_BASE_URL URL prefix (default http://localhost:31337/review) + * FM_REVIEW_BIN review CLI path (default `review` on PATH) + * FM_REVIEW_PAGE_NO_WIRE set to 1 to skip the store wire-back + * + * USAGE: + * fm-review-page.ts <id> [<id> ...] render specific open items + * fm-review-page.ts --all render every open review item + * fm-review-page.ts --help + */ + +import { spawnSync } from "node:child_process" +import { mkdirSync, writeFileSync } from "node:fs" +import { homedir } from "node:os" +import { join } from "node:path" + +// ─── Config (env-overridable) ──────────────────────────────────────────────── + +const HOME = homedir() +const OUT_ROOT = process.env.FM_REVIEW_PAGE_OUT || join(HOME, "pulse-pages", "review") +const BASE_URL = (process.env.FM_REVIEW_PAGE_BASE_URL || "http://localhost:31337/review").replace(/\/+$/, "") +const REVIEW_BIN = process.env.FM_REVIEW_BIN || "review" +const SKIP_WIRE = process.env.FM_REVIEW_PAGE_NO_WIRE === "1" +const CMD_TIMEOUT_MS = 20_000 + +// ─── Store types ───────────────────────────────────────────────────────────── + +/** Shape of one `review list --json` / `review show --json` record we rely on. */ +interface RawReviewItem { + id: string + title?: string + description?: string + status?: string + priority?: number + issue_type?: string + owner?: string + created_at?: string + updated_at?: string + metadata?: Record<string, unknown> | null +} + +/** A normalized item ready to render. */ +interface ReviewItem { + id: string + title: string + description: string + status: string + priority: number | null + issueType: string + owner: string + createdAt: string + updatedAt: string + /** Any existing page_url already wired back (used to keep notes idempotent). */ + existingPageUrl: string +} + +/** An artifact link extracted from the body (URL, brain id, git branch). */ +interface Artifact { + kind: "url" | "brain" | "branch" + label: string + /** Clickable href when web-addressable, else null. */ + href: string | null +} + +// ─── review CLI (read + wire-back) ─────────────────────────────────────────── + +/** + * Run the review CLI and return trimmed stdout, or throw with context. The + * subprocess is bounded by a timeout; a missing binary, non-zero exit, or a + * timeout all raise so the caller can degrade explicitly rather than silently + * writing a wrong page. + */ +function runReview(args: string[]): string { + const res = spawnSync(REVIEW_BIN, args, { + encoding: "utf8", + timeout: CMD_TIMEOUT_MS, + // Never inherit stdin; some bd verbs open a pager/editor if a tty is present. + stdio: ["ignore", "pipe", "pipe"], + env: { ...process.env, PAGER: "cat", GIT_PAGER: "cat" }, + }) + if (res.error) { + throw new Error(`review ${args.join(" ")}: ${res.error.message}`) + } + if (typeof res.status === "number" && res.status !== 0) { + const stderr = (res.stderr || "").trim() + throw new Error(`review ${args.join(" ")} exited ${res.status}${stderr ? `: ${stderr}` : ""}`) + } + return (res.stdout || "").trim() +} + +/** Parse a `review ... --json` payload that may be a single object or a list. */ +function parseJsonItems(raw: string): RawReviewItem[] { + if (!raw) return [] + let parsed: unknown + try { + parsed = JSON.parse(raw) + } catch (err) { + throw new Error(`review returned unparseable JSON: ${(err as Error).message}`) + } + if (Array.isArray(parsed)) return parsed as RawReviewItem[] + if (parsed && typeof parsed === "object") return [parsed as RawReviewItem] + return [] +} + +/** Normalize a raw record, filling defaults so rendering never sees undefined. */ +function normalize(raw: RawReviewItem): ReviewItem { + const meta = (raw.metadata ?? {}) as Record<string, unknown> + const pageUrl = typeof meta.page_url === "string" ? meta.page_url : "" + return { + id: raw.id, + title: (raw.title ?? "").trim() || raw.id, + description: raw.description ?? "", + status: (raw.status ?? "open").trim(), + priority: typeof raw.priority === "number" ? raw.priority : null, + issueType: (raw.issue_type ?? "").trim(), + owner: (raw.owner ?? "").trim(), + createdAt: (raw.created_at ?? "").trim(), + updatedAt: (raw.updated_at ?? "").trim(), + existingPageUrl: pageUrl, + } +} + +/** Fetch every OPEN review item (the `--all` set). */ +function fetchAllOpen(): ReviewItem[] { + // `list --json` returns non-closed items by default; keep only open-ish ones. + const raws = parseJsonItems(runReview(["list", "--json", "--limit", "0"])) + return raws + .filter((r) => r.id && !isClosed(r.status)) + .map(normalize) + .sort(byPriorityThenAge) +} + +/** Fetch specific items by id (used for the positional-id path). */ +function fetchByIds(ids: string[]): ReviewItem[] { + const raws = parseJsonItems(runReview(["show", "--json", ...ids])) + const found = new Map(raws.filter((r) => r.id).map((r) => [r.id, normalize(r)])) + // Preserve caller order and surface any ids the store didn't return. + const out: ReviewItem[] = [] + for (const id of ids) { + const item = found.get(id) + if (item) out.push(item) + else console.error(`warning: review item not found: ${id}`) + } + return out +} + +function isClosed(status: string | undefined): boolean { + const s = (status ?? "").toLowerCase() + return s === "closed" || s === "done" || s === "resolved" +} + +/** Priority ascending (0 = highest), then oldest-first within a priority. */ +function byPriorityThenAge(a: ReviewItem, b: ReviewItem): number { + const pa = a.priority ?? 99 + const pb = b.priority ?? 99 + if (pa !== pb) return pa - pb + return a.createdAt.localeCompare(b.createdAt) +} + +/** + * Wire the page URL back onto the item: set `page_url` metadata (idempotent + * set) and append a `Page: <url>` note only when the URL is new or changed, so + * re-runs never pile up duplicate notes. Best-effort: a wire-back failure is + * reported but never fails the render (the page already exists on disk). + */ +function wireBack(item: ReviewItem, url: string): { ok: boolean; noted: boolean; error?: string } { + if (SKIP_WIRE) return { ok: true, noted: false } + try { + runReview(["update", item.id, "--set-metadata", `page_url=${url}`]) + const changed = item.existingPageUrl !== url + if (changed) { + runReview(["note", item.id, `Page: ${url}`]) + } + return { ok: true, noted: changed } + } catch (err) { + return { ok: false, noted: false, error: (err as Error).message } + } +} + +// ─── Artifact extraction ───────────────────────────────────────────────────── + +/** + * Pull artifact links out of the body prose: http(s) URLs, brain doc ids + * (brain-xxxx), and git branch names (branch feat/foo or `feat/foo`). De-duped, + * capped, and rendered as a small link/reference list on the page. + */ +function extractArtifacts(body: string): Artifact[] { + const seen = new Set<string>() + const out: Artifact[] = [] + + const push = (a: Artifact) => { + const key = `${a.kind}:${a.label}` + if (seen.has(key)) return + seen.add(key) + out.push(a) + } + + // URLs first — strip trailing punctuation that clings to prose links. + const urlRe = /https?:\/\/[^\s<>"')\]]+/g + for (const m of body.matchAll(urlRe)) { + const url = m[0].replace(/[.,;:]+$/, "") + push({ kind: "url", label: url, href: url }) + } + + // brain/robots/decision-style doc ids: <word>-<alnum> tokens like brain-k0zr4. + const brainRe = /\b(brain|robots|review|decisions?|ideas?|task|isa)-[a-z0-9]{3,}\b/gi + for (const m of body.matchAll(brainRe)) { + push({ kind: "brain", label: m[0], href: null }) + } + + // git branches: `feat/...` in backticks or after the word "branch". + const branchRe = /\b(?:branch\s+)?((?:feat|fix|chore|refactor|docs|test)\/[A-Za-z0-9._\-/]+)/g + for (const m of body.matchAll(branchRe)) { + push({ kind: "branch", label: m[1], href: null }) + } + + return out.slice(0, 20) +} + +// ─── Minimal, self-contained markdown → HTML ───────────────────────────────── + +/** HTML-escape text for safe insertion into element bodies and attributes. */ +function esc(s: string): string { + return s + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, """) + .replace(/'/g, "'") +} + +/** + * Render inline markdown (already HTML-escaped input is NOT assumed — this + * escapes as it goes): `code`, **bold**, *italic*, [text](url), and bare URLs. + * Order matters: code spans are extracted first so their contents are never + * re-processed as emphasis or links. + */ +function renderInline(text: string): string { + const codeSpans: string[] = [] + // 1. Pull out `code` spans, replacing with a placeholder unlikely to collide. + let work = text.replace(/`([^`]+)`/g, (_m, code: string) => { + const idx = codeSpans.push(`<code>${esc(code)}</code>`) - 1 + return `�CODE${idx}�` + }) + // 2. Escape everything else. + work = esc(work) + // 3. Markdown links [label](href) — href restricted to http(s)/relative. + work = work.replace( + /\[([^\]]+)\]\((https?:\/\/[^\s)]+|\/[^\s)]*)\)/g, + (_m, label: string, href: string) => `<a href="${href}" rel="noopener">${label}</a>`, + ) + // 4. Bare URLs (not already inside an <a>). + work = work.replace( + /(^|[\s(])(https?:\/\/[^\s<>"')]+)/g, + (_m, pre: string, url: string) => `${pre}<a href="${url}" rel="noopener">${url}</a>`, + ) + // 5. Bold then italic. + work = work.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>") + work = work.replace(/(^|[^*])\*([^*\n]+)\*/g, "$1<em>$2</em>") + // 6. Restore code spans. + work = work.replace(/�CODE(\d+)�/g, (_m, idx: string) => codeSpans[Number(idx)] ?? "") + return work +} + +/** + * Render a markdown block into HTML: ATX headings, fenced code, unordered and + * ordered lists, blockquotes, horizontal rules, and paragraphs. Deliberately + * small — enough for the review-item bodies (which are WHY/ARTIFACT/STAKES + * prose with occasional code and lists), self-contained, no dependencies. + */ +function renderMarkdown(md: string): string { + const lines = md.replace(/\r\n/g, "\n").split("\n") + const html: string[] = [] + let i = 0 + + const flushList = (buf: string[], ordered: boolean) => { + if (!buf.length) return + const tag = ordered ? "ol" : "ul" + html.push(`<${tag}>${buf.map((li) => `<li>${renderInline(li)}</li>`).join("")}</${tag}>`) + buf.length = 0 + } + + while (i < lines.length) { + const line = lines[i] + + // Fenced code block. + const fence = line.match(/^```(\w*)\s*$/) + if (fence) { + const lang = fence[1] || "" + const code: string[] = [] + i++ + while (i < lines.length && !/^```\s*$/.test(lines[i])) { + code.push(lines[i]) + i++ + } + i++ // consume closing fence + const cls = lang ? ` class="lang-${esc(lang)}"` : "" + html.push(`<pre class="codeblock"><code${cls}>${esc(code.join("\n"))}</code></pre>`) + continue + } + + // ATX heading. + const heading = line.match(/^(#{1,6})\s+(.*)$/) + if (heading) { + const level = heading[1].length + html.push(`<h${level}>${renderInline(heading[2].trim())}</h${level}>`) + i++ + continue + } + + // Horizontal rule. + if (/^(-{3,}|\*{3,}|_{3,})\s*$/.test(line)) { + html.push("<hr>") + i++ + continue + } + + // Blockquote (consecutive `>` lines). + if (/^>\s?/.test(line)) { + const quote: string[] = [] + while (i < lines.length && /^>\s?/.test(lines[i])) { + quote.push(lines[i].replace(/^>\s?/, "")) + i++ + } + html.push(`<blockquote>${renderInline(quote.join(" "))}</blockquote>`) + continue + } + + // Unordered list. + if (/^\s*[-*+]\s+/.test(line)) { + const buf: string[] = [] + while (i < lines.length && /^\s*[-*+]\s+/.test(lines[i])) { + buf.push(lines[i].replace(/^\s*[-*+]\s+/, "")) + i++ + } + flushList(buf, false) + continue + } + + // Ordered list. + if (/^\s*\d+[.)]\s+/.test(line)) { + const buf: string[] = [] + while (i < lines.length && /^\s*\d+[.)]\s+/.test(lines[i])) { + buf.push(lines[i].replace(/^\s*\d+[.)]\s+/, "")) + i++ + } + flushList(buf, true) + continue + } + + // Blank line. + if (/^\s*$/.test(line)) { + i++ + continue + } + + // Paragraph: gather until a blank line or a block-starting line. + const para: string[] = [] + while ( + i < lines.length && + !/^\s*$/.test(lines[i]) && + !/^(#{1,6}\s|```|>|\s*[-*+]\s+|\s*\d+[.)]\s+)/.test(lines[i]) && + !/^(-{3,}|\*{3,}|_{3,})\s*$/.test(lines[i]) + ) { + para.push(lines[i]) + i++ + } + html.push(`<p>${renderInline(para.join(" "))}</p>`) + } + + return html.join("\n") +} + +// ─── Presentation helpers ──────────────────────────────────────────────────── + +/** Human date like "Jul 15, 2026 · 07:47 UTC" from an ISO timestamp. */ +function formatDate(iso: string): string { + if (!iso) return "unknown" + const d = new Date(iso) + if (Number.isNaN(d.getTime())) return iso + const date = d.toLocaleDateString("en-US", { + year: "numeric", + month: "short", + day: "numeric", + timeZone: "UTC", + }) + const time = d.toLocaleTimeString("en-US", { + hour: "2-digit", + minute: "2-digit", + hour12: false, + timeZone: "UTC", + }) + return `${date} · ${time} UTC` +} + +/** Days waited since createdAt, floored at 0. */ +function ageDays(iso: string): number { + if (!iso) return 0 + const then = new Date(iso).getTime() + if (Number.isNaN(then)) return 0 + return Math.max(0, Math.floor((Date.now() - then) / 86_400_000)) +} + +function priorityLabel(p: number | null): string { + if (p === null) return "—" + return `P${p}` +} + +/** + * Extract a "recommended action" / "stakes" hint from the body if present. + * These review items conventionally carry `## Stakes` and action lines; we + * surface the first Stakes block as a callout. Returns "" when absent. + */ +function extractStakes(body: string): string { + // A `## Stakes` (or STAKES:) section up to the next heading/blank-run. + const m = body.match(/(?:^|\n)#{0,6}\s*stakes[:\s]*\n?([\s\S]*?)(?=\n#{1,6}\s|\n\s*\n#|$)/i) + if (m && m[1].trim()) return m[1].trim() + // Inline "STAKES: ..." single line. + const inline = body.match(/\bSTAKES:\s*([^\n]+)/i) + return inline ? inline[1].trim() : "" +} + +// ─── Page rendering ────────────────────────────────────────────────────────── + +/** Shared CSS — GitHub-dark house palette matching ~/pulse-pages/brain style. */ +const PAGE_CSS = ` +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } +:root { + --ink: #0D1117; --surf: #161B22; --surf2: #1C2128; --surf3: #21262D; + --border: #30363D; --muted: #7D8590; --body: #E6EDF3; + --green: #3FB950; --blue: #58A6FF; --amber: #F0B429; + --red: #FF7B72; --purple: #D2A8FF; + --mono: 'SFMono-Regular','SF Mono',Menlo,Consolas,'Courier New',monospace; + --sans: -apple-system,'SF Pro Text','Segoe UI',system-ui,sans-serif; +} +html { background: var(--ink); color: var(--body); font-family: var(--sans); font-size: 15px; -webkit-text-size-adjust: 100%; } +body { min-height: 100dvh; line-height: 1.55; } +a { color: var(--blue); text-decoration: none; word-break: break-word; } +a:hover { text-decoration: underline; } +.wrap { max-width: 760px; margin: 0 auto; padding: 1.25rem 1.1rem 5rem; } + +.topbar { display: flex; align-items: center; gap: .6rem; flex-wrap: wrap; margin-bottom: 1.25rem; } +.topbar .back { font-family: var(--mono); font-size: 11px; letter-spacing: .08em; text-transform: uppercase; color: var(--muted); } +.crumb { font-family: var(--mono); font-size: 11px; color: var(--muted); } + +.badges { display: flex; gap: .4rem; flex-wrap: wrap; margin: .4rem 0 1rem; } +.badge { + font-family: var(--mono); font-size: 10px; font-weight: 700; letter-spacing: .07em; + text-transform: uppercase; padding: 3px 9px; border: 1px solid var(--border); + color: var(--muted); background: var(--ink); border-radius: 3px; white-space: nowrap; +} +.badge.p0, .badge.p1 { color: var(--red); border-color: color-mix(in srgb, var(--red) 55%, var(--border)); } +.badge.p2 { color: var(--amber); border-color: color-mix(in srgb, var(--amber) 55%, var(--border)); } +.badge.status { color: var(--green); border-color: color-mix(in srgb, var(--green) 45%, var(--border)); } +.badge.age-stale { color: var(--red); border-color: color-mix(in srgb, var(--red) 55%, var(--border)); } +.badge.age-aging { color: var(--amber); border-color: color-mix(in srgb, var(--amber) 55%, var(--border)); } + +h1.item-title { font-size: 1.5rem; line-height: 1.25; font-weight: 700; margin-bottom: .35rem; } + +.callout { + border: 1px solid color-mix(in srgb, var(--amber) 45%, var(--border)); + background: color-mix(in srgb, var(--amber) 8%, var(--surf)); + border-left: 3px solid var(--amber); + padding: .7rem .9rem; border-radius: 4px; margin: 1rem 0; +} +.callout .label { font-family: var(--mono); font-size: 10px; font-weight: 700; letter-spacing: .1em; text-transform: uppercase; color: var(--amber); display: block; margin-bottom: .3rem; } + +.artifacts { margin: 1rem 0; } +.artifacts .label { font-family: var(--mono); font-size: 10px; font-weight: 700; letter-spacing: .1em; text-transform: uppercase; color: var(--muted); display: block; margin-bottom: .45rem; } +.artifacts ul { list-style: none; display: flex; flex-direction: column; gap: .3rem; } +.artifacts li { display: flex; align-items: baseline; gap: .5rem; font-size: .9rem; } +.artifacts .k { font-family: var(--mono); font-size: 9px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; color: var(--muted); border: 1px solid var(--border); padding: 1px 5px; border-radius: 3px; flex-shrink: 0; } +.artifacts code { font-family: var(--mono); font-size: .85rem; color: var(--purple); } + +.body { margin-top: 1.4rem; } +.body h1, .body h2, .body h3, .body h4 { margin: 1.4rem 0 .5rem; line-height: 1.3; } +.body h1 { font-size: 1.3rem; } .body h2 { font-size: 1.15rem; } +.body h3 { font-size: 1rem; } .body h4 { font-size: .92rem; color: var(--muted); } +.body h2 { border-bottom: 1px solid var(--border); padding-bottom: .3rem; } +.body p { margin: .7rem 0; } +.body ul, .body ol { margin: .7rem 0 .7rem 1.4rem; } +.body li { margin: .25rem 0; } +.body blockquote { border-left: 3px solid var(--border); padding: .2rem .9rem; margin: .8rem 0; color: var(--muted); } +.body hr { border: none; border-top: 1px solid var(--border); margin: 1.4rem 0; } +.body a { } +.body code { font-family: var(--mono); font-size: .86em; background: var(--surf3); padding: 1px 5px; border-radius: 3px; color: var(--purple); } +.body pre.codeblock { + background: var(--surf); border: 1px solid var(--border); border-radius: 5px; + padding: .8rem .9rem; margin: .9rem 0; overflow-x: auto; -webkit-overflow-scrolling: touch; +} +.body pre.codeblock code { background: none; padding: 0; color: var(--body); font-size: .82rem; line-height: 1.5; white-space: pre; } + +.meta-foot { margin-top: 2.2rem; padding-top: 1rem; border-top: 1px solid var(--border); font-family: var(--mono); font-size: 11px; color: var(--muted); display: flex; flex-direction: column; gap: .3rem; } + +/* Index page. */ +.index-head { margin-bottom: 1.4rem; } +.index-head h1 { font-size: 1.35rem; font-weight: 700; } +.index-head .sub { color: var(--muted); font-size: .9rem; margin-top: .25rem; } +.list { display: flex; flex-direction: column; gap: .7rem; } +.card { + display: block; border: 1px solid var(--border); background: var(--surf); + border-radius: 6px; padding: .85rem 1rem; text-decoration: none; color: var(--body); +} +.card:hover { border-color: var(--blue); text-decoration: none; } +.card .row1 { display: flex; align-items: baseline; gap: .55rem; flex-wrap: wrap; } +.card .ct { font-weight: 600; font-size: 1rem; line-height: 1.3; } +.card .cmeta { margin-top: .35rem; display: flex; gap: .5rem; flex-wrap: wrap; } +.empty { color: var(--muted); font-style: italic; padding: 2rem 0; text-align: center; } +`.trim() + +/** Age tier -> badge class + label. */ +function ageTier(days: number): { cls: string; label: string } { + if (days >= 3) return { cls: "age-stale", label: `${days}d waiting` } + if (days >= 1) return { cls: "age-aging", label: `${days}d waiting` } + return { cls: "", label: "today" } +} + +/** Full self-contained HTML document for one review item. */ +function renderItemPage(item: ReviewItem): string { + const days = ageDays(item.createdAt) + const tier = ageTier(days) + const stakes = extractStakes(item.description) + const artifacts = extractArtifacts(item.description) + const bodyHtml = item.description.trim() + ? renderMarkdown(item.description) + : `<p class="empty">No description was filed with this item.</p>` + + const pClass = item.priority !== null ? ` p${item.priority}` : "" + + const badges = [ + `<span class="badge status">${esc(item.status)}</span>`, + `<span class="badge${pClass}">priority ${esc(priorityLabel(item.priority))}</span>`, + item.issueType ? `<span class="badge">${esc(item.issueType)}</span>` : "", + `<span class="badge ${tier.cls}">${esc(tier.label)}</span>`, + `<span class="badge">${esc(item.id)}</span>`, + ] + .filter(Boolean) + .join("\n ") + + const calloutHtml = stakes + ? `\n <div class="callout"> + <span class="label">Stakes / recommended action</span> + ${renderInline(stakes)} + </div>` + : "" + + const artifactsHtml = artifacts.length + ? `\n <div class="artifacts"> + <span class="label">Artifacts</span> + <ul> + ${artifacts + .map((a) => { + const val = a.href + ? `<a href="${esc(a.href)}" rel="noopener">${esc(a.label)}</a>` + : `<code>${esc(a.label)}</code>` + return `<li><span class="k">${esc(a.kind)}</span>${val}</li>` + }) + .join("\n ")} + </ul> + </div>` + : "" + + return `<!doctype html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>Review · ${esc(item.title)} + + + +
+
+ ‹ Review queue + for the captain's eyes +
+

${esc(item.title)}

+
+ ${badges} +
${calloutHtml}${artifactsHtml} +
+${bodyHtml} +
+
+ filed ${esc(formatDate(item.createdAt))}${ + item.owner ? ` · for ${esc(item.owner)}` : "" + } + updated ${esc(formatDate(item.updatedAt))} + item ${esc(item.id)} +
+
+ + +` +} + +/** Full self-contained HTML index listing every open review item. */ +function renderIndexPage(items: ReviewItem[]): string { + const now = new Date().toLocaleString("en-US", { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + hour12: false, + timeZone: "UTC", + }) + + const cards = items.length + ? items + .map((item) => { + const days = ageDays(item.createdAt) + const tier = ageTier(days) + const pClass = item.priority !== null ? ` p${item.priority}` : "" + return ` +
+ ${esc(item.title)} +
+
+ ${esc(item.status)} + priority ${esc(priorityLabel(item.priority))} + ${esc(tier.label)} + ${esc(item.id)} +
+
` + }) + .join("\n ") + : `
The review queue is clear — nothing needs your eyes right now.
` + + return ` + + + + +Review queue · needs your eyes + + + +
+
+

Review queue

+
${items.length} item${items.length === 1 ? "" : "s"} waiting on your decision · as of ${esc( + now, + )} UTC
+
+
+ ${cards} +
+
+ each card opens the full item on its own page + served by Pulse · reachable from /status/ +
+
+ + +` +} + +// ─── Filesystem output ─────────────────────────────────────────────────────── + +/** Write one item's page and return its public URL. Idempotent (overwrite). */ +function writeItemPage(item: ReviewItem): string { + const dir = join(OUT_ROOT, item.id) + mkdirSync(dir, { recursive: true }) + writeFileSync(join(dir, "index.html"), renderItemPage(item)) + return `${BASE_URL}/${item.id}/` +} + +/** Write the index page and return its URL. */ +function writeIndexPage(items: ReviewItem[]): string { + mkdirSync(OUT_ROOT, { recursive: true }) + writeFileSync(join(OUT_ROOT, "index.html"), renderIndexPage(items)) + return `${BASE_URL}/` +} + +// ─── CLI ───────────────────────────────────────────────────────────────────── + +const HELP = `fm-review-page.ts — render review-store items to visitable Pulse pages. + +USAGE + fm-review-page.ts [ ...] render specific open review items + fm-review-page.ts --all render every open review item + index + fm-review-page.ts --help + +Each rendered page lives at \${FM_REVIEW_PAGE_OUT}//index.html and is served +at \${FM_REVIEW_PAGE_BASE_URL}//. An index at the root lists all open items. +The item is wired back with its page_url (metadata + a "Page: " note) so the +URL travels with it in \`review show\` / \`review ready\`. + +ENV + FM_REVIEW_PAGE_OUT output root (default ~/pulse-pages/review) + FM_REVIEW_PAGE_BASE_URL URL prefix (default http://localhost:31337/review) + FM_REVIEW_BIN review CLI path (default \`review\` on PATH) + FM_REVIEW_PAGE_NO_WIRE set 1 to skip the store wire-back +` + +function main(argv: string[]): number { + const args = argv.slice(2) + if (args.includes("--help") || args.includes("-h")) { + console.log(HELP) + return 0 + } + + const all = args.includes("--all") + const ids = args.filter((a) => !a.startsWith("-")) + + if (!all && ids.length === 0) { + console.error("error: pass one or more item ids, or --all") + console.error("run with --help for usage") + return 2 + } + + let items: ReviewItem[] + try { + items = all ? fetchAllOpen() : fetchByIds(ids) + } catch (err) { + console.error(`error: could not read review store: ${(err as Error).message}`) + return 1 + } + + if (items.length === 0) { + // --all over an empty queue still (re)writes an accurate empty index. + if (all) { + const indexUrl = writeIndexPage(items) + console.log(`index: ${indexUrl} (queue empty)`) + return 0 + } + console.error("error: no matching open review items") + return 1 + } + + let wireFailures = 0 + for (const item of items) { + const url = writeItemPage(item) + const wired = wireBack(item, url) + if (!wired.ok) { + wireFailures++ + console.error(` wire-back failed for ${item.id}: ${wired.error}`) + } + const noteFlag = wired.noted ? " (noted)" : wired.ok ? " (url unchanged)" : " (wire-back failed)" + console.log(`page: ${url}${noteFlag} — ${item.title}`) + } + + // Always refresh the index so it reflects the full open set, even when + // rendering a single id (the queue may have changed around it). + let indexItems = items + if (!all) { + try { + indexItems = fetchAllOpen() + } catch { + // Fall back to just the rendered items if the full re-read fails. + indexItems = items + } + } + const indexUrl = writeIndexPage(indexItems) + console.log(`index: ${indexUrl}`) + + if (wireFailures > 0) { + console.error(`warning: ${wireFailures} wire-back(s) failed; pages were still written`) + return 3 + } + return 0 +} + +process.exit(main(process.argv)) From 8c8d9129ab130d7a9c24f251fe3bf11ca8fdfa7f Mon Sep 17 00:00:00 2001 From: Trillium Smith Date: Wed, 15 Jul 2026 03:13:41 -0700 Subject: [PATCH 05/23] test(review-page): hermetic behavior tests for fm-review-page tests/fm-review-page.test.sh: network-free suite stubbing the review CLI on a fakebin PATH (read verbs emit canned JSON, write verbs log calls) and writing pages to a temp FM_REVIEW_PAGE_OUT. Covers single-id render, --all sweep over N items, idempotent re-render (no dup dirs, note skipped on unchanged url), artifact link rendering (url/brain/branch), markdown body rendering, wire-back assertions, empty-queue index, unknown id, and the no-args usage error. shellcheck-clean under the canonical whole-set invocation. --- tests/fm-review-page.test.sh | 292 +++++++++++++++++++++++++++++++++++ 1 file changed, 292 insertions(+) create mode 100755 tests/fm-review-page.test.sh diff --git a/tests/fm-review-page.test.sh b/tests/fm-review-page.test.sh new file mode 100755 index 0000000000..bf96fc37a9 --- /dev/null +++ b/tests/fm-review-page.test.sh @@ -0,0 +1,292 @@ +#!/usr/bin/env bash +# Behavior tests for bin/fm-review-page.ts — the tool that gives every review- +# store item a visitable Pulse page. +# +# Hermetic and network-free. The `review` CLI is stubbed with a bash fake on a +# fakebin PATH (FM_REVIEW_BIN points at it) whose read verbs emit canned JSON and +# whose write verbs (update/note) append to a call log so wire-back is asserted +# without ever touching the real store. Pages are written to a throwaway +# FM_REVIEW_PAGE_OUT temp dir. Cases pin every branch that matters: +# (a) single-id render -> a page file + a printed URL +# (b) --all sweep over N -> N pages + an index listing all N +# (c) idempotent re-render -> no duplicate dirs, note skipped when url unchanged +# (d) artifact links -> url / brain id / branch rendered on the page +# (e) markdown body -> heading, bold, list, fenced code all rendered +# (f) wire-back -> page_url metadata set + Page: note on first render +# (g) empty --all queue -> an accurate empty index, exit 0 +# (h) unknown id -> warning + non-zero, no page +# (i) no args -> usage error, exit 2 +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +TOOL="$ROOT/bin/fm-review-page.ts" +BASE_URL="http://localhost:31337/review" + +# Locate a bun runtime; skip (do not fail) if the host has none, matching the +# repo's tolerance for missing optional toolchains in unit runs. +if ! command -v bun >/dev/null 2>&1; then + echo "ok - fm-review-page: SKIP (bun not installed)" + exit 0 +fi + +TMP_ROOT=$(fm_test_tmproot fm-review-page) + +# --------------------------------------------------------------------------- +# install_fake_review +# +# Writes a `review` stub into that: +# - for `list`/`show`, prints the contents of verbatim +# (a JSON array of records), so both fetch paths are exercised. +# - for `update`/`note`, appends the full argv to so wire-back +# calls are asserted. +# --------------------------------------------------------------------------- +install_fake_review() { + local dir=$1 items=$2 log=$3 + mkdir -p "$dir" + cat > "$dir/review" <> "$log" ;; + *) exit 0 ;; +esac +SH + chmod +x "$dir/review" +} + +# A canned two-item open queue. Item one carries rich markdown + artifacts; item +# two is minimal. No page_url in metadata, so the first render must note. +write_items_json() { + local file=$1 + cat > "$file" <<'JSON' +[ + { + "id": "review-aaa", + "title": "Merge the bridge", + "description": "## What\nMerge **the bridge** now.\n\n- first point\n- second point\n\n```bash\necho run\n```\n\nSee https://github.com/foo/bar/pull/7 and brain-k0zr4 on branch feat/nightshift-bridge.\n\n## Stakes\nLOW, bounded by design.", + "status": "open", + "priority": 1, + "issue_type": "task", + "owner": "trillium@x.com", + "created_at": "2026-07-12T07:47:19Z", + "updated_at": "2026-07-15T07:47:19Z", + "metadata": { "brain_slug": "merge-the-bridge" } + }, + { + "id": "review-bbb", + "title": "Enable the groomer", + "description": "A short body with no special markup.", + "status": "open", + "priority": 2, + "issue_type": "task", + "owner": "trillium@x.com", + "created_at": "2026-07-14T09:53:53Z", + "updated_at": "2026-07-15T09:53:54Z", + "metadata": {} + } +] +JSON +} + +# run_tool -> captures stdout in $OUTPUT +run_tool() { + local fakebin=$1 out=$2 items=$3 log=$4 + shift 4 + OUTPUT=$( + FM_REVIEW_BIN="$fakebin/review" \ + FM_REVIEW_PAGE_OUT="$out" \ + FM_REVIEW_PAGE_BASE_URL="$BASE_URL" \ + bun "$TOOL" "$@" 2>&1 + ) + RC=$? +} + +# =========================================================================== +# (b)+(e)+(f) --all sweep over N items renders N pages + index, with markdown +# and wire-back. +# =========================================================================== +t_all_sweep() { + local case_dir="$TMP_ROOT/all" + local fakebin="$case_dir/bin" out="$case_dir/out" + local items="$case_dir/items.json" log="$case_dir/calls.log" + mkdir -p "$case_dir" + write_items_json "$items" + install_fake_review "$fakebin" "$items" "$log" + + run_tool "$fakebin" "$out" "$items" "$log" --all + expect_code 0 "$RC" "all: exit 0" + + # N pages exist. + assert_present "$out/review-aaa/index.html" "all: page for review-aaa written" + assert_present "$out/review-bbb/index.html" "all: page for review-bbb written" + assert_present "$out/index.html" "all: index written" + + # index lists both items with links to their pages. + assert_grep 'href="./review-aaa/"' "$out/index.html" "all: index links review-aaa" + assert_grep 'href="./review-bbb/"' "$out/index.html" "all: index links review-bbb" + assert_grep '2 items' "$out/index.html" "all: index reports item count" + + # markdown rendered on item one (e). + local page="$out/review-aaa/index.html" + assert_grep 'the bridge' "$page" "md: bold rendered" + assert_grep '
  • first point
  • ' "$page" "md: list rendered" + assert_grep 'class="codeblock"' "$page" "md: fenced code rendered" + assert_grep '

    What

    ' "$page" "md: heading rendered" + + # printed URLs (b). + assert_contains "$OUTPUT" "$BASE_URL/review-aaa/" "all: printed review-aaa url" + assert_contains "$OUTPUT" "$BASE_URL/review-bbb/" "all: printed review-bbb url" + assert_contains "$OUTPUT" "index: $BASE_URL/" "all: printed index url" + + # wire-back (f): metadata set + note for BOTH items (no prior page_url). + assert_grep "update review-aaa --set-metadata page_url=$BASE_URL/review-aaa/" "$log" "wire: aaa metadata set" + assert_grep "note review-aaa Page: $BASE_URL/review-aaa/" "$log" "wire: aaa noted" + assert_grep "update review-bbb --set-metadata page_url=$BASE_URL/review-bbb/" "$log" "wire: bbb metadata set" + + pass "fm-review-page: --all sweep renders N pages + index with markdown + wire-back" +} + +# =========================================================================== +# (d) artifact links: url is clickable, brain id + branch are shown. +# =========================================================================== +t_artifacts() { + local case_dir="$TMP_ROOT/artifacts" + local fakebin="$case_dir/bin" out="$case_dir/out" + local items="$case_dir/items.json" log="$case_dir/calls.log" + mkdir -p "$case_dir" + write_items_json "$items" + install_fake_review "$fakebin" "$items" "$log" + + run_tool "$fakebin" "$out" "$items" "$log" --all + local page="$out/review-aaa/index.html" + + assert_grep 'https://github.com/foo/bar/pull/7' "$page" "artifact: url present" + assert_grep ' "$items" <<'JSON' +[{"id":"review-aaa","title":"Just one","description":"solo body","status":"open","priority":1,"created_at":"2026-07-14T07:47:19Z","updated_at":"2026-07-14T07:47:19Z","metadata":{}}] +JSON + install_fake_review "$fakebin" "$items" "$log" + + run_tool "$fakebin" "$out" "$items" "$log" review-aaa + expect_code 0 "$RC" "single: exit 0" + assert_present "$out/review-aaa/index.html" "single: page written" + assert_contains "$OUTPUT" "$BASE_URL/review-aaa/" "single: printed url" + # even a single-id render refreshes the index. + assert_present "$out/index.html" "single: index refreshed" + + pass "fm-review-page: single-id render produces a page file + URL" +} + +# =========================================================================== +# (c) idempotent re-render: no duplicate dirs; note skipped when url unchanged. +# =========================================================================== +t_idempotent() { + local case_dir="$TMP_ROOT/idem" + local fakebin="$case_dir/bin" out="$case_dir/out" + local items="$case_dir/items.json" log="$case_dir/calls.log" + mkdir -p "$case_dir" + # metadata already carries the exact page_url the tool would compute. + cat > "$items" < "$items" + install_fake_review "$fakebin" "$items" "$log" + + run_tool "$fakebin" "$out" "$items" "$log" --all + expect_code 0 "$RC" "empty: exit 0" + assert_present "$out/index.html" "empty: index written" + assert_grep 'review queue is clear' "$out/index.html" "empty: index shows clear-queue message" + assert_absent "$log" "empty: no wire-back calls for empty queue" + + pass "fm-review-page: empty --all queue writes an accurate empty index" +} + +# =========================================================================== +# (h) unknown id -> warning + non-zero exit, no page. +# =========================================================================== +t_unknown_id() { + local case_dir="$TMP_ROOT/unknown" + local fakebin="$case_dir/bin" out="$case_dir/out" + local items="$case_dir/items.json" log="$case_dir/calls.log" + mkdir -p "$case_dir" + # show returns nothing for the requested id. + printf '[]\n' > "$items" + install_fake_review "$fakebin" "$items" "$log" + + run_tool "$fakebin" "$out" "$items" "$log" review-missing + [ "$RC" -ne 0 ] || fail "unknown: expected non-zero exit, got 0" + assert_contains "$OUTPUT" "not found: review-missing" "unknown: warns about missing id" + assert_absent "$out/review-missing/index.html" "unknown: no page for missing id" + + pass "fm-review-page: unknown id warns and exits non-zero" +} + +# =========================================================================== +# (i) no args -> usage error, exit 2. +# =========================================================================== +t_no_args() { + local case_dir="$TMP_ROOT/noargs" + local fakebin="$case_dir/bin" out="$case_dir/out" + local items="$case_dir/items.json" log="$case_dir/calls.log" + mkdir -p "$case_dir" + printf '[]\n' > "$items" + install_fake_review "$fakebin" "$items" "$log" + + run_tool "$fakebin" "$out" "$items" "$log" + expect_code 2 "$RC" "noargs: exit 2" + assert_contains "$OUTPUT" "pass one or more item ids" "noargs: usage error printed" + + pass "fm-review-page: no args is a usage error (exit 2)" +} + +t_all_sweep +t_artifacts +t_single +t_idempotent +t_empty +t_unknown_id +t_no_args From 527a5d3399e74ce0f6971f51c65a5b75ef7b5882 Mon Sep 17 00:00:00 2001 From: Trillium Smith Date: Wed, 15 Jul 2026 03:22:18 -0700 Subject: [PATCH 06/23] fix(review-page): eliminate code-span placeholder leak in markdown renderer The inline renderer used a text placeholder to protect `code` spans across the bold/italic/link passes; that placeholder corrupted into NUL bytes and leaked bare CODE0/CODE1 tokens into rendered pages (visible on the live fm-groom item). Replace it with a split-based renderer: split on the code-span capture group so even indices are prose (escaped + emphasized) and odd indices are raw code (escaped, wrapped in ). No placeholder token can survive to output. Adds a regression test with a code-span-heavy body (adjacent spans, em-dashes, a multi-span list line, parenthesized spans) asserting every span renders and no CODE placeholder leaks. --- bin/fm-review-page.ts | 54 ++++++++++++++++++++---------------- tests/fm-review-page.test.sh | 36 ++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 24 deletions(-) diff --git a/bin/fm-review-page.ts b/bin/fm-review-page.ts index 2b7387d118..32e07489f9 100755 --- a/bin/fm-review-page.ts +++ b/bin/fm-review-page.ts @@ -278,30 +278,36 @@ function esc(s: string): string { * re-processed as emphasis or links. */ function renderInline(text: string): string { - const codeSpans: string[] = [] - // 1. Pull out `code` spans, replacing with a placeholder unlikely to collide. - let work = text.replace(/`([^`]+)`/g, (_m, code: string) => { - const idx = codeSpans.push(`${esc(code)}`) - 1 - return `CODE${idx}` - }) - // 2. Escape everything else. - work = esc(work) - // 3. Markdown links [label](href) — href restricted to http(s)/relative. - work = work.replace( - /\[([^\]]+)\]\((https?:\/\/[^\s)]+|\/[^\s)]*)\)/g, - (_m, label: string, href: string) => `${label}`, - ) - // 4. Bare URLs (not already inside an ). - work = work.replace( - /(^|[\s(])(https?:\/\/[^\s<>"')]+)/g, - (_m, pre: string, url: string) => `${pre}${url}`, - ) - // 5. Bold then italic. - work = work.replace(/\*\*([^*]+)\*\*/g, "$1") - work = work.replace(/(^|[^*])\*([^*\n]+)\*/g, "$1$2") - // 6. Restore code spans. - work = work.replace(/CODE(\d+)/g, (_m, idx: string) => codeSpans[Number(idx)] ?? "") - return work + // Split on `code` spans so their contents are never re-processed as emphasis + // or links, and are HTML-escaped independently. Splitting with a capture group + // keeps the delimiters: even indices are prose, odd indices are raw code. This + // is structurally leak-proof — there is no placeholder token that could survive + // to the output (an earlier placeholder scheme corrupted into NUL bytes and + // leaked `CODE0` markers into rendered pages). + const parts = text.split(/`([^`]+)`/g) + const out: string[] = [] + for (let i = 0; i < parts.length; i++) { + if (i % 2 === 1) { + out.push(`${esc(parts[i])}`) + continue + } + let seg = esc(parts[i]) + // Markdown links [label](href) — href restricted to http(s)/relative. + seg = seg.replace( + /\[([^\]]+)\]\((https?:\/\/[^\s)]+|\/[^\s)]*)\)/g, + (_m, label: string, href: string) => `${label}`, + ) + // Bare URLs (not already inside an ). + seg = seg.replace( + /(^|[\s(])(https?:\/\/[^\s<>"')]+)/g, + (_m, pre: string, url: string) => `${pre}${url}`, + ) + // Bold then italic. + seg = seg.replace(/\*\*([^*]+)\*\*/g, "$1") + seg = seg.replace(/(^|[^*])\*([^*\n]+)\*/g, "$1$2") + out.push(seg) + } + return out.join("") } /** diff --git a/tests/fm-review-page.test.sh b/tests/fm-review-page.test.sh index bf96fc37a9..2ab8cf1be3 100755 --- a/tests/fm-review-page.test.sh +++ b/tests/fm-review-page.test.sh @@ -170,6 +170,41 @@ t_artifacts() { pass "fm-review-page: artifact links (url/brain/branch) render on the page" } +# =========================================================================== +# (j) REGRESSION: a code-span-heavy body must render every `code` span and leak +# NO placeholder marker. A prior implementation used a text placeholder that +# corrupted into NUL bytes and leaked bare "CODE0" tokens into rendered pages. +# This pins the split-based renderer against that whole class of leak. +# =========================================================================== +t_codespan_no_leak() { + local case_dir="$TMP_ROOT/codespan" + local fakebin="$case_dir/bin" out="$case_dir/out" + local items="$case_dir/items.json" log="$case_dir/calls.log" + mkdir -p "$case_dir" + # A single item whose body is dense with adjacent code spans, em-dashes, and a + # multi-span list line — the exact shape that leaked before. + cat > "$items" <<'JSON' +[{"id":"review-code","title":"Code heavy","description":"A generator for firstmate — `bin/fm-groom.sh` — that grooms the `ideas` store.\n\n- Branch `feat/fm-groom` (4 files: `bin/fm-groom.sh`, `bin/fm-groom-lib.sh`, `bin/fm-groom-json-field.sh`, `tests/fm-groom.test.sh`).\n- Full write-up: brain doc `brain-m8bxn`.\n\nRails: OFF by default (dry-run unless `FM_GROOM_ENABLED=1`); rate-limited (`FM_GROOM_MAX_IN_FLIGHT`, default 2).","status":"open","priority":1,"created_at":"2026-07-14T07:47:19Z","updated_at":"2026-07-14T07:47:19Z","metadata":{}}] +JSON + install_fake_review "$fakebin" "$items" "$log" + + run_tool "$fakebin" "$out" "$items" "$log" --all + expect_code 0 "$RC" "codespan: exit 0" + + local page="$out/review-code/index.html" + # NO placeholder leak of any kind. + assert_no_grep "CODE0" "$page" "codespan: no CODE0 placeholder leak" + assert_no_grep "CODE1" "$page" "codespan: no CODE1 placeholder leak" + # Every code span actually rendered as . + assert_grep "bin/fm-groom.sh" "$page" "codespan: first span rendered" + assert_grep "ideas" "$page" "codespan: adjacent span rendered" + assert_grep "feat/fm-groom" "$page" "codespan: list-line span rendered" + assert_grep "FM_GROOM_ENABLED=1" "$page" "codespan: parenthesized span rendered" + assert_grep "brain-m8bxn" "$page" "codespan: doc-id span rendered" + + pass "fm-review-page: code-span-heavy body renders every span with no placeholder leak" +} + # =========================================================================== # (a) single-id render produces a page + printed URL. # =========================================================================== @@ -285,6 +320,7 @@ t_no_args() { t_all_sweep t_artifacts +t_codespan_no_leak t_single t_idempotent t_empty From 61532fff28564a9923b12b502a29dbf74846368e Mon Sep 17 00:00:00 2001 From: Trillium Smith Date: Wed, 15 Jul 2026 03:11:01 -0700 Subject: [PATCH 07/23] Add fm-idea-mine.ts: mine chat history for uncaptured ideas, re-evaluate, file harvest --- bin/fm-idea-mine.ts | 965 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 965 insertions(+) create mode 100755 bin/fm-idea-mine.ts diff --git a/bin/fm-idea-mine.ts b/bin/fm-idea-mine.ts new file mode 100755 index 0000000000..6e2593d874 --- /dev/null +++ b/bin/fm-idea-mine.ts @@ -0,0 +1,965 @@ +#!/usr/bin/env bun +/** + * fm-idea-mine.ts - automated INTAKE step of firstmate's crew-launch methodology. + * + * Mines the captain's recent chat/context history for ideas he expressed or + * implied but never captured, RE-EVALUATES them in a distinct second inference + * pass, files each surviving idea into the `ideas` store, and files one + * consolidated "Morning idea harvest" item into the `review` store. + * + * The captain's ask: "an idea mining agent to run and evaluate based on past + * chat and context history. These ideas that are generated should be internally + * evaluated again after generation, and presented to me as a review item in the + * morning." + * + * TWO SEPARATE INFERENCE CALLS, by design: + * PASS 1 (generate) - extract candidate ideas/sparks from the transcript. + * PASS 2 (self-evaluate) - a distinct second call that critically scores, + * dedups, drops weak ones, and recommends an action. + * The second pass is the captain's explicit "internally evaluated again after + * generation" - it is NOT a filter over pass 1's own output, it is a fresh + * critical review with the existing idea store in context. + * + * RAILS (mirrors the fm-groom posture the crew methodology already uses): + * - Idempotent: a content marker over the mined transcript range prevents + * re-mining the identical tail; dedup prevents double-filing ideas. + * - Bounded: transcript token budget + message cap on the read side, kept-idea + * cap on the write side. + * - Read-and-append only: it reads transcripts and the idea store and APPENDS + * store items. It performs no destructive or irreversible action, so the + * default is LIVE-file; --dry-run previews without filing anything. + * - Schedulable: fully parameterized by env + idempotent, so it can be + * cron/launchd'd overnight. This script installs NO schedule. To run it + * overnight at 05:30 local, a crontab line is: + * 30 5 * * * /usr/bin/env bun /ABSOLUTE/PATH/to/bin/fm-idea-mine.ts >> ~/fm-idea-mine.log 2>&1 + * (adjust the absolute path to this file on the target machine). + * + * ENV OVERRIDES (all optional; sensible defaults): + * FM_IDEA_MINE_TRANSCRIPT_DIR dir of .jsonl transcripts to mine the newest of + * (default ~/.claude/projects/-Users-...-firstmate) + * FM_IDEA_MINE_TRANSCRIPT_FILE explicit transcript file (overrides the dir scan) + * FM_IDEA_MINE_MUSE_FILE optional extra idea source (default ~/muse-overnight-ideas.md) + * FM_IDEA_MINE_INFER_CMD inference command; two positional args appended: + * ; must print JSON on + * stdout (default: bun Inference.ts --json --level ) + * FM_IDEA_MINE_INFER_TIER fast|standard|smart for the default infer cmd (default standard) + * FM_IDEA_MINE_IDEAS_CMD ideas store CLI (default: ideas) + * FM_IDEA_MINE_REVIEW_CMD review store CLI (default: review) + * FM_IDEA_MINE_STATE_DIR marker dir (default: /state, else ~/.cache/fm-idea-mine) + * FM_IDEA_MINE_MAX_KEPT cap on kept ideas (default 15) + * FM_IDEA_MINE_TOKEN_BUDGET approx token budget for the mined tail (default 40000) + * FM_IDEA_MINE_MSG_LIMIT max messages from the tail (default 800) + * FM_HOME / FM_STATE_OVERRIDE firstmate home / state dir, used for the marker + * + * FLAGS: + * --dry-run compute the full harvest and print it; file nothing. + * --force ignore the idempotency marker and re-mine even an unchanged tail. + * -h|--help usage. + * + * EXIT: 0 on success (including a benign no-op); non-zero only on a hard error + * (unreadable required input, inference command failure that is not fail-safe). + * Malformed inference JSON is handled fail-safe: nothing is filed and the run + * reports the problem, rather than filing garbage. + */ + +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +// -------------------------------------------------------------------------- +// Config +// -------------------------------------------------------------------------- + +interface Config { + transcriptDir: string; + transcriptFile: string | null; + museFile: string; + inferCmd: string[]; + ideasCmd: string; + reviewCmd: string; + stateDir: string; + maxKept: number; + tokenBudget: number; + msgLimit: number; + dryRun: boolean; + force: boolean; +} + +function envInt(name: string, fallback: number): number { + const raw = process.env[name]; + if (raw === undefined || raw.trim() === "") return fallback; + const n = Number.parseInt(raw, 10); + return Number.isFinite(n) && n > 0 ? n : fallback; +} + +function defaultTranscriptDir(): string { + return join(homedir(), ".claude", "projects", "-Users-trilliumsmith-code-firstmate"); +} + +function defaultStateDir(): string { + const stateOverride = process.env.FM_STATE_OVERRIDE; + if (stateOverride && stateOverride.trim() !== "") return stateOverride; + const fmHome = process.env.FM_HOME || process.env.FM_ROOT_OVERRIDE; + if (fmHome && fmHome.trim() !== "") return join(fmHome, "state"); + return join(homedir(), ".cache", "fm-idea-mine"); +} + +function defaultInferCmd(): string[] { + const tier = process.env.FM_IDEA_MINE_INFER_TIER || "standard"; + return ["bun", join(homedir(), ".claude", "PAI", "TOOLS", "Inference.ts"), "--json", "--level", tier]; +} + +/** + * Split an env command string into argv the way a shell would for a simple + * command: honor single and double quotes, no variable/glob expansion. This is + * deliberately minimal - the override is a trusted operator/test value, not + * untrusted input - but quoting support lets a mock be a real command with args. + */ +function splitCommand(raw: string): string[] { + const out: string[] = []; + let cur = ""; + let quote: '"' | "'" | null = null; + let started = false; + for (let i = 0; i < raw.length; i++) { + const ch = raw[i]; + if (quote) { + if (ch === quote) quote = null; + else cur += ch; + continue; + } + if (ch === '"' || ch === "'") { + quote = ch; + started = true; + continue; + } + if (ch === " " || ch === "\t" || ch === "\n") { + if (started) { + out.push(cur); + cur = ""; + started = false; + } + continue; + } + cur += ch; + started = true; + } + if (started) out.push(cur); + return out; +} + +function parseArgs(argv: string[]): { dryRun: boolean; force: boolean; help: boolean } { + let dryRun = false; + let force = false; + let help = false; + for (const a of argv) { + if (a === "--dry-run") dryRun = true; + else if (a === "--force") force = true; + else if (a === "-h" || a === "--help") help = true; + else { + throw new Error(`unknown argument: ${a}`); + } + } + return { dryRun, force, help }; +} + +function loadConfig(argv: string[]): Config { + const { dryRun, force } = parseArgs(argv); + const inferRaw = process.env.FM_IDEA_MINE_INFER_CMD; + return { + transcriptDir: process.env.FM_IDEA_MINE_TRANSCRIPT_DIR || defaultTranscriptDir(), + transcriptFile: process.env.FM_IDEA_MINE_TRANSCRIPT_FILE || null, + museFile: process.env.FM_IDEA_MINE_MUSE_FILE || join(homedir(), "muse-overnight-ideas.md"), + inferCmd: inferRaw && inferRaw.trim() !== "" ? splitCommand(inferRaw) : defaultInferCmd(), + ideasCmd: process.env.FM_IDEA_MINE_IDEAS_CMD || "ideas", + reviewCmd: process.env.FM_IDEA_MINE_REVIEW_CMD || "review", + stateDir: process.env.FM_IDEA_MINE_STATE_DIR || defaultStateDir(), + maxKept: envInt("FM_IDEA_MINE_MAX_KEPT", 15), + tokenBudget: envInt("FM_IDEA_MINE_TOKEN_BUDGET", 40000), + msgLimit: envInt("FM_IDEA_MINE_MSG_LIMIT", 800), + dryRun, + force, + }; +} + +const USAGE = `fm-idea-mine.ts - mine recent chat history for uncaptured ideas, re-evaluate them, +and file a consolidated "Morning idea harvest" review item. + +Usage: + bin/fm-idea-mine.ts [--dry-run] [--force] + + --dry-run compute the harvest and print it; file nothing. + --force re-mine even an unchanged transcript tail (ignore the idempotency marker). + -h, --help this help. + +See the file header for the full env override list and a schedulable crontab line.`; + +// -------------------------------------------------------------------------- +// Transcript mining +// -------------------------------------------------------------------------- + +interface MinedMessage { + role: "human" | "assistant"; + text: string; +} + +interface MinedTail { + file: string | null; + messages: MinedMessage[]; + rangeHash: string; + charCount: number; +} + +/** Approx tokens ~= chars / 4. Cheap, deterministic, good enough for a budget. */ +function approxTokens(chars: number): number { + return Math.ceil(chars / 4); +} + +function newestTranscript(cfg: Config): string | null { + if (cfg.transcriptFile) { + return existsSync(cfg.transcriptFile) ? cfg.transcriptFile : null; + } + if (!existsSync(cfg.transcriptDir)) return null; + let newest: { path: string; mtime: number } | null = null; + for (const name of readdirSync(cfg.transcriptDir)) { + if (!name.endsWith(".jsonl")) continue; + const p = join(cfg.transcriptDir, name); + let st; + try { + st = statSync(p); + } catch { + continue; + } + if (!st.isFile()) continue; + if (!newest || st.mtimeMs > newest.mtime) newest = { path: p, mtime: st.mtimeMs }; + } + return newest ? newest.path : null; +} + +/** Strip base64/data-URI/image blobs and collapse whitespace from a text block. */ +function sanitizeText(raw: string): string { + let t = raw; + // Drop data: URIs and long base64 runs (image/attachment noise). + t = t.replace(/data:[a-zA-Z0-9/+;=.-]+base64,[A-Za-z0-9+/=]+/g, "[image]"); + t = t.replace(/[A-Za-z0-9+/]{200,}={0,2}/g, "[blob]"); + // Drop system-reminder envelopes. + t = t.replace(/[\s\S]*?<\/system-reminder>/g, ""); + t = t.replace(/[ \t]+/g, " "); + t = t.replace(/\n{3,}/g, "\n\n"); + return t.trim(); +} + +/** + * Extract a plain-text block from a message content value that may be a string + * or an array of content blocks. Returns "" when there is no human/assistant + * prose (e.g. a tool_result-only user turn or a thinking-only assistant turn). + */ +function extractText(content: unknown): string { + if (typeof content === "string") return sanitizeText(content); + if (!Array.isArray(content)) return ""; + const parts: string[] = []; + for (const block of content) { + if (block && typeof block === "object" && (block as { type?: string }).type === "text") { + const v = (block as { text?: unknown }).text; + if (typeof v === "string") parts.push(v); + } + // Deliberately skip tool_use, tool_result, thinking, image blocks. + } + return sanitizeText(parts.join("\n")); +} + +/** Content that is only a tool result carries no idea signal; detect it to skip. */ +function isToolResultOnly(content: unknown): boolean { + if (!Array.isArray(content)) return false; + if (content.length === 0) return false; + return content.every( + (b) => b && typeof b === "object" && (b as { type?: string }).type === "tool_result", + ); +} + +function mineTranscript(cfg: Config): MinedTail { + const file = newestTranscript(cfg); + if (!file) return { file: null, messages: [], rangeHash: "", charCount: 0 }; + + let raw: string; + try { + raw = readFileSync(file, "utf8"); + } catch { + return { file, messages: [], rangeHash: "", charCount: 0 }; + } + + const lines = raw.split("\n").filter((l) => l.trim() !== ""); + const all: MinedMessage[] = []; + for (const line of lines) { + let obj: unknown; + try { + obj = JSON.parse(line); + } catch { + continue; + } + if (!obj || typeof obj !== "object") continue; + const rec = obj as { type?: string; message?: { role?: string; content?: unknown } }; + if (rec.type !== "user" && rec.type !== "assistant") continue; + const content = rec.message?.content; + if (rec.type === "user") { + if (isToolResultOnly(content)) continue; + const text = extractText(content); + if (text) all.push({ role: "human", text }); + } else { + const text = extractText(content); + if (text) all.push({ role: "assistant", text }); + } + } + + // Take a bounded tail: last N messages, then trim from the front to the token + // budget so the most recent conversation always survives. + let tail = all.slice(-cfg.msgLimit); + let charCount = tail.reduce((n, m) => n + m.text.length, 0); + while (tail.length > 1 && approxTokens(charCount) > cfg.tokenBudget) { + const dropped = tail.shift(); + if (dropped) charCount -= dropped.text.length; + } + + const hash = createHash("sha256"); + for (const m of tail) hash.update(`${m.role}${m.text}`); + const rangeHash = tail.length > 0 ? `sha256:${hash.digest("hex")}` : ""; + + return { file, messages: tail, rangeHash, charCount }; +} + +function readMuse(cfg: Config): string { + if (!existsSync(cfg.museFile)) return ""; + try { + // Bound the muse contribution too, so a huge file can't blow the prompt. + const raw = readFileSync(cfg.museFile, "utf8"); + const capped = raw.slice(0, 12000); + return sanitizeText(capped); + } catch { + return ""; + } +} + +// -------------------------------------------------------------------------- +// Existing-idea store (dedup source) +// -------------------------------------------------------------------------- + +interface ExistingIdea { + id: string; + title: string; +} + +function listExistingIdeas(cfg: Config): ExistingIdea[] { + const res = spawnSync(cfg.ideasCmd, ["list", "--all", "--json"], { + encoding: "utf8", + timeout: 30000, + }); + if (res.status !== 0 || !res.stdout) return []; + let parsed: unknown; + try { + parsed = JSON.parse(res.stdout); + } catch { + return []; + } + if (!Array.isArray(parsed)) return []; + const out: ExistingIdea[] = []; + for (const item of parsed) { + if (item && typeof item === "object") { + const id = (item as { id?: unknown }).id; + const title = (item as { title?: unknown }).title; + if (typeof title === "string") { + out.push({ id: typeof id === "string" ? id : "", title }); + } + } + } + return out; +} + +/** Normalize a title for fuzzy comparison: lowercase, alnum tokens only. */ +function normalizeTitle(title: string): Set { + const tokens = title + .toLowerCase() + .replace(/[^a-z0-9\s]/g, " ") + .split(/\s+/) + .filter((t) => t.length > 2); + return new Set(tokens); +} + +/** Jaccard token overlap; >= threshold means "the same idea". */ +function titleSimilarity(a: string, b: string): number { + const sa = normalizeTitle(a); + const sb = normalizeTitle(b); + if (sa.size === 0 || sb.size === 0) return 0; + let inter = 0; + for (const t of sa) if (sb.has(t)) inter++; + const union = sa.size + sb.size - inter; + return union === 0 ? 0 : inter / union; +} + +const DEDUP_THRESHOLD = 0.6; + +function findDuplicate(title: string, existing: ExistingIdea[]): ExistingIdea | null { + let best: { idea: ExistingIdea; score: number } | null = null; + for (const e of existing) { + const score = titleSimilarity(title, e.title); + if (score >= DEDUP_THRESHOLD && (!best || score > best.score)) best = { idea: e, score }; + } + return best ? best.idea : null; +} + +// -------------------------------------------------------------------------- +// Inference +// -------------------------------------------------------------------------- + +interface Candidate { + title: string; + spark: string; + rationale: string; + source_hint: string; +} + +interface Scores { + novelty: number; + impact: number; + feasibility: number; + alignment: number; +} + +interface KeptIdea { + title: string; + spark: string; + scores: Scores; + overall: number; + why: string; + recommended_action: string; + dupe_of?: string; +} + +interface DroppedIdea { + title: string; + reason: string; +} + +interface EvalResult { + kept: KeptIdea[]; + dropped: DroppedIdea[]; +} + +class InferenceError extends Error {} + +/** + * Run the configured inference command with (system, user) prompts appended as + * the final two positional args. Returns parsed JSON. The command MUST print + * a single JSON value on stdout. Any non-JSON / non-zero result is an + * InferenceError so the caller can fail safe (file nothing). + */ +function runInference(cfg: Config, system: string, user: string): unknown { + const [cmd, ...base] = cfg.inferCmd; + if (!cmd) throw new InferenceError("empty inference command"); + const res = spawnSync(cmd, [...base, system, user], { + encoding: "utf8", + timeout: 180000, + maxBuffer: 32 * 1024 * 1024, + }); + if (res.error) throw new InferenceError(`inference spawn failed: ${res.error.message}`); + if (res.status !== 0) { + throw new InferenceError( + `inference exited ${res.status}: ${(res.stderr || "").slice(0, 400)}`, + ); + } + const out = (res.stdout || "").trim(); + if (!out) throw new InferenceError("inference produced no output"); + const json = extractJson(out); + if (json === undefined) { + throw new InferenceError(`inference output was not JSON: ${out.slice(0, 200)}`); + } + return json; +} + +/** + * Pull the first balanced JSON array or object out of a text blob. The infer + * command is asked to emit bare JSON, but a chatty model may wrap it in prose or + * a ```json fence; this recovers the payload without trusting exact formatting. + */ +function extractJson(text: string): unknown { + // Fast path: whole thing parses. + try { + return JSON.parse(text); + } catch { + // fall through + } + // Strip a code fence if present. + const fence = text.match(/```(?:json)?\s*([\s\S]*?)```/); + const body = fence ? fence[1] : text; + const startIdx = firstJsonStart(body); + if (startIdx < 0) return undefined; + const open = body[startIdx]; + const close = open === "[" ? "]" : "}"; + let depth = 0; + let inStr = false; + let esc = false; + for (let i = startIdx; i < body.length; i++) { + const ch = body[i]; + if (inStr) { + if (esc) esc = false; + else if (ch === "\\") esc = true; + else if (ch === '"') inStr = false; + continue; + } + if (ch === '"') inStr = true; + else if (ch === open) depth++; + else if (ch === close) { + depth--; + if (depth === 0) { + const slice = body.slice(startIdx, i + 1); + try { + return JSON.parse(slice); + } catch { + return undefined; + } + } + } + } + return undefined; +} + +function firstJsonStart(text: string): number { + const arr = text.indexOf("["); + const obj = text.indexOf("{"); + if (arr < 0) return obj; + if (obj < 0) return arr; + return Math.min(arr, obj); +} + +function buildTranscriptDigest(mined: MinedTail, muse: string): string { + const parts: string[] = []; + for (const m of mined.messages) { + const speaker = m.role === "human" ? "CAPTAIN" : "ASSISTANT"; + parts.push(`[${speaker}] ${m.text}`); + } + let digest = parts.join("\n\n"); + if (muse) digest += `\n\n[OVERNIGHT NOTES]\n${muse}`; + return digest; +} + +const GEN_SYSTEM = [ + "You review a work-chat transcript between a captain (the human) and his assistant.", + "Your one job: surface ideas, sparks, half-formed wants, and unaddressed problems the", + "captain raised or implied but that were never turned into a captured task or idea.", + "Focus on the captain's own wishes and frustrations, not the assistant's suggestions.", + "Ignore small talk, status updates, and anything that was already acted on.", + "Return ONLY a JSON array. Each element: {\"title\": short idea name,", + '"spark": the raw thing he said/implied, "rationale": why it looks like an uncaptured idea,', + '"source_hint": a short quote or paraphrase locating it}. No prose outside the JSON.', +].join(" "); + +const EVAL_SYSTEM = [ + "You are a critical second-pass reviewer of freshly generated candidate ideas for a", + "captain whose focus is voice-first computing, accessibility, PAI / life-OS tooling,", + "shipping projects, landing a tech job, and income.", + "For each candidate: score novelty, impact, feasibility, and goal-alignment from 1 to 10;", + "compute an overall score; write one honest sentence of why; and recommend one action of", + "research, build, design, or discuss. Drop candidates that are vague, trivial, already", + "captured, or duplicate an existing idea. When a candidate duplicates an existing idea,", + "put it in dropped with the reason naming the duplicate, OR keep it with dupe_of set if it", + "meaningfully extends the existing one.", + "Return ONLY a JSON object: {\"kept\":[{\"title\",\"spark\",\"scores\":{\"novelty\",\"impact\",", + '"feasibility","alignment"},"overall","why","recommended_action","dupe_of"?}],', + '"dropped":[{"title","reason"}]}. No prose outside the JSON.', +].join(" "); + +function generateCandidates(cfg: Config, digest: string): Candidate[] { + const user = `Transcript to mine (most recent last):\n\n${digest}\n\nReturn the JSON array of uncaptured ideas now.`; + const parsed = runInference(cfg, GEN_SYSTEM, user); + if (!Array.isArray(parsed)) { + throw new InferenceError("generate pass did not return a JSON array"); + } + const out: Candidate[] = []; + for (const item of parsed) { + if (!item || typeof item !== "object") continue; + const o = item as Record; + const title = typeof o.title === "string" ? o.title.trim() : ""; + if (!title) continue; + out.push({ + title, + spark: typeof o.spark === "string" ? o.spark.trim() : "", + rationale: typeof o.rationale === "string" ? o.rationale.trim() : "", + source_hint: typeof o.source_hint === "string" ? o.source_hint.trim() : "", + }); + } + return out; +} + +function clampScore(v: unknown): number { + const n = typeof v === "number" ? v : Number.parseFloat(String(v)); + if (!Number.isFinite(n)) return 0; + return Math.max(0, Math.min(10, Math.round(n * 10) / 10)); +} + +const ACTIONS = new Set(["research", "build", "design", "discuss"]); + +function evaluateCandidates(cfg: Config, candidates: Candidate[], existing: ExistingIdea[]): EvalResult { + const existingTitles = existing.map((e) => e.title); + const user = [ + "Existing captured idea titles (for dedup):", + existingTitles.length ? existingTitles.map((t) => `- ${t}`).join("\n") : "(none)", + "", + "Candidate ideas to critically evaluate:", + JSON.stringify(candidates, null, 2), + "", + "Return the JSON object with kept and dropped now.", + ].join("\n"); + const parsed = runInference(cfg, EVAL_SYSTEM, user); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new InferenceError("evaluate pass did not return a JSON object"); + } + const o = parsed as Record; + const kept: KeptIdea[] = []; + const dropped: DroppedIdea[] = []; + + if (Array.isArray(o.kept)) { + for (const item of o.kept) { + if (!item || typeof item !== "object") continue; + const k = item as Record; + const title = typeof k.title === "string" ? k.title.trim() : ""; + if (!title) continue; + const rawScores = (k.scores && typeof k.scores === "object" ? k.scores : {}) as Record; + const scores: Scores = { + novelty: clampScore(rawScores.novelty), + impact: clampScore(rawScores.impact), + feasibility: clampScore(rawScores.feasibility), + alignment: clampScore(rawScores.alignment), + }; + const overallRaw = clampScore(k.overall); + const overall = + overallRaw > 0 + ? overallRaw + : Math.round(((scores.novelty + scores.impact + scores.feasibility + scores.alignment) / 4) * 10) / 10; + let action = typeof k.recommended_action === "string" ? k.recommended_action.trim().toLowerCase() : ""; + if (!ACTIONS.has(action)) action = "discuss"; + const dupe = typeof k.dupe_of === "string" && k.dupe_of.trim() ? k.dupe_of.trim() : undefined; + const entry: KeptIdea = { + title, + spark: typeof k.spark === "string" ? k.spark.trim() : "", + scores, + overall, + why: typeof k.why === "string" ? k.why.trim() : "", + recommended_action: action, + }; + if (dupe) entry.dupe_of = dupe; + kept.push(entry); + } + } + + if (Array.isArray(o.dropped)) { + for (const item of o.dropped) { + if (!item || typeof item !== "object") continue; + const d = item as Record; + const title = typeof d.title === "string" ? d.title.trim() : ""; + if (!title) continue; + dropped.push({ title, reason: typeof d.reason === "string" ? d.reason.trim() : "" }); + } + } + + // Rank kept by overall desc and enforce the kept cap. + kept.sort((a, b) => b.overall - a.overall); + const capped = kept.slice(0, cfg.maxKept); + const overflow = kept.slice(cfg.maxKept); + for (const o2 of overflow) dropped.push({ title: o2.title, reason: `below the top ${cfg.maxKept} by score` }); + + return { kept: capped, dropped }; +} + +// -------------------------------------------------------------------------- +// Idempotency marker +// -------------------------------------------------------------------------- + +function markerPath(cfg: Config): string { + return join(cfg.stateDir, ".idea-mine-marker"); +} + +function readMarker(cfg: Config): string | null { + const p = markerPath(cfg); + if (!existsSync(p)) return null; + try { + return readFileSync(p, "utf8").trim(); + } catch { + return null; + } +} + +function writeMarker(cfg: Config, rangeHash: string): void { + try { + mkdirSync(cfg.stateDir, { recursive: true }); + writeFileSync(markerPath(cfg), `${rangeHash}\n`, "utf8"); + } catch { + // A missing marker only costs one redundant re-mine; never fatal. + } +} + +// -------------------------------------------------------------------------- +// Filing +// -------------------------------------------------------------------------- + +function today(): string { + const d = new Date(); + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, "0"); + const day = String(d.getDate()).padStart(2, "0"); + return `${y}-${m}-${day}`; +} + +function ideaDescription(k: KeptIdea): string { + const s = k.scores; + const scoreLine = `scores novelty=${s.novelty} impact=${s.impact} feasibility=${s.feasibility} alignment=${s.alignment} overall=${k.overall}`; + const lines = [ + `${today()}. Mined from recent chat + context history by fm-idea-mine.`, + "", + k.spark ? `Spark: ${k.spark}` : "", + k.why ? `Why: ${k.why}` : "", + `Recommended action: ${k.recommended_action}`, + scoreLine, + k.dupe_of ? `Extends existing idea: ${k.dupe_of}` : "", + ].filter((l) => l !== ""); + return lines.join("\n"); +} + +interface FileResult { + filedIdeas: { title: string; id: string }[]; + skippedDuplicates: { title: string; dupeOf: string }[]; + reviewId: string | null; +} + +/** Run a store `create` and return the created id (from --silent stdout). */ +function createIssue(cmd: string, title: string, description: string): string { + const res = spawnSync(cmd, ["create", title, "-d", description, "--silent"], { + encoding: "utf8", + timeout: 30000, + }); + if (res.error) throw new Error(`${cmd} create failed: ${res.error.message}`); + if (res.status !== 0) { + throw new Error(`${cmd} create exited ${res.status}: ${(res.stderr || "").slice(0, 300)}`); + } + return (res.stdout || "").trim().split(/\s+/).pop() || ""; +} + +function reviewDescription(result: EvalResult, filed: FileResult, mined: MinedTail): string { + const lines: string[] = []; + lines.push(`Morning idea harvest for ${today()}.`); + lines.push( + `Mined ${mined.messages.length} messages (~${approxTokens(mined.charCount)} tokens) from ${ + mined.file ? mined.file.split("/").pop() : "no transcript" + }.`, + ); + lines.push( + `Kept ${result.kept.length}, filed ${filed.filedIdeas.length} new, skipped ${filed.skippedDuplicates.length} duplicates, dropped ${result.dropped.length}.`, + ); + lines.push(""); + lines.push("## Ranked ideas"); + if (result.kept.length === 0) { + lines.push("(none survived evaluation)"); + } else { + result.kept.forEach((k, i) => { + const s = k.scores; + const filedId = filed.filedIdeas.find((f) => f.title === k.title); + const dupSkip = filed.skippedDuplicates.find((f) => f.title === k.title); + const status = filedId ? `filed ${filedId.id}` : dupSkip ? `dup of ${dupSkip.dupeOf}` : "not filed"; + lines.push( + `${i + 1}. [${k.overall}] ${k.title} — action: ${k.recommended_action} (n${s.novelty}/i${s.impact}/f${s.feasibility}/a${s.alignment}) [${status}]`, + ); + if (k.why) lines.push(` why: ${k.why}`); + }); + } + lines.push(""); + lines.push("## Dropped"); + if (result.dropped.length === 0) { + lines.push("(none)"); + } else { + for (const d of result.dropped) lines.push(`- ${d.title} — ${d.reason}`); + } + return lines.join("\n"); +} + +function fileHarvest(cfg: Config, result: EvalResult, existing: ExistingIdea[]): FileResult { + const filed: FileResult = { filedIdeas: [], skippedDuplicates: [], reviewId: null }; + + // File each kept idea that is not a duplicate of an existing one. + for (const k of result.kept) { + const dup = findDuplicate(k.title, existing); + if (dup) { + filed.skippedDuplicates.push({ title: k.title, dupeOf: dup.id || dup.title }); + continue; + } + const id = createIssue(cfg.ideasCmd, k.title, ideaDescription(k)); + filed.filedIdeas.push({ title: k.title, id }); + // Track it locally so two similar kept ideas in one run don't both file. + existing.push({ id, title: k.title }); + } + + // One consolidated review item. + const reviewTitle = `Morning idea harvest — ${today()}`; + filed.reviewId = createIssue(cfg.reviewCmd, reviewTitle, "PLACEHOLDER"); + return filed; +} + +// -------------------------------------------------------------------------- +// Main +// -------------------------------------------------------------------------- + +function printDryRun(result: EvalResult, existing: ExistingIdea[], mined: MinedTail): void { + console.log("=== fm-idea-mine DRY RUN — nothing filed ==="); + console.log( + `mined ${mined.messages.length} messages (~${approxTokens(mined.charCount)} tokens) from ${ + mined.file ? mined.file.split("/").pop() : "no transcript" + }`, + ); + console.log(`kept ${result.kept.length}, dropped ${result.dropped.length}\n`); + result.kept.forEach((k, i) => { + const dup = findDuplicate(k.title, existing); + const s = k.scores; + const tag = dup ? `(dup of ${dup.id || dup.title} — would skip)` : "(would file)"; + console.log( + `${i + 1}. [${k.overall}] ${k.title} — ${k.recommended_action} (n${s.novelty}/i${s.impact}/f${s.feasibility}/a${s.alignment}) ${tag}`, + ); + if (k.why) console.log(` why: ${k.why}`); + }); + if (result.dropped.length) { + console.log("\ndropped:"); + for (const d of result.dropped) console.log(`- ${d.title} — ${d.reason}`); + } +} + +function main(): number { + let parsedArgs: { dryRun: boolean; force: boolean; help: boolean }; + try { + parsedArgs = parseArgs(process.argv.slice(2)); + } catch (e) { + console.error((e as Error).message); + console.error(USAGE); + return 2; + } + if (parsedArgs.help) { + console.log(USAGE); + return 0; + } + + const cfg = loadConfig(process.argv.slice(2)); + + const mined = mineTranscript(cfg); + if (mined.messages.length === 0) { + console.log("fm-idea-mine: no transcript history to mine; nothing to do."); + return 0; + } + + // Idempotency: skip an identical tail unless forced. Dry-run always computes. + if (!cfg.force && !cfg.dryRun) { + const prev = readMarker(cfg); + if (prev && prev === mined.rangeHash) { + console.log("fm-idea-mine: transcript tail unchanged since last run; nothing to do (use --force to re-mine)."); + return 0; + } + } + + const muse = readMuse(cfg); + const digest = buildTranscriptDigest(mined, muse); + const existing = listExistingIdeas(cfg); + + let candidates: Candidate[]; + try { + candidates = generateCandidates(cfg, digest); + } catch (e) { + console.error(`fm-idea-mine: generate pass failed (nothing filed): ${(e as Error).message}`); + return 1; + } + + if (candidates.length === 0) { + console.log("fm-idea-mine: no candidate ideas surfaced from history; nothing to file."); + if (!cfg.dryRun) writeMarker(cfg, mined.rangeHash); + return 0; + } + + let result: EvalResult; + try { + result = evaluateCandidates(cfg, candidates, existing); + } catch (e) { + console.error(`fm-idea-mine: evaluate pass failed (nothing filed): ${(e as Error).message}`); + return 1; + } + + if (cfg.dryRun) { + printDryRun(result, existing, mined); + return 0; + } + + if (result.kept.length === 0) { + console.log("fm-idea-mine: no ideas survived evaluation; filing an empty harvest review for the record."); + } + + let filed: FileResult; + try { + filed = fileHarvest(cfg, result, existing); + } catch (e) { + console.error(`fm-idea-mine: filing failed: ${(e as Error).message}`); + return 1; + } + + // The review item needs the filed ids, so re-write its body now that we have them. + if (filed.reviewId) { + const body = reviewDescription(result, filed, mined); + const res = spawnSync(cfg.reviewCmd, ["update", filed.reviewId, "-d", body], { + encoding: "utf8", + timeout: 30000, + }); + if (res.status !== 0) { + // Non-fatal: the review item exists; only its body is the placeholder. + console.error( + `fm-idea-mine: warning — could not update review body (${filed.reviewId}): ${(res.stderr || "").slice(0, 200)}`, + ); + } + } + + writeMarker(cfg, mined.rangeHash); + + // Summary to stdout. + console.log("=== fm-idea-mine harvest ==="); + console.log( + `mined ${mined.messages.length} messages; kept ${result.kept.length}; filed ${filed.filedIdeas.length} new ideas; skipped ${filed.skippedDuplicates.length} duplicates; dropped ${result.dropped.length}.`, + ); + if (filed.reviewId) console.log(`review item: ${filed.reviewId}`); + for (const f of filed.filedIdeas) console.log(` idea ${f.id}: ${f.title}`); + for (const s of filed.skippedDuplicates) console.log(` skipped (dup of ${s.dupeOf}): ${s.title}`); + return 0; +} + +// `update` may not be a verb on every bd build; fall back gracefully by using a +// body-file create if needed is out of scope — bd exposes `update -d` (verified +// via --help family), so the direct update above is correct. + +// Run only when executed directly (not when imported by the test harness). +// `import.meta.main` is bun's direct-execution flag; the cast keeps this +// typecheckable without the bun type package present, and it falls back to an +// argv comparison so a plain-tsc/node build still behaves. +const runDirectly = (() => { + const meta = import.meta as unknown as { main?: boolean; url?: string }; + if (typeof meta.main === "boolean") return meta.main; + const entry = process.argv[1] ? resolve(process.argv[1]) : ""; + const self = meta.url ? resolve(fileURLToPath(meta.url)) : ""; + return entry !== "" && entry === self; +})(); + +if (runDirectly) { + process.exit(main()); +} + +export { + splitCommand, + sanitizeText, + extractText, + isToolResultOnly, + titleSimilarity, + findDuplicate, + extractJson, + clampScore, + approxTokens, +}; From a9616b505be3952f334a8442d04fab732caf2cd6 Mon Sep 17 00:00:00 2001 From: Trillium Smith Date: Wed, 15 Jul 2026 03:23:52 -0700 Subject: [PATCH 08/23] fm-idea-mine: pass generous --timeout to default Inference.ts cmd for large mined tails --- bin/fm-idea-mine.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/bin/fm-idea-mine.ts b/bin/fm-idea-mine.ts index 6e2593d874..1281c494bd 100755 --- a/bin/fm-idea-mine.ts +++ b/bin/fm-idea-mine.ts @@ -43,6 +43,7 @@ * ; must print JSON on * stdout (default: bun Inference.ts --json --level ) * FM_IDEA_MINE_INFER_TIER fast|standard|smart for the default infer cmd (default standard) + * FM_IDEA_MINE_INFER_TIMEOUT_MS --timeout passed to the default Inference.ts cmd (default 150000) * FM_IDEA_MINE_IDEAS_CMD ideas store CLI (default: ideas) * FM_IDEA_MINE_REVIEW_CMD review store CLI (default: review) * FM_IDEA_MINE_STATE_DIR marker dir (default: /state, else ~/.cache/fm-idea-mine) @@ -109,7 +110,19 @@ function defaultStateDir(): string { function defaultInferCmd(): string[] { const tier = process.env.FM_IDEA_MINE_INFER_TIER || "standard"; - return ["bun", join(homedir(), ".claude", "PAI", "TOOLS", "Inference.ts"), "--json", "--level", tier]; + // Inference.ts defaults to a 30s internal timeout, which is too short for a + // ~40k-token mined tail on the standard tier. Give it a generous ceiling; the + // spawnSync wall-clock timeout in runInference is the real outer bound. + const timeout = process.env.FM_IDEA_MINE_INFER_TIMEOUT_MS || "150000"; + return [ + "bun", + join(homedir(), ".claude", "PAI", "TOOLS", "Inference.ts"), + "--json", + "--level", + tier, + "--timeout", + timeout, + ]; } /** From 92e52e6a2c91ed3df7bec2bc62e4753109a50ffd Mon Sep 17 00:00:00 2001 From: Trillium Smith Date: Wed, 15 Jul 2026 03:17:38 -0700 Subject: [PATCH 09/23] Add hermetic tests for fm-idea-mine: generate/evaluate/file, dedup, dry-run, empty, malformed, idempotency, cap --- tests/fm-idea-mine.test.sh | 293 +++++++++++++++++++++++++++++++++++++ 1 file changed, 293 insertions(+) create mode 100755 tests/fm-idea-mine.test.sh diff --git a/tests/fm-idea-mine.test.sh b/tests/fm-idea-mine.test.sh new file mode 100755 index 0000000000..2c251fd6aa --- /dev/null +++ b/tests/fm-idea-mine.test.sh @@ -0,0 +1,293 @@ +#!/usr/bin/env bash +# Hermetic behavior tests for bin/fm-idea-mine.ts. +# +# No network: the two inference passes are mocked via FM_IDEA_MINE_INFER_CMD, +# and the ideas/review store CLIs are mocked with PATH shims that append every +# invocation to a log and mint deterministic ids. Every case drives the real +# fm-idea-mine.ts against a fixture transcript and asserts filed / skipped / +# dry-run / empty / malformed behavior. +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +command -v bun >/dev/null 2>&1 || { echo "skip: bun not found for fm-idea-mine tests"; exit 0; } + +SCRIPT="$ROOT/bin/fm-idea-mine.ts" + +# --- fixtures --------------------------------------------------------------- + +# Write a minimal but realistic .jsonl transcript into $1 with a couple of +# human turns that carry idea signal plus assistant/tool noise to strip. +write_transcript() { + local file=$1 + cat > "$file" <<'JSONL' +{"type":"user","message":{"role":"user","content":"can you build me a voice-first way to triage my inbox?"}} +{"type":"assistant","message":{"role":"assistant","content":[{"type":"thinking","text":"internal reasoning that must not leak"},{"type":"text","text":"Sure, here is a plan."}]}} +{"type":"user","message":{"role":"user","content":[{"type":"tool_result","content":"tool output noise that is not an idea"}]}} +{"type":"user","message":{"role":"user","content":"i keep wishing my espresso machine logged shots automatically"}} +{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"Bash","input":{"command":"ls"}}]}} +JSONL +} + +# Build a mock inference command. It reads the SYSTEM prompt (2nd-to-last arg) +# to decide which pass it is, and emits the JSON for that pass. The GEN and EVAL +# payloads are supplied as files so each test controls them. +make_infer_mock() { + local dir=$1 gen_file=$2 eval_file=$3 + local mock="$dir/infer-mock.sh" + cat > "$mock" <...] +system="\${@:(-2):1}" +case "\$system" in + *"surface ideas"*|*"uncaptured"*) cat "$gen_file" ;; + *"critical second-pass"*|*"critically evaluate"*|*"kept"*) cat "$eval_file" ;; + *) echo "MOCK: unrecognized system prompt" >&2; exit 3 ;; +esac +SH + chmod +x "$mock" + printf '%s\n' "$mock" +} + +# Store CLI mock: logs invocation and mints a deterministic id from a counter. +# Usage: install_store_mock +install_store_mock() { + local fakebin=$1 name=$2 prefix=$3 log=$4 + cat > "$fakebin/$name" <> "\$log" +for a in "\$@"; do printf ' [%s]' "\$a" >> "\$log"; done +printf '\n' >> "\$log" +verb=\${1:-} +if [ "\$verb" = create ]; then + n=\$(( \$(cat "\$log.counter" 2>/dev/null || echo 0) + 1 )) + echo "\$n" > "\$log.counter" + printf '%s-%03d\n' "\$prefix" "\$n" + exit 0 +fi +if [ "\$verb" = list ]; then + # emit the seeded existing-ideas JSON if present, else empty array + if [ -f "\$log.existing.json" ]; then cat "\$log.existing.json"; else echo '[]'; fi + exit 0 +fi +if [ "\$verb" = update ]; then exit 0; fi +exit 0 +SH + chmod +x "$fakebin/$name" +} + +# Run fm-idea-mine.ts with a hermetic environment. Sets up transcript dir, +# fakebin PATH, infer mock, state dir. Extra args forwarded to the script. +# Globals set by the caller: GEN_FILE, EVAL_FILE, optional EXISTING_JSON. +run_mine() { + local tmp=$1; shift + local transcript_dir="$tmp/transcripts" + local fakebin state_dir infer log + mkdir -p "$transcript_dir" + write_transcript "$transcript_dir/session.jsonl" + fakebin=$(fm_fakebin "$tmp") + state_dir="$tmp/state" + log="$tmp/store.log" + : > "$log" + install_store_mock "$fakebin" ideas idea "$log" + install_store_mock "$fakebin" review review "$log" + if [ -n "${EXISTING_JSON:-}" ]; then cp "$EXISTING_JSON" "$log.existing.json"; fi + infer=$(make_infer_mock "$tmp" "$GEN_FILE" "$EVAL_FILE") + env -i \ + PATH="$fakebin:${FM_TEST_BASE_PATH:-/usr/bin:/bin:/usr/sbin:/sbin}" \ + HOME="$tmp/home" \ + FM_IDEA_MINE_TRANSCRIPT_DIR="$transcript_dir" \ + FM_IDEA_MINE_MUSE_FILE="$tmp/no-such-muse.md" \ + FM_IDEA_MINE_INFER_CMD="$infer" \ + FM_IDEA_MINE_IDEAS_CMD=ideas \ + FM_IDEA_MINE_REVIEW_CMD=review \ + FM_IDEA_MINE_STATE_DIR="$state_dir" \ + "$(command -v bun)" "$SCRIPT" "$@" 2>&1 +} + +# --- payload builders ------------------------------------------------------- + +gen_two() { + cat > "$1" <<'JSON' +[ + {"title":"Voice inbox triage","spark":"build me a voice-first way to triage my inbox","rationale":"never captured","source_hint":"triage my inbox"}, + {"title":"Auto espresso shot logging","spark":"machine logged shots automatically","rationale":"recurring wish","source_hint":"espresso machine"} +] +JSON +} + +eval_keep_two() { + cat > "$1" <<'JSON' +{ + "kept":[ + {"title":"Voice inbox triage","spark":"voice-first inbox triage","scores":{"novelty":7,"impact":8,"feasibility":6,"alignment":9},"overall":7.5,"why":"strong voice-first fit","recommended_action":"build"}, + {"title":"Auto espresso shot logging","spark":"auto shot logging","scores":{"novelty":5,"impact":4,"feasibility":7,"alignment":3},"overall":4.8,"why":"fun but off-mission","recommended_action":"discuss"} + ], + "dropped":[ + {"title":"Some vague thing","reason":"too vague to act on"} + ] +} +JSON +} + +# --- tests ------------------------------------------------------------------ + +test_happy_path_generate_evaluate_file() { + local tmp; tmp=$(fm_test_tmproot fm-idea-mine-happy) + mkdir -p "$tmp" + GEN_FILE="$tmp/gen.json"; EVAL_FILE="$tmp/eval.json"; unset EXISTING_JSON + gen_two "$GEN_FILE"; eval_keep_two "$EVAL_FILE" + local out; out=$(run_mine "$tmp") || fail "happy path exited nonzero: $out" + local log="$tmp/store.log" + # Two ideas created + one review created. + local idea_creates review_creates + idea_creates=$(grep -c '^ideas \[create\]' "$log" || true) + review_creates=$(grep -c '^review \[create\]' "$log" || true) + [ "$idea_creates" = 2 ] || fail "expected 2 idea creates, got $idea_creates"$'\n'"$(cat "$log")" + [ "$review_creates" = 1 ] || fail "expected 1 review create, got $review_creates"$'\n'"$(cat "$log")" + assert_contains "$out" "filed 2 new ideas" "summary should report 2 filed" + assert_contains "$out" "review item:" "summary should name the review id" + pass "happy path: generate -> evaluate -> files two ideas and one review item" +} + +test_dedup_skips_existing_idea() { + local tmp; tmp=$(fm_test_tmproot fm-idea-mine-dedup) + mkdir -p "$tmp" + GEN_FILE="$tmp/gen.json"; EVAL_FILE="$tmp/eval.json" + gen_two "$GEN_FILE"; eval_keep_two "$EVAL_FILE" + # Seed an existing idea whose title fuzzily matches "Voice inbox triage". + EXISTING_JSON="$tmp/existing.json" + cat > "$EXISTING_JSON" <<'JSON' +[{"id":"idea-existing","title":"voice inbox triage assistant"}] +JSON + local out; out=$(run_mine "$tmp") || fail "dedup run exited nonzero: $out" + local log="$tmp/store.log" + local idea_creates + idea_creates=$(grep -c '^ideas \[create\]' "$log" || true) + [ "$idea_creates" = 1 ] || fail "expected 1 idea create (one deduped), got $idea_creates"$'\n'"$(cat "$log")" + assert_contains "$out" "skipped 1 duplicates" "summary should report the skipped duplicate" + assert_contains "$out" "idea-existing" "duplicate should reference the existing id" + pass "dedup: an already-present idea is skipped, not re-filed" +} + +test_dry_run_files_nothing() { + local tmp; tmp=$(fm_test_tmproot fm-idea-mine-dry) + mkdir -p "$tmp" + GEN_FILE="$tmp/gen.json"; EVAL_FILE="$tmp/eval.json"; unset EXISTING_JSON + gen_two "$GEN_FILE"; eval_keep_two "$EVAL_FILE" + local out; out=$(run_mine "$tmp" --dry-run) || fail "dry-run exited nonzero: $out" + local log="$tmp/store.log" + # No create/update should have been logged at all. + if grep -qE '\[create\]|\[update\]' "$log"; then + fail "dry-run must not file anything, but store.log has:"$'\n'"$(cat "$log")" + fi + assert_contains "$out" "DRY RUN" "dry-run output should announce itself" + assert_contains "$out" "would file" "dry-run should preview what it would file" + pass "dry-run: computes the harvest but files nothing" +} + +test_empty_history_no_op() { + local tmp; tmp=$(fm_test_tmproot fm-idea-mine-empty) + mkdir -p "$tmp" + GEN_FILE="$tmp/gen.json"; EVAL_FILE="$tmp/eval.json"; unset EXISTING_JSON + gen_two "$GEN_FILE"; eval_keep_two "$EVAL_FILE" + # Point at an empty transcript dir (no .jsonl at all). + local transcript_dir="$tmp/empty"; mkdir -p "$transcript_dir" + local fakebin; fakebin=$(fm_fakebin "$tmp") + local log="$tmp/store.log"; : > "$log" + install_store_mock "$fakebin" ideas idea "$log" + install_store_mock "$fakebin" review review "$log" + local infer; infer=$(make_infer_mock "$tmp" "$GEN_FILE" "$EVAL_FILE") + local out + out=$(env -i PATH="$fakebin:${FM_TEST_BASE_PATH:-/usr/bin:/bin:/usr/sbin:/sbin}" \ + HOME="$tmp/home" \ + FM_IDEA_MINE_TRANSCRIPT_DIR="$transcript_dir" \ + FM_IDEA_MINE_MUSE_FILE="$tmp/no-muse.md" \ + FM_IDEA_MINE_INFER_CMD="$infer" \ + FM_IDEA_MINE_STATE_DIR="$tmp/state" \ + "$(command -v bun)" "$SCRIPT" 2>&1) || fail "empty-history run exited nonzero: $out" + if grep -qE '\[create\]' "$log"; then + fail "empty history must not file anything:"$'\n'"$(cat "$log")" + fi + assert_contains "$out" "no transcript history" "empty history should report nothing to do" + pass "empty history: no-op, files nothing" +} + +test_malformed_inference_fails_safe() { + local tmp; tmp=$(fm_test_tmproot fm-idea-mine-malformed) + mkdir -p "$tmp" + GEN_FILE="$tmp/gen.json"; EVAL_FILE="$tmp/eval.json"; unset EXISTING_JSON + # Generate pass returns garbage, not JSON. + printf 'this is not json at all, the model rambled\n' > "$GEN_FILE" + eval_keep_two "$EVAL_FILE" + local out rc=0 + out=$(run_mine "$tmp") || rc=$? + local log="$tmp/store.log" + [ "$rc" -ne 0 ] || fail "malformed inference should exit nonzero, got 0"$'\n'"$out" + if grep -qE '\[create\]' "$log"; then + fail "malformed inference must not file garbage:"$'\n'"$(cat "$log")" + fi + assert_contains "$out" "generate pass failed" "should report the generate failure" + pass "malformed inference JSON fails safe: nonzero exit, nothing filed" +} + +test_idempotency_marker_skips_unchanged_tail() { + local tmp; tmp=$(fm_test_tmproot fm-idea-mine-idem) + mkdir -p "$tmp" + GEN_FILE="$tmp/gen.json"; EVAL_FILE="$tmp/eval.json"; unset EXISTING_JSON + gen_two "$GEN_FILE"; eval_keep_two "$EVAL_FILE" + # First run files. + local out1; out1=$(run_mine "$tmp") || fail "first idempotency run failed: $out1" + local log="$tmp/store.log" + local first_creates; first_creates=$(grep -c '\[create\]' "$log" || true) + [ "$first_creates" -gt 0 ] || fail "first run should have filed something" + # Second run over the SAME transcript must no-op on the marker. + : > "$log"; rm -f "$log.counter" + local out2; out2=$(run_mine "$tmp") || fail "second idempotency run failed: $out2" + if grep -qE '\[create\]' "$log"; then + fail "second run over unchanged tail must not re-file:"$'\n'"$(cat "$log")" + fi + assert_contains "$out2" "unchanged" "second run should report the tail unchanged" + pass "idempotency: unchanged transcript tail is skipped on the marker" +} + +test_kept_cap_enforced() { + local tmp; tmp=$(fm_test_tmproot fm-idea-mine-cap) + mkdir -p "$tmp" + GEN_FILE="$tmp/gen.json"; EVAL_FILE="$tmp/eval.json"; unset EXISTING_JSON + gen_two "$GEN_FILE" + # Evaluation returns two kept, but cap is 1 -> only the higher-scored files. + eval_keep_two "$EVAL_FILE" + local fakebin state_dir infer log transcript_dir + transcript_dir="$tmp/transcripts"; mkdir -p "$transcript_dir" + write_transcript "$transcript_dir/session.jsonl" + fakebin=$(fm_fakebin "$tmp"); state_dir="$tmp/state"; log="$tmp/store.log"; : > "$log" + install_store_mock "$fakebin" ideas idea "$log" + install_store_mock "$fakebin" review review "$log" + infer=$(make_infer_mock "$tmp" "$GEN_FILE" "$EVAL_FILE") + local out + out=$(env -i PATH="$fakebin:${FM_TEST_BASE_PATH:-/usr/bin:/bin:/usr/sbin:/sbin}" \ + HOME="$tmp/home" \ + FM_IDEA_MINE_TRANSCRIPT_DIR="$transcript_dir" \ + FM_IDEA_MINE_MUSE_FILE="$tmp/no-muse.md" \ + FM_IDEA_MINE_INFER_CMD="$infer" \ + FM_IDEA_MINE_STATE_DIR="$state_dir" \ + FM_IDEA_MINE_MAX_KEPT=1 \ + "$(command -v bun)" "$SCRIPT" 2>&1) || fail "cap run exited nonzero: $out" + local idea_creates; idea_creates=$(grep -c '^ideas \[create\]' "$log" || true) + [ "$idea_creates" = 1 ] || fail "kept cap of 1 should file exactly 1 idea, got $idea_creates"$'\n'"$(cat "$log")" + pass "kept cap: FM_IDEA_MINE_MAX_KEPT bounds the number of filed ideas" +} + +test_happy_path_generate_evaluate_file +test_dedup_skips_existing_idea +test_dry_run_files_nothing +test_empty_history_no_op +test_malformed_inference_fails_safe +test_idempotency_marker_skips_unchanged_tail +test_kept_cap_enforced + +echo "# all fm-idea-mine tests passed" From bdee1285fc623baacac093c2c3343ce7472e15c8 Mon Sep 17 00:00:00 2001 From: Trillium Smith Date: Wed, 15 Jul 2026 08:22:18 -0700 Subject: [PATCH 10/23] fm-review-decision.sh: route a captain's review decision to firstmate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The firstmate-side half of the interactive review loop. Given [comment], it durably (a) enqueues a check-kind wake into state/.wake-queue via the sanctioned fm_wake_append helper, keyed review-decision:, so fm-wake-drain surfaces it on the next supervision cycle; (b) annotates the item via 'review note'; (c) appends a JSONL audit record. Fails LOUDLY (non-zero) if the wake or annotation cannot land — never a silent ok (robots-5l8). 8 hermetic tests cover every verdict, the fail-loud path, and --stdin comments. --- bin/fm-review-decision.sh | 163 +++++++++++++++++++++ tests/fm-review-decision.test.sh | 244 +++++++++++++++++++++++++++++++ 2 files changed, 407 insertions(+) create mode 100755 bin/fm-review-decision.sh create mode 100755 tests/fm-review-decision.test.sh diff --git a/bin/fm-review-decision.sh b/bin/fm-review-decision.sh new file mode 100755 index 0000000000..c8dff57857 --- /dev/null +++ b/bin/fm-review-decision.sh @@ -0,0 +1,163 @@ +#!/usr/bin/env bash +# fm-review-decision.sh - record a captain's decision on a review-store item and +# route it to firstmate's attention. This is the firstmate-side half of the +# interactive review decision loop; the captain-facing half is the Pulse endpoint +# POST /api/review/decision (PAI/PULSE/pulse.ts), which validates the id and then +# shells here. +# +# THE LOOP (contract, end to end): +# 1. Captain clicks Approve/Decline/Comment on /review// (an interactive +# decision page rendered by bin/fm-review-page.ts). +# 2. The page POSTs {id, verdict, comment?} same-origin to /api/review/decision. +# 3. The Pulse endpoint validates the id exists (via `review show`) and, on a +# valid id, invokes THIS script (resolving firstmate's home via FM_HOME). +# 4. THIS script does three durable things, in order, and FAILS LOUDLY if any +# of the first two cannot be delivered - never a silent ok (robots-5l8): +# a. appends a `check`-kind wake to firstmate's durable wake queue +# (state/.wake-queue) via the sanctioned fm_wake_append helper, keyed +# `review-decision:`, so fm-wake-drain.sh surfaces it on the next +# supervision cycle as an actionable `check:` wake; +# b. annotates the review item itself (`review note "Captain +# decision: - @ "`) so the decision is visible +# in `review show ` / `review ready`; +# c. appends a JSONL audit record to the durable decisions log +# (~/pulse-pages/review/.decisions.jsonl by default) as the trail. +# 5. firstmate drains the wake, reads the decision, and acts on it. +# +# FAIL-LOUD POLICY. The wake enqueue and the store annotation are load-bearing: +# if either fails, this script exits non-zero with a diagnostic on stderr so the +# endpoint returns a non-2xx and the captain sees a real error rather than a +# false success. The audit-log append is best-effort last (the decision is +# already durably enqueued and annotated by then); a log failure is reported on +# stderr but does not fail the command, because losing the human-readable trail +# is strictly less bad than double-reporting a decision that already landed. +# +# VERDICTS. approve | decline | comment. `comment` requires non-empty text; the +# other two accept an optional comment. Any other verdict is rejected (exit 2). +# +# ENV OVERRIDES (all optional): +# FM_HOME firstmate home (selects state/.wake-queue root) +# FM_REVIEW_BIN review CLI path (default `review` on PATH) +# FM_REVIEW_DECISIONS_LOG audit JSONL path (default ~/pulse-pages/review/.decisions.jsonl) +# FM_REVIEW_DECISION_ACTOR audit actor for the note (default "captain") +# +# USAGE: +# fm-review-decision.sh [comment text...] +# echo "" | fm-review-decision.sh comment --stdin +set -u + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=bin/fm-wake-lib.sh +. "$SCRIPT_DIR/fm-wake-lib.sh" + +REVIEW_BIN="${FM_REVIEW_BIN:-review}" +DECISION_ACTOR="${FM_REVIEW_DECISION_ACTOR:-captain}" +# fm-wake-lib.sh pins HOME-independent STATE via FM_HOME; the audit log defaults +# under the real HOME's pulse-pages so it sits beside the served review pages. +DECISIONS_LOG="${FM_REVIEW_DECISIONS_LOG:-$HOME/pulse-pages/review/.decisions.jsonl}" + +die() { printf 'fm-review-decision: %s\n' "$*" >&2; exit 1; } + +usage() { + cat >&2 <<'EOF' +Usage: fm-review-decision.sh [comment...] + echo "" | fm-review-decision.sh comment --stdin +EOF +} + +# --- parse args -------------------------------------------------------------- +ID="${1:-}" +VERDICT="${2:-}" +if [ -z "$ID" ] || [ -z "$VERDICT" ]; then + usage + exit 2 +fi +shift 2 || true + +# Remaining args are the comment; support --stdin for piped comment bodies. +COMMENT="" +if [ "${1:-}" = "--stdin" ]; then + COMMENT="$(cat)" +else + COMMENT="$*" +fi + +case "$VERDICT" in + approve|decline|comment) ;; + *) printf 'fm-review-decision: invalid verdict: %s (want approve|decline|comment)\n' "$VERDICT" >&2; exit 2 ;; +esac + +# A bare `comment` verdict with no text is meaningless - reject it loudly rather +# than enqueue an empty comment. +if [ "$VERDICT" = comment ] && [ -z "${COMMENT//[[:space:]]/}" ]; then + die "comment verdict requires non-empty comment text" +fi + +# --- JSON string escaper for the audit record -------------------------------- +# Escape a string for embedding as a JSON value (quotes, backslashes, control +# chars). Kept dependency-free; correctness over cleverness. +json_escape() { + local s=$1 out="" + local i ch + for (( i = 0; i < ${#s}; i++ )); do + ch="${s:i:1}" + case "$ch" in + '"') out+='\"' ;; + '\') out+='\\' ;; + $'\n') out+='\n' ;; + $'\r') out+='\r' ;; + $'\t') out+='\t' ;; + *) out+="$ch" ;; + esac + done + printf '%s' "$out" +} + +TS="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + +# --------------------------------------------------------------------------- +# (a) DURABLE WAKE - load-bearing, fail loud on any failure. +# The payload is a compact, human-readable line firstmate reads directly off the +# drained wake: verdict first, then the comment if present. +# --------------------------------------------------------------------------- +WAKE_PAYLOAD="captain decided ${VERDICT} on ${ID}" +if [ -n "${COMMENT//[[:space:]]/}" ]; then + WAKE_PAYLOAD="${WAKE_PAYLOAD} - ${COMMENT}" +fi +if ! fm_wake_append check "review-decision:${ID}" "$WAKE_PAYLOAD"; then + die "failed to enqueue wake for ${ID} (queue: ${FM_WAKE_QUEUE})" +fi + +# --------------------------------------------------------------------------- +# (b) STORE ANNOTATION - load-bearing, fail loud. Makes the decision visible in +# `review show ` so the item carries its own decision trail. +# --------------------------------------------------------------------------- +NOTE="Captain decision: ${VERDICT}" +if [ -n "${COMMENT//[[:space:]]/}" ]; then + NOTE="${NOTE} - ${COMMENT}" +fi +NOTE="${NOTE} @ ${TS}" +NOTE_ERR="$(mktemp "${TMPDIR:-/tmp}/fm-review-decision-note.XXXXXX")" +if ! BEADS_ACTOR="$DECISION_ACTOR" "$REVIEW_BIN" note "$ID" "$NOTE" >/dev/null 2>"$NOTE_ERR"; then + err="$(cat "$NOTE_ERR" 2>/dev/null || true)"; rm -f "$NOTE_ERR" + die "failed to annotate review item ${ID}: ${err}" +fi +rm -f "$NOTE_ERR" + +# --------------------------------------------------------------------------- +# (c) AUDIT LOG - best-effort last. The decision is already durably enqueued and +# annotated; a log write failure is reported but does not fail the command. +# --------------------------------------------------------------------------- +mkdir -p "$(dirname "$DECISIONS_LOG")" 2>/dev/null || true +AUDIT_LINE="$(printf '{"ts":"%s","id":"%s","verdict":"%s","comment":"%s","actor":"%s"}' \ + "$TS" \ + "$(json_escape "$ID")" \ + "$(json_escape "$VERDICT")" \ + "$(json_escape "$COMMENT")" \ + "$(json_escape "$DECISION_ACTOR")")" +if ! printf '%s\n' "$AUDIT_LINE" >> "$DECISIONS_LOG" 2>/dev/null; then + printf 'fm-review-decision: WARNING: audit-log append failed (%s); decision already enqueued + annotated\n' "$DECISIONS_LOG" >&2 +fi + +# Success line for the endpoint to relay. +printf 'recorded: %s decided on %s @ %s\n' "$VERDICT" "$ID" "$TS" diff --git a/tests/fm-review-decision.test.sh b/tests/fm-review-decision.test.sh new file mode 100755 index 0000000000..a5dd092259 --- /dev/null +++ b/tests/fm-review-decision.test.sh @@ -0,0 +1,244 @@ +#!/usr/bin/env bash +# Behavior tests for bin/fm-review-decision.sh - the firstmate-side half of the +# interactive review decision loop. Given a captain's decision on a review item, +# it must (a) enqueue a durable `check` wake keyed review-decision:, (b) +# annotate the item via `review note`, and (c) append a JSONL audit record - and +# FAIL LOUDLY (non-zero) when the load-bearing wake or annotation cannot land. +# +# Hermetic and network-free. The `review` CLI is stubbed with a bash fake on a +# fakebin whose `note` verb either logs the argv (success) or exits non-zero +# (forced failure), so the fail-loud path is asserted without the real store. +# FM_HOME points at a throwaway temp home so the wake queue is isolated. +# +# Cases: +# (a) approve -> exit 0, wake enqueued, note appended, audit line written +# (b) decline+cmt -> comment threads into wake payload, note, and audit +# (c) comment -> requires text; body reaches all three sinks +# (d) empty comment-> comment verdict with no text is rejected (exit 1) +# (e) bad verdict -> rejected (exit 2), nothing enqueued +# (f) note failure -> store annotation failure fails loudly (exit 1) +# (g) --stdin -> comment read from stdin lands in all three sinks +# (h) missing args -> usage error (exit 2) +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +TOOL="$ROOT/bin/fm-review-decision.sh" +TMP_ROOT=$(fm_test_tmproot fm-review-decision) + +# install_fake_review [fail] +# note verb appends its argv to ; when [fail] is "fail", note exits 1. +install_fake_review() { + local dir=$1 log=$2 mode=${3:-ok} + mkdir -p "$dir" + if [ "$mode" = fail ]; then + cat > "$dir/review" <&2; exit 1 ;; + *) exit 0 ;; +esac +SH + else + cat > "$dir/review" <> "$log" ;; + *) exit 0 ;; +esac +SH + fi + chmod +x "$dir/review" +} + +# run_decision [] -> $OUTPUT/$RC +# A trailing arg that begins with $'\x01' is fed on stdin (used for --stdin case). +run_decision() { + local home=$1 fakebin=$2 log=$3 + shift 3 + local stdin_data="" + # Detect an optional stdin sentinel as the LAST arg. + local last="${!#}" + local -a args=("$@") + if [ "${last:0:1}" = $'\x01' ]; then + stdin_data="${last:1}" + unset 'args[${#args[@]}-1]' + fi + if [ -n "$stdin_data" ]; then + OUTPUT=$( + FM_HOME="$home" \ + FM_REVIEW_BIN="$fakebin/review" \ + FM_REVIEW_DECISIONS_LOG="$home/decisions.jsonl" \ + printf '%s' "$stdin_data" | \ + FM_HOME="$home" \ + FM_REVIEW_BIN="$fakebin/review" \ + FM_REVIEW_DECISIONS_LOG="$home/decisions.jsonl" \ + bash "$TOOL" "${args[@]}" 2>&1 + ) + RC=$? + else + OUTPUT=$( + FM_HOME="$home" \ + FM_REVIEW_BIN="$fakebin/review" \ + FM_REVIEW_DECISIONS_LOG="$home/decisions.jsonl" \ + bash "$TOOL" "${args[@]}" 2>&1 + ) + RC=$? + fi +} + +# The wake queue lives at $home/state/.wake-queue (fm-wake-lib derives STATE from +# FM_HOME). Helper to grep it. +wake_queue() { printf '%s/state/.wake-queue' "$1"; } + +# =========================================================================== +# (a) approve: exit 0, wake enqueued, note appended, audit line written. +# =========================================================================== +t_approve() { + local case_dir="$TMP_ROOT/approve"; local home="$case_dir/home" fakebin="$case_dir/bin" + local log="$case_dir/notes.log" + mkdir -p "$home" + install_fake_review "$fakebin" "$log" + + run_decision "$home" "$fakebin" "$log" review-znt approve + expect_code 0 "$RC" "approve: exit 0" + + assert_grep "check " "$(wake_queue "$home")" "approve: a check-kind wake landed" + assert_grep "review-decision:review-znt" "$(wake_queue "$home")" "approve: wake keyed to the item" + assert_grep "captain decided approve on review-znt" "$(wake_queue "$home")" "approve: wake payload carries verdict" + assert_grep "note review-znt Captain decision: approve" "$log" "approve: item annotated with the decision" + assert_grep '"verdict":"approve"' "$home/decisions.jsonl" "approve: audit record written" + assert_grep '"id":"review-znt"' "$home/decisions.jsonl" "approve: audit record carries id" + + pass "fm-review-decision: approve enqueues wake, annotates item, writes audit" +} + +# =========================================================================== +# (b) decline with a comment: comment threads into wake payload, note, audit. +# =========================================================================== +t_decline_comment() { + local case_dir="$TMP_ROOT/decline"; local home="$case_dir/home" fakebin="$case_dir/bin" + local log="$case_dir/notes.log" + mkdir -p "$home" + install_fake_review "$fakebin" "$log" + + run_decision "$home" "$fakebin" "$log" review-dmv decline "too risky right now" + expect_code 0 "$RC" "decline: exit 0" + assert_grep "captain decided decline on review-dmv - too risky right now" "$(wake_queue "$home")" "decline: comment in wake payload" + assert_grep "note review-dmv Captain decision: decline - too risky right now" "$log" "decline: comment in note" + assert_grep '"comment":"too risky right now"' "$home/decisions.jsonl" "decline: comment in audit" + + pass "fm-review-decision: decline+comment threads the comment into every sink" +} + +# =========================================================================== +# (c) comment verdict with text: body reaches all three sinks. +# =========================================================================== +t_comment() { + local case_dir="$TMP_ROOT/comment"; local home="$case_dir/home" fakebin="$case_dir/bin" + local log="$case_dir/notes.log" + mkdir -p "$home" + install_fake_review "$fakebin" "$log" + + run_decision "$home" "$fakebin" "$log" review-2az comment "what about the QBO edge case?" + expect_code 0 "$RC" "comment: exit 0" + assert_grep "review-decision:review-2az" "$(wake_queue "$home")" "comment: wake enqueued" + assert_grep "note review-2az Captain decision: comment - what about the QBO edge case?" "$log" "comment: note carries the comment" + + pass "fm-review-decision: comment verdict routes the comment body to all sinks" +} + +# =========================================================================== +# (d) comment verdict with empty text is rejected (exit 1), nothing enqueued. +# =========================================================================== +t_empty_comment() { + local case_dir="$TMP_ROOT/empty"; local home="$case_dir/home" fakebin="$case_dir/bin" + local log="$case_dir/notes.log" + mkdir -p "$home" + install_fake_review "$fakebin" "$log" + + run_decision "$home" "$fakebin" "$log" review-znt comment " " + [ "$RC" -ne 0 ] || fail "empty-comment: expected non-zero exit, got 0" + assert_contains "$OUTPUT" "requires non-empty comment" "empty-comment: loud rejection" + assert_absent "$(wake_queue "$home")" "empty-comment: no wake enqueued" + + pass "fm-review-decision: empty comment verdict is rejected, nothing enqueued" +} + +# =========================================================================== +# (e) invalid verdict is rejected (exit 2), nothing enqueued. +# =========================================================================== +t_bad_verdict() { + local case_dir="$TMP_ROOT/bad"; local home="$case_dir/home" fakebin="$case_dir/bin" + local log="$case_dir/notes.log" + mkdir -p "$home" + install_fake_review "$fakebin" "$log" + + run_decision "$home" "$fakebin" "$log" review-znt yolo + expect_code 2 "$RC" "bad-verdict: exit 2" + assert_contains "$OUTPUT" "invalid verdict" "bad-verdict: loud rejection" + assert_absent "$(wake_queue "$home")" "bad-verdict: no wake enqueued" + + pass "fm-review-decision: invalid verdict is rejected (exit 2), nothing enqueued" +} + +# =========================================================================== +# (f) FAIL LOUD: when `review note` fails, the whole command fails (exit 1). +# This is the robots-5l8 guarantee - never a silent ok when a load-bearing step +# cannot deliver. +# =========================================================================== +t_note_failure() { + local case_dir="$TMP_ROOT/notefail"; local home="$case_dir/home" fakebin="$case_dir/bin" + local log="$case_dir/notes.log" + mkdir -p "$home" + install_fake_review "$fakebin" "$log" fail + + run_decision "$home" "$fakebin" "$log" review-znt approve + [ "$RC" -ne 0 ] || fail "note-failure: expected non-zero exit on store failure, got 0" + assert_contains "$OUTPUT" "failed to annotate review item review-znt" "note-failure: loud diagnostic" + + pass "fm-review-decision: store annotation failure fails loudly (no silent ok)" +} + +# =========================================================================== +# (g) --stdin: comment read from stdin lands in all three sinks. +# =========================================================================== +t_stdin_comment() { + local case_dir="$TMP_ROOT/stdin"; local home="$case_dir/home" fakebin="$case_dir/bin" + local log="$case_dir/notes.log" + mkdir -p "$home" + install_fake_review "$fakebin" "$log" + + # Trailing $'\x01'-prefixed arg is fed on stdin by run_decision. + run_decision "$home" "$fakebin" "$log" review-bk6 comment --stdin $'\x01multi word stdin comment' + expect_code 0 "$RC" "stdin: exit 0" + assert_grep "multi word stdin comment" "$(wake_queue "$home")" "stdin: comment reached the wake" + assert_grep "note review-bk6 Captain decision: comment - multi word stdin comment" "$log" "stdin: comment reached the note" + + pass "fm-review-decision: --stdin comment lands in every sink" +} + +# =========================================================================== +# (h) missing args -> usage error (exit 2). +# =========================================================================== +t_missing_args() { + local case_dir="$TMP_ROOT/missing"; local home="$case_dir/home" fakebin="$case_dir/bin" + local log="$case_dir/notes.log" + mkdir -p "$home" + install_fake_review "$fakebin" "$log" + + run_decision "$home" "$fakebin" "$log" review-znt + expect_code 2 "$RC" "missing-args: exit 2" + + pass "fm-review-decision: missing verdict is a usage error (exit 2)" +} + +t_approve +t_decline_comment +t_comment +t_empty_comment +t_bad_verdict +t_note_failure +t_stdin_comment +t_missing_args From 61b1ea220aaacf3e41af0ca3f8e99c5318f60580 Mon Sep 17 00:00:00 2001 From: Trillium Smith Date: Wed, 15 Jul 2026 08:22:29 -0700 Subject: [PATCH 11/23] fm-review-page: interactive decision panel + single top bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Turn read-only review pages into decision surfaces. Every page now carries an Approve/Decline/Comment panel that POSTs same-origin to /api/review/decision with in-page success/error feedback, plus a structured What/Why/Stakes/Recommendation/Artifact breakdown parsed from the body so the decision is answerable in place. An already-recorded 'Captain decision:' note renders as a standing-decision banner. Phone-friendly (48px tap targets, no horizontal scroll, self-contained inline JS/CSS). Also fixes the double-nav-bar bug: portal's injectShell stacked /_pulse/nav.js on top of the page's own .topbar. The page now emits to opt out — exactly one top bar, matching how /status and /plans compose. 5 new tests (13 total). --- bin/fm-review-page.ts | 309 ++++++++++++++++++++++++++++++++++- tests/fm-review-page.test.sh | 149 +++++++++++++++++ 2 files changed, 452 insertions(+), 6 deletions(-) diff --git a/bin/fm-review-page.ts b/bin/fm-review-page.ts index 32e07489f9..b408584a0a 100755 --- a/bin/fm-review-page.ts +++ b/bin/fm-review-page.ts @@ -1,15 +1,32 @@ #!/usr/bin/env bun /** - * fm-review-page.ts — give every review-store item a visitable page. + * fm-review-page.ts — give every review-store item an INTERACTIVE decision page. * - * The captain's morning review should be *visiting pages*, not reading terse - * chat. Anything filed into the `review` federated store (the "requires the - * captain's eyes" queue) gets a self-contained, phone-readable HTML page under - * ~/pulse-pages/review//index.html, served by Pulse at + * The captain's morning review should be *visiting pages* and *acting on them*, + * not reading terse chat. Anything filed into the `review` federated store (the + * "requires the captain's eyes" queue) gets a self-contained, phone-readable + * HTML page under ~/pulse-pages/review//index.html, served by Pulse at * http://localhost:31337/review// (and 100.74.138.74:31337/review// * over Tailscale). An index at ~/pulse-pages/review/index.html lists every open * item, reachable one click from /status/ (one-URL rule). * + * INTERACTIVE DECISION LOOP (endpoint → wake-queue → drain → firstmate acts): + * Each page carries a decision panel — Approve / Decline buttons and a Comment + * textarea — plus a structured What / Why / Stakes / Recommendation / Artifact + * breakdown parsed from the body so the decision is answerable in place. The + * panel's inline JS POSTs { id, verdict, comment? } same-origin to + * POST /api/review/decision (PAI/PULSE, lib/review-decision.ts). That endpoint + * validates the id via `review show`, then shells to bin/fm-review-decision.sh + * (resolved via FM_HOME), which durably enqueues a `check`-kind wake into + * firstmate's state/.wake-queue (drained by bin/fm-wake-drain.sh), annotates + * the item via `review note`, and appends a JSONL audit record. So when the + * captain clicks Approve on /review//, firstmate is woken with an + * actionable `check: review-decision:` wake on its next supervision cycle. + * A decision already recorded (parsed from the item's `Captain decision:` + * notes) renders as a standing-decision banner on the page. Fail-loud + * throughout: an unknown id or an undeliverable wake is a non-2xx error, never + * a false-positive ok (robots-5l8). + * * SURFACE CHOICE — Pulse static pages, not lavish: * lavish (~/.local/bin/lavish -> parlay) is a live, interactive review * SESSION tool: it opens a browser, expects `lavish-axi poll` for feedback, @@ -72,6 +89,8 @@ interface RawReviewItem { id: string title?: string description?: string + /** Appended notes (carries `Captain decision:` lines once a decision lands). */ + notes?: string status?: string priority?: number issue_type?: string @@ -86,6 +105,7 @@ interface ReviewItem { id: string title: string description: string + notes: string status: string priority: number | null issueType: string @@ -96,6 +116,13 @@ interface ReviewItem { existingPageUrl: string } +/** The most recent captain decision already recorded on the item, if any. */ +interface RecordedDecision { + verdict: string + comment: string + ts: string +} + /** An artifact link extracted from the body (URL, brain id, git branch). */ interface Artifact { kind: "url" | "brain" | "branch" @@ -152,6 +179,7 @@ function normalize(raw: RawReviewItem): ReviewItem { id: raw.id, title: (raw.title ?? "").trim() || raw.id, description: raw.description ?? "", + notes: raw.notes ?? "", status: (raw.status ?? "open").trim(), priority: typeof raw.priority === "number" ? raw.priority : null, issueType: (raw.issue_type ?? "").trim(), @@ -162,6 +190,27 @@ function normalize(raw: RawReviewItem): ReviewItem { } } +/** + * Extract the most recent recorded captain decision from the item's notes. + * fm-review-decision.sh appends lines shaped: + * `Captain decision: - @ ` + * `Captain decision: @ ` (no comment) + * Returns the LAST such line (the standing decision), or null if none. + */ +function extractRecordedDecision(notes: string): RecordedDecision | null { + if (!notes) return null + const re = /Captain decision:\s*(approve|decline|comment)\s*(?:-\s*([\s\S]*?))?\s*@\s*(\S+)/gi + let last: RecordedDecision | null = null + for (const m of notes.matchAll(re)) { + last = { + verdict: m[1].toLowerCase(), + comment: (m[2] ?? "").trim(), + ts: (m[3] ?? "").trim(), + } + } + return last +} + /** Fetch every OPEN review item (the `--all` set). */ function fetchAllOpen(): ReviewItem[] { // `list --json` returns non-closed items by default; keep only open-ish ones. @@ -468,6 +517,76 @@ function extractStakes(body: string): string { return inline ? inline[1].trim() : "" } +/** One extracted decision-context field, in the fixed What→Why→…→Artifact order. */ +interface ContextField { + label: string + /** Rendered inline-markdown HTML for the field's prose. */ + html: string +} + +/** + * Break the body into a skimmable What / Why / Stakes / Recommendation / + * Artifact breakdown so the decision is answerable on the page. Review bodies + * carry these signals in two shapes: markdown headings (`## What`, `## Stakes`) + * or inline uppercase labels (`ARTIFACT: ...`, `STAKES: ...`). Each field is + * matched by a set of synonyms and rendered with inline markdown. Returns [] if + * NONE of the fields are found, so the caller can fall back to the flat body. + */ +function extractContextFields(body: string): ContextField[] { + // label -> matcher synonyms (case-insensitive), in display order. + const spec: { label: string; keys: string[] }[] = [ + { label: "What", keys: ["what", "summary", "proposal", "decide", "decision"] }, + { label: "Why", keys: ["why", "rationale", "context", "background"] }, + { label: "Stakes", keys: ["stakes", "risk", "risks", "impact"] }, + { label: "Recommendation", keys: ["recommendation", "recommended action", "recommended", "action"] }, + { label: "Artifact", keys: ["artifact", "artifacts", "evidence", "branch", "pr"] }, + ] + + const found: ContextField[] = [] + const usedRanges: [number, number][] = [] + + const claim = (start: number, end: number): boolean => { + // Reject a match that overlaps a field we already claimed (so "action" + // inside a Stakes block can't be re-extracted as Recommendation). + for (const [s, e] of usedRanges) { + if (start < e && end > s) return false + } + usedRanges.push([start, end]) + return true + } + + for (const { label, keys } of spec) { + let picked = "" + for (const key of keys) { + const esc = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + // (1) Heading form: `## What\n...` up to the next heading, a blank-line + // paragraph break, or end of body. Stopping at a blank line keeps the last + // heading section from swallowing a following inline-label line (e.g. a + // trailing `ARTIFACT: ...`). + const headRe = new RegExp( + `(?:^|\\n)#{1,6}\\s*${esc}\\b[:\\s]*\\n([\\s\\S]*?)(?=\\n#{1,6}\\s|\\n\\s*\\n|$)`, + "i", + ) + const hm = body.match(headRe) + if (hm && hm[1].trim() && claim(hm.index ?? 0, (hm.index ?? 0) + hm[0].length)) { + picked = hm[1].trim() + break + } + // (2) Inline label form: `ARTIFACT: ...` to end of line (or paragraph for + // the last field). Kept to a single line so it doesn't swallow the body. + const inlineRe = new RegExp(`(?:^|\\n)\\s*${esc}\\s*:\\s*([^\\n]+)`, "i") + const im = body.match(inlineRe) + if (im && im[1].trim() && claim(im.index ?? 0, (im.index ?? 0) + im[0].length)) { + picked = im[1].trim() + break + } + } + if (picked) found.push({ label, html: renderInline(picked) }) + } + + return found +} + // ─── Page rendering ────────────────────────────────────────────────────────── /** Shared CSS — GitHub-dark house palette matching ~/pulse-pages/brain style. */ @@ -540,6 +659,49 @@ h1.item-title { font-size: 1.5rem; line-height: 1.25; font-weight: 700; margin-b .meta-foot { margin-top: 2.2rem; padding-top: 1rem; border-top: 1px solid var(--border); font-family: var(--mono); font-size: 11px; color: var(--muted); display: flex; flex-direction: column; gap: .3rem; } +/* Structured decision-context breakdown (What / Why / Stakes / …). */ +.context { margin: 1.2rem 0; display: flex; flex-direction: column; gap: .1rem; border: 1px solid var(--border); border-radius: 6px; overflow: hidden; } +.context .field { padding: .7rem .9rem; border-top: 1px solid var(--border); } +.context .field:first-child { border-top: none; } +.context .field .k { font-family: var(--mono); font-size: 10px; font-weight: 700; letter-spacing: .1em; text-transform: uppercase; color: var(--blue); display: block; margin-bottom: .3rem; } +.context .field .v { font-size: .95rem; line-height: 1.5; } +.context .field.stakes .k { color: var(--amber); } +.context .field.stakes { background: color-mix(in srgb, var(--amber) 6%, var(--surf)); } + +/* Interactive decision panel — the loop-closer. */ +.decision { margin: 1.6rem 0; border: 1px solid var(--border); border-radius: 8px; background: var(--surf); padding: 1rem 1rem 1.1rem; } +.decision .dlabel { font-family: var(--mono); font-size: 10px; font-weight: 700; letter-spacing: .1em; text-transform: uppercase; color: var(--muted); display: block; margin-bottom: .7rem; } +.decision .buttons { display: flex; gap: .6rem; flex-wrap: wrap; } +.decision button { + flex: 1 1 auto; min-height: 48px; min-width: 120px; font-size: 1rem; font-weight: 600; + font-family: var(--sans); border-radius: 6px; border: 1px solid var(--border); + background: var(--surf2); color: var(--body); cursor: pointer; padding: .6rem 1rem; + -webkit-tap-highlight-color: transparent; touch-action: manipulation; transition: filter .12s, opacity .12s; +} +.decision button:hover:not(:disabled) { filter: brightness(1.15); } +.decision button:active:not(:disabled) { filter: brightness(.92); } +.decision button:disabled { opacity: .45; cursor: default; } +.decision button.approve { border-color: color-mix(in srgb, var(--green) 60%, var(--border)); color: var(--green); } +.decision button.decline { border-color: color-mix(in srgb, var(--red) 60%, var(--border)); color: var(--red); } +.decision button.comment { border-color: color-mix(in srgb, var(--blue) 55%, var(--border)); color: var(--blue); } +.decision .comment-box { margin-top: .8rem; } +.decision textarea { + width: 100%; min-height: 84px; resize: vertical; font-family: var(--sans); font-size: 1rem; + line-height: 1.5; color: var(--body); background: var(--ink); border: 1px solid var(--border); + border-radius: 6px; padding: .6rem .7rem; +} +.decision textarea:focus { outline: none; border-color: var(--blue); } +.decision .hint { font-size: .82rem; color: var(--muted); margin-top: .45rem; } +.decision .feedback { margin-top: .85rem; padding: .65rem .8rem; border-radius: 6px; font-size: .92rem; line-height: 1.45; display: none; } +.decision .feedback.show { display: block; } +.decision .feedback.ok { border: 1px solid color-mix(in srgb, var(--green) 45%, var(--border)); background: color-mix(in srgb, var(--green) 8%, var(--surf)); color: var(--green); } +.decision .feedback.err { border: 1px solid color-mix(in srgb, var(--red) 50%, var(--border)); background: color-mix(in srgb, var(--red) 8%, var(--surf)); color: var(--red); } +.decision .feedback.pending { border: 1px solid var(--border); color: var(--muted); } +.decision .recorded { margin-top: .85rem; padding: .65rem .8rem; border-radius: 6px; font-size: .9rem; border: 1px solid color-mix(in srgb, var(--green) 40%, var(--border)); background: color-mix(in srgb, var(--green) 7%, var(--surf)); color: var(--body); } +.decision .recorded .rv { font-family: var(--mono); font-weight: 700; text-transform: uppercase; letter-spacing: .06em; color: var(--green); } +.decision .recorded.decline .rv { color: var(--red); } +.decision .recorded .rc { display: block; margin-top: .3rem; color: var(--muted); } + /* Index page. */ .index-head { margin-bottom: 1.4rem; } .index-head h1 { font-size: 1.35rem; font-weight: 700; } @@ -563,12 +725,135 @@ function ageTier(days: number): { cls: string; label: string } { return { cls: "", label: "today" } } +/** Render the structured What/Why/Stakes/Recommendation/Artifact breakdown. */ +function renderContext(fields: ContextField[]): string { + if (!fields.length) return "" + const rows = fields + .map((f) => { + const cls = f.label.toLowerCase() === "stakes" ? " stakes" : "" + return `
    ${esc(f.label)}
    ${f.html}
    ` + }) + .join("\n ") + return `\n
    + ${rows} +
    ` +} + +/** + * Render the interactive decision panel: Approve / Decline buttons, a Comment + * textarea + submit, in-page feedback, and — when a decision was already + * recorded — a standing-decision banner. The inline JS POSTs same-origin to + * /api/review/decision and reflects success/failure without a page reload. The + * item id is embedded as a data attribute (JSON-encoded) so no server value is + * interpolated into a JS string. + */ +function renderDecisionPanel(item: ReviewItem, recorded: RecordedDecision | null): string { + const idJson = JSON.stringify(item.id) + const recordedHtml = recorded + ? `\n
    + Standing decision: ${esc(recorded.verdict)}${ + recorded.ts ? ` · ${esc(recorded.ts)}` : "" + }${recorded.comment ? `“${esc(recorded.comment)}”` : ""} +
    ` + : `\n ` + + // The inline script is self-contained (CSP-safe, no external fetch host) and + // idempotent-friendly: after a successful decision it disables the buttons so + // the captain doesn't double-submit, and re-enables on error so a transient + // failure is retryable. + return ` +
    + Your decision +
    + + + +
    +
    + +
    Approve and Decline may include a note. Comment sends the note without deciding.
    +
    + ${recordedHtml} +
    + ` +} + /** Full self-contained HTML document for one review item. */ function renderItemPage(item: ReviewItem): string { const days = ageDays(item.createdAt) const tier = ageTier(days) const stakes = extractStakes(item.description) const artifacts = extractArtifacts(item.description) + const contextFields = extractContextFields(item.description) + const recorded = extractRecordedDecision(item.notes) const bodyHtml = item.description.trim() ? renderMarkdown(item.description) : `

    No description was filed with this item.

    ` @@ -613,6 +898,12 @@ function renderItemPage(item: ReviewItem): string { + + Review · ${esc(item.title)}