diff --git a/.opencode/plugins/fm-primary-turnend-guard.js b/.opencode/plugins/fm-primary-turnend-guard.js index fb8f42eaf4..bdec553264 100644 --- a/.opencode/plugins/fm-primary-turnend-guard.js +++ b/.opencode/plugins/fm-primary-turnend-guard.js @@ -22,6 +22,13 @@ function runProcess(command, args, input = "") { }); child.on("error", () => resolve({ code: 0, stdout: "", stderr: "" })); child.on("close", (code) => resolve({ code: code ?? 0, stdout, stderr })); + // A guard child may exit before it drains stdin (bin/fm-turnend-guard.sh + // rejects bad usage with exit 2 before its `cat`, and any child can lose + // the race between spawn and this write). The payload write then fails + // EPIPE, and an unhandled stream "error" event would take the whole + // OpenCode host process down. The child's exit code and stderr carry the + // guard verdict, so a payload the child never wanted is safe to drop. + child.stdin.on("error", () => {}); child.stdin.end(input); }); } diff --git a/.pi/extensions/fm-primary-turnend-guard.ts b/.pi/extensions/fm-primary-turnend-guard.ts index 1b2a3ec39a..578bb8f38b 100644 --- a/.pi/extensions/fm-primary-turnend-guard.ts +++ b/.pi/extensions/fm-primary-turnend-guard.ts @@ -163,6 +163,10 @@ function runGuard(): Promise<{ code: number; stderr: string }> { }); child.on("error", () => resolveResult({ code: 0, stderr: "" })); child.on("close", (code) => resolveResult({ code: code ?? 0, stderr })); + // Same EPIPE tolerance the OpenCode plugin needs: a guard child that exits + // before draining stdin turns this write into an unhandled stream "error" + // event, which would kill the Pi host. The verdict is the exit code. + child.stdin.on("error", () => {}); child.stdin.end('{"stop_hook_active":false}'); }); } diff --git a/AGENTS.md b/AGENTS.md index d021652b35..f1fa07ab4c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -86,6 +86,7 @@ data/ personal fleet records; LOCAL, gitignored as a whole secondmates.md local and remote secondmate routing table; firstmate-private, maintained by the secondmate seed helpers (section 6) /brief.md per-task crewmate brief, or per-secondmate charter brief when kind=secondmate /report.md scout task deliverable, written by the crewmate; survives teardown + decisions/*.md decision records; survives teardown projects/ cloned repos; gitignored; read-only except under hard rule 1's concrete captain-approved project operation exception state/ runtime records and signals; gitignored .status appended by crewmates: ": " wake-event lines, not current-state truth diff --git a/bin/fm-hindsight-recall.sh b/bin/fm-hindsight-recall.sh new file mode 100755 index 0000000000..070e554f7f --- /dev/null +++ b/bin/fm-hindsight-recall.sh @@ -0,0 +1,104 @@ +#!/usr/bin/env bash +# fm-hindsight-recall.sh - query Hindsight memory bank for Firstmate on demand. +# +# Semantic policy: +# - On-demand recall for firstmate to search past investigations and decisions. +# - Never injected automatically into prompt or context. +# - Compact, greppable output format by default; JSON supported via --json. +# +# Usage: +# fm-hindsight-recall.sh [--bank ] [--url ] [--limit ] [--json] + +set -u + +HINDSIGHT_URL="${FM_HINDSIGHT_URL:-${HINDSIGHT_URL:-http://hindsight-1:8888}}" +HINDSIGHT_BANK="${FM_HINDSIGHT_BANK:-${HINDSIGHT_BANK:-firstmate}}" +QUERY="" +FORMAT_JSON=0 +MAX_TOKENS=4096 + +usage() { + cat << 'EOF' +Usage: + fm-hindsight-recall.sh [--bank ] [--url ] [--json] + +Options: + --bank Hindsight bank ID (default: firstmate) + --url Hindsight base URL (default: http://hindsight-1:8888) + --json Output raw JSON response + -h, --help Show this help message +EOF +} + +while [ $# -gt 0 ]; do + case "$1" in + --json) + FORMAT_JSON=1 + shift + ;; + --bank) + HINDSIGHT_BANK=$2 + shift 2 + ;; + --url) + HINDSIGHT_URL=$2 + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + -*) + printf 'unknown option: %s\n' "$1" >&2 + usage >&2 + exit 2 + ;; + *) + if [ -z "$QUERY" ]; then + QUERY=$1 + else + QUERY="$QUERY $1" + fi + shift + ;; + esac +done + +if [ -z "$QUERY" ]; then + usage >&2 + exit 2 +fi + +payload=$(jq -n \ + --arg q "$QUERY" \ + --argjson max_tokens "$MAX_TOKENS" \ + '{ + query: $q, + max_tokens: $max_tokens + }') + +url="$HINDSIGHT_URL/v1/default/banks/$HINDSIGHT_BANK/memories/recall" +resp=$(curl -s -S --connect-timeout 3 --max-time 60 \ + -X POST "$url" \ + -H "Content-Type: application/json" \ + -d "$payload" 2>&1) || rc=$? + +if [ "${rc:-0}" -ne 0 ]; then + printf 'error: recall request failed (exit %s): %s\n' "$rc" "$resp" >&2 + exit 1 +fi + +if [ "$FORMAT_JSON" -eq 1 ]; then + printf '%s\n' "$resp" | jq . + exit 0 +fi + +# Format output compactly: [] () +printf '%s\n' "$resp" | jq -r ' + if (.results | length) == 0 then + "no memories found for query" + else + .results[] | + "[" + (.document_id // "observation") + "] (" + .type + ") " + (.text | gsub("\n"; " ")) + end +' diff --git a/bin/fm-hindsight-retain.sh b/bin/fm-hindsight-retain.sh new file mode 100755 index 0000000000..a2586d52b2 --- /dev/null +++ b/bin/fm-hindsight-retain.sh @@ -0,0 +1,469 @@ +#!/usr/bin/env bash +# fm-hindsight-retain.sh - retain finished investigation reports and decisions in Hindsight. +# +# Semantic policy: +# - Captain boundary: Investigation reports (data/*/report.md) and decision +# records (data/decisions/*.md). Nothing else. +# - Explicitly prohibited: captain.md, captain-shared.md, data/memory/*, +# data/backlog.md, data/done-archive.md, and state/*. +# - Never send credentials, tokens, or .env secrets. Files containing them are +# skipped and reported. +# - Retain is fire-and-forget on critical paths: never blocks teardown, session +# start, or worker lifecycle. +# - Idempotent on document_id (report: or decision:). +# +# Usage: +# fm-hindsight-retain.sh [--fire-and-forget] [--url ] [--bank ] +# fm-hindsight-retain.sh --backfill [--url ] [--bank ] + +set -u + +SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +FM_HOME="${FM_HOME:-$(pwd)}" +DATA="${FM_DATA_OVERRIDE:-$FM_HOME/data}" +HINDSIGHT_URL="${FM_HINDSIGHT_URL:-${HINDSIGHT_URL:-http://hindsight-1:8888}}" +HINDSIGHT_BANK="${FM_HINDSIGHT_BANK:-${HINDSIGHT_BANK:-firstmate}}" + +DOC_ID="" +CONTEXT="" +TAGS_JSON="[]" +REL_PATH="" + +usage() { + cat << 'EOF' +Usage: + fm-hindsight-retain.sh [--fire-and-forget] [--url ] [--bank ] + fm-hindsight-retain.sh --backfill [--url ] [--bank ] + +Options: + --fire-and-forget, -f Run retain in background and return immediately + --backfill Backfill all reports and decisions in data/ + --url Hindsight base URL (default: http://hindsight-1:8888) + --bank Hindsight bank ID (default: firstmate) + -h, --help Show this help message +EOF +} + +# Resolve canonical path +canonical_path() { + local p=$1 + if command -v realpath >/dev/null 2>&1; then + realpath -m "$p" 2>/dev/null || readlink -f "$p" 2>/dev/null || printf '%s\n' "$p" + elif command -v readlink >/dev/null 2>&1; then + readlink -f "$p" 2>/dev/null || printf '%s\n' "$p" + else + printf '%s\n' "$p" + fi +} + +# Validate that a path is strictly inside the authorized corpus: +# data/*/report.md (excluding memory/ and decisions/) OR data/decisions/*.md +validate_corpus() { + local file=$1 canon_file canon_data rel + canon_file=$(canonical_path "$file") + canon_data=$(canonical_path "$DATA") + + case "$canon_file" in + "$canon_data"/decisions/*.md) + return 0 + ;; + "$canon_data"/*/report.md) + rel=${canon_file#"$canon_data"/} + case "$rel" in + memory/*|decisions/*) + return 1 + ;; + *) + return 0 + ;; + esac + ;; + *) + return 1 + ;; + esac +} + +# Scan content for credentials and tokens +contains_credentials() { + local file=$1 content + content=$(cat "$file" 2>/dev/null || true) + + # Check standard API key / token patterns + if printf '%s' "$content" | grep -E -q \ + 'sk-[-a-zA-Z0-9_]{20,}|ghp_[a-zA-Z0-9]{30,}|gho_[a-zA-Z0-9]{30,}|github_pat_[-a-zA-Z0-9_]{30,}|AIza[-0-9A-Za-z_]{35}|nvapi-[-a-zA-Z0-9_]{20,}|xai-[-a-zA-Z0-9_]{20,}|eyJ[-a-zA-Z0-9_]{30,}\.eyJ[-a-zA-Z0-9_]{30,}'; then + return 0 + fi + + # Check bearer token pattern + if printf '%s' "$content" | grep -E -i -q 'bearer[[:space:]]+[-a-zA-Z0-9_.-]{25,}'; then + return 0 + fi + + # Check against local .env if present + if [ -f "$FM_HOME/.env" ]; then + while IFS= read -r line || [ -n "$line" ]; do + case "$line" in + ''|'#'*) continue ;; + *'='*) + local val + val=$(printf '%s' "${line#*=}" | tr -d '"' | tr -d "'" | xargs) + if [ "${#val}" -ge 12 ] && printf '%s' "$content" | grep -F -q "$val"; then + return 0 + fi + ;; + esac + done < "$FM_HOME/.env" + fi + + return 1 +} + +# Derive document_id, context, tags, and relative path +derive_metadata() { + local file=$1 canon_file canon_data rel + canon_file=$(canonical_path "$file") + canon_data=$(canonical_path "$DATA") + rel=${canon_file#"$canon_data"/} + + case "$canon_file" in + "$canon_data"/decisions/*.md) + local slug + slug=$(basename "$file" .md) + DOC_ID="decision:$slug" + CONTEXT="decision record: $slug" + TAGS_JSON=$(jq -n --arg s "$slug" '["decision", $s]') + REL_PATH="data/decisions/$slug.md" + ;; + "$canon_data"/*/report.md) + local task_id + task_id=$(basename "$(dirname "$canon_file")") + DOC_ID="report:$task_id" + CONTEXT="investigation report: $task_id" + TAGS_JSON=$(jq -n --arg t "$task_id" '["report", $t]') + REL_PATH="data/$task_id/report.md" + ;; + *) + return 1 + ;; + esac +} + +# Perform synchronous retain of one file +retain_single_file() { + local file=$1 tmp_payload resp rc=0 url + + if ! validate_corpus "$file"; then + printf 'REFUSED: path '\''%s'\'' is outside authorized Hindsight corpus (data/*/report.md and data/decisions/*.md only)\n' "$file" >&2 + return 1 + fi + + if [ ! -f "$file" ]; then + printf 'error: file not found: %s\n' "$file" >&2 + return 1 + fi + + if contains_credentials "$file"; then + printf 'SKIP: '\''%s'\'' contains potential credentials/tokens; skipped from Hindsight retention.\n' "$file" >&2 + return 0 + fi + + derive_metadata "$file" || return 1 + tmp_payload=$(mktemp) + + jq -n \ + --rawfile content "$file" \ + --arg doc_id "$DOC_ID" \ + --arg context "$CONTEXT" \ + --arg path "$REL_PATH" \ + --argjson tags "$TAGS_JSON" \ + '{ + items: [ + { + content: $content, + document_id: $doc_id, + context: $context, + tags: $tags, + metadata: {path: $path} + } + ], + async: false + }' > "$tmp_payload" + + url="$HINDSIGHT_URL/v1/default/banks/$HINDSIGHT_BANK/memories" + resp=$(curl -s -S --connect-timeout 3 --max-time 300 \ + -X POST "$url" \ + -H "Content-Type: application/json" \ + --data-binary @"$tmp_payload" 2>&1) || rc=$? + rm -f "$tmp_payload" + + if [ "$rc" -ne 0 ]; then + printf 'error: retain request failed (exit %s): %s\n' "$rc" "$resp" >&2 + return 1 + fi + + if printf '%s' "$resp" | grep -q '"success":true'; then + printf 'retained: %s (%s)\n' "$DOC_ID" "$REL_PATH" + return 0 + else + printf 'error: retain failed for %s: %s\n' "$file" "$resp" >&2 + return 1 + fi +} + +# Retain a batch of files asynchronously +retain_batch() { + local files=("$@") tmp_payload url resp rc=0 + tmp_payload=$(mktemp) + + python3 - "${files[@]}" > "$tmp_payload" << 'PY' +import sys, json, os + +items = [] +for file_path in sys.argv[1:]: + with open(file_path, 'r', errors='ignore') as fp: + content = fp.read() + + canon_file = os.path.realpath(file_path) + if "/decisions/" in canon_file and canon_file.endswith(".md"): + slug = os.path.basename(file_path)[:-3] + doc_id = f"decision:{slug}" + context = f"decision record: {slug}" + tags = ["decision", slug] + rel_path = f"data/decisions/{slug}.md" + else: + task_id = os.path.basename(os.path.dirname(canon_file)) + doc_id = f"report:{task_id}" + context = f"investigation report: {task_id}" + tags = ["report", task_id] + rel_path = f"data/{task_id}/report.md" + + items.append({ + "content": content, + "document_id": doc_id, + "context": context, + "tags": tags, + "metadata": {"path": rel_path} + }) + +json.dump({"items": items, "async": True}, sys.stdout) +PY + + url="$HINDSIGHT_URL/v1/default/banks/$HINDSIGHT_BANK/memories" + resp=$(curl -s -S --connect-timeout 5 --max-time 120 \ + -X POST "$url" \ + -H "Content-Type: application/json" \ + --data-binary @"$tmp_payload" 2>&1) || rc=$? + rm -f "$tmp_payload" + + if [ "$rc" -ne 0 ]; then + printf 'error: batch retain request failed (exit %s): %s\n' "$rc" "$resp" >&2 + return 1 + fi + + if printf '%s' "$resp" | grep -q '"success":true'; then + for file in "${files[@]}"; do + derive_metadata "$file" || continue + printf 'retained: %s (%s)\n' "$DOC_ID" "$REL_PATH" + done + return 0 + else + printf 'error: batch retain failed: %s\n' "$resp" >&2 + return 1 + fi +} + +# Ensure bank exists +ensure_bank() { + local url="$HINDSIGHT_URL/v1/default/banks/$HINDSIGHT_BANK" + curl -s -S --connect-timeout 3 --max-time 10 \ + -X PUT "$url" \ + -H "Content-Type: application/json" \ + -d "$(jq -n --arg name "$HINDSIGHT_BANK" '{name: $name}')" >/dev/null 2>&1 || true +} + +# Fetch currently retained document IDs +fetch_existing_docs() { + local url="$HINDSIGHT_URL/v1/default/banks/$HINDSIGHT_BANK/documents?limit=1000" + curl -s --connect-timeout 3 --max-time 30 "$url" 2>/dev/null | jq -r '.items[].id' 2>/dev/null || true +} + +# Wait for operations to complete +wait_for_operations() { + local url="$HINDSIGHT_URL/v1/default/banks/$HINDSIGHT_BANK/operations" + local start_time now elapsed ops_json pending_count + start_time=$(date +%s) + + while true; do + ops_json=$(curl -s --connect-timeout 3 --max-time 10 "$url" 2>/dev/null || true) + if [ -z "$ops_json" ]; then + break + fi + + pending_count=$(printf '%s' "$ops_json" | jq '[.operations[]? | select(.status == "pending" or .status == "processing")] | length' 2>/dev/null || echo 0) + if [ "$pending_count" -eq 0 ]; then + break + fi + + now=$(date +%s) + elapsed=$((now - start_time)) + if [ "$elapsed" -ge 900 ]; then + printf 'warning: operations wait timed out after %ss (%s operations still in progress)\n' "$elapsed" "$pending_count" >&2 + break + fi + + sleep 3 + done +} + +# Perform backfill +run_backfill() { + local existing_docs candidate_files missing_items=() file + local total=0 retained=0 skipped_existing=0 skipped_cred=0 failed=0 + + ensure_bank + existing_docs=$(fetch_existing_docs) + + candidate_files=() + if [ -d "$DATA" ]; then + while IFS= read -r f; do + [ -n "$f" ] && candidate_files+=("$f") + done < <(find "$DATA" -mindepth 2 -maxdepth 2 -type f -name report.md 2>/dev/null | grep -v "/memory/" | grep -v "/decisions/" | sort) + + if [ -d "$DATA/decisions" ]; then + while IFS= read -r f; do + [ -n "$f" ] && candidate_files+=("$f") + done < <(find "$DATA/decisions" -maxdepth 1 -type f -name "*.md" 2>/dev/null | sort) + fi + fi + + for file in "${candidate_files[@]}"; do + total=$((total + 1)) + if ! validate_corpus "$file"; then + continue + fi + + if contains_credentials "$file"; then + printf 'SKIP: '\''%s'\'' contains potential credentials/tokens; skipped from Hindsight retention.\n' "$file" >&2 + skipped_cred=$((skipped_cred + 1)) + continue + fi + + derive_metadata "$file" || continue + + if printf '%s\n' "$existing_docs" | grep -F -x -q "$DOC_ID"; then + printf 'already present: %s (%s)\n' "$DOC_ID" "$REL_PATH" + skipped_existing=$((skipped_existing + 1)) + continue + fi + + missing_items+=("$file") + done + + if [ "${#missing_items[@]}" -gt 0 ]; then + local batch_size=10 batch=() + for file in "${missing_items[@]}"; do + batch+=("$file") + if [ "${#batch[@]}" -ge "$batch_size" ]; then + if retain_batch "${batch[@]}"; then + retained=$((retained + ${#batch[@]})) + else + failed=$((failed + ${#batch[@]})) + fi + batch=() + fi + done + + if [ "${#batch[@]}" -gt 0 ]; then + if retain_batch "${batch[@]}"; then + retained=$((retained + ${#batch[@]})) + else + failed=$((failed + ${#batch[@]})) + fi + fi + + wait_for_operations + fi + + printf 'backfill summary: %s retained, %s already present, %s skipped for credentials, %s failed, %s total\n' \ + "$retained" "$skipped_existing" "$skipped_cred" "$failed" "$total" + + [ "$failed" -eq 0 ] +} + +# Entrypoint argument parsing +TARGET_FILE="" +FIRE_AND_FORGET=0 +IS_BACKFILL=0 + +while [ $# -gt 0 ]; do + case "$1" in + --backfill|backfill) + IS_BACKFILL=1 + shift + ;; + --fire-and-forget|-f) + FIRE_AND_FORGET=1 + shift + ;; + --url) + HINDSIGHT_URL=$2 + shift 2 + ;; + --bank) + HINDSIGHT_BANK=$2 + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + -*) + printf 'unknown option: %s\n' "$1" >&2 + usage >&2 + exit 2 + ;; + *) + if [ -z "$TARGET_FILE" ]; then + TARGET_FILE=$1 + else + printf 'unexpected extra argument: %s\n' "$1" >&2 + usage >&2 + exit 2 + fi + shift + ;; + esac +done + +if [ "$IS_BACKFILL" -eq 1 ]; then + run_backfill + exit $? +fi + +if [ -z "$TARGET_FILE" ]; then + usage >&2 + exit 2 +fi + +if [ "$FIRE_AND_FORGET" -eq 1 ]; then + # Pre-validate boundary and existence synchronously so invalid calls fail fast + if ! validate_corpus "$TARGET_FILE"; then + printf 'REFUSED: path '\''%s'\'' is outside authorized Hindsight corpus (data/*/report.md and data/decisions/*.md only)\n' "$TARGET_FILE" >&2 + exit 1 + fi + if [ ! -f "$TARGET_FILE" ]; then + printf 'error: file not found: %s\n' "$TARGET_FILE" >&2 + exit 1 + fi + + # Detach in background + ( + FM_HOME="$FM_HOME" FM_DATA_OVERRIDE="$DATA" FM_HINDSIGHT_URL="$HINDSIGHT_URL" FM_HINDSIGHT_BANK="$HINDSIGHT_BANK" \ + "$SCRIPT_DIR/fm-hindsight-retain.sh" "$TARGET_FILE" --url "$HINDSIGHT_URL" --bank "$HINDSIGHT_BANK" >/dev/null 2>&1 + ) >/dev/null 2>&1 & + disown $! 2>/dev/null || true + exit 0 +fi + +retain_single_file "$TARGET_FILE" diff --git a/bin/fm-test-run.sh b/bin/fm-test-run.sh index a4e8de1ef5..58affacc0f 100755 --- a/bin/fm-test-run.sh +++ b/bin/fm-test-run.sh @@ -369,6 +369,7 @@ family_for_basename() { fm-composer-ghost.test.sh|fm-composer-lib.test.sh|\ fm-crew-state.test.sh|fm-decision-hold-lifecycle.test.sh|\ fm-documentation-audiences.test.sh|fm-ensure-agents-md.test.sh|fm-grok-harness.test.sh|\ + fm-hindsight.test.sh|\ fm-kimi-harness.test.sh|fm-muse-harness.test.sh|fm-herdr-lab.test.sh|fm-lint.test.sh|\ fm-lint-workflows.test.sh|\ fm-operational-input.test.sh|fm-pi-primary-types.test.sh|\ diff --git a/docs/configuration.md b/docs/configuration.md index de28cca9a7..f154792611 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -10,7 +10,7 @@ The shared orchestrator behavior lives in [`AGENTS.md`](../AGENTS.md) - edit it This section is the single owner of the top-level operational-home layout; producer script headers and their help own exact child-file fields and mutation contracts. The tracked code root contains the shared instruction, skill, documentation, workflow, and `bin/` surfaces, while each effective `FM_HOME` contains private operational directories. -`data/` holds durable private fleet records such as the project and secondmate registries, captain preferences, optional shared captain preferences, the compiled working memory under `data/memory/`, backlog, briefs, and scout reports. +`data/` holds durable private fleet records such as the project and secondmate registries, captain preferences, optional shared captain preferences, the compiled working memory under `data/memory/`, backlog, briefs, scout reports, and decision records under `data/decisions/`. `state/` holds runtime records such as task metadata, append-only status events, endpoint signals, watcher and wake-queue coordination, inactive terminal-outcome receipts under `state/terminal-outcomes/`, away-mode state, generated Relay artifacts, private secondmate config-reread generations with their retry and quarantine state, and parent-owned secondmate pending-reply records under `state/pending-replies/` (`bin/fm-pending-reply-lib.sh`). `config/` holds local gitignored operating choices, and `projects/` holds the local project clones that Firstmate reads but changes only through the narrow guarded and concrete captain-approved exceptions in `AGENTS.md`. @@ -569,6 +569,8 @@ FM_STATE_OVERRIDE= # alternate state dir, mainly for tests FM_DATA_OVERRIDE= # alternate data dir, mainly for tests FM_PROJECTS_OVERRIDE= # alternate projects dir, mainly for tests FM_CONFIG_OVERRIDE= # alternate config dir, mainly for tests +FM_HINDSIGHT_URL=http://hindsight-1:8888 # Hindsight base URL for memory retention and on-demand recall; HINDSIGHT_URL fallback +FM_HINDSIGHT_BANK=firstmate # Hindsight bank ID for memory retention and on-demand recall; HINDSIGHT_BANK fallback FM_PROC_ROOT_OVERRIDE= # alternate /proc root for Linux process-identity reads in fm-wake-lib.sh and fm-teardown.sh, mainly for tests FM_BACKEND= # optional runtime backend override for new spawns; tmux/herdr/zellij/orca/cmux support ship/scout spawns, codex-app is not accepted FM_TRACE_CONTEXT= # optional trace-context override; see "Trace context propagation" diff --git a/docs/scripts.md b/docs/scripts.md index 9a579f5dcc..4bc398dec4 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -126,3 +126,5 @@ The shared no-mistakes gate refusal for fleet lifecycle entrypoints is summarize | `fm-memory-drop.sh` | Deposit candidate claims from completed tasks into the drop tray `data/memory/drop/` | | `fm-memory-verify.sh` | Mechanical safety verifier and generation publication guard for `data/memory/` | | `fm-memory-publish.sh` | Verify and atomically update `data/memory/HEAD` to point to a proposed generation | +| `fm-hindsight-retain.sh` | Retain finished investigation reports and decisions in Hindsight or run backfill | +| `fm-hindsight-recall.sh` | Search Hindsight memory bank on demand for Firstmate investigations and decisions | diff --git a/tests/fm-hindsight.test.sh b/tests/fm-hindsight.test.sh new file mode 100755 index 0000000000..18925fc269 --- /dev/null +++ b/tests/fm-hindsight.test.sh @@ -0,0 +1,282 @@ +#!/usr/bin/env bash +# Behavioral coverage for Hindsight retention, recall, and corpus boundary. +# +# Every assertion here exercises bin/fm-hindsight-retain.sh and +# bin/fm-hindsight-recall.sh through their executable interfaces. +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +TMP_ROOT=$(fm_test_tmproot fm-hindsight) +RETAIN="$ROOT/bin/fm-hindsight-retain.sh" +RECALL="$ROOT/bin/fm-hindsight-recall.sh" + +new_home() { + local home="$TMP_ROOT/$1" + mkdir -p "$home/data" "$home/data/decisions" "$home/state" "$home/config" "$home/data/memory/notes" + printf '%s\n' "$home" +} + +make_fake_hindsight_curl() { + local dir=$1 fakebin + fakebin=$(fm_fakebin "$dir") + cat > "$fakebin/curl" <<'SH' +#!/usr/bin/env bash +method=GET +url="" +data="" +while [ $# -gt 0 ]; do + case "$1" in + -X) method=$2; shift 2 ;; + -d|--data|--data-raw|--data-binary) + case "$2" in + @*) data=$(cat -- "${2#@}") ;; + *) data=$2 ;; + esac + shift 2 + ;; + -H|-s|-S|--connect-timeout|--max-time) shift ;; + http://*|https://*) url=$1; shift ;; + *) shift ;; + esac +done + +if [ -n "${FAKE_CURL_LOG:-}" ]; then + { + echo "method=$method" + echo "url=$url" + echo "data=$data" + } >> "$FAKE_CURL_LOG" +fi + +case "$url" in + */v1/default/banks/*) + case "$url" in + */memories/recall) + if [ -n "${FAKE_RECALL_RESPONSE:-}" ]; then + printf '%s\n' "$FAKE_RECALL_RESPONSE" + else + printf '{"results":[{"id":"res1","text":"Sample recalled memory text","type":"experience","document_id":"report:task-1"}]}\n' + fi + exit 0 + ;; + */memories) + if [ "$method" = "POST" ]; then + printf '{"success":true,"bank_id":"firstmate","items_count":1,"async":false,"usage":{"input_tokens":100,"output_tokens":50,"total_tokens":150}}\n' + exit 0 + fi + ;; + */documents*) + if [ -n "${FAKE_DOCUMENTS_RESPONSE:-}" ]; then + printf '%s\n' "$FAKE_DOCUMENTS_RESPONSE" + else + printf '{"items":[],"total":0,"limit":100,"offset":0}\n' + fi + exit 0 + ;; + *) + if [ "$method" = "PUT" ]; then + printf '{"bank_id":"firstmate","name":"firstmate"}\n' + exit 0 + fi + ;; + esac + ;; +esac +printf '{"detail":"Not Found"}\n' +exit 1 +SH + chmod +x "$fakebin/curl" + printf '%s\n' "$fakebin" +} + +# --- 1. Corpus boundary enforcement ----------------------------------------- + +test_corpus_boundary_enforcement() { + local home out rc + home=$(new_home boundary) + mkdir -p "$home/data/scout-1" "$home/data/decisions" "$home/data/memory" "$home/state" + + # Valid corpus files + printf '# Scout Report\nBody content\n' > "$home/data/scout-1/report.md" + printf '# Decision\nDecision text\n' > "$home/data/decisions/dec-1.md" + + # Invalid files outside corpus + printf 'captain text\n' > "$home/data/captain.md" + printf 'shared captain\n' > "$home/data/captain-shared.md" + printf 'core memory\n' > "$home/data/memory/core.md" + printf 'note memory\n' > "$home/data/memory/notes/note-1.md" + printf 'backlog\n' > "$home/data/backlog.md" + printf 'archive\n' > "$home/data/done-archive.md" + printf 'meta\n' > "$home/state/scout-1.meta" + + # Test each prohibited file path + local invalid_files=( + "$home/data/captain.md" + "$home/data/captain-shared.md" + "$home/data/memory/core.md" + "$home/data/memory/notes/note-1.md" + "$home/data/backlog.md" + "$home/data/done-archive.md" + "$home/state/scout-1.meta" + ) + + for file in "${invalid_files[@]}"; do + out=$(FM_HOME="$home" "$RETAIN" "$file" 2>&1) && rc=0 || rc=$? + [ "$rc" -ne 0 ] || fail "expected refusal for $file, but got exit 0" + assert_contains "$out" "REFUSED" "refusal message missing for $file" + assert_contains "$out" "outside authorized Hindsight corpus" "expected boundary explanation for $file" + done + + pass "corpus boundary enforcement rejects all files outside data/*/report.md and data/decisions/*.md" +} + +# --- 2. Credential and secret skipping -------------------------------------- + +test_credential_skipping() { + local home fakebin log out rc + home=$(new_home creds) + fakebin=$(make_fake_hindsight_curl "$home") + log="$home/curl.log" + mkdir -p "$home/data/scout-cred" + + # Write a report with an API key + cat > "$home/data/scout-cred/report.md" << 'EOF' +# Sensitive Report +The test worker configured sk-proj-12345678901234567890123456789012 for validation. +EOF + + out=$(PATH="$fakebin:$PATH" FAKE_CURL_LOG="$log" FM_HOME="$home" "$RETAIN" "$home/data/scout-cred/report.md" 2>&1) && rc=0 || rc=$? + [ "$rc" -eq 0 ] || fail "skipping credential-bearing report should exit cleanly" + assert_contains "$out" "SKIP" "expected skip notice in output" + assert_contains "$out" "scout-cred/report.md" "expected filename in skip notice" + assert_contains "$out" "credentials" "expected mention of credentials in skip notice" + + # Verify curl was never called + if [ -f "$log" ]; then + fail "curl should never be called when credentials are detected in the document" + fi + + pass "credential scanner skips document and never sends secrets to Hindsight" +} + +# --- 3. Fire-and-forget unreachable Hindsight ------------------------------- + +test_unreachable_hindsight_fire_and_forget() { + local home out rc start_ms end_ms elapsed_ms + home=$(new_home unreachable) + mkdir -p "$home/data/scout-1" + printf '# Scout Report\nBody\n' > "$home/data/scout-1/report.md" + + # Point to dead port + start_ms=$(date +%s%N 2>/dev/null || date +%s) + out=$(FM_HOME="$home" FM_HINDSIGHT_URL="http://127.0.0.1:59999" "$RETAIN" "$home/data/scout-1/report.md" --fire-and-forget 2>&1) && rc=0 || rc=$? + end_ms=$(date +%s%N 2>/dev/null || date +%s) + + # Clean up background job if still in flight + sleep 0.1 + pkill -f "fm-hindsight-retain.sh.*127.0.0.1:59999" 2>/dev/null || true + pkill -f "curl.*127.0.0.1:59999" 2>/dev/null || true + wait 2>/dev/null || true + + [ "$rc" -eq 0 ] || fail "fire-and-forget should exit 0 even if Hindsight is unreachable" + + # If nanoseconds supported, assert duration < 500ms + if [ "${#start_ms}" -gt 10 ]; then + elapsed_ms=$(( (end_ms - start_ms) / 1000000 )) + if [ "$elapsed_ms" -gt 500 ]; then + fail "fire-and-forget took ${elapsed_ms}ms, exceeding measurable budget (500ms)" + fi + fi + + pass "fire-and-forget retain on unreachable host returns immediately with zero measurable delay" +} + +# --- 4. Synchronous retention and document_id idempotency ------------------- + +test_synchronous_retention_and_idempotency() { + local home fakebin log out rc + home=$(new_home retain) + fakebin=$(make_fake_hindsight_curl "$home") + log="$home/curl.log" + mkdir -p "$home/data/scout-1" "$home/data/decisions" + printf '# Scout Report\nInvestigated bug causal factors.\n' > "$home/data/scout-1/report.md" + printf '# Decision Record\nDecided to use tmux session backend.\n' > "$home/data/decisions/backend-choice.md" + + # Retain scout report + out=$(PATH="$fakebin:$PATH" FAKE_CURL_LOG="$log" FM_HOME="$home" "$RETAIN" "$home/data/scout-1/report.md" 2>&1) && rc=0 || rc=$? + [ "$rc" -eq 0 ] || fail "retain scout report failed: $out" + assert_contains "$out" "retained: report:scout-1" "expected report document_id in output" + + # Verify curl log has document_id report:scout-1 + assert_contains "$(cat "$log")" "report:scout-1" "curl payload missing report:scout-1 document_id" + assert_contains "$(cat "$log")" "investigation report: scout-1" "curl payload missing context" + + # Retain decision record + : > "$log" + out=$(PATH="$fakebin:$PATH" FAKE_CURL_LOG="$log" FM_HOME="$home" "$RETAIN" "$home/data/decisions/backend-choice.md" 2>&1) && rc=0 || rc=$? + [ "$rc" -eq 0 ] || fail "retain decision record failed: $out" + assert_contains "$out" "retained: decision:backend-choice" "expected decision document_id in output" + assert_contains "$(cat "$log")" "decision:backend-choice" "curl payload missing decision:backend-choice document_id" + + pass "synchronous retain formats document_id and payload correctly for reports and decisions" +} + +# --- 5. Recall formatting --------------------------------------------------- + +test_recall_formatting() { + local home fakebin out rc + home=$(new_home recall) + fakebin=$(make_fake_hindsight_curl "$home") + + # Default compact formatted output + out=$(PATH="$fakebin:$PATH" FM_HOME="$home" "$RECALL" "sample query" 2>&1) && rc=0 || rc=$? + [ "$rc" -eq 0 ] || fail "recall command failed: $out" + assert_contains "$out" "[report:task-1]" "expected document_id tag in formatted output" + assert_contains "$out" "(experience)" "expected memory type in formatted output" + assert_contains "$out" "Sample recalled memory text" "expected memory text in formatted output" + + # JSON output mode + out=$(PATH="$fakebin:$PATH" FM_HOME="$home" "$RECALL" "sample query" --json 2>&1) && rc=0 || rc=$? + [ "$rc" -eq 0 ] || fail "recall --json failed: $out" + assert_contains "$out" '"results"' "expected JSON results object" + + pass "recall command outputs compact greppable results and supports --json" +} + +# --- 6. Backfill resumability ----------------------------------------------- + +test_backfill_resumability() { + local home fakebin log out rc docs_json + home=$(new_home backfill) + mkdir -p "$home/data/scout-1" "$home/data/scout-2" "$home/data/decisions" + printf '# Scout 1\nBody 1\n' > "$home/data/scout-1/report.md" + printf '# Scout 2\nBody 2\n' > "$home/data/scout-2/report.md" + printf '# Decision 1\nDec 1\n' > "$home/data/decisions/dec-1.md" + + # Simulate Hindsight already having scout-1 and dec-1 + docs_json='{"items":[{"id":"report:scout-1"},{"id":"decision:dec-1"}],"total":2,"limit":100,"offset":0}' + fakebin=$(make_fake_hindsight_curl "$home") + log="$home/curl.log" + + out=$(PATH="$fakebin:$PATH" FAKE_DOCUMENTS_RESPONSE="$docs_json" FAKE_CURL_LOG="$log" FM_HOME="$home" "$RETAIN" --backfill 2>&1) && rc=0 || rc=$? + [ "$rc" -eq 0 ] || fail "backfill failed: $out" + assert_contains "$out" "already present: report:scout-1" "expected scout-1 to be skipped as already present" + assert_contains "$out" "already present: decision:dec-1" "expected dec-1 to be skipped as already present" + assert_contains "$out" "retained: report:scout-2" "expected scout-2 to be retained" + assert_contains "$out" "backfill summary: 1 retained, 2 already present, 0 skipped for credentials, 0 failed, 3 total" "expected accurate backfill summary counts" + + pass "backfill detects already-retained documents and resumes by retaining only missing documents" +} + +run_suite() { + test_corpus_boundary_enforcement + test_credential_skipping + test_unreachable_hindsight_fire_and_forget + test_synchronous_retention_and_idempotency + test_recall_formatting + test_backfill_resumability +} + +run_suite diff --git a/tests/fm-landing-remote.test.sh b/tests/fm-landing-remote.test.sh index b2cc129569..85f40a5c46 100755 --- a/tests/fm-landing-remote.test.sh +++ b/tests/fm-landing-remote.test.sh @@ -182,8 +182,8 @@ EOF assert_grep "repo set-default origin" "$log" \ "apply did not point gh at origin, so gh pr create would still default elsewhere" - assert_grep "--yes init" "$log" \ - "apply did not re-init no-mistakes without --fork-url" + grep -qx 'init' "$log" \ + || fail "apply did not re-init no-mistakes without --fork-url" if grep -q 'fork-url' "$log"; then fail "no-mistakes init still passed --fork-url, so PRs would open on the parent" fi diff --git a/tests/fm-pi-watch-extension.test.sh b/tests/fm-pi-watch-extension.test.sh index 9e29adc793..36ee39e85d 100755 --- a/tests/fm-pi-watch-extension.test.sh +++ b/tests/fm-pi-watch-extension.test.sh @@ -11,6 +11,15 @@ EXT="$ROOT/.pi/extensions/fm-primary-pi-watch.ts" # from a clean checkout with no tracked .opencode/package.json. The warning is # unrelated to plugin output, which the assertions intentionally require empty. export NODE_NO_WARNINGS=1 +# The watch extensions arm their watcher through `bash -lc`, so every arm child +# first runs the caller's login profile. A developer or CI profile can take a +# second or more, which outruns the readiness budgets these tests set and makes +# the hung-successor assertions race process startup instead of the behavior +# under test. Point HOME at an empty fixture directory so the login shell has a +# deterministic, near-zero startup cost. +HOME="$TMP_ROOT/login-home" +export HOME +mkdir -p "$HOME" install_pi_watch_extension_fixture() { local repo=$1 @@ -1338,14 +1347,22 @@ const hooks = await mod.FmPrimaryWatchArm({ const event = { event: { type: "session.idle", properties: { sessionID: "session-test" } } }; writeFileSync(`${process.env.FM_HOME}/state/.lock`, "999999\n"); await hooks.event(event); -await new Promise((resolve) => setTimeout(resolve, 120)); +// The hook decides asynchronously and the foreign-lock decision itself runs +// `git` and walks the parent chain with `ps`, so watch the whole window rather +// than sampling once: an arm that leaks through fails here immediately. +for (let i = 0; i < 50 && !existsSync(process.env.FM_ARM_LOG); i += 1) { + await new Promise((resolve) => setTimeout(resolve, 20)); +} if (existsSync(process.env.FM_ARM_LOG)) { console.error("watch arm ran without owning the session lock"); process.exit(1); } writeFileSync(`${process.env.FM_HOME}/state/.lock`, `${process.pid}\n`); -await hooks.event(event); +// Re-fire idle the way OpenCode does until the arm lands. Arming is idempotent, +// and re-firing means a still-in-flight foreign-lock decision cannot absorb the +// event of the owning session and leave the watcher unarmed. for (let i = 0; i < 250 && !existsSync(process.env.FM_ARM_LOG); i += 1) { + await hooks.event(event); await new Promise((resolve) => setTimeout(resolve, 20)); } if (!existsSync(process.env.FM_ARM_LOG)) { diff --git a/tests/fm-turnend-guard.test.sh b/tests/fm-turnend-guard.test.sh index d05bcaf0ba..f3726ff3c5 100755 --- a/tests/fm-turnend-guard.test.sh +++ b/tests/fm-turnend-guard.test.sh @@ -968,6 +968,119 @@ EOF pass ".opencode primary plugin: guard path is anchored to worktree, not directory" } +# A turn-end guard child is free to exit before it reads the payload: +# bin/fm-turnend-guard.sh rejects bad usage with exit 2 before its `cat`, and +# even a draining guard can win the race between spawn and the adapter's write. +# The write then fails EPIPE. Both adapters must absorb that and still report +# the guard's verdict, because an unhandled stream error takes the whole harness +# host process down with it. The guard fixture below exits without reading +# stdin, and both tests fire many turn-ends at once so the EPIPE race is +# effectively certain to be lost at least once per run. +GUARD_STDIN_RACE_TURNS=40 + +install_undrained_guard() { + local repo=$1 + # /bin/sh, not bash: the shorter the child lives, the more often the adapter's + # payload write lands after the child has already dropped its read end. + cat > "$repo/bin/fm-turnend-guard.sh" <<'SH' +#!/bin/sh +exit 2 +SH + chmod +x "$repo/bin/fm-turnend-guard.sh" +} + +test_opencode_plugin_survives_guard_that_never_reads_stdin() { + local plugin repo out status + plugin="$ROOT/.opencode/plugins/fm-primary-turnend-guard.js" + [ -f "$plugin" ] || fail "tracked OpenCode primary plugin is missing" + repo="$TMP_ROOT/opencode-guard-stdin-race" + mkdir -p "$repo/bin" + install_undrained_guard "$repo" + out=$(NODE_NO_WARNINGS=1 PLUGIN="$plugin" WORKTREE="$repo" TURNS="$GUARD_STDIN_RACE_TURNS" node 2>&1 <<'EOF' +import { pathToFileURL } from "node:url"; + +const mod = await import(pathToFileURL(process.env.PLUGIN).href); +let prompts = 0; +let lastPrompt = ""; +const client = { + session: { + promptAsync: async (request) => { + prompts += 1; + lastPrompt = request.body.parts[0].text; + }, + }, +}; +const hooks = await mod.FmPrimaryTurnendGuard({ + client, + directory: process.env.WORKTREE, + worktree: process.env.WORKTREE, +}); +const turns = Number(process.env.TURNS); +await Promise.all( + Array.from({ length: turns }, () => + hooks.event({ event: { type: "session.idle", properties: { sessionID: "session-test" } } })), +); +if (prompts !== turns) { + console.error(`guard verdict lost: ${prompts} of ${turns} turn-ends followed up`); + process.exit(1); +} +if (!lastPrompt.includes("TURN WOULD END BLIND")) { + console.error(`missing blind-turn prompt: ${lastPrompt}`); + process.exit(1); +} +EOF +) + status=$? + expect_code 0 "$status" "OpenCode plugin must survive a guard that exits before reading its payload" + [ -z "$out" ] || fail "OpenCode guard stdin-race test printed output: $out" + pass ".opencode primary plugin: a guard that never reads stdin still yields its verdict" +} + +test_pi_extension_survives_guard_that_never_reads_stdin() { + local repo home ext out status + repo="$TMP_ROOT/pi-guard-stdin-race-root" + home="$TMP_ROOT/pi-guard-stdin-race-home" + ext="$repo/.pi/extensions/fm-primary-turnend-guard.ts" + mkdir -p "$repo/.pi/extensions/lib" "$repo/bin" "$home/state" + cp "$ROOT/.pi/extensions/fm-primary-turnend-guard.ts" "$ext" + cp "$ROOT/.pi/extensions/lib/fm-operational-input.ts" "$repo/.pi/extensions/lib/fm-operational-input.ts" + cp "$ROOT/bin/fm-operational-input.sh" "$repo/bin/fm-operational-input.sh" + install_undrained_guard "$repo" + out=$(PLUGIN="$ext" FM_HOME="$home" TURNS="$GUARD_STDIN_RACE_TURNS" node --input-type=module 2>&1 <<'EOF' +import { pathToFileURL } from "node:url"; + +const handlers = new Map(); +let prompts = 0; +let lastPrompt = ""; +const pi = { + on(event, handler) { + handlers.set(event, handler); + }, + async sendUserMessage(message) { + prompts += 1; + lastPrompt = message; + }, +}; +const mod = await import(pathToFileURL(process.env.PLUGIN).href); +mod.default(pi); +const settled = handlers.get("agent_settled"); +if (!settled) throw new Error("agent_settled handler was not registered"); +const turns = Number(process.env.TURNS); +await Promise.all(Array.from({ length: turns }, () => settled({ type: "agent_settled" }, {}))); +if (prompts !== turns) { + throw new Error(`guard verdict lost: ${prompts} of ${turns} logical runs followed up`); +} +if (!lastPrompt.includes("TURN WOULD END BLIND")) { + throw new Error(`missing blind-turn prompt: ${lastPrompt}`); +} +EOF +) + status=$? + expect_code 0 "$status" "Pi extension must survive a guard that exits before reading its payload" + [ -z "$out" ] || fail "Pi guard stdin-race test printed output: $out" + pass ".pi primary extension: a guard that never reads stdin still yields its verdict" +} + test_pi_extension_injects_once_per_logical_agent_run() { local repo home ext log out status repo="$TMP_ROOT/pi-logical-run-root" @@ -1885,6 +1998,8 @@ test_tracked_claude_entries_inert_under_grok test_codex_hook_uses_process_pwd_when_payload_cwd_is_outside_root test_codex_hook_ignores_nested_git_root_guard test_opencode_plugin_anchors_guard_to_worktree +test_opencode_plugin_survives_guard_that_never_reads_stdin +test_pi_extension_survives_guard_that_never_reads_stdin test_pi_extension_injects_once_per_logical_agent_run test_pi_extension_retries_after_followup_delivery_failure test_hook_claude_mode_reblocks_stop_hook_active_when_unhealthy