Skip to content
Merged
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/skills/decisionboard/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ Captain-actionability is a single predicate in `bin/fm-fleet-snapshot.sh` and a
Measured on a two-decision fixture, adding one `blocked-by` edge takes the reported inventory from "records: 2 decisions kept: 2" to "records: 1 decisions kept: 1", with no footnote anywhere.
So this is the captain-actionable set, not the open set: say that on the board in the same breath as the unverified fold, and never present it as everything waiting on him.
For one named undertaking, `.agents/skills/sea-chart` reconciles its own records against the backlog and reports each withheld one by name with its cause - fleet-wide there is no such count yet.
The same predicate separately hides a captain hold carried on a record of another kind, filed as `fm-snapshot-captain-shape-invisible`; both losses belong to the snapshot and neither can be closed from this board.
The blocker is the only shape of this loss left, and it belongs to the snapshot rather than to this board: the predicate reads the hold kind alone, so a captain hold carried on a record of any other kind does reach here.

## Every folded record stays visible

Expand Down
2 changes: 1 addition & 1 deletion .agents/skills/sea-chart/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ Whether a piece of work is destructive, irreversible, security-sensitive, or out
The chart reads them; it never files them.
They are ordinary backlog records, owned by the backlog contract in `AGENTS.md` section 10, which is where their spelling and their meaning live.
Follow it exactly on the record kind rather than the hold: `hold --kind` refuses both names, and reading that refusal as "these cannot be stored" is what once left every chart's fog and boundaries permanently empty.
Neither can ever be mistaken for a captain decision, because captain-actionability requires `kind: captain` and neither kind is that - structure, not a rule in prose.
Neither can ever be mistaken for a captain decision, because captain-actionability requires `hold-kind: captain` and both are held as `future` - structure, not a rule in prose.

A fog patch that becomes sharp is not promoted in place: close it, then register the real decision through `bin/fm-decision-hold.sh`, which already accepts later keys on a live or torn-down origin.
A course boundary never rises at all.
Expand Down
3 changes: 2 additions & 1 deletion .agents/skills/to-backlog/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,8 @@ That is the decision, not a working demo.
## What this skill does not do

It authors and sizes **questions and work**.
It never answers a captain decision: `bin/fm-decision-hold.sh` and `.agents/skills/decision-hold-lifecycle` own that, and this skill refuses to file a captain-actionable record so it cannot become a second owner by accident.
It never answers a captain decision: `bin/fm-decision-hold.sh` and `.agents/skills/decision-hold-lifecycle` own that, and this skill refuses the `captain` kind so it cannot become a second owner by accident.
That refusal is about ownership rather than the captain-actionable surface, which reads the hold kind: `bin/fm-to-backlog.sh` never calls `tasks-axi hold` at all, so it could not file an actionable record whatever kind it accepted.
It never files fog or a course boundary either - those are the sea chart's own markers, spelled by `bin/fm-chart-kinds-lib.sh` and filed under `AGENTS.md` section 10.
It never dispatches a worker, opens a branch, or writes code.

Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -352,8 +352,8 @@ jobs:
bearings_output=$(/bin/bash tests/fm-bearings-snapshot.test.sh)
printf '%s\n' "$bearings_output"
bearings_count=$(printf '%s\n' "$bearings_output" | grep -c '^ok - ')
[ "$bearings_count" -eq 44 ] || {
echo "::error::expected 44 Bearings tests, got $bearings_count"
[ "$bearings_count" -eq 45 ] || {
echo "::error::expected 45 Bearings tests, got $bearings_count"
exit 1
}

Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -485,7 +485,7 @@ It tracks work items only, never agents; persistent secondmates never appear as
Work routed to a secondmate is recorded in that secondmate home's own backlog, not the main backlog.
When a main-side thread such as a pending captain decision or relay reminder is worth durable tracking, file it as its own work item; use `tasks-axi hold <id> --reason "<reason>" --kind captain` for a captain-gated thread.
Unresolved decisions discovered by investigations or visual reviews follow `decision-hold-lifecycle`, which owns their mandatory backlog lifecycle.
Two further kinds carry a sea chart's own material and can never be mistaken for a captain decision, because captain-actionability requires `kind: captain`: `fog` for a question an investigation could not yet make sharp, and `out-of-course` for a deliberate scope boundary.
Two further kinds carry a sea chart's own material and can never be mistaken for a captain decision, because captain-actionability requires `hold-kind: captain` and both are held as `future`: `fog` for a question an investigation could not yet make sharp, and `out-of-course` for a deliberate scope boundary.
File both under the originating undertaking's id as `<chart>-fog-<slug>` and `<chart>-oos-<slug>`, so each belongs to exactly one chart.
Both names go on the record kind, which is the only field the chart classifies by: file a dark patch with `tasks-axi add <id> "<title>" --kind fog` and a scope boundary with `tasks-axi add <id> "<title>" --kind out-of-course`, one command per record.
Then `tasks-axi hold <id> --reason "<why>" --kind future` records the reason the chart prints, because `hold --kind` is a separate closed vocabulary that rejects both names.
Expand Down
10 changes: 7 additions & 3 deletions bin/fm-chart-kinds-lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,13 @@
#
# WHY THESE KINDS ARE SAFE BY CONSTRUCTION
# Neither can ever be mistaken for a captain decision. Captain-actionability is
# one predicate in bin/fm-fleet-snapshot.sh and it requires `kind == "captain"`,
# so a record of either kind below reads `captain_actionable: false` no matter
# what anyone writes into it. That is structure, not a rule in prose that drifts.
# one predicate in bin/fm-fleet-snapshot.sh and it requires `hold_kind ==
# "captain"`, which is the field that names who is being asked. Both kinds below
# are filed with `hold --kind future`, so both read `captain_actionable: false`.
# That is structure, not a rule in prose that drifts - but it is structure in the
# HOLD kind, so a fog record filed with a captain hold would surface as a captain
# decision, which is the correct direction: a misfiled question the captain can
# see is recoverable, one he cannot is not.
#
# Sourced, never executed.

Expand Down
15 changes: 14 additions & 1 deletion bin/fm-fleet-snapshot.sh
Original file line number Diff line number Diff line change
Expand Up @@ -470,8 +470,21 @@ backlog_json() { # [<backlog-path>] - defaults to this home's $BACKLOG
elif .state == "queued" then "queued"
else "done" end)
| .requires_child_metadata = (.current_role == "worker")
# The audience of a hold is `hold_kind`, never the record kind. A record
# kind says what the work IS - ship, scout, fog - and `tasks-axi hold
# --kind captain` is what says the captain is the one being asked, which
# is why AGENTS.md section 10 files a captain-gated thread with that one
# command and says nothing about the record kind. Requiring both
# fields made every hold filed exactly as instructed invisible: measured
# on this home 2026-08-09, 35 records carried `hold-kind: captain` and the
# surface returned 32, the missing ones being a production deploy filed as
# kind ship and a thread carrying no record kind at all.
# `hold --kind` is a closed vocabulary (captain, external, load, parked,
# future), so widening admits only records someone deliberately held for
# the captain. A blocked record is still withheld here - that is a
# separate, separately filed gap and this predicate does not change it.
| .captain_actionable =
(.state == "queued" and .kind == "captain" and .hold_kind == "captain"
(.state == "queued" and .hold_kind == "captain"
and .hold_reason != null and (.unresolved_blocker_ids | length) == 0)
else . end)
| del(.section,.order)
Expand Down
55 changes: 34 additions & 21 deletions bin/fm-sea-chart.sh
Original file line number Diff line number Diff line change
Expand Up @@ -74,13 +74,22 @@
# ruling in its group is reported there too, as `unpaired-variant`: the fold
# rests on an assumption nothing verifies, so a question only an analyst raised
# must stay on the page rather than fall between the two surfaces. What makes a record
# captain-gated is its KIND, never its name: `kind: captain` together with
# `hold-kind: captain` is the only shape captain-actionability can ever admit,
# and a captain record named without `-decision-` is exactly as lost when it is
# blocked. A record of a DIFFERENT kind carrying a captain hold is not this gap
# and is not recovered here: it never reaches `decisions_open` at all, so the
# decision board cannot see it either. That belongs to the predicate in
# bin/fm-fleet-snapshot.sh and is filed as `fm-snapshot-captain-shape-invisible`.
# captain-gated is its KIND, never its name: what captain-actionability admits is
# `hold-kind: captain` (bin/fm-fleet-snapshot.sh), and a captain record named
# without `-decision-` is exactly as lost when it is blocked. This chart draws its
# own reconciliation baseline narrower, from records of `kind: captain` under this
# chart, because those are the ones its sections can classify. An UNBLOCKED
# record of a DIFFERENT kind carrying a captain hold no longer needs that
# baseline: since 2026-08-09 the predicate admits it on the hold kind alone, so
# it arrives here through the inventory and is drawn like any other decision.
# A BLOCKED one reaches NEITHER surface: the predicate fails it on the blocker,
# and this chart's baseline fails it on the kind, so `withheld[]` never names it
# either. It falls through to `unplaced[]`, where the reason reads cause
# `blocked` and names its kind and its blocker but never says the captain is
# being asked. That class is recovered nowhere as a decision - it lies outside
# the recovery this header opens with rather than inside it. It behaved
# identically before 2026-08-09, so this is not a regression, and the narrow
# baseline is deliberate rather than an oversight.
# Being per-chart is what makes the recovery possible without the fleet-wide
# `decisions_blocked[]` surface that the design defers - a chart knows its own
# scope, so it can ask a bounded question the fleet-wide board cannot.
Expand Down Expand Up @@ -378,9 +387,9 @@ CHART_JSON=$(printf '%s\n%s\n%s\n' "$LIVE" "$ARCH" "$INV" 2>/dev/null | jq -n \
# Why a captain-gated record is not on the decision list of this chart. These are
# different pieces of news and must not share one sentence: a blocked record is
# one the fleet has lost track of, while an in-flight one is being worked right
# now. Captain-actionability (bin/fm-fleet-snapshot.sh) wants a queued record of
# kind captain, held with hold-kind captain, and nothing unresolved against it,
# so each failing clause gets its own name and its own words.
# now. Captain-actionability (bin/fm-fleet-snapshot.sh) wants a queued record
# held with hold-kind captain and nothing unresolved against it, so each failing
# clause gets its own name and its own words.
# The unpaired-variant clause comes FIRST because it is the one case where the
# record did reach the actionable surface, so every sentence below it - each of
# which says the surface never carried it - would be false of such a record.
Expand Down Expand Up @@ -487,17 +496,21 @@ CHART_JSON=$(printf '%s\n%s\n%s\n' "$LIVE" "$ARCH" "$INV" 2>/dev/null | jq -n \

# RECONCILIATION. Every record this chart owns that waits on the captain,
# straight from the backlog - then whatever the actionable surface did not
# return. The test is the KIND, never the identifier: `kind: captain` with
# `hold-kind: captain` is the only shape captain-actionability can ever admit,
# so the record kind IS the thing while a name is only what it happens to be
# called. Keying on `-decision-` in the id would leave a blocked captain record
# named any other way not merely undercounted but invisible, every count
# reading zero - the same silent loss this chart exists against, on a third
# flank. A record of some other kind carrying a captain hold is a different
# gap: it never reaches `decisions_open` either, so the decision board cannot
# see it in the first place. That one belongs to the captain-actionable
# predicate in bin/fm-fleet-snapshot.sh and is filed as
# `fm-snapshot-captain-shape-invisible`; this chart cannot close it.
# return. The test is the KIND, never the identifier, so the record kind IS the
# thing while a name is only what it happens to be called. Keying on
# `-decision-` in the id would leave a blocked captain record named any other
# way not merely undercounted but invisible, every count reading zero - the same
# silent loss this chart exists against, on a third flank.
# This baseline stays narrower than the captain-actionable predicate, which
# admits any queued record held with `hold-kind: captain`. That is deliberate:
# an UNBLOCKED record of some other kind now reaches `decisions_open` on its own
# and is drawn from the inventory, so naming it here would only reconcile it
# against a surface that already carries it. A BLOCKED one reaches neither: the
# predicate fails it on the blocker and this line fails it on the kind, so it is
# recovered nowhere as a decision and lands in `unplaced[]` under cause
# `blocked`, named by kind and blocker but never as a question put to the
# captain. It behaved identically before 2026-08-09, so that is not a loss this
# line introduced, and staying narrow here is deliberate.
| ([ $mine[] | select(open_state and .kind == "captain") ]) as $own_decision_records
| ([ $own_decision_records[]
| select(.id as $id | ($seen | index($id)) == null)
Expand Down
8 changes: 5 additions & 3 deletions bin/fm-to-backlog.sh
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,11 @@
# it is cheap to fix, rather than leaving orphan units nobody's chart shows.
#
# WHY THREE KINDS ARE REFUSED
# `captain` is the one predicate that makes a record captain-actionable
# (bin/fm-fleet-snapshot.sh), and durable captain decisions are owned by
# bin/fm-decision-hold.sh under .agents/skills/decision-hold-lifecycle. `fog` and
# `captain` is the kind durable captain decisions carry, and those are owned by
# bin/fm-decision-hold.sh under .agents/skills/decision-hold-lifecycle. This
# script never calls `tasks-axi hold`, and a hold is what
# bin/fm-fleet-snapshot.sh reads for captain-actionability, so the refusal is
# about ownership rather than the surface. `fog` and
# `out-of-course` are the sea chart's own kinds, spelled by
# bin/fm-chart-kinds-lib.sh and carried on `-fog-`/`-oos-` ids this script does
# not compose. Slicing work must never manufacture a record of any of the three
Expand Down
3 changes: 2 additions & 1 deletion docs/to-backlog-provenance.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,8 @@ Ours outright, added rather than adapted:
A unit may never be filed as `kind: captain`, `fog`, or `out-of-course`, because those records belong to `bin/fm-decision-hold.sh` and to the sea chart's own markers.
Nor may a slug compose the reserved `-decision-` marker into its id, because `bin/fm-sea-chart.sh` reads that marker positionally rather than by kind, so an ordinary unit carrying it would leave the chart's takeable work and later read as a settled captain decision - the same hole as the kind refusal, reached through the id instead of the kind.
And no unit may be blocked by its own origin, an edge that could never clear because a breakdown may never close the undertaking it is a slice of.
Without the kind and id refusals this skill could manufacture captain-actionable records as a side effect of slicing work, which is the one write the read-only argument was right about.
Without the kind and id refusals this skill could manufacture records that read as captain decisions as a side effect of slicing work, which is the one write the read-only argument was right about.
Captain-actionability itself is read off the hold, not the record kind (`bin/fm-fleet-snapshot.sh`), and this script never calls `tasks-axi hold`, so the refusals guard ownership and the chart's reading rather than that surface.

## What we dropped

Expand Down
36 changes: 36 additions & 0 deletions tests/fm-bearings-snapshot.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -943,6 +943,41 @@ test_open_decision_surfaces_end_to_end() {
pass "an authoritative captain hold surfaces end-to-end"
}

# AGENTS.md section 10 files a captain-gated thread with `tasks-axi hold <id>
# --reason "<reason>" --kind captain` and says nothing about the record kind, so a
# record filed exactly as instructed keeps whatever kind it already had - or none.
# The captain-actionable predicate used to demand `kind: captain` as well, which
# made every such hold invisible to this surface, to the decision board, and to
# every bearings read. Measured on the live home 2026-08-09: 35 records carried a
# captain hold and 32 reached the surface.
test_captain_hold_on_a_non_captain_kind_record_surfaces() {
local home fakebin json
home=$(make_home captain-hold-shape); write_fixture "$home"
cat >> "$home/data/backlog.md" <<'EOF'

## Queued
- [ ] deploy-prod - Deploy the stack (repo: firstmate) (kind: ship) (hold: production action needs his live word) (hold-kind: captain)
- [ ] allships-notice - Tell the fleet an update exists (repo: firstmate) (hold: captain chose to hold it until close-out) (hold-kind: captain)
- [ ] blocked-deploy - Clear the residue rows blocked-by: ship-task (repo: firstmate) (kind: ship) (hold: production data change, captain-gated) (hold-kind: captain)
EOF
fakebin=$(make_fakebin "$home")
json=$(run "$home" "$fakebin" --json)
printf '%s' "$json" | jq -e '
(.decisions_open | any(.[]; .id == "deploy-prod" and .verb == "captain-hold"))
and (.decisions_open | any(.[]; .id == "allships-notice" and .verb == "captain-hold"))
and (.gates | any(.[]; .id == "deploy-prod") | not)
and (.gates | any(.[]; .id == "allships-notice") | not)
' >/dev/null || fail "a captain hold filed on a record whose own kind is not captain must reach the captain: $json"
# The neighbouring loss is left exactly as it was: a captain hold behind an
# unresolved blocker still leaves decisions_open and lands in gates carrying no
# kind. That gap is separately filed and this change must not quietly move it.
printf '%s' "$json" | jq -e '
(.decisions_open | any(.[]; .id == "blocked-deploy") | not)
and (.gates | any(.[]; .id == "blocked-deploy" and .blocked_by == "ship-task"))
' >/dev/null || fail "a blocked captain hold must stay withheld exactly as before: $json"
pass "a captain hold reaches the captain whatever the record kind, and a blocked one still does not"
}

test_report_pointers_surface() {
local home fakebin json
home=$(make_home reports); write_fixture "$home"
Expand Down Expand Up @@ -2043,6 +2078,7 @@ test_dangling_blocker_surfaces_ready_with_integrity_warning
test_secondmate_dangling_blocker_surfaces_ready_with_integrity_warning
test_completed_scout_report_not_pending
test_open_decision_surfaces_end_to_end
test_captain_hold_on_a_non_captain_kind_record_surfaces
test_report_pointers_surface
test_superseded_queued_item_dropped_by_default
test_include_prs_is_the_only_fetch_path
Expand Down
3 changes: 2 additions & 1 deletion tests/fm-sea-chart.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -326,7 +326,8 @@ EOF
}

test_fog_and_out_of_course_can_never_be_a_captain_decision() {
# Structure, not prose: captain-actionability requires kind captain.
# Structure, not prose: captain-actionability requires hold-kind captain, and
# AGENTS.md section 10 files both kinds below with a `future` hold.
local home out
home=$(make_home kinds)
cat > "$home/data/backlog.md" <<'EOF'
Expand Down
Loading