diff --git a/.agents/skills/backlog-operations/SKILL.md b/.agents/skills/backlog-operations/SKILL.md new file mode 100644 index 00000000000..dd8d7c3b087 --- /dev/null +++ b/.agents/skills/backlog-operations/SKILL.md @@ -0,0 +1,15 @@ +--- +name: backlog-operations +description: >- + Agent-only intake reference for deciding what to do with a captain request. + Load before using the opt-in System One intake classifier. +user-invocable: false +metadata: + internal: true +--- + +# backlog-operations + +Before classifying a captain request, run the opt-in `bin/fm-intake-classify.sh` directly on the request file in the same turn. +Its result recommends only `ship`, `scout`, or `answer_now` with urgency and never authorizes a spawn or replaces your intake judgment. +`docs/configuration.md` "Typed intake classification" owns the complete contract and every status meaning. diff --git a/.agents/skills/harness-adapters/references/common/dispatch.md b/.agents/skills/harness-adapters/references/common/dispatch.md index ee322a2160e..20e1150dfc7 100644 --- a/.agents/skills/harness-adapters/references/common/dispatch.md +++ b/.agents/skills/harness-adapters/references/common/dispatch.md @@ -8,6 +8,7 @@ Use the router's detection and safety sections for static crew and secondmate ha `config/crew-dispatch.json` can override that static default for one crewmate or scout with concrete harness, model, and effort axes. For a profile array, load `quota-array-dispatch` after establishing harness and provider facts here. When the opt-in `bin/fm-dispatch-resolve.sh` is on, its `clear` answer already names the concrete axes; `docs/configuration.md` "Typed dispatch resolution" owns that contract. +At request intake, `bin/fm-intake-classify.sh` is an advisory disposition recommendation only; `docs/configuration.md` "Typed intake classification" owns its contract. `../secondmate-provisioning/SKILL.md` owns inherited local material. Its harness consequence is that a secondmate's workers receive literal `config/crew-harness` and `config/crew-dispatch.json`, while the primary-only `config/secondmate-harness` is never inherited because secondmates do not spawn secondmates. diff --git a/AGENTS.md b/AGENTS.md index 98801e9fc5d..2b2982bf85a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -68,7 +68,7 @@ README.md public overview and development notes .claude/mods/ Claude Code mods (function-hooks plugins), committed; Calm's module may load through CLAUDE_CODE_ENABLE_FUNCTION_HOOKS or tengu_plugin_hooks_modules, but activates only when CLAUDE_CODE_ENABLE_FUNCTION_HOOKS is exactly "1" and is otherwise a complete no-op (docs/calm.md) skills/ standalone public installer-facing skills, committed; not loaded by firstmate bin/ helper scripts, committed; read each script's header before first use -.env optional Relay pairing token (presence-gates section 14), mail-plane credentials (schema: docs/configuration.md "Mail plane"), and typed dispatch resolution key TYPESAFE_API_KEY (presence-gates bin/fm-dispatch-resolve.sh; docs/configuration.md "Typed dispatch resolution"); LOCAL, gitignored +.env optional Relay pairing token (presence-gates section 14), mail-plane credentials (schema: docs/configuration.md "Mail plane"), and typed-classifier key TYPESAFE_API_KEY (presence-gates bin/fm-dispatch-resolve.sh and bin/fm-intake-classify.sh; docs/configuration.md owns both typed classifier contracts); LOCAL, gitignored config/crew-harness crewmate harness override; LOCAL, gitignored; absent or "default" = same as firstmate. Inherited as the literal file: a concrete primary adapter value also controls a secondmate home's own crewmates (section 4) config/claude-permission-mode optional one-token permission posture for every Claude worker launch: absent or "bypass" keeps --dangerously-skip-permissions, "auto" launches with --permission-mode auto; LOCAL, gitignored; inherited by secondmate homes; see docs/configuration.md "Claude permission mode" config/crew-dispatch.json optional crewmate dispatch profiles; LOCAL, gitignored; firstmate-maintained but human-editable natural-language rules that choose a per-task harness/model/effort profile (section 4). Inherited by secondmate homes @@ -232,6 +232,7 @@ Break genuine evidence ties without array-order or harness bias. `quota-axi` owns how model or product windows relate to bounding account windows and remains data-only. Load `quota-array-dispatch` before choosing among a matched profile array; that skill is the single owner of the TOON-first spendPriority selection procedure. Run `bin/fm-dispatch-resolve.sh` directly on the written brief in the same turn, with no preflight, and on `clear` pass its `profile:` line to `fm-spawn` unless you state a reason to override; `ambiguous`, `escalate`, `error`, and off all mean the intake above, unchanged (contract: `docs/configuration.md` "Typed dispatch resolution"). +Load `backlog-operations` before using the opt-in request-intake classifier; its disposition remains advisory until firstmate decides the next action. The generic effort fallback and its precedence are owned by `harness-adapters`: explicit captain and standing configured effort win; otherwise use low for well-understood explicit work, xhigh for ambiguous investigation or design, intermediate levels proportionally, and never max without explicit captain preference. Do not add model-specific versions of that policy. For a crewmate's native child delegation, [`docs/configuration.md`](docs/configuration.md) "Nested delegation" owns the same-profile requirement, legacy-tier retirement, and the boundary of Firstmate's runtime enforcement; generated ship and scout briefs carry its operational reinforcement. @@ -579,6 +580,7 @@ These skills are not captain-invocable; load them only at their precise triggers - `bootstrap-diagnostics` - load whenever the session-start digest's bootstrap or network-checks section prints an actionable diagnostic line (`MISSING:`, `MISSING_MANUAL:`, `PRESENTATION_UNAVAILABLE:`, `BACKEND_INVALID:`, `NEEDS_GH_AUTH`, `TANGLE:`, `STARTUP_MEMORY_BUDGET:`, `CREW_DISPATCH: invalid`, `FLEET_SYNC:`, `NETWORK_CHECKS:`, `HOME_SUMMARY:`, `BACKLOG_RECONCILE:`, `SECONDMATE_SYNC:`, `SECONDMATE_LIVENESS:`, `SECONDMATE_HANDOFF:`, `NUDGE_SECONDMATES:`, or `FMX:`), or when `BOOTSTRAP_INFO:` says an interrupted backlog cleanup may have left an endpoint or local copy; silence and other `BOOTSTRAP_INFO:` facts need no load. - `diagnostic-reasoning` - load before scoping a reported bug and before acting on a diagnostic report. - `ask-user-authority` - load before deciding any ask-user finding. +- `backlog-operations` - load before classifying a captain request with the opt-in intake classifier. - `quota-array-dispatch` - load before choosing among a matched crew-dispatch profile array from current quota-axi default TOON. - `harness-adapters` - load before spawning or recovering a crewmate or secondmate, handling a trust dialog, sending a harness-specific skill invocation, interrupting or exiting an agent, resuming an exited agent, or verifying a new harness adapter. - `firstmate-orca` - load before switching to Orca, spawning or supervising Orca-backed work, smoke-testing Orca backend behavior, debugging Orca task state, or reconciling Orca-backed task metadata. diff --git a/bin/fm-bootstrap.sh b/bin/fm-bootstrap.sh index 31792fa37ba..737e29baca6 100755 --- a/bin/fm-bootstrap.sh +++ b/bin/fm-bootstrap.sh @@ -156,6 +156,11 @@ # nothing; bin/fm-brief.sh uses it to gate scout Lavish hosting. set -u +# A caller may invoke bootstrap under `bash -x` while testing the opt-in typed +# dispatch gate. +# Disable tracing before copying its key so neither the environment value nor +# the private handoff variable reaches the shell trace. +set +x TYPESAFE_API_KEY_PRIVATE=${TYPESAFE_API_KEY:-} export -n TYPESAFE_API_KEY_PRIVATE 2>/dev/null || true unset TYPESAFE_API_KEY diff --git a/bin/fm-classify-lib.sh b/bin/fm-classify-lib.sh index 0752e52f370..ef45d5c0e7d 100755 --- a/bin/fm-classify-lib.sh +++ b/bin/fm-classify-lib.sh @@ -1775,9 +1775,12 @@ _fm_status_open_decision_origins() { # [] while IFS= read -r line || [ -n "$line" ]; do number=$((number + 1)) after=$(_fm_decision_fold_line "$open" "$line" "$resolve" "$held" "$kind") - [ -n "$after" ] || origins='' - key=$(_fm_decision_key "$line") || { open=$after; continue; } verb=$(status_line_verb "$line") + # A failure clears the current open-decision set but still needs its unseen decision origin in the same escalation span. + # A completed task drops the historical origin from this escalation-specific view. + # An explicit resolution or verified captain-held transfer drops its own origin below. + [ -n "$after" ] || [ "$verb" != "done" ] || origins='' + key=$(_fm_decision_key "$line") || { open=$after; continue; } note=$(status_line_note "$line") case "$verb" in needs-decision|blocked) diff --git a/bin/fm-intake-classify.sh b/bin/fm-intake-classify.sh new file mode 100755 index 00000000000..cf5a7ea2afb --- /dev/null +++ b/bin/fm-intake-classify.sh @@ -0,0 +1,233 @@ +#!/usr/bin/env bash +# fm-intake-classify.sh - recommend a firstmate intake disposition with +# typesafe.ai's System One model (Jev), opt-in. +# +# Usage: +# fm-intake-classify.sh [--project ] +# +# Opt-in gate: TYPESAFE_API_KEY non-empty in this process environment, else a +# TYPESAFE_API_KEY= line in $FM_HOME/.env read with fmx_env_get. The +# environment wins. Absent in both: one "intake-classify: off" line on +# stderr, nothing on stdout, exit 0, and no network call. +# +# What it does when on: one POST to https://api.typesafe.ai/v1/systemone with +# the project name and a bounded request-text snapshot as state. It asks three +# parallel questions: a Choice recommendation of ship, scout, answer_now, or +# unclear; a NOUL answer about implementation authorization; and a three-level +# urgency Score. Jq composes clear, ambiguous, escalate, or error afterwards. +# The result is recommendation-only: it never spawns a worker, chooses a +# harness or model, or changes firstmate's intake judgment. +# +# Output (stdout, TOON-style block): +# intake-classify: +# status: clear | ambiguous | escalate | error +# model/latency_ms/tokens, deliverable and confidence, intent_clear and +# NOUL score, urgency and score, request truncation, and any reason +# clear -> a high-confidence recommendation; ship is also authorized +# ambiguous -> a low-confidence or unclear recommendation +# escalate -> a ship recommendation without implementation authorization +# error -> local runtime, API, network, response, or rendering failure +# Every runtime outcome exits 0 so intake is never blocked by this tool. +# Exit 2 only for invalid argv, an initially unreadable request, or missing +# jq, which are actionable usage or configuration errors. +# +# Environment: +# TYPESAFE_API_KEY is the only classifier-specific environment setting. +# +# Authority: docs/configuration.md "Typed intake classification" owns the +# operator contract. This script owns flags and exact output. The classifier +# never replaces firstmate judgment or auto-spawns work. +set +x +set -u + +TYPESAFE_API_KEY_PRIVATE=${TYPESAFE_API_KEY:-} +export -n TYPESAFE_API_KEY_PRIVATE 2>/dev/null || true +unset TYPESAFE_API_KEY + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" +FM_HOME="${FM_HOME:-$FM_ROOT}" + +# shellcheck source=bin/fm-env-lib.sh +. "$SCRIPT_DIR/fm-env-lib.sh" +# shellcheck source=bin/fm-timing-lib.sh +. "$SCRIPT_DIR/fm-timing-lib.sh" + +CONFIDENCE_FLOOR=0.6 +TS_MODEL=jev-latest +TS_BASE=https://api.typesafe.ai +TS_TIMEOUT=5 +REQUEST_MAX_BYTES=32768 + +die() { printf 'error: %s\n' "$1" >&2; exit 2; } +usage() { + awk ' + NR == 1 { next } + /^#/ { sub(/^# ?/, ""); print; next } + { exit } + ' "$0" +} +emit_error() { + local reason=$1 + printf 'intake-classify: error (%s)\n' "$reason" >&2 + printf 'intake-classify:\n status: error\n reason: %s\n' "$reason" + exit 0 +} + +REQUEST_FILE='' PROJECT='' +while [ $# -gt 0 ]; do + case "$1" in + --project) [ $# -ge 2 ] || die "--project needs a value"; PROJECT=$2; shift 2 ;; + -h|--help) usage; exit 0 ;; + -*) die "unknown flag $1" ;; + *) [ -z "$REQUEST_FILE" ] || die "one request file only"; REQUEST_FILE=$1; shift ;; + esac +done + +# ---- opt-in gate --------------------------------------------------------------- +if [ -z "$TYPESAFE_API_KEY_PRIVATE" ]; then + TYPESAFE_API_KEY_PRIVATE=$(fmx_env_get TYPESAFE_API_KEY "$FM_HOME/.env") +fi +if [ -z "$TYPESAFE_API_KEY_PRIVATE" ]; then + printf '%s\n' 'intake-classify: off' >&2 + exit 0 +fi + +# ---- inputs -------------------------------------------------------------------- +[ -n "$REQUEST_FILE" ] || die "request file required (see --help)" +[ -r "$REQUEST_FILE" ] || die "request file not readable: $REQUEST_FILE" +command -v jq >/dev/null 2>&1 || die "jq required" + +REQUEST_SNAPSHOT=$(mktemp 2>/dev/null) || emit_error "temporary file setup failed" +RESP_FILE=$(mktemp 2>/dev/null) || { rm -f "$REQUEST_SNAPSHOT"; emit_error "temporary file setup failed"; } +trap 'rm -f "$REQUEST_SNAPSHOT" "$RESP_FILE"' EXIT +head -c "$REQUEST_MAX_BYTES" "$REQUEST_FILE" > "$REQUEST_SNAPSHOT" || emit_error "request read failed" +REQUEST_BYTES=$(wc -c < "$REQUEST_FILE") || emit_error "request measurement failed" +REQUEST_BYTES=${REQUEST_BYTES//[[:space:]]/} +case "$REQUEST_BYTES" in ''|*[!0-9]*) emit_error "request measurement failed" ;; esac +if [ "$REQUEST_BYTES" -gt "$REQUEST_MAX_BYTES" ]; then REQUEST_TRUNCATED=true; else REQUEST_TRUNCATED=false; fi + +# ---- one System One request ---------------------------------------------------- +command -v curl >/dev/null 2>&1 || emit_error "curl not installed" +REQUEST=$(jq -n --rawfile request "$REQUEST_SNAPSHOT" --arg project "$PROJECT" --arg model "$TS_MODEL" ' + { + model: $model, + state: {intake: {project: $project, request: $request}}, + questions: { + deliverable: { + type: "choice", + instructions: "Which disposition should firstmate consider for this request? This is advice only and must not create, dispatch, or select any worker.", + criteria: { + ship: "A concrete, authorized implementation change should be handled as a ship task.", + scout: "The request needs bounded investigation, reproduction, audit, or planning before an implementation decision.", + answer_now: "The request can be answered directly now without creating a worker task.", + unclear: "The request is too incomplete or conflicting to recommend a disposition." + } + }, + intent_clear: { + type: "noul", + instructions: "Is the captain already authorizing a concrete implementation change in this request? Answer true only when the requested change and scope are sufficiently concrete." + }, + urgency: { + type: "score", + instructions: "Score urgency on this ordered scale: 0 routine means ordinary work with no material time pressure; 1 soon means a stated near-term deadline or materially useful prompt follow-up; 2 blocking means current work, a release, safety, or a stated deadline is blocked until this is addressed.", + criteria: [ + "0 routine: ordinary work with no material time pressure.", + "1 soon: a stated near-term deadline or materially useful prompt follow-up.", + "2 blocking: current work, a release, safety, or a stated deadline is blocked until this is addressed." + ] + } + } + }') || emit_error "request rendering failed" + +T0=$(fm_timing_now_ms) +HTTP=$(printf '%s' "$REQUEST" | curl -q -sS --max-time "$TS_TIMEOUT" -o "$RESP_FILE" -w '%{http_code}' \ + -X POST "$TS_BASE/v1/systemone" -H 'Content-Type: application/json' \ + -H @/dev/fd/3 3< <(printf 'Authorization: Bearer %s\n' "$TYPESAFE_API_KEY_PRIVATE") \ + --data-binary @- 2>/dev/null) || HTTP=000 +T1=$(fm_timing_now_ms) +LAT_MS=$(( T1 - T0 )) +[ "$HTTP" = 200 ] || emit_error "http $HTTP after ${LAT_MS} ms: $(head -c 200 "$RESP_FILE" 2>/dev/null | tr '\n' ' ')" + +# ---- response validation and status composition -------------------------------- +jq -e ' + def confidence($a): $a.confidence; + def intent_noul($a): $a.noul; + def urgency_score($a): $a.score; + def confidence_ok($a): (confidence($a) | type) == "number" and confidence($a) >= 0 and confidence($a) <= 1; + (.answers | type) == "object" and + (.answers.deliverable | type) == "object" and + (.answers.deliverable.choice | type) == "string" and + (.answers.deliverable.choice as $choice | ["ship", "scout", "answer_now", "unclear"] | index($choice)) != null and + confidence_ok(.answers.deliverable) and + (.answers.deliverable.probabilities | type) == "object" and + ((.answers.deliverable.probabilities | keys | sort) == ["answer_now", "scout", "ship", "unclear"]) and + all(.answers.deliverable.probabilities[]; type == "number" and . >= 0 and . <= 1) and + ((.answers.deliverable.probabilities | [.[]] | add) as $total | $total >= 0.99 and $total <= 1.01) and + (.answers.intent_clear | type) == "object" and + (intent_noul(.answers.intent_clear) | type) == "number" and + intent_noul(.answers.intent_clear) >= 0 and intent_noul(.answers.intent_clear) <= 1 and + (.answers.urgency | type) == "object" and + (urgency_score(.answers.urgency) | type) == "number" and + urgency_score(.answers.urgency) >= 0 and urgency_score(.answers.urgency) <= 2 and + confidence_ok(.answers.urgency) and + ((has("usage") | not) or + ((.usage | type) == "object" and + (.usage.input_tokens | type) == "number" and + (.usage.output_tokens | type) == "number")) +' "$RESP_FILE" >/dev/null 2>&1 || emit_error "response is not a typed intake answer" + +RESULT=$(jq -n --argjson floor "$CONFIDENCE_FLOOR" --argjson latency "$LAT_MS" --argjson truncated "$REQUEST_TRUNCATED" --slurpfile response "$RESP_FILE" ' + ($response[0]) as $r | + ($r.answers.deliverable) as $deliverable | + ($r.answers.intent_clear) as $intent | + ($r.answers.urgency) as $urgency | + def intent_noul: .noul; + def urgency_score: .score; + def urgency_level($score): + if $score < 0.5 then "routine" + elif $score < 1.5 then "soon" + else "blocking" + end; + ($deliverable.confidence) as $deliverable_confidence | + ($intent | intent_noul) as $intent_noul | + ($urgency.confidence) as $urgency_confidence | + ([ $deliverable_confidence, $urgency_confidence ] | min) as $confidence | + { + model: $r.model, + latency_ms: $latency, + tokens: ($r.usage // null), + deliverable: $deliverable.choice, + deliverable_confidence: $deliverable_confidence, + deliverable_probabilities: $deliverable.probabilities, + intent_clear: ($intent_noul >= $floor), + intent_clear_noul: $intent_noul, + urgency: urgency_level($urgency | urgency_score), + urgency_score: ($urgency | urgency_score), + urgency_confidence: $urgency_confidence, + confidence: $confidence, + request_truncated: $truncated + } | + if $confidence < $floor then + . + {status: "ambiguous", reason: "lowest answer confidence \($confidence) below floor \($floor)"} + elif .deliverable == "ship" and .intent_clear != true then + . + {status: "escalate", reason: "concrete implementation authorization is not clear"} + elif .deliverable == "unclear" then + . + {status: "ambiguous", reason: "deliverable recommendation is unclear"} + else . + {status: "clear"} + end') || emit_error "status composition failed" + +TEXT=$(jq -r ' + def flat: tostring | gsub("[\t\r\n]"; " "); + def show($value): ($value // "-") | flat; + "intake-classify:", + " status: \(.status | flat)", + " model: \(show(.model)) latency_ms: \(show(.latency_ms)) tokens: \(show(.tokens.input_tokens))/\(show(.tokens.output_tokens))", + " deliverable: \(.deliverable | flat) confidence: \(.deliverable_confidence | flat)", + " probabilities: \([.deliverable_probabilities | to_entries[] | "\(.key | flat)=\(.value | flat)"] | join(" "))", + " intent_clear: \(.intent_clear | flat) noul: \(.intent_clear_noul | flat)", + " urgency: \(.urgency | flat) score: \(.urgency_score | flat) confidence: \(.urgency_confidence | flat)", + " request_truncated: \(.request_truncated | flat)", + (if .reason then " reason: \(.reason | flat)" else empty end)' <<<"$RESULT") || emit_error "output rendering failed" +printf '%s\n' "$TEXT" +exit 0 diff --git a/bin/fm-teardown.sh b/bin/fm-teardown.sh index 412e388b527..48efeb57676 100755 --- a/bin/fm-teardown.sh +++ b/bin/fm-teardown.sh @@ -3620,15 +3620,16 @@ META_LOCK_HELD=0 # Durable dispatch record (bin/fm-dispatch-log.sh header owns the log format). # Appended once the task record has been removed and its backlog transition has # committed, so a teardown that failed and will be retried leaves no record. -# Best-effort and non-fatal: a logging failure must never fail an otherwise -# successful teardown. Deliberately minimal (id only) - see the header -# cross-reference. -{ - mkdir -p "$DATA" 2>/dev/null - printf '{"event":"teardown","ts":"%s","id":"%s"}\n' \ - "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$ID" \ - >> "$DATA/dispatch-log.jsonl" -} 2>/dev/null || true +# A nested remote retirement removes the overridden state and data home itself, +# so do not recreate that retired home solely to append a best-effort record. +if [ "$KIND" != secondmate ] || [ -d "$STATE" ]; then + { + mkdir -p "$DATA" 2>/dev/null + printf '{"event":"teardown","ts":"%s","id":"%s"}\n' \ + "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$ID" \ + >> "$DATA/dispatch-log.jsonl" + } 2>/dev/null || true +fi if [ "$KIND" != scout ] && [ "$KIND" != secondmate ] && [ "$MODE" != local-only ]; then "$FM_ROOT/bin/fm-fleet-sync.sh" "$PROJ" || true diff --git a/bin/fm-test-run.sh b/bin/fm-test-run.sh index 9f7658ffbbe..1eb9efaf383 100755 --- a/bin/fm-test-run.sh +++ b/bin/fm-test-run.sh @@ -334,7 +334,7 @@ family_for_basename() { printf '%s\n' secondmate ;; fm-backlog-atomicity.test.sh|\ - fm-bootstrap.test.sh|fm-bootstrap-network-parallel.test.sh|fm-dispatch-resolve.test.sh|fm-fleet-sync.test.sh|fm-gate-refuse.test.sh|fm-gotmp.test.sh|\ + fm-bootstrap.test.sh|fm-bootstrap-network-parallel.test.sh|fm-intake-classify.test.sh|fm-fleet-sync.test.sh|fm-gate-refuse.test.sh|fm-gotmp.test.sh|\ fm-session-start.test.sh|fm-sessionstart-nudge.test.sh|fm-startup-network.test.sh|\ fm-tangle-guard.test.sh|fm-update.test.sh) printf '%s\n' session-bootstrap @@ -715,6 +715,7 @@ tests/fm-cursor-primary-live-e2e.test.sh 72 tests/fm-cursor-primary.test.sh 52269 tests/fm-daemon.test.sh 27262 tests/fm-dispatch-resolve.test.sh 4397 +tests/fm-intake-classify.test.sh 9056 tests/fm-documentation-audiences.test.sh 847 tests/fm-extension-binding.test.sh 9053 tests/fm-fleet-snapshot-view.test.sh 17465 @@ -1451,11 +1452,15 @@ families_for_changed_path() { bin/fm-dispatch-resolve.sh) printf '%s\n' "__script__:fm-dispatch-resolve.test.sh" ;; + bin/fm-intake-classify.sh) + printf '%s\n' "__script__:fm-intake-classify.test.sh" + ;; bin/fm-env-lib.sh) # The one .env accessor, sourced by bin/fm-x-lib.sh (Relay token) and - # bin/fm-dispatch-resolve.sh (TYPESAFE_API_KEY). + # the typed dispatch and intake classifiers (TYPESAFE_API_KEY). printf '%s\n' pr-forge printf '%s\n' "__script__:fm-dispatch-resolve.test.sh" + printf '%s\n' "__script__:fm-intake-classify.test.sh" ;; .pi/extensions/fm-branch-supervision.ts|.pi/extensions/lib/fm-async-exec.ts|\ .pi/extensions/lib/fm-branch-dispatch.ts|.pi/extensions/lib/fm-native-contract.ts) diff --git a/docs/configuration.md b/docs/configuration.md index afbc157db14..80c2326d40b 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -483,6 +483,26 @@ Missing `jq` is reported through the normal `MISSING: jq` install-consent flow. While the file remains present, no crewmate or scout spawn may proceed without an explicit resolved harness; malformed configuration must be reported and corrected rather than selected around. Secondmate homes inherit this file from the primary, so a secondmate's own crewmates apply the same dispatch profile behavior. +## Typed dispatch resolution (.env TYPESAFE_API_KEY) + +`bin/fm-dispatch-resolve.sh [--project ]` is the opt-in System One resolver for a written crewmate or scout brief. +Its script header owns exact flags and output while this dispatch-profile section owns the configuration it applies. + +## Typed intake classification (.env TYPESAFE_API_KEY) + +`bin/fm-intake-classify.sh [--project ]` provides an opt-in System One recommendation before firstmate decides whether to ship work, scout it, or answer now. +It is off unless `TYPESAFE_API_KEY` is non-empty in the calling environment or the effective home's gitignored `.env` contains it, with the environment taking precedence through `bin/fm-env-lib.sh`. +Off prints exactly `intake-classify: off` on stderr, prints nothing on stdout, exits 0, and makes no network call. +When on, the tool sends one POST to `https://api.typesafe.ai/v1/systemone`, fixed at model `jev-latest` and a five-second timeout. +Its state contains the project name and at most the first 32,768 bytes of the request file, with `request_truncated: true` in output when that bound was applied. +The one request asks three parallel questions: Choice `deliverable` (`ship`, `scout`, `answer_now`, or `unclear`), NOUL `intent_clear` (whether a concrete implementation change is already authorized), and Score `urgency` (routine, soon, or blocking under the criteria sent with the question). +The tool disables shell tracing before reading either key source, keeps the bearer key in a non-exported variable, and passes it to curl only through a file-descriptor header, never argv, output, or a child environment. +Its named confidence floor is 0.6, matching typed dispatch resolution. +`clear` means a high-confidence concrete recommendation, with a `ship` recommendation additionally requiring a NOUL authorization score at or above that floor; `ambiguous` means a low-confidence answer or `unclear` deliverable, `escalate` means a high-confidence `ship` recommendation lacks implementation authorization, and `error` covers local runtime, API, network, response, and rendering failures. +Every classified or runtime outcome exits 0, while invalid argv, an initially unreadable request file, or missing `jq` is an actionable usage or configuration error that exits 2. +This is advisory only: it never spawns, chooses harness/model/effort, replaces firstmate judgment, or blocks intake on failure. +`docs/verification/intake-classify.md` records the current live API observation and the fake-curl regression command. + ### Nested delegation When active, `config/crew-dispatch.json` is also the current authority for every worker's model and effort selection at every delegation depth. @@ -1084,7 +1104,7 @@ FMX_RELAY_URL=https://myfirstmate.io # optional Relay endpoint override, mainl FMX_ENV_FILE= # optional alternate .env file for direct Relay client invocations; bootstrap still checks $FM_HOME/.env FMX_DRY_RUN= # truthy previews Relay replies and dismissals to state/x-outbox/ without posting or requiring a token FMX_X_REPLY_MAX_CHARS=280 # X reply per-message split budget; values below 50 clamp to 50 -TYPESAFE_API_KEY= # typed dispatch resolution opt-in, from the environment or .env; absent means bin/fm-dispatch-resolve.sh is off (docs/configuration.md "Typed dispatch resolution") +TYPESAFE_API_KEY= # typed dispatch resolution and intake classification opt-in, from the environment or .env; absent leaves both tools off (docs/configuration.md "Typed intake classification") FMX_DISCORD_REPLY_MAX_CHARS=1900 # Discord reply per-message split budget; values below 50 clamp to 50, values above 2000 reset to 1900 FMX_X_THREAD_MAX=25 # maximum messages in one auto-split reply thread FMX_FOLLOWUP_MAX_AGE_SECS=604800 # local window for posting Relay completion follow-ups (7 days) diff --git a/docs/documentation-audiences.json b/docs/documentation-audiences.json index e459e95006a..162ce72be25 100644 --- a/docs/documentation-audiences.json +++ b/docs/documentation-audiences.json @@ -120,6 +120,10 @@ "path": ".agents/skills/ask-user-authority/SKILL.md", "audience": "agent-runtime" }, + { + "path": ".agents/skills/backlog-operations/SKILL.md", + "audience": "agent-runtime" + }, { "path": ".agents/skills/bearings/SKILL.md", "audience": "agent-runtime" @@ -452,6 +456,10 @@ "path": "docs/verification/dispatch-resolve.md", "audience": "maintainer-verification" }, + { + "path": "docs/verification/intake-classify.md", + "audience": "maintainer-verification" + }, { "path": "docs/verification/lint-option-a.md", "audience": "maintainer-verification" diff --git a/docs/verification/intake-classify.md b/docs/verification/intake-classify.md new file mode 100644 index 00000000000..d9a4c039eb7 --- /dev/null +++ b/docs/verification/intake-classify.md @@ -0,0 +1,40 @@ +# Typed intake classification verification + +Audience: maintainer verification. + +This record supports the opt-in `bin/fm-intake-classify.sh` contract owned by [`../configuration.md`](../configuration.md) "Typed intake classification". + +## Live System One probe + +Run 2026-09-20 with the real key supplied only through the effective `FM_HOME/.env`, model `jev-latest`, confidence floor 0.6, timeout 5 seconds, and a request asking for a bounded pager investigation. + +The exact command was: + +```console +$ FM_HOME= bin/fm-intake-classify.sh request.md --project firstmate +``` + +The relevant output fields observed in that run were: + +```text + model: jev-1.13.0 latency_ms: 369 tokens: 614/83 + deliverable: scout confidence: 0.99 + probabilities: answer_now=0.0 unclear=0.0 scout=1.0 ship=0.0 + intent_clear: false noul: 0.04 + urgency: routine score: 0.05 confidence: 0.93 +``` + +The API accepted one request containing Choice, NOUL, and Score questions in parallel. +Choice returned `choice`, `confidence`, and `probabilities`. +NOUL returned a 0 through 1 `noul` value without a confidence field. +Score accepted an ordered criteria list and returned `score`, `confidence`, `legend`, and probabilities keyed by the criteria positions. +The observed `scout` answer and low implementation-authorization score are compatible because implementation authorization gates only `ship`. + +## Offline regression + +`tests/fm-intake-classify.test.sh` uses fake curl and failing local-command fixtures to prove the exact off gate, `.env` precedence, one bounded typed request, shell-trace and process-boundary secret containment, status composition, and non-blocking runtime setup and request-I/O failures. + +```console +$ bash tests/fm-intake-classify.test.sh | tail -1 +# all fm-intake-classify tests passed +``` diff --git a/tests/fm-afk-inject-herdr-e2e.test.sh b/tests/fm-afk-inject-herdr-e2e.test.sh index e761336e7b4..3ee5370e4d7 100755 --- a/tests/fm-afk-inject-herdr-e2e.test.sh +++ b/tests/fm-afk-inject-herdr-e2e.test.sh @@ -49,7 +49,44 @@ herdr_forget_inherited_pane fail() { printf 'not ok - %s\n' "$1" >&2; cleanup_all; exit 1; } pass() { printf 'ok - %s\n' "$1"; } -SESSION="fm-lab-afk-herdr-e2e-$$" +LAB_HELPER=${FM_HERDR_LAB_HELPER:-} +LAB_SESSION=${FM_HERDR_LAB_SESSION:-} +LAB_PROXY_DIR= +LAB_BASE_PATH=$PATH +if [ -n "$LAB_HELPER" ] || [ -n "$LAB_SESSION" ]; then + [ -n "$LAB_HELPER" ] && [ -n "$LAB_SESSION" ] \ + || { echo "not ok - FM_HERDR_LAB_HELPER and FM_HERDR_LAB_SESSION must be set together" >&2; exit 1; } + [ -x "$LAB_HELPER" ] || { echo "not ok - lab helper is not executable: $LAB_HELPER" >&2; exit 1; } + LAB_PROXY_DIR=$(mktemp -d "${TMPDIR:-/tmp}/fm-afk-herdr-lab-proxy.XXXXXX") || exit 1 + cat > "$LAB_PROXY_DIR/herdr" <<'PROXY' +#!/usr/bin/env bash +set -u +args=() +while [ "$#" -gt 0 ]; do + case "$1" in + --session) + [ "$#" -gt 1 ] || exit 2 + [ "$2" = "$FM_HERDR_LAB_SESSION" ] || exit 2 + shift 2 + ;; + --session=*) + [ "${1#--session=}" = "$FM_HERDR_LAB_SESSION" ] || exit 2 + shift + ;; + *) + args+=("$1") + shift + ;; + esac +done +PATH="$FM_HERDR_LAB_BASE_PATH" exec "$FM_HERDR_LAB_HELPER" run "$FM_HERDR_LAB_SESSION" "${args[@]}" +PROXY + chmod +x "$LAB_PROXY_DIR/herdr" + export FM_HERDR_LAB_BASE_PATH="$LAB_BASE_PATH" + export PATH="$LAB_PROXY_DIR:$PATH" +fi + +SESSION=${LAB_SESSION:-"fm-lab-afk-herdr-e2e-$$"} export HERDR_SESSION="$SESSION" STATE_DIR= HERDR_SHIM_DIR= @@ -65,12 +102,22 @@ cleanup_all() { kill "$DAEMON_PID" 2>/dev/null || true wait "$DAEMON_PID" 2>/dev/null || true fi - herdr_safe_stop_and_delete "$SESSION" 2>/dev/null || true + if [ -n "$LAB_HELPER" ]; then + "$LAB_HELPER" teardown "$SESSION" 2>/dev/null || true + else + herdr_safe_stop_and_delete "$SESSION" 2>/dev/null || true + fi + rm -rf "${LAB_PROXY_DIR:-}" 2>/dev/null || true rm -rf "${HERDR_SHIM_DIR:-}" 2>/dev/null || true rm -rf "${STATE_DIR:-}" 2>/dev/null || true } trap cleanup_all EXIT -fm_herdr_lab_prepare "$SESSION" || fail "could not prepare isolated Herdr lab session" +if [ -n "$LAB_HELPER" ]; then + "$LAB_HELPER" run "$SESSION" status --json >/dev/null \ + || fail "could not confirm the supplied isolated Herdr lab session" +else + fm_herdr_lab_prepare "$SESSION" || fail "could not prepare isolated Herdr lab session" +fi # --- source the daemon (for afk_enter/afk_exit/FM_INJECT_MARK) + the backend - # shellcheck source=/dev/null @@ -85,6 +132,7 @@ STATE_DIR=$(mktemp -d "${TMPDIR:-/tmp}/fm-afk-herdr-e2e.XXXXXX") mkdir -p "$STATE_DIR" LOG_FILE="$STATE_DIR/submitted.log" : > "$LOG_FILE" +: > "$STATE_DIR/fake-c1.meta" CONTAINER_RAW=$(fm_backend_herdr_container_ensure /tmp) || fail "container_ensure failed" CONTAINER=${CONTAINER_RAW%%$'\t'*} diff --git a/tests/fm-intake-classify.test.sh b/tests/fm-intake-classify.test.sh new file mode 100755 index 00000000000..8c8964f1b08 --- /dev/null +++ b/tests/fm-intake-classify.test.sh @@ -0,0 +1,289 @@ +#!/usr/bin/env bash +# Behavior tests for bin/fm-intake-classify.sh. +# +# Drives the public argv and environment interface with a fake curl that records +# argv, body, and the header read from file descriptor 3 before returning a +# canned System One response. +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +TOOL="$ROOT/bin/fm-intake-classify.sh" +TMP_ROOT=$(fm_test_tmproot fm-intake-classify) +HOME_DIR="$TMP_ROOT/home" +FAKEBIN=$(fm_fakebin "$TMP_ROOT") +LOG="$TMP_ROOT/log" +REQUEST_FILE="$TMP_ROOT/request.md" +SCOUT_REQUEST_FILE="$TMP_ROOT/scout-request.md" +ANSWER_REQUEST_FILE="$TMP_ROOT/answer-request.md" +RESPONSE="$TMP_ROOT/response.json" +BASE_PATH=$PATH +REAL_HEAD=$(command -v head) +REAL_WC=$(command -v wc) +mkdir -p "$HOME_DIR" "$LOG" + +cat > "$REQUEST_FILE" <<'MD' +# Request + +Fix the off-by-one in the pager, whose cause and expected behavior are already stated. +MD + +cat > "$SCOUT_REQUEST_FILE" <<'MD' +# Request + +Investigate the intermittent pager skip and report the cause without changing code. +MD + +cat > "$ANSWER_REQUEST_FILE" <<'MD' +# Request + +What does the pager's follow flag do? +MD + +cat > "$FAKEBIN/curl" <<'SH' +#!/usr/bin/env bash +set -u +if [ -n "${TYPESAFE_API_KEY+x}" ] || [ -n "${TYPESAFE_API_KEY_PRIVATE+x}" ]; then + printf '%s\n' 'curl:secret-present' >> "${CHILD_ENV_LOG:?}" +else + printf '%s\n' 'curl:clean' >> "${CHILD_ENV_LOG:?}" +fi +out='' +while [ $# -gt 0 ]; do + case "$1" in + -o) out=$2; shift 2 ;; + *) printf '%s\n' "$1" >> "${FAKE_CURL_LOG:?}/argv"; shift ;; + esac +done +cat > "${FAKE_CURL_LOG:?}/body" +cat /dev/fd/3 > "${FAKE_CURL_LOG:?}/header" 2>/dev/null || printf '%s\n' 'fd3 unreadable' > "${FAKE_CURL_LOG:?}/header" +if [ "${FAKE_CURL_FAIL:-0}" = 1 ]; then exit 7; fi +cp "${FAKE_CURL_RESPONSE:?}" "$out" +printf '%s' "${FAKE_CURL_HTTP:-200}" +SH +chmod +x "$FAKEBIN/curl" + +cat > "$FAKEBIN/head" <<'SH' +#!/usr/bin/env bash +if [ "${FAKE_HEAD_FAIL:-0}" = 1 ]; then + printf '%s' 'partial request' + exit 74 +fi +exec "${REAL_HEAD:?}" "$@" +SH +chmod +x "$FAKEBIN/head" + +cat > "$FAKEBIN/wc" <<'SH' +#!/usr/bin/env bash +if [ "${FAKE_WC_FAIL:-0}" = 1 ]; then + printf '%s\n' '17' + exit 74 +fi +exec "${REAL_WC:?}" "$@" +SH +chmod +x "$FAKEBIN/wc" + +write_response() { # + local ship=0.02 scout=0.02 answer_now=0.02 unclear=0.02 + case "$1" in + ship) ship=0.94 ;; + scout) scout=0.94 ;; + answer_now) answer_now=0.94 ;; + unclear) unclear=0.94 ;; + esac + cat > "$RESPONSE" < [args...] +run() { + local __exit=$1 __out=$2 __err=$3 _out _code + shift 3 + _out=$(PATH="$FAKEBIN:$BASE_PATH" FM_HOME="$HOME_DIR" "$TOOL" "$@" 2> "$TMP_ROOT/stderr") + _code=$? + printf -v "$__exit" '%s' "$_code" + printf -v "$__out" '%s' "$_out" + printf -v "$__err" '%s' "$(cat "$TMP_ROOT/stderr")" +} + +export FAKE_CURL_LOG="$LOG" FAKE_CURL_RESPONSE="$RESPONSE" CHILD_ENV_LOG="$LOG/child-env" REAL_HEAD REAL_WC +KEY='test-key-9f1c2d3e-never-on-argv' +code='' out='' err='' + +# --- absent key: off, exact stderr, no network --------------------------------- +reset_log +write_response ship 0.95 0.94 0 0.93 +run code out err "$REQUEST_FILE" --project pager +expect_code 0 "$code" "absent key exits 0" +assert_equals '' "$out" "absent key prints nothing on stdout" +assert_equals 'intake-classify: off' "$err" "absent key has the fixed off diagnostic" +assert_absent "$LOG/argv" "absent key never calls curl" +pass "absent key leaves classification off without a network call" + +# --- .env key, environment precedence, and request shape ----------------------- +printf '%s\n' "export TYPESAFE_API_KEY=\"$KEY\"" > "$HOME_DIR/.env" +reset_log +write_response ship 0.95 0.94 0 0.93 +run code out err "$REQUEST_FILE" --project pager +expect_code 0 "$code" ".env key exits 0" +assert_contains "$out" ' status: clear' ".env key enables classification" +assert_equals "Authorization: Bearer $KEY" "$(cat "$LOG/header")" ".env key reaches curl through fd 3" +reset_log +TYPESAFE_API_KEY=env-wins run code out err "$REQUEST_FILE" --project pager +assert_equals 'Authorization: Bearer env-wins' "$(cat "$LOG/header")" "environment key wins over .env" +rm -f "$HOME_DIR/.env" + +argv=$(cat "$LOG/argv") +body=$(cat "$LOG/body") +assert_not_contains "$argv" "$KEY" "the key never appears on curl argv" +assert_equals '-q' "$(head -n 1 "$LOG/argv")" "curl disables ambient configuration before every other option" +assert_contains "$argv" 'https://api.typesafe.ai/v1/systemone' "the fixed endpoint is used" +assert_contains "$argv" $'--max-time\n5' "the fixed timeout is used" +assert_contains "$argv" '@/dev/fd/3' "curl reads the header from a file descriptor" +assert_equals 'curl:clean' "$(cat "$LOG/child-env")" "the key is absent from curl's environment" +assert_equals 'jev-latest' "$(jq -r .model <<<"$body")" "the request pins the Jev model" +assert_equals 'pager' "$(jq -r .state.intake.project <<<"$body")" "project is in state" +assert_contains "$(jq -r .state.intake.request <<<"$body")" 'off-by-one in the pager' "request text is in state" +assert_equals '["deliverable","intent_clear","urgency"]' "$(jq -c '.questions | keys' <<<"$body")" "all questions share one request" +assert_equals 'choice' "$(jq -r .questions.deliverable.type <<<"$body")" "deliverable is Choice" +assert_equals 'noul' "$(jq -r .questions.intent_clear.type <<<"$body")" "authorization is NOUL" +assert_equals 'score' "$(jq -r .questions.urgency.type <<<"$body")" "urgency is Score" +assert_equals '["answer_now","scout","ship","unclear"]' "$(jq -c '.questions.deliverable.criteria | keys' <<<"$body")" "deliverable choices are fixed" +assert_contains "$(jq -r '.questions.urgency.criteria[0]' <<<"$body")" '0 routine' "urgency criteria explains routine" +assert_contains "$(jq -r '.questions.urgency.criteria[2]' <<<"$body")" '2 blocking' "urgency criteria explains blocking" +pass "opted-in request uses all typed questions and keeps the key off argv and child environments" + +# --- tracing never exposes either key source ----------------------------------- +reset_log +write_response ship 0.95 0.94 0 0.93 +TRACE_ENV_KEY='trace-environment-key-never-emitted' +trace_output=$(PATH="$FAKEBIN:$BASE_PATH" FM_HOME="$HOME_DIR" TYPESAFE_API_KEY="$TRACE_ENV_KEY" bash -x "$TOOL" "$REQUEST_FILE" 2>&1) +trace_code=$? +expect_code 0 "$trace_code" "traced environment-key invocation exits 0" +assert_not_contains "$trace_output" "$TRACE_ENV_KEY" "shell tracing never emits the environment key" +TRACE_FILE_KEY='trace-file-key-never-emitted' +printf '%s\n' "export TYPESAFE_API_KEY=\"$TRACE_FILE_KEY\"" > "$HOME_DIR/.env" +reset_log +trace_output=$(env -u TYPESAFE_API_KEY PATH="$FAKEBIN:$BASE_PATH" FM_HOME="$HOME_DIR" bash -x "$TOOL" "$REQUEST_FILE" 2>&1) +trace_code=$? +expect_code 0 "$trace_code" "traced file-key invocation exits 0" +assert_not_contains "$trace_output" "$TRACE_FILE_KEY" "shell tracing never emits the file key" +rm -f "$HOME_DIR/.env" +pass "shell tracing is disabled before either key source is read" + +# --- clear classifications ------------------------------------------------------ +reset_log +write_response ship 0.95 0.94 0 0.93 +TYPESAFE_API_KEY=$KEY run code out err "$REQUEST_FILE" +expect_code 0 "$code" "clear classification exits 0" +assert_contains "$out" 'intake-classify:' "TOON header is present" +assert_contains "$out" ' status: clear' "authorized ship is clear" +assert_contains "$out" ' deliverable: ship confidence: 0.95' "ship recommendation is printed" +assert_contains "$out" ' intent_clear: true noul: 0.94' "authorization answer is printed" +assert_contains "$out" ' urgency: routine score: 0 confidence: 0.93' "routine urgency is printed" +assert_contains "$out" ' request_truncated: false' "short request is not truncated" +write_response scout 0.95 0.05 1 0.93 +TYPESAFE_API_KEY=$KEY run code out err "$SCOUT_REQUEST_FILE" +assert_contains "$out" ' status: clear' "high-confidence scout is clear without implementation authorization" +assert_contains "$out" ' deliverable: scout' "scout remains a recommendation" +assert_contains "$out" ' intent_clear: false noul: 0.05' "scout preserves its low implementation authorization" +write_response answer_now 0.95 0.05 2 0.93 +TYPESAFE_API_KEY=$KEY run code out err "$ANSWER_REQUEST_FILE" +assert_contains "$out" ' status: clear' "high-confidence answer-now is clear without implementation authorization" +assert_contains "$out" ' deliverable: answer_now' "answer-now remains a recommendation" +assert_contains "$out" ' intent_clear: false noul: 0.05' "answer-now preserves its low implementation authorization" +assert_contains "$out" ' urgency: blocking score: 2' "blocking urgency does not auto-spawn or change status" +pass "ship authorization gates only ship while all recommendations stay advisory" + +# --- ambiguous and escalate outcomes ------------------------------------------- +reset_log +write_response scout 0.55 0.05 1 0.93 +TYPESAFE_API_KEY=$KEY run code out err "$SCOUT_REQUEST_FILE" +assert_contains "$out" ' status: ambiguous' "low confidence is ambiguous" +assert_contains "$out" 'lowest answer confidence 0.55 below floor 0.6' "the confidence floor is named" +write_response ship 0.95 0.05 1 0.93 +TYPESAFE_API_KEY=$KEY run code out err "$REQUEST_FILE" +assert_contains "$out" ' status: escalate' "missing authorization escalates" +assert_contains "$out" 'concrete implementation authorization is not clear' "authorization escalation is named" +write_response unclear 0.95 0.05 1 0.93 +TYPESAFE_API_KEY=$KEY run code out err "$REQUEST_FILE" +assert_contains "$out" ' status: ambiguous' "unclear deliverable is ambiguous" +assert_contains "$out" 'deliverable recommendation is unclear' "unclear recommendation is named" +pass "confidence, authorization, and unclear recommendations compose in code" + +# --- bounded input and runtime failures ----------------------------------------- +BIG_REQUEST="$TMP_ROOT/large-request.md" +head -c 40000 /dev/zero | tr '\0' x > "$BIG_REQUEST" +reset_log +write_response scout 0.95 0.94 1 0.93 +TYPESAFE_API_KEY=$KEY run code out err "$BIG_REQUEST" +assert_equals '32768' "$(jq -r '.state.intake.request | length' < "$LOG/body")" "request text is byte-bounded" +assert_contains "$out" ' request_truncated: true' "truncation is disclosed" +reset_log +FAKE_CURL_HTTP=429 TYPESAFE_API_KEY=$KEY run code out err "$REQUEST_FILE" +expect_code 0 "$code" "HTTP failure exits 0" +assert_contains "$out" ' status: error' "HTTP failure is structured" +assert_contains "$out" 'http 429' "HTTP status is named" +reset_log +write_response ship 0.95 0.94 1 0.93 +jq 'del(.answers.urgency.score) | .answers.urgency.value = 1 | .answers.urgency.answer = 2' "$RESPONSE" > "$RESPONSE.tmp" +mv "$RESPONSE.tmp" "$RESPONSE" +TYPESAFE_API_KEY=$KEY run code out err "$REQUEST_FILE" +expect_code 0 "$code" "undocumented urgency fields exit 0" +assert_contains "$out" ' status: error' "undocumented urgency fields are rejected" +assert_contains "$out" 'response is not a typed intake answer' "the Score contract requires score" +cat > "$RESPONSE" <<'JSON' +{"answers":{"deliverable":{"choice":"ship"}}} +JSON +TYPESAFE_API_KEY=$KEY run code out err "$REQUEST_FILE" +expect_code 0 "$code" "malformed response exits 0" +assert_contains "$out" 'response is not a typed intake answer' "malformed response is structured" +reset_log +FAKE_HEAD_FAIL=1 TYPESAFE_API_KEY=$KEY run code out err "$REQUEST_FILE" +expect_code 0 "$code" "request snapshot failure exits 0" +assert_contains "$out" ' status: error' "request snapshot failure is structured" +assert_contains "$out" 'request read failed' "request snapshot failure is named" +assert_absent "$LOG/argv" "request snapshot failure makes no network call" +reset_log +FAKE_WC_FAIL=1 TYPESAFE_API_KEY=$KEY run code out err "$REQUEST_FILE" +expect_code 0 "$code" "request measurement failure exits 0" +assert_contains "$out" ' status: error' "request measurement failure is structured" +assert_contains "$out" 'request measurement failed' "request measurement failure is named" +assert_absent "$LOG/argv" "request measurement failure makes no network call" +reset_log +TYPESAFE_API_KEY=$KEY TMPDIR="$TMP_ROOT/missing-tmp" run code out err "$REQUEST_FILE" +expect_code 0 "$code" "temporary file setup failure exits 0" +assert_contains "$out" ' status: error' "temporary file setup failure is structured" +assert_contains "$out" 'temporary file setup failed' "temporary file setup failure is named" +assert_absent "$LOG/argv" "temporary file setup failure makes no network call" +pass "bounded input and ordinary runtime failures never block intake" + +# --- usage errors stay actionable ------------------------------------------------ +TYPESAFE_API_KEY=$KEY run code out err +expect_code 2 "$code" "missing request file exits 2" +assert_contains "$err" 'request file required' "missing request is named" +TYPESAFE_API_KEY=$KEY run code out err "$REQUEST_FILE" --bogus +expect_code 2 "$code" "unknown option exits 2" +assert_contains "$err" 'unknown flag --bogus' "unknown option is named" + +printf '%s\n' '# all fm-intake-classify tests passed' diff --git a/tests/fm-quota-gate.test.sh b/tests/fm-quota-gate.test.sh index afab07183a9..ceaa18dcd04 100755 --- a/tests/fm-quota-gate.test.sh +++ b/tests/fm-quota-gate.test.sh @@ -263,7 +263,7 @@ run_spawn() { extra+=("$kindflag") fi mkdir -p "$home/data/$id" - printf 'brief\n' > "$home/data/$id/brief.md" + printf '# Task\n\nbrief\n' > "$home/data/$id/brief.md" ( cd "$NORMAL_CWD" && env -u NO_MISTAKES_GATE -u FM_GATE_REFUSE_BYPASS \ "FM_ROOT_OVERRIDE=" "FM_HOME=$home" \ "FM_STATE_OVERRIDE=$home/state" "FM_DATA_OVERRIDE=$home/data" \ diff --git a/tests/fm-spawn-trust-prompt.test.sh b/tests/fm-spawn-trust-prompt.test.sh index 9fd66e0ae02..ce0e2cd8c47 100755 --- a/tests/fm-spawn-trust-prompt.test.sh +++ b/tests/fm-spawn-trust-prompt.test.sh @@ -88,7 +88,7 @@ make_trust_case() { mkdir -p "$home/data" "$home/projects" "$home/state" "$home/config" fm_git_worktree "$proj" "$wt" "wt-$name" mkdir -p "$home/data/$id" - printf 'brief for %s\n' "$id" > "$home/data/$id/brief.md" + printf '# Task\n\n[captain] brief for %s\n' "$id" > "$home/data/$id/brief.md" touch "$home/state/.last-watcher-beat" : > "$keylog" printf '%s\n' "$case_dir|$home|$proj|$wt|$fakebin|$countfile|$keylog" diff --git a/tests/fm-turnend-guard.test.sh b/tests/fm-turnend-guard.test.sh index f9a5f0845e8..73f3cd44174 100755 --- a/tests/fm-turnend-guard.test.sh +++ b/tests/fm-turnend-guard.test.sh @@ -449,14 +449,28 @@ test_hook_blocks_when_unhealthy_in_primary() { # Non-claude harnesses (Codex, Grok, OpenCode, Pi) never read this advisory's # stdout systemMessage, so they keep the unchanged blocking behaviour. test_hook_foreign_live_home_owner_is_advisory_not_block() { - local dir out status pid + local dir out status pid comm attempt dir=$(make_primary_dir "$TMP_ROOT/hook-foreign-owner") : > "$dir/state/task1.meta" + # The predicate requires a verified current-session harness ancestry before + # it can distinguish a foreign owner from an uncertain one. + # CI's ordinary shell has none, so run this case through a harness-named + # Bash copy while the owner remains a separate live Claude-shaped process. + cp "$(command -v bash)" "$dir/codex" cp "$(command -v sleep)" "$dir/claude" "$dir/claude" 300 & pid=$! + attempt=0 + while [ "$attempt" -lt 50 ]; do + comm=$(ps -o comm= -p "$pid" 2>/dev/null | xargs basename 2>/dev/null || true) + [ "$comm" = claude ] && break + attempt=$((attempt + 1)) + sleep 0.01 + done + [ "$comm" = claude ] || fail "foreign owner did not become the expected claude process" printf '%s\n' "$pid" > "$dir/state/.lock" - out=$(run_hook_claude "$dir" false); status=$? + out=$(printf '{"stop_hook_active":false,"session_id":"sess-claude-mode"}' \ + | CLAUDECODE=1 FM_HOME="$dir" "$dir/codex" "$dir/bin/fm-turnend-guard.sh" --claude 2>&1); status=$? kill "$pid" 2>/dev/null || true wait "$pid" 2>/dev/null || true expect_code 0 "$status" "--claude mode must not block a home owned by another live session" diff --git a/tests/fm-wake-drain-open-decisions-cursor.test.sh b/tests/fm-wake-drain-open-decisions-cursor.test.sh index d206cea72e4..9edee1c214e 100755 --- a/tests/fm-wake-drain-open-decisions-cursor.test.sh +++ b/tests/fm-wake-drain-open-decisions-cursor.test.sh @@ -377,8 +377,8 @@ test_terminal_supersession_reaches_cached_drains() { || fail "$kind whole-file, incremental, and key-history reads disagree with terminal supersession" done span=$(bash -c '. "$1"; status_span_first_actionable "$2" 0' _ "$ROOT/bin/fm-classify-lib.sh" "$status") - if [ "$kind" = secondmate ]; then - assert_contains "$span" 'blocked [key=access]: waiting' "secondmate opening must remain actionable" + if [ "$kind" = secondmate ] || [ "$terminal" = failed ]; then + assert_contains "$span" 'blocked [key=access]: waiting' "secondmate or failed-task opening must remain actionable" else assert_not_contains "$span" 'waiting' "$kind superseded opening remained actionable in a captured span" fi diff --git a/tests/fm-watch-checkpoint.test.sh b/tests/fm-watch-checkpoint.test.sh index 7424aaba3c8..cbd31b742bc 100755 --- a/tests/fm-watch-checkpoint.test.sh +++ b/tests/fm-watch-checkpoint.test.sh @@ -33,6 +33,7 @@ test_signal_passes_through_and_exits_zero() { home=$(make_home signal) out="$home/out.txt" err="$home/err.txt" + : > "$home/state/demo.meta" ( sleep 1 printf 'done: synthetic wake\n' > "$home/state/demo.status"