From 48457ba6cca6e25c897899d57e62c09fcf6b2949 Mon Sep 17 00:00:00 2001 From: rafaelfadel Date: Tue, 1 Sep 2026 12:50:14 -0300 Subject: [PATCH 01/32] feat(bin): record durable task usage so cleanup cannot erase attribution A task's implementation axes - harness, model, effort, kind, project, delivery mode, autonomy posture, backend, incarnation - live only in state/.meta, and teardown removes that record as one atomic step with closing the backlog item. Nothing durable survived, so no merged PR could be joined back to the model that produced it. Add bin/fm-usage-ledger.sh as the single owner of a home-private, append-only ledger at data/task-usage.jsonl, and bin/fm-usage-ledger-lib.sh as the single owner of how lifecycle scripts call it. Records are appended at the four points where every supported harness and every spawn-capable backend converges: a successful spawn or relaunch (plus the remote-secondmate launch that returns earlier), a PR or merge request registration, a confirmed merge or approved local landing, and the moment before cleanup removes the task record. A refused teardown records nothing, because its task is still live. The record format is fixed-key JSONL needing no JSON processor, versioned, and forward compatible with a newer writer's rows. Every axis comes from a strict allowlist read through the existing fm_meta_get and the documented "absent backend= means tmux" contract; the final status class comes from the existing verb reader, and only the verb is stored. Paths, trace carriers, relay payloads, account identity, and status prose have no field to land in. An axis that applies but cannot be proven is the literal "unknown" - including the no-mistakes validator identity, which Firstmate cannot prove today and which is kept in its own fields rather than inferred from the implementer. Writes are serialized under the store's own lock, atomic at the record boundary, mode 0600, and refuse a symlinked, hardlinked, non-regular, or cross-device target without writing. A malformed record stops every verb and leaves the file's bytes untouched. Idempotency is by stable event identity, so a retried spawn, a re-armed poll, an at-least-once merge notification, and a rerun teardown converge while a genuinely new incarnation, PR head, or PR appends its own row. Nothing is backfilled: the store opens with an explicit first-observed record and an analysis must treat that timestamp as the start of coverage. History is bounded only by the explicit prune verb (400-day default), so recording one task never rewrites another's. Recording is instrumentation and never a gate: a failed write warns loudly and leaves the completed launch, merge, or cleanup successful. --- AGENTS.md | 1 + bin/fm-merge-local.sh | 9 + bin/fm-merge-outcome-lib.sh | 12 + bin/fm-pr-check.sh | 12 + bin/fm-spawn.sh | 17 + bin/fm-teardown.sh | 32 ++ bin/fm-test-run.sh | 13 +- bin/fm-usage-ledger-lib.sh | 41 ++ bin/fm-usage-ledger.sh | 545 ++++++++++++++++++++++ docs/architecture.md | 22 + docs/configuration.md | 26 +- docs/scripts.md | 2 + tests/fm-pr-merge.test.sh | 41 +- tests/fm-usage-ledger.test.sh | 828 ++++++++++++++++++++++++++++++++++ 14 files changed, 1598 insertions(+), 3 deletions(-) create mode 100644 bin/fm-usage-ledger-lib.sh create mode 100755 bin/fm-usage-ledger.sh create mode 100755 tests/fm-usage-ledger.test.sh diff --git a/AGENTS.md b/AGENTS.md index d2ad7a7438c..57ec7928925 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -86,6 +86,7 @@ data/ personal fleet records; LOCAL, gitignored as a whole captain-shared.md main-authoritative shared captain preferences propagated read-only to secondmate homes; LOCAL, gitignored, owned by secondmate-provisioning learnings.md fleet-local operational facts and gotchas; LOCAL, gitignored; dated, evidence-backed, curated, and updated with inspect-then-update - rewrite and prune rather than append forever, the same contract as captain.md; created lazily, absent until this home has a learning to store projects.md thin fleet navigation registry recording each project's standing delivery posture; firstmate-private, parsed for mechanical sync and seeding by fm-project-mode.sh (section 6) + task-usage.jsonl durable append-only record of the harness, model, and workflow behind each task outcome, written before ordinary cleanup deletes state/.meta; bin/fm-usage-ledger.sh owns its schema, privacy boundary, and retention, and no session reads it 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 diff --git a/bin/fm-merge-local.sh b/bin/fm-merge-local.sh index 70ac9b7be2c..d01f21b459e 100755 --- a/bin/fm-merge-local.sh +++ b/bin/fm-merge-local.sh @@ -9,6 +9,9 @@ # auto-approves), and only as a clean fast-forward - it refuses a diverged branch # and tells you to have the crewmate rebase. See AGENTS.md prime directives, # project management, and task lifecycle. +# A confirmed landing also appends the landed commit to this home's durable +# task-usage ledger, so a local-only outcome is joinable to the harness and +# model that produced it; bin/fm-usage-ledger.sh owns that schema. # Usage: fm-merge-local.sh set -eu @@ -16,6 +19,9 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" +DATA="${FM_DATA_OVERRIDE:-$FM_HOME/data}" +# shellcheck source=bin/fm-usage-ledger-lib.sh +. "$SCRIPT_DIR/fm-usage-ledger-lib.sh" "$FM_ROOT/bin/fm-guard.sh" || true # Role partition: landing local-only work is MAIN-owned; the Pi supervision # branch reports readiness and never lands (contract: bin/fm-lease-lib.sh; @@ -71,4 +77,7 @@ fi before=$(git -C "$PROJ" rev-parse --short "$DEFAULT") git -C "$PROJ" merge --ff-only "$BRANCH" >/dev/null after=$(git -C "$PROJ" rev-parse --short "$DEFAULT") +LANDED=$(git -C "$PROJ" rev-parse "$DEFAULT") +fm_usage_ledger_record "$FM_HOME" "$STATE" "$DATA" merge "$ID" \ + --meta "$META" --landing "$LANDED" --outcome merged echo "merged $BRANCH into local $DEFAULT ($before -> $after) in $PROJ" diff --git a/bin/fm-merge-outcome-lib.sh b/bin/fm-merge-outcome-lib.sh index 849a0d54a25..afffd962025 100755 --- a/bin/fm-merge-outcome-lib.sh +++ b/bin/fm-merge-outcome-lib.sh @@ -23,6 +23,12 @@ # is committed, so a failed commit stays eligible for at-least-once retry and # may rarely duplicate rather than leave a merge silent. # +# A confirmed merge is also appended to this home's durable task-usage ledger +# before the marker is committed, so an ordinary teardown cannot later erase the +# only link between the landed PR and the harness and model that produced it. +# bin/fm-usage-ledger.sh owns that schema and bin/fm-usage-ledger-lib.sh owns the +# rule that a failed ledger write never turns a landed merge into a failure. +# # Sourced by bin/fm-pr-merge.sh, bin/fm-watch.sh, and tests. No side effects on # source beyond its sourced libraries. @@ -31,6 +37,8 @@ _FM_MERGE_OUTCOME_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" . "$_FM_MERGE_OUTCOME_LIB_DIR/fm-pr-lib.sh" # shellcheck source=bin/fm-secondmate-parent-lib.sh . "$_FM_MERGE_OUTCOME_LIB_DIR/fm-secondmate-parent-lib.sh" +# shellcheck source=bin/fm-usage-ledger-lib.sh +. "$_FM_MERGE_OUTCOME_LIB_DIR/fm-usage-ledger-lib.sh" # The secondmate identity of the home reporting, or non-zero when this home is # a main home (1) or carries an unusable identity marker (2). Mirrors @@ -130,6 +138,10 @@ fm_merge_outcome_report() { # "check: merge landed: $id $FM_PR_URL" || status=1 fi if [ "$status" -eq 0 ]; then + # Both origins land here, so a merge this home performed and a merge its + # poll detected leave the same durable usage record. + fm_usage_ledger_record "$home" "$state" "${FM_DATA_OVERRIDE:-$home/data}" \ + merge "$id" --meta "$state/$id.meta" --pr "$FM_PR_URL" --outcome merged fm_pr_poll_merge_mark_notified "$state" "$id" \ "$provider" "$host" "$path" "$number" || status=1 fi diff --git a/bin/fm-pr-check.sh b/bin/fm-pr-check.sh index 198755207f7..220d2506fd3 100755 --- a/bin/fm-pr-check.sh +++ b/bin/fm-pr-check.sh @@ -5,6 +5,9 @@ # live only in a private sidecar and are never interpolated into shell source. # A GitHub pull request URL and a GitLab merge request URL are both accepted, # including a merge request on a self-hosted GitLab instance. +# After the poll is published, the PR's identity is also appended to this home's +# durable task-usage ledger, so the outcome can later be joined to the harness +# and model that produced it; bin/fm-usage-ledger.sh owns that schema. # Usage: fm-pr-check.sh set -eu @@ -12,11 +15,14 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" +DATA="${FM_DATA_OVERRIDE:-$FM_HOME/data}" # shellcheck source=bin/fm-pr-lib.sh . "$SCRIPT_DIR/fm-pr-lib.sh" # shellcheck source=bin/fm-wake-lib.sh . "$SCRIPT_DIR/fm-wake-lib.sh" +# shellcheck source=bin/fm-usage-ledger-lib.sh +. "$SCRIPT_DIR/fm-usage-ledger-lib.sh" if [ "$#" -ne 2 ]; then echo "error: invalid PR check request" >&2 @@ -132,4 +138,10 @@ fm_pr_poll_publish_prepared || { echo "error: could not publish PR poll" >&2 exit 1 } +# The meta now carries the canonical pr= (and pr_head= when the forge supplied +# one), so the ledger reads this task's axes and PR identity from one source. +PR_HEAD_ARGS=() +[ -z "$PR_HEAD" ] || PR_HEAD_ARGS=(--pr-head "$PR_HEAD") +fm_usage_ledger_record "$FM_HOME" "$STATE" "$DATA" pr "$ID" \ + --meta "$META" --pr "$URL" "${PR_HEAD_ARGS[@]+"${PR_HEAD_ARGS[@]}"}" printf 'armed: state/%s.check.sh\n' "$ID" diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index 9158fce64df..d98c181ec24 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -205,6 +205,9 @@ # success line and state/.meta omit them. # Every fresh spawn or relaunch records a new spawn_gen= incarnation token so durable # consumers can distinguish a replacement worker that reuses the same task id. +# A successful launch also appends this incarnation's durable usage record; +# bin/fm-usage-ledger.sh owns that schema and bin/fm-usage-ledger-lib.sh owns the +# rule that the record never gates a launch which already succeeded. # When the home session's frozen trace-context decision is enabled (see # docs/configuration.md and bin/fm-trace-context-lib.sh), the meta also records # one W3C traceparent= carrier, the same value injected into the pane as @@ -303,6 +306,8 @@ fm_backlog_directory_present "$STATE" "state directory" || { . "$SCRIPT_DIR/fm-trace-context-lib.sh" # shellcheck source=bin/fm-remote-readiness-lib.sh . "$SCRIPT_DIR/fm-remote-readiness-lib.sh" +# shellcheck source=bin/fm-usage-ledger-lib.sh +. "$SCRIPT_DIR/fm-usage-ledger-lib.sh" # Fail closed before any fleet mutation: a no-mistakes gate agent must never spawn # a direct report (see bin/fm-gate-refuse-lib.sh). fm_refuse_if_gate_agent @@ -694,6 +699,10 @@ spawn_remote_secondmate() { echo "error: remote secondmate $id launched, but its reply source could not be armed; endpoint metadata is preserved" >&2 return 1 fi + # A remote secondmate's task record is owned by this home, so its usage row + # belongs in this home's ledger too. That record carries no spawn_gen, so its + # incarnation reads as unknown (bin/fm-usage-ledger.sh's IDENTITY limitation). + fm_usage_ledger_record "$FM_HOME" "$STATE" "$DATA" spawn "$id" --meta "$meta" echo "spawned $id harness=$harness kind=secondmate mode=secondmate yolo=off window=remote:$id worktree=$home remote=$host backend=$remote_backend" return 0 } @@ -3148,6 +3157,14 @@ if [ -n "$SPAWN_DEFERRED_SIGNAL" ]; then exit "$SPAWN_DEFERRED_SIGNAL_STATUS" fi +# The durable usage record for this incarnation. It is written here, past the +# commit point, so it describes a worker that actually launched, and it reaches +# every harness and every spawn-capable backend because the single-task path +# below is the one place all of them converge (a batch re-execs it per pair, and +# a relaunch mints a new spawn_gen so its replacement worker is its own row). +fm_usage_ledger_record "$FM_HOME" "$STATE" "$DATA" spawn "$ID" \ + --meta "$STATE/$ID.meta" + SPAWN_DELIVERY= [ -z "$MODE" ] || SPAWN_DELIVERY=" mode=$MODE yolo=$YOLO" echo "spawned $ID harness=$HARNESS kind=$KIND$SPAWN_DELIVERY window=$META_WINDOW worktree=$WT" diff --git a/bin/fm-teardown.sh b/bin/fm-teardown.sh index ad9e042ba11..5c8277435ea 100755 --- a/bin/fm-teardown.sh +++ b/bin/fm-teardown.sh @@ -5,6 +5,9 @@ # tasks before reporting success (a secondmate teardown closes none, since # secondmates are not backlog items), then refresh/prune the project's clone for # PR-based ship tasks. +# Just before that record is removed, teardown appends the task's final durable +# usage record so the implementation axes cleanup would otherwise erase survive; +# bin/fm-usage-ledger.sh owns that schema. A REFUSED teardown records nothing. # Removing state/.meta and closing the backlog item are one step, not two: # bin/fm-backlog-transition-lib.sh owns that invariant, and both halves run under # the task's own meta lock before this script reports success. Because the @@ -189,6 +192,8 @@ SUB_HOME_PARENT_MARKER=".fm-secondmate-parent" . "$SCRIPT_DIR/fm-pending-reply-lib.sh" # shellcheck source=bin/fm-nm-run-lib.sh . "$SCRIPT_DIR/fm-nm-run-lib.sh" +# shellcheck source=bin/fm-usage-ledger-lib.sh +. "$SCRIPT_DIR/fm-usage-ledger-lib.sh" if [ "$#" -lt 1 ] || ! fm_task_id_path_safe "$1"; then echo "error: invalid teardown request" >&2 exit 2 @@ -283,6 +288,29 @@ TEARDOWN_META_KIND=$(fm_meta_get "$META" kind) [ -n "$TEARDOWN_META_KIND" ] || TEARDOWN_META_KIND=ship TEARDOWN_CLEANUP_RECOVERY=$(fm_meta_get "$META" cleanup_recovery) TEARDOWN_META_SPAWN_GEN= + +# The task's last durable usage record, written while state/.meta still +# exists. Cleanup is where the implementation axes would otherwise be lost, so +# this runs only on the paths that have already passed every landed-work, +# report, and endpoint refusal above: a REFUSED teardown deliberately records +# nothing, because its task is still live. bin/fm-usage-ledger.sh owns the +# schema and bin/fm-usage-ledger-lib.sh owns the rule that this never turns a +# completed cleanup into a failure. +usage_ledger_record_cleanup() { + local outcome + if [ "$FORCE" = --force ]; then + outcome=discarded + else + case "$TEARDOWN_META_KIND" in + secondmate) outcome=retired ;; + scout) outcome=reported ;; + *) outcome=landed ;; + esac + fi + fm_usage_ledger_record "$FM_HOME" "$STATE" "$DATA" cleanup "$ID" \ + --meta "$META" --outcome "$outcome" --status-file "$STATE/$ID.status" +} + TEARDOWN_BACKLOG_APPLIES=0 TEARDOWN_BACKLOG_SKIP_REASON= if [ "$TEARDOWN_CLEANUP_RECOVERY" != orca ]; then @@ -683,6 +711,8 @@ remote_secondmate_teardown() { grep -vE "^- $ID( |$)" "$SECONDMATE_REG" > "$tmp" || true mv -f -- "$tmp" "$SECONDMATE_REG" status_retire_presentation_task "$STATE" "$ID" || return 1 + # Last read of the task record before it is removed. + usage_ledger_record_cleanup fm_backlog_atomic_transition remove "$STATE/$ID.meta" "task record" "$STATE" || return 1 rm -f -- "$STATE/$ID.turn-ended" printf 'teardown %s complete (remote %s:%s)\n' "$ID" "$remote_host" "$remote_home" @@ -2883,6 +2913,8 @@ rm -f "$STATE/$ID.turn-ended" \ # retired endpoint; teardown only runs after landing is confirmed, so any # leftover unhandled steer here is moot rather than unlanded work. rm -rf "$STATE/$ID.inbox" +# Last read of the task record before either branch below removes it. +usage_ledger_record_cleanup # The record is gone, so the backlog must not still show this task in flight # when teardown reports success. Still under this task's meta lock, so a steer # racing the same id stays serialized exactly as it was before. diff --git a/bin/fm-test-run.sh b/bin/fm-test-run.sh index a4ef94500fe..acc13172198 100755 --- a/bin/fm-test-run.sh +++ b/bin/fm-test-run.sh @@ -288,7 +288,8 @@ family_for_basename() { printf '%s\n' backend-dispatch ;; fm-check-unregister.test.sh|fm-pr-check-security.test.sh|fm-pr-merge.test.sh|\ - fm-review-diff.test.sh|fm-teardown.test.sh|fm-x-mode.test.sh) + fm-review-diff.test.sh|fm-teardown.test.sh|fm-usage-ledger.test.sh|\ + fm-x-mode.test.sh) printf '%s\n' pr-forge ;; fm-afk-inject-e2e.test.sh|fm-afk-return.test.sh) @@ -1238,6 +1239,16 @@ families_for_changed_path() { bin/fm-x-*|bin/fm-check*) printf '%s\n' pr-forge ;; + bin/fm-usage-ledger.sh|bin/fm-usage-ledger-lib.sh|bin/fm-merge-outcome-lib.sh) + # The durable task-usage ledger and its call policy are written from the + # spawn path, the teardown/PR/merge path, the watcher's merge poll, and the + # remote-secondmate launch and retirement paths, so a change to either + # re-selects every family that drives one of those call sites. + printf '%s\n' pr-forge + printf '%s\n' backend-dispatch + printf '%s\n' watcher-wake-lock + printf '%s\n' secondmate + ;; bin/fm-nm-run-lib.sh) # Shared no-mistakes run-attribution primitives, sourced by both # bin/fm-crew-state.sh (pure-contract-unit) and bin/fm-teardown.sh's diff --git a/bin/fm-usage-ledger-lib.sh b/bin/fm-usage-ledger-lib.sh new file mode 100644 index 00000000000..c0d86d94c9d --- /dev/null +++ b/bin/fm-usage-ledger-lib.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# fm-usage-ledger-lib.sh - the single owner of HOW firstmate's lifecycle scripts +# call the task-usage ledger. +# +# bin/fm-usage-ledger.sh owns the ledger's schema, safety, identity, and +# retention. This file owns only the call policy the lifecycle scripts share, so +# that policy is stated once instead of copied into every call site: +# +# - The effective home is passed explicitly (home, state, and data), never +# inherited, so a secondmate home or an override-driven test home always +# records into its own ledger rather than resolving a different one. +# - Recording is INSTRUMENTATION, never a gate. A launch that already +# succeeded, a merge that already landed, and a cleanup whose safety checks +# already passed must not be turned into a failure because an observability +# record could not be written. So this helper always returns 0 and reports a +# failure as a loud stderr warning naming the concrete consequence; the +# ledger's own diagnostic is left on stderr underneath it. +# - The spawn record is the durable anchor. It is written first, so a task +# that is abandoned, preserved indefinitely, or whose later enrichment fails +# is still attributable to a harness and model. +# +# Sourced by bin/fm-spawn.sh, bin/fm-teardown.sh, bin/fm-pr-check.sh, +# bin/fm-merge-local.sh, and bin/fm-merge-outcome-lib.sh. No side effects on +# source. + +_FM_USAGE_LEDGER_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# fm_usage_ledger_record [record args...] +# Always returns 0; see the call policy above. +fm_usage_ledger_record() { + local home=$1 state=$2 data=$3 event=$4 task=$5 + shift 5 + if FM_HOME="$home" FM_STATE_OVERRIDE="$state" FM_DATA_OVERRIDE="$data" \ + "$_FM_USAGE_LEDGER_LIB_DIR/fm-usage-ledger.sh" record \ + --event "$event" --task "$task" "$@" >/dev/null; then + return 0 + fi + printf 'warning: the task-usage ledger did not record the %s event for %s; model and workflow analysis will be missing it\n' \ + "$event" "$task" >&2 + return 0 +} diff --git a/bin/fm-usage-ledger.sh b/bin/fm-usage-ledger.sh new file mode 100755 index 00000000000..5b61b676f2e --- /dev/null +++ b/bin/fm-usage-ledger.sh @@ -0,0 +1,545 @@ +#!/usr/bin/env bash +# fm-usage-ledger.sh - the single owner of Firstmate's durable, home-private +# task-usage ledger: its schema, its safety rules, its event identity, and its +# retention. Every other script calls this one instead of serializing a record +# itself, so the format is stated here exactly once. +# +# WHY IT EXISTS. A task's implementation axes (harness, model, effort, kind, +# project, delivery mode, autonomy posture, backend) live only in +# state/.meta, and bin/fm-teardown.sh removes that record as part of +# ordinary successful cleanup. Nothing durable then remained to join a merged +# PR back to the model that produced it. This ledger is that durable join: it +# is written at the lifecycle points below, under $FM_HOME/data/, which +# teardown never touches. +# +# STORE. $FM_HOME/data/task-usage.jsonl (FM_DATA_OVERRIDE wins), strictly +# APPEND-ONLY outside the explicit `prune` verb, mode 0600, one JSON object per +# line. Its sibling lock is data/.task-usage.jsonl.lock. Nothing else may write +# it. +# +# RECORD. Every v1 record carries the SAME fixed key set in the SAME order, so +# the file is parseable with awk/sed and needs no JSON processor (firstmate does +# not require jq): +# +# {"v":1,"seq":N,"at":EPOCH,"event":"E","id":"I","task":"T","gen":"G", +# "kind":"K","harness":"H","model":"M","effort":"F","project":"P", +# "mode":"D","yolo":"Y","backend":"B","pr":"U","pr_head":"S", +# "landing":"L","outcome":"O","status_class":"C", +# "validator_harness":"VH","validator_model":"VM"} +# +# v schema version, 1 today. +# seq 1-based position, assigned under the lock and strictly increasing. +# Appends never leave a gap; `prune` removes records, so gaps after a +# retention run are expected. +# at epoch seconds when the record was appended, never a time inferred +# from somewhere else. Because each record is written at its own +# lifecycle point, a spawn record's `at` IS that incarnation's spawn +# time and a cleanup record's `at` IS its cleanup timestamp. +# event ledger-open | spawn | pr | merge | cleanup. +# id the stable event identity that makes a repeated call idempotent +# (see IDENTITY below). +# task task id. +# gen the task's spawn generation (incarnation token) from meta spawn_gen=, +# or "unknown" when that record carries none. +# kind ship | scout | secondmate. Every record states what the task +# record said at that moment, so a scout promoted to a ship keeps +# kind=scout on its spawn row and carries kind=ship on its cleanup +# row rather than being rewritten. +# harness / model / effort +# the IMPLEMENTING worker's axes, exactly as spawn recorded them. +# "default" is fm-spawn's own recorded value for an unpinned axis, and +# "unknown" appears when the task record could not be read at all. +# project the project or home DIRECTORY NAME from meta project=, never its +# path. This is the name data/projects.md registers, so it is the +# joinable key. +# mode no-mistakes | direct-PR | local-only | secondmate; "" for a scout, +# which records no delivery posture until promotion. +# yolo on | off; "" where no delivery posture is recorded. +# backend the runtime session backend, resolved through fm-spawn's documented +# default that an absent backend= means tmux. +# pr the full canonical PR or MR URL. +# pr_head the forge's exact head commit when it could be read. +# landing the landing commit for an approved local-only merge. +# outcome landed | discarded | reported | retired | merged. +# status_class the task's FINAL status verb, mapped to the closed vocabulary +# done | failed | blocked | needs-decision | paused | working | +# resolved | captain-held, or "none" when the task logged no status at +# all, or "unknown" when the last line carries no recognised verb. The +# status NOTE is never stored. +# validator_harness / validator_model +# the no-mistakes VALIDATOR's identity, deliberately separate from the +# implementer's axes above. Firstmate cannot prove them today (the +# pipeline does not expose the agent it ran), so they are recorded as +# the explicit literal "unknown" rather than inferred from the +# implementer. The flags exist so a caller that CAN prove them records +# them without a schema change. +# +# A field that does not apply to an event is the empty string; a field that +# applies but could not be proven is the literal "unknown". The two are +# deliberately different: "" means not-applicable, "unknown" means unproven. +# +# PRIVACY BOUNDARY. Only the allowlisted meta keys above are ever read, and +# project is reduced to its directory name. Credentials, tokens, account or +# host identity, prompt or response text, captain text, PHI, free-form status +# notes, worktree/tasktmp/home paths, traceparent carriers, and relay request +# payloads are never read and have no field to land in. Values are reduced to +# printable ASCII with " and \ removed and truncated at 200 characters, so a +# record is always well-formed JSON with no escaping, and nothing is inferred +# from a name or from prose. +# +# IDENTITY (idempotency). A repeated call with the same identity is a no-op +# that reports `duplicate` and exits 0; a genuinely distinct event appends a +# new record. +# ledger-open "ledger-open" +# spawn spawn:: +# pr pr:::: +# merge merge::: +# cleanup cleanup:: +# Every fresh spawn and relaunch mints a new gen, so a replacement worker is a +# distinct spawn row and its own cleanup row. LIMITATION: a task record with no +# spawn_gen= - today only a remote secondmate launch - has gen "unknown", so +# repeated launches of that one id collapse into a single spawn row rather than +# being invented as separate incarnations. +# +# FIRST OBSERVED. The store's first record is `ledger-open`, whose `at` is the +# instant this home started recording. NOTHING before it is backfilled, and no +# verb here fabricates history: an analysis must treat that timestamp as the +# start of coverage. +# +# SAFETY. Every mutation runs under the store's lock. The store and any temp +# file must be a regular, single-linked, non-symlink file at mode 0600 on the +# data directory's own device; anything else refuses without writing. A +# malformed existing record stops the operation and leaves the file's bytes +# untouched, so a damaged ledger is never silently rewritten or extended. +# Forward compatibility: a record whose v is not 1 is accepted as opaque if it +# still carries the common v/seq/at/event/id prefix, so a newer writer's rows +# are preserved rather than declared malformed. +# +# RETENTION. History is bounded ONLY by the explicit `prune` verb. No lifecycle +# call ever rewrites history, so an unrelated task mutation cannot lose a row. +# `prune` keeps the ledger-open record plus every record within +# FM_USAGE_LEDGER_RETENTION_DAYS (default 400) days, which preserves 30-day, +# quarterly, and year-over-year comparisons. At a few hundred bytes per record +# and a handful of records per task, a busy home costs single-digit megabytes a +# year, so pruning is an operator decision rather than an automatic one. +# +# Usage: +# fm-usage-ledger.sh record --event --task +# [--meta ] [--gen ] [--pr ] [--pr-head ] +# [--landing ] [--outcome ] +# [--status-file ] [--validator-harness ] +# [--validator-model ] +# Append one record, or report `duplicate` when its identity is already +# stored. --meta supplies the implementation axes; --status-file supplies +# the final status class. +# fm-usage-ledger.sh list [--recent ] +# Print the last n records (default 20) as raw JSONL. +# fm-usage-ledger.sh verify +# Validate every record and print `ok records= first_observed=`. +# fm-usage-ledger.sh prune [--days ] +# Apply retention atomically. Refuses a malformed store. +# fm-usage-ledger.sh path +# Print the store path. +set -eu + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" +FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" +STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" +DATA="${FM_DATA_OVERRIDE:-$FM_HOME/data}" + +usage() { + # The whole leading comment block, ending at the first non-comment line. + sed -n '2,${/^#/!q;p;}' "$0" | sed 's/^# \{0,1\}//' +} + +case "${1:-}" in + -h|--help) usage; exit 0 ;; +esac + +# shellcheck source=bin/fm-pr-lib.sh +. "$SCRIPT_DIR/fm-pr-lib.sh" +# fm-backend.sh owns fm_meta_get and the "absent backend= means tmux" +# compatibility contract; this script reads task records through it rather than +# restating either. +# shellcheck source=bin/fm-backend.sh +. "$SCRIPT_DIR/fm-backend.sh" +# shellcheck source=bin/fm-classify-lib.sh +. "$SCRIPT_DIR/fm-classify-lib.sh" +# shellcheck source=bin/fm-wake-lib.sh +. "$SCRIPT_DIR/fm-wake-lib.sh" + +STORE_NAME='task-usage.jsonl' +SCHEMA_VERSION=1 +RETENTION_DAYS=${FM_USAGE_LEDGER_RETENTION_DAYS:-400} + +# The common prefix every record of every schema version carries, plus the +# closing brace. Captures: 1 v, 2 seq, 3 at, 4 event, 5 id. +UL_PREFIX_RE='^[{]"v":([1-9][0-9]*),"seq":([1-9][0-9]*),"at":([0-9]+),"event":"([a-z-]+)","id":"([^"\]*)",.*[}]$' +# The exact v1 shape: same key set, same order, every value a plain string. +UL_V1_RE='^[{]"v":1,"seq":[1-9][0-9]*,"at":[0-9]+,"event":"[a-z-]+","id":"[^"\]*","task":"[^"\]*","gen":"[^"\]*","kind":"[^"\]*","harness":"[^"\]*","model":"[^"\]*","effort":"[^"\]*","project":"[^"\]*","mode":"[^"\]*","yolo":"[^"\]*","backend":"[^"\]*","pr":"[^"\]*","pr_head":"[^"\]*","landing":"[^"\]*","outcome":"[^"\]*","status_class":"[^"\]*","validator_harness":"[^"\]*","validator_model":"[^"\]*"[}]$' + +die() { + printf 'error: %s\n' "$*" >&2 + exit 1 +} + +usage_error() { + printf 'error: %s\n' "$*" >&2 + printf 'run fm-usage-ledger.sh --help for the contract\n' >&2 + exit 2 +} + +# Reduce one caller value to a ledger-safe scalar: printable ASCII only, no +# quote or backslash, bounded length. This is what makes every emitted line +# well-formed JSON without escaping logic. +ul_clean() { # + local v=${1-} + v=$(printf '%s' "$v" | LC_ALL=C tr -cd '\040-\176' | LC_ALL=C tr -d '\042\134') + printf '%s' "${v:0:200}" +} + +ul_resolve_dir() { #