Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ state/ volatile runtime signals; gitignored
<id>.turn-ended touched by turn-end hooks
<id>.grok-turnend-token firstmate-owned grok hook registry token for the task; removed by teardown
<id>.kimi-turnend-token firstmate-owned Kimi hook registry token for the task; removed by teardown
<id>.meta written by fm-spawn: window=, endpoint_task_id=, worktree=, project=, harness=, model=, effort=, kind=, mode=, yolo=, tasktmp=; kind=secondmate also records home= and projects=; a non-default runtime backend records further backend-specific fields (docs/configuration.md "Runtime backend"; bin/fm-backend.sh, section 8); fm-pr-check, including through fm-pr-merge, records one canonical pr= and the forge's pr_head= when available (GitHub pull requests and GitLab merge requests; docs/gitlab-merge-watch.md); fm-x-link appends x_request=, x_request_ts=, x_followups=, and optional x_platform=/x_reply_max_chars= for an X-mode-originated task (section 14)
<id>.meta written by fm-spawn: window=, endpoint_task_id=, worktree=, project=, harness=, model=, effort=, kind=, mode=, yolo=, tasktmp=; kind=secondmate also records home= and projects=; a non-default runtime backend records further backend-specific fields (docs/configuration.md "Runtime backend"; bin/fm-backend.sh, section 8); fm-pr-check, including through fm-pr-merge, records one canonical pr= and the forge's pr_head= when available (GitHub pull requests and GitLab merge requests; docs/gitlab-merge-watch.md); fm-x-link appends x_request=, x_request_ts=, x_followups=, and optional x_platform=/x_reply_max_chars= for an X-mode-originated task (section 14); fm-report-finalize.sh records report_archive= and report_finalized=1 once the task's report is archived (docs/task-report-archive.md)
<id>.herdr-presentation quarantinable attempt and restart-binding journal for Herdr's optional visual projection; never task or endpoint authority; see docs/herdr-backend.md "Optional presentation spaces"
<id>.check.sh authenticated slow poll; the watcher dispatches validated PR data and the byte-identified X shim through trusted repository scripts, runs registered custom checks from hash-validated private snapshots, and rejects every other state check without execution
<id>.check-trust private content binding created by fm-check-register.sh for an intentional custom check
Expand Down
6 changes: 4 additions & 2 deletions bin/fm-brief.sh
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
# Usage: fm-brief.sh <task-id> <repo-name> [--scout] [--herdr-lab]
# fm-brief.sh <task-id> --secondmate {<project>...|--no-projects}
# --scout writes the scout contract instead: the deliverable is a report at
# data/<task-id>/report.md (no branch, no push, no PR) and the worktree is scratch.
# the private home path data/<task-id>/report.md (never inside the project
# worktree), with no branch, no push, and no PR; the worktree is scratch.
# --secondmate writes a persistent secondmate charter. The project list
# is cloned into the secondmate home, while the natural-language scope
# tells the main firstmate when to route work there; routine churn stays in its own home;
Expand Down Expand Up @@ -243,7 +244,7 @@ The report is the only thing that survives, so anything worth keeping must be in

# Rules
1. Never push to any remote and never open a PR.
2. Stay inside this worktree; the only files you may write outside it are the report and the status file below.
2. Stay inside this worktree; the only files you may write outside it are the private-home report at \`$DATA/$ID/report.md\` and the status file below.
3. Use gh-axi for GitHub operations and chrome-devtools-axi for browser operations.
4. Report status by appending one line:
\`echo "{state}: {one short line}" >> $STATUS_FILE\`
Expand All @@ -265,6 +266,7 @@ The report is the only thing that survives, so anything worth keeping must be in

# Definition of done
Write your findings to \`$DATA/$ID/report.md\`.
That path is firstmate's private home data, not inside the project worktree.
The report must stand alone: what you did, what you found, the evidence (commands run, output, file:line references), and what you recommend.
Before reporting done, read and follow \`$FM_ROOT/.agents/skills/decision-hold-lifecycle/SKILL.md\` and pass its shared completion gate for the report and any visual review.
When the report is complete, append \`done: {one-line conclusion}\` to the status file and stop.
Expand Down
11 changes: 8 additions & 3 deletions bin/fm-decision-hold.sh
Original file line number Diff line number Diff line change
Expand Up @@ -116,9 +116,14 @@ show_field() { # <show-output> <field>
}

origin_exists_here() { # <origin-id>
[ -f "$STATE/$1.meta" ] && return 0
[ -f "$DATA/$1/report.md" ] && return 0
task_show "$1" >/dev/null 2>&1
local id=$1 archived
[ -f "$STATE/$id.meta" ] && return 0
[ -f "$DATA/$id/report.md" ] && return 0
for archived in "$DATA"/reports/*/"$id".md; do
[ -e "$archived" ] || continue
[ -f "$archived" ] && return 0
done
task_show "$id" >/dev/null 2>&1
}

list_has_key() { # <comma-list> <key>
Expand Down
7 changes: 6 additions & 1 deletion bin/fm-fleet-snapshot.sh
Original file line number Diff line number Diff line change
Expand Up @@ -418,7 +418,12 @@ task_json_lines() {
backend=$(fm_backend_of_meta "$meta")
target=$(fm_backend_target_of_meta "$meta")
status_log="$STATE/$id.status"
report_path="$DATA/$id/report.md"
report_path=$(meta_value "$meta" report_archive)
if [ -z "$report_path" ]; then
report_path="$DATA/$id/report.md"
else
report_path="$FM_HOME/$report_path"
fi
pr=$(meta_value "$meta" pr)
pr_source=meta
if [ -z "$pr" ]; then
Expand Down
130 changes: 130 additions & 0 deletions bin/fm-report-finalize.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
#!/usr/bin/env bash
# Capture a task report or ship delivery receipt into the canonical private
# archive under data/reports/<project>/, record report_archive= in task meta,
# and regenerate the per-project INDEX.md.
#
# Scouts require the working report at data/<id>/report.md and a completed
# unresolved-decision inventory. Ship tasks accept an optional working report
# or synthesize a short structured delivery receipt from task meta and status.
#
# Idempotent: repeating finalize for the same bytes succeeds; conflicting
# archive content refuses safely.
#
# Usage: fm-report-finalize.sh <task-id>
#
# The normative contract is owned by docs/task-report-archive.md.
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}"

# shellcheck source=bin/fm-report-lib.sh
. "$SCRIPT_DIR/fm-report-lib.sh"

usage() {
awk '
NR == 1 { next }
/^#/ { sub(/^# ?/, ""); print; next }
{ exit }
' "$0"
}

fail() {
printf 'fm-report-finalize: %s\n' "$*" >&2
exit 1
}

if [ "$#" -ne 1 ] || ! fm_pr_task_id_valid "$1"; then
usage >&2
exit 2
fi

ID=$1
META="$STATE/$ID.meta"
STATUS="$STATE/$ID.status"

[ -f "$META" ] && [ ! -L "$META" ] || fail "task metadata is absent: $META"

KIND=$(fm_report_meta_value "$META" kind)
[ -n "$KIND" ] || KIND=ship
PROJECT=$(fm_report_meta_value "$META" project)
[ -n "$PROJECT" ] || fail "task $ID has no project= in metadata; cannot choose an archive owner"

SLUG=$(fm_report_project_slug "$PROJECT") || fail "project slug is not archive-safe: $(basename "$PROJECT")"

WORKING=$(fm_report_working_path "$DATA" "$ID")
ARCHIVE_REL=$(fm_report_archive_rel "$SLUG" "$ID")
ARCHIVE_ABS=$(fm_report_archive_abs "$DATA" "$SLUG" "$ID")

existing_archive=$(fm_report_meta_value "$META" report_archive)
already_finalized=$(fm_report_meta_value "$META" report_finalized)
if [ -n "$existing_archive" ] && [ "$existing_archive" != "$ARCHIVE_REL" ]; then
fail "task $ID already finalized to a different archive path: $existing_archive"
fi

if [ "$already_finalized" = 1 ] && [ -f "$ARCHIVE_ABS" ]; then
fm_report_regenerate_index "$DATA" "$SLUG" \
|| fail "could not regenerate index for project $SLUG"
printf 'finalized: %s\n' "$ARCHIVE_REL"
exit 0
fi

if [ "$KIND" = scout ]; then
if [ ! -f "$WORKING" ]; then
fail "scout task $ID has no working report at $WORKING"
fi
if ! FM_HOME="$FM_HOME" FM_STATE_OVERRIDE="$STATE" FM_DATA_OVERRIDE="$DATA" \
"$SCRIPT_DIR/fm-decision-hold.sh" verify "$ID" >/dev/null; then
fail "scout task $ID has not passed the unresolved-decision completion gate"
fi
fi

if [ "$KIND" = secondmate ]; then
fail "secondmate homes are not finalized through fm-report-finalize.sh"
fi

SOURCE_TMP=
SOURCE_TMP_IS_TEMP=0
cleanup() {
if [ "$SOURCE_TMP_IS_TEMP" = 1 ] && [ -n "$SOURCE_TMP" ]; then
rm -f "$SOURCE_TMP"
fi
}
trap cleanup EXIT HUP INT TERM

if [ -f "$WORKING" ]; then
SOURCE_TMP=$(mktemp "${TMPDIR:-/tmp}/fm-report-source.XXXXXX") || exit 1
SOURCE_TMP_IS_TEMP=1
cp -f "$WORKING" "$SOURCE_TMP" || fail "could not read working report at $WORKING"
elif [ "$KIND" = ship ]; then
SOURCE_TMP=$(mktemp "${TMPDIR:-/tmp}/fm-report-source.XXXXXX") || exit 1
SOURCE_TMP_IS_TEMP=1
fm_report_write_ship_receipt "$SOURCE_TMP" "$ID" "$META" "$STATUS" \
|| fail "could not synthesize ship delivery receipt for $ID"
else
fail "task $ID has no working report at $WORKING"
fi

if [ -f "$ARCHIVE_ABS" ]; then
existing_hash=$(fm_report_sha256_file "$ARCHIVE_ABS") \
|| fail "existing archive at $ARCHIVE_ABS is unreadable"
source_hash=$(fm_report_sha256_file "$SOURCE_TMP") \
|| fail "source report for $ID is unreadable"
if [ "$existing_hash" != "$source_hash" ]; then
fail "archive at $ARCHIVE_ABS already exists with different content"
fi
else
fm_report_atomic_write "$ARCHIVE_ABS" "$SOURCE_TMP" \
|| fail "could not write archive at $ARCHIVE_ABS"
fi

fm_report_record_meta "$META" "$ARCHIVE_REL" \
|| fail "could not record report_archive= for $ID"

fm_report_regenerate_index "$DATA" "$SLUG" \
|| fail "could not regenerate index for project $SLUG"

printf 'finalized: %s\n' "$ARCHIVE_REL"
135 changes: 135 additions & 0 deletions bin/fm-report-lib.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
#!/usr/bin/env bash
# Shared helpers for canonical task-report archive and finalization.
# The normative contract is owned by docs/task-report-archive.md.
# shellcheck disable=SC2034
set -eu

FM_REPORT_LIB_SOURCED=1

# shellcheck source=bin/fm-pr-lib.sh
. "${FM_REPORT_LIB_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)}/fm-pr-lib.sh"

fm_report_meta_value() { # <meta-file> <key>
grep "^$2=" "$1" 2>/dev/null | tail -1 | cut -d= -f2- || true
}

fm_report_slug_valid() { # <slug>
local slug=${1-}
case "$slug" in
''|*[!A-Za-z0-9._-]*) return 1 ;;
esac
}

fm_report_project_slug() { # <project-path>
local project=${1-} base
[ -n "$project" ] || return 1
base=$(basename "$project")
fm_report_slug_valid "$base" || return 1
printf '%s\n' "$base"
}

fm_report_working_path() { # <data-dir> <task-id>
printf '%s/%s/report.md\n' "$1" "$2"
}

fm_report_archive_rel() { # <project-slug> <task-id>
printf 'data/reports/%s/%s.md\n' "$1" "$2"
}

fm_report_archive_abs() { # <data-dir> <project-slug> <task-id>
printf '%s/reports/%s/%s.md\n' "$1" "$2" "$3"
}

fm_report_index_abs() { # <data-dir> <project-slug>
printf '%s/reports/%s/INDEX.md\n' "$1" "$2"
}

fm_report_sha256_file() { # <path>
local path=$1
if command -v shasum >/dev/null 2>&1; then
shasum -a 256 "$path" | awk '{print $1}'
elif command -v sha256sum >/dev/null 2>&1; then
sha256sum "$path" | awk '{print $1}'
else
return 1
fi
}

fm_report_atomic_write() { # <dest> <src>
local dest=$1 src=$2 dir tmp
dir=$(dirname "$dest")
mkdir -p "$dir" || return 1
umask 077
tmp=$(mktemp "$dir/.fm-report.XXXXXX") || return 1
if ! cp -f "$src" "$tmp" || ! chmod 0600 "$tmp" 2>/dev/null; then
rm -f "$tmp"
return 1
fi
mv -f "$tmp" "$dest"
}

fm_report_last_done_line() { # <status-file>
local status_file=$1 line
[ -f "$status_file" ] || return 0
line=$(grep -E '^(done|failed):' "$status_file" 2>/dev/null | tail -1 || true)
printf '%s' "$line"
}

fm_report_write_ship_receipt() { # <dest> <id> <meta> <status-file>
local dest=$1 id=$2 meta=$3 status_file=$4
local kind mode project pr outcome tmp
kind=$(fm_report_meta_value "$meta" kind)
[ -n "$kind" ] || kind=ship
mode=$(fm_report_meta_value "$meta" mode)
project=$(fm_report_meta_value "$meta" project)
pr=$(fm_report_meta_value "$meta" pr)
outcome=$(fm_report_last_done_line "$status_file")
[ -n "$outcome" ] || outcome='(no terminal done: or failed: status recorded)'
tmp=$(mktemp "${TMPDIR:-/tmp}/fm-report-receipt.XXXXXX") || return 1
{
printf '# Ship delivery receipt: %s\n\n' "$id"
printf '%s\n' "- kind: $kind"
[ -n "$mode" ] && printf '%s\n' "- mode: $mode"
[ -n "$project" ] && printf '%s\n' "- project: $project"
[ -n "$pr" ] && printf '%s\n' "- pr: $pr"
printf '%s\n' "- outcome: $outcome"
printf '%s\n' "- finalized: $(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -u)"
} > "$tmp" || { rm -f "$tmp"; return 1; }
fm_report_atomic_write "$dest" "$tmp"
rm -f "$tmp"
}

fm_report_regenerate_index() { # <data-dir> <project-slug>
local data=$1 slug=$2 dir index tmp report id kind archived
dir="$data/reports/$slug"
index="$dir/INDEX.md"
mkdir -p "$dir" || return 1
tmp=$(mktemp "${TMPDIR:-/tmp}/fm-report-index.XXXXXX") || return 1
{
printf '# Report index: %s\n\n' "$slug"
printf 'Generated by bin/fm-report-finalize.sh. Do not edit by hand.\n\n'
printf '| task | archived |\n'
printf '| --- | --- |\n'
for report in "$dir"/*.md; do
[ -e "$report" ] || continue
[ "$(basename "$report")" != INDEX.md ] || continue
id=$(basename "$report" .md)
archived=$(date -r "$report" +%Y-%m-%d 2>/dev/null || stat -f '%Sm' -t '%Y-%m-%d' "$report" 2>/dev/null || echo '-')
printf '| %s | %s |\n' "$id" "$archived"
done
} > "$tmp" || { rm -f "$tmp"; return 1; }
fm_report_atomic_write "$index" "$tmp"
rm -f "$tmp"
}

fm_report_record_meta() { # <meta> <archive-rel>
local meta=$1 archive_rel=$2
local existing
existing=$(fm_report_meta_value "$meta" report_archive)
if [ -n "$existing" ] && [ "$existing" != "$archive_rel" ]; then
return 1
fi
if [ "$existing" != "$archive_rel" ]; then
printf 'report_archive=%s\nreport_finalized=1\n' "$archive_rel" >> "$meta"
fi
}
24 changes: 10 additions & 14 deletions bin/fm-teardown.sh
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,10 @@
# branch (firstmate performs that merge after configured approval) as a fallback
# for the common case where there is no remote at all.
# Scout tasks (kind=scout in meta) carve out of that check: their worktree is
# declared scratch and the report at data/<task-id>/report.md is the work
# product. Teardown proceeds only once the report exists and the shared
# unresolved-decision completion gate verifies its captain-held inventory.
# declared scratch and the report at data/<task-id>/report.md is the working
# copy. Teardown proceeds only once bin/fm-report-finalize.sh archives it and
# the shared unresolved-decision completion gate verifies its captain-held inventory.
# Ship tasks archive a short delivery receipt through the same finalization step.
# Before destructive cleanup, teardown validates task check artifacts and any
# matching quarantine entries as ordinary single-link files on the state
# device. It refuses and preserves task state when that proof fails; otherwise
Expand Down Expand Up @@ -427,7 +428,8 @@ backlog_refresh_reminder() {
if fm_tasks_axi_backend_available "$CONFIG"; then
case "$KIND" in
scout)
report_path="data/$ID/report.md"
report_path=$(meta_value "$META" report_archive)
[ -n "$report_path" ] || report_path="data/$ID/report.md"
done_cmd="tasks-axi done $ID --report $report_path"
;;
*)
Expand Down Expand Up @@ -1078,17 +1080,11 @@ if [ "$KIND" = secondmate ] && [ "$FORCE" = "--force" ]; then
cleanup_firstmate_home_children "$HOME_PATH"
fi

if [ "$KIND" = scout ] && [ "$FORCE" != "--force" ]; then
REPORT="$DATA/$ID/report.md"
if [ ! -f "$REPORT" ]; then
echo "REFUSED: scout task $ID has no report at $REPORT." >&2
echo "The report is the work product. Have the crewmate write it, or use --force after explicit discard approval." >&2
exit 1
fi
if [ "$KIND" != secondmate ] && [ "$FORCE" != "--force" ]; then
if ! FM_HOME="$FM_HOME" FM_STATE_OVERRIDE="$STATE" FM_DATA_OVERRIDE="$DATA" \
FM_CONFIG_OVERRIDE="$CONFIG" "$SCRIPT_DIR/fm-decision-hold.sh" verify "$ID" >/dev/null; then
echo "REFUSED: scout task $ID has not passed the unresolved-decision completion gate." >&2
echo "Inventory its report and any visual review through bin/fm-decision-hold.sh before teardown." >&2
FM_CONFIG_OVERRIDE="$CONFIG" "$SCRIPT_DIR/fm-report-finalize.sh" "$ID" >/dev/null; then
echo "REFUSED: task $ID report is not finalized into the canonical archive." >&2
echo "Run bin/fm-report-finalize.sh $ID after the deliverable is ready, or use --force after explicit discard approval." >&2
exit 1
fi
fi
Expand Down
2 changes: 1 addition & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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, learnings, backlog, briefs, and scout reports.
`data/` holds durable private fleet records such as the project and secondmate registries, captain preferences, optional shared captain preferences, learnings, backlog, briefs, working scout reports at `data/<task-id>/report.md`, and finalized task artifacts under `data/reports/<project>/` (see [task-report-archive.md](task-report-archive.md)).
`state/` holds volatile runtime records such as task metadata, append-only status events, endpoint signals, watcher and wake-queue coordination, away-mode state, generated X-mode 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 guarded exceptions in `AGENTS.md`.

Expand Down
Loading
Loading