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
5 changes: 5 additions & 0 deletions .agents/skills/bearings/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,11 @@ Compose the payload from the same snapshot with the same ranking judgment as the
- When the card's task is a captain-gated WORK item (the answer should free it to proceed rather than complete it), set the card's `close: "release"` so the answer lifts the hold instead of closing the task; question-shaped items omit it.
- A Charted Next row's optional `kind` separates work from alarms: omit it (or set `"queued"`) for real queued work, and set `"warning"` on every action-free fleet-integrity notice - the `(main-inventory)` gate, an unavailable secondmate home, and an inventory-mismatch repair notice. The board badges a warning row `needs repair` instead of `waiting` and leaves it out of the Charted Next count, so those rows never read as dispatchable queued work.
- `charted_more` counts omitted queued rows only, while `charted_warning_more` counts omitted warning rows only; keep both counts separate whenever the board payload truncates Charted Next.
- Every Underway row copies the task-identifying `in_flight.name` from the snapshot into an explicit `name` field, which the board leads with while keeping the run status on its second line.
The snapshot command's header owns its durable-title-or-id normalization; never replace the projected label with run status or invent another label.
- Every Charted Next row copies the snapshot gate's durable filed date into `filed`, and the board orders the section by it, newest filed first.
Follow `bin/fm-bearings-board.sh`'s payload contract for the accepted format.
Omit it or pass null for a row with no durable filed date - the main-inventory warning, an unavailable secondmate home, or a queued row filed before dates were recorded - and the board keeps those rows in payload order after every dated row.
- Every Captain's Call item and every Underway, Recently Landed, and Charted Next row carries an explicit `repo` field. Fill it from the snapshot and task records wherever known; use null or an empty string only as the deliberate genuinely-no-repo marker, in which case the template may show the internal id. Ids otherwise stay in the payload only as the routing channel, and composed reasons name blockers in plain words.

Run `build` once after composing the payload.
Expand Down
20 changes: 17 additions & 3 deletions .agents/skills/bearings/assets/board-template.html
Original file line number Diff line number Diff line change
Expand Up @@ -439,6 +439,17 @@
so every count of queued work excludes them. */
function isWarning(t) { return t && t.kind === "warning"; }
function chartedQueued(rows) { return (rows || []).filter(function (t) { return !isWarning(t); }); }
/* Charted Next reads newest filed first, so the most recently filed upcoming
work is at the top. ISO filed dates compare as text; a row with no
comparable date keeps its payload order after every dated row. */
function chartedOrder(rows) {
var dated = [], undated = [];
(rows || []).forEach(function (t) {
if (t && typeof t.filed === "string" && t.filed) dated.push(t); else undated.push(t);
});
dated.sort(function (a, b) { return a.filed < b.filed ? 1 : (a.filed > b.filed ? -1 : 0); });
return dated.concat(undated);
}
var chartedMoreQueued = data.charted_more || 0;
var chartedMoreWarnings = data.charted_warning_more || 0;
function utf8ByteLength(text) { return new TextEncoder().encode(text).length; }
Expand Down Expand Up @@ -617,10 +628,13 @@
var row = el("div", "bb-row");
row.appendChild(badge(t.state === "working" ? "online" : "info", t.state));
var main = el("div", "bb-row__main");
main.appendChild(el("div", "bb-row__title", t.doing));
/* the snapshot's durable name-or-id label leads the row so a scan says
WHICH task this is; the run status keeps its place on the second line */
main.appendChild(el("div", "bb-row__title", t.name));
/* captain-facing rows name the repo; the internal task id shows only when
no repo is known */
main.appendChild(el("div", "bb-row__sub", t.kind + " · " + (t.repo || t.id)));
main.appendChild(el("div", "bb-row__sub",
t.doing + " · " + t.kind + " · " + (t.repo || t.id)));
row.appendChild(main);
uw.appendChild(row);
});
Expand Down Expand Up @@ -664,7 +678,7 @@
if (!chartedQueued(data.charted).length && !chartedMoreQueued) {
ch.appendChild(el("div", "bb-empty", "Nothing is queued."));
}
data.charted.forEach(function (t) {
chartedOrder(data.charted).forEach(function (t) {
var row = el("div", "bb-row");
if (t.dispatchable && !isWarning(t)) {
anyPickable = true;
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -414,8 +414,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 56 ] || {
echo "::error::expected 56 Bearings tests, got $bearings_count"
[ "$bearings_count" -eq 59 ] || {
echo "::error::expected 59 Bearings tests, got $bearings_count"
exit 1
}

Expand Down
21 changes: 20 additions & 1 deletion bin/fm-bearings-board.sh
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,13 @@
# the template may display the routing id. Anything else refuses before the
# existing board is touched.
#
# Every Underway row likewise carries a non-empty `name`: the durable task name
# when known, otherwise its durable identifier.
# A Charted Next row MAY carry `filed`, the durable filed date (YYYY-MM-DD, or
# that date with a UTC timestamp) the template orders the section by, newest
# first; a row with no comparable date keeps its payload order after every dated
# row. Anything else in that field refuses rather than sorting on garbage.
#
# The board path is stable - $FM_HOME/.lavish/bearings-board.html - so a
# re-invocation rebuilds the same file in place, which keeps the same Lavish
# session URL and the same canonical process-event source id. Injection escapes
Expand Down Expand Up @@ -109,6 +116,17 @@ validate_payload() { # <data.json>
def nonempty_string: type == "string" and length > 0;
def slug($max): type == "string" and test("^[A-Za-z0-9._-]{1," + ($max | tostring) + "}$");
def repo_marker: has("repo") and (.repo == null or (.repo | type == "string"));
def name_marker: has("name") and (.name | nonempty_string);
def valid_filed:
. as $filed
| type == "string"
and test("^[0-9]{4}-[0-9]{2}-[0-9]{2}(T[0-9]{2}:[0-9]{2}:[0-9]{2}Z)?$")
and (if test("T")
then try ((fromdateiso8601 | strftime("%Y-%m-%dT%H:%M:%SZ")) == $filed) catch false
else try (((. + "T00:00:00Z") | fromdateiso8601 | strftime("%Y-%m-%d")) == $filed) catch false
end);
def optional_filed:
(has("filed") | not) or (.filed == null) or (.filed | valid_filed);
def optional_string($name): (has($name) | not) or (.[$name] | type == "string");
def optional_https_url($name):
(has($name) | not)
Expand Down Expand Up @@ -152,7 +170,7 @@ validate_payload() { # <data.json>
and ([.options[].value] | index("reconcile") == null)
and (if .type == "merge" then (.risk | nonempty_string) else true end);
def underway_item:
type == "object" and repo_marker and (.id | nonempty_string)
type == "object" and repo_marker and name_marker and (.id | nonempty_string)
and (.state | nonempty_string) and (.doing | nonempty_string) and (.kind | nonempty_string);
def landed_item:
type == "object" and repo_marker and (.id | nonempty_string)
Expand All @@ -164,6 +182,7 @@ validate_payload() { # <data.json>
and (.title | nonempty_string) and (.reason | type == "string")
and (.dispatchable | type == "boolean")
and ((has("kind") | not) or (.kind == "queued" or .kind == "warning"))
and optional_filed
and (if .kind == "warning" then .dispatchable == false else true end);
type == "object"
and (.schema == $schema)
Expand Down
41 changes: 34 additions & 7 deletions bin/fm-bearings-snapshot.sh
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,10 @@
# decisions from report or visual-review prose or reimplements snapshot semantics.
# Underway (in_flight) projects every main live worker plus every active child
# from every readable secondmate ledger, independently of that home's
# bearings_state. A home classified captain_decision because it has an open
# bearings_state. Each row's name is the durable task title when nonblank and
# its durable task id otherwise, so renderers always receive a task-identifying
# label instead of having to substitute run status. A home classified
# captain_decision because it has an open
# captain hold still contributes each working child as its own Underway row;
# the home row on secondmates[] keeps the decision and gate classification.
# Captain-hold placement follows the canonical snapshot's hold_bucket and
Expand All @@ -41,6 +44,10 @@
# Aging is a projection safety net only; the durable
# deferral remains re-holding with --until.
#
# Charted Next gates are ordered by durable filed date, newest first, before the
# FM_BEARINGS_GATES bound is applied. Gates without a comparable filed date keep
# their input order after dated gates.
#
# Main-home inventory validity comes from the canonical snapshot's main_inventory
# object (orphan structured in-flight without meta, unstructured current rows).
# Bearings never invents Underway rows from backlog-only ids; it discloses those
Expand Down Expand Up @@ -129,12 +136,14 @@ Default collection performs bounded concurrent remote-ledger reads for registere
remote homes under one shared snapshot budget and may refresh the parent-side cache.
--include-prs additionally performs live GitHub discovery and checks.

Default fields: schema, home, generated, prs, in_flight{id,kind,state,repo,doing},
Default fields: schema, home, generated, prs, in_flight{id,kind,state,repo,name,doing},
secondmates{id,state,doing,provenance,freshness,age_seconds,contradiction,reason},
secondmate_reconcile{id,spawn_gen,host,kind,ids},
decisions_open{id,key,verb,summary,owner}, landed{id,what,artifact,owner},
gates{id,title,blocked_by,reason,owner}, reports{id,path}, recorded_prs{id,url},
gates{id,title,blocked_by,reason,owner,filed}, reports{id,path}, recorded_prs{id,url},
unhealthy_endpoints{...} (only when non-empty), omitted{surface,reveal}.
Default gates are selected newest filed first before their bound; undated gates
retain input order after dated gates.
landed merges this home's Done with registered secondmate homes' Done, bounded by
a per-home cap (FM_BEARINGS_LANDED_PER_HOME) and an overall cap (FM_BEARINGS_LANDED),
with omitted[] disclosure. Default selection is balanced across deterministic home
Expand Down Expand Up @@ -381,7 +390,8 @@ MODEL=$(printf '%s' "$SNAP" | jq \
def as_gate($owner):
{id, title:(.title | trunc(60)),
blocked_by:((.unresolved_blocker_ids // []) | if length > 0 then join(",") else "-" end | trunc(120)),
reason:(hold_gate_reason | trunc(40)), owner:$owner};
reason:(hold_gate_reason | trunc(40)), owner:$owner,
filed:((.since // null) | trunc(40))};
def round_robin_landed($n):
. as $groups
| [range(0; (($groups | map(length) | max) // 0)) as $i
Expand Down Expand Up @@ -457,6 +467,8 @@ MODEL=$(printf '%s' "$SNAP" | jq \
| {id, kind,
state: .current_state.state,
repo:(.backlog.repo // .project // null),
name:((.backlog.title // "") as $name
| (if ($name | test("[^[:space:]]")) then $name else .id end) | trunc(70)),
doing: ((.current_state.detail // "") as $d
| (if $d != "" then $d else (.hints.last_event_text // "") end) | trunc(90))
} ]
Expand All @@ -466,6 +478,9 @@ MODEL=$(printf '%s' "$SNAP" | jq \
kind:(.kind // "secondmate"),
state:(.state // "working"),
repo:(.repo // null),
name:((.name // "") as $name
| (if (($name | type) == "string" and ($name | test("[^[:space:]]")))
then $name else ($m.id + "/" + .id) end) | trunc(70)),
doing:((.doing // .state) | trunc(90))} ]) as $in_flight_all
| ([ .backlog.records[]
| . as $record
Expand Down Expand Up @@ -501,7 +516,8 @@ MODEL=$(printf '%s' "$SNAP" | jq \
title:((.main_inventory.reason // "main inventory invalid") | trunc(60)),
blocked_by:"-",
reason:"main inventory",
owner:"(main)"}]
owner:"(main)",
filed:null}]
else [] end)
+ [ .backlog.records[]
| . as $record
Expand All @@ -522,7 +538,17 @@ MODEL=$(printf '%s' "$SNAP" | jq \
| select(($all_reports == 1) or (($rel_ids | index($r.id)) != null))
| {id, path} ]) as $reports_all
| ([ .tasks[] | select(.kind != "secondmate" and .pr.url != null and .pr.source == "meta") | {id, url:.pr.url} ]) as $recorded_prs_all
| . as $snap
| def filed_epoch:
(.filed // null) as $filed
| if ($filed | type) != "string" then null
elif ($filed | test("T")) then try ($filed | fromdateiso8601) catch null
else try (($filed + "T00:00:00Z") | fromdateiso8601) catch null end;
def newest_filed_first:
to_entries
| sort_by((.value | filed_epoch) as $epoch
| if $epoch == null then [1, 0, .key] else [0, -$epoch, .key] end)
| map(.value);
. as $snap
| {
schema: "fm-bearings.v1",
home: $home,
Expand All @@ -536,7 +562,8 @@ MODEL=$(printf '%s' "$SNAP" | jq \
decisions_open: (if $all_decisions == 1 then $decisions_all else $decisions_all[:$decisions_n] end),
landed: ($done | map({id, what:(.title | trunc(70)),
artifact:(landed_artifact // "-"),owner:.home_id})),
gates: (if $all_queued == 1 then $gates_all else $gates_all[:$gates_n] end),
gates: ($gates_all | newest_filed_first
| if $all_queued == 1 then . else .[:$gates_n] end),
reports: (if $all_reports == 1 then $reports_all else $reports_all[:$reports_n] end),
recorded_prs: (if $all_recorded_prs == 1 then $recorded_prs_all else $recorded_prs_all[:$recorded_prs_n] end)
}
Expand Down
21 changes: 20 additions & 1 deletion bin/fm-fleet-snapshot.sh
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,10 @@
# reconcile_inventory independently of projection trust.
# Actionable captain holds appear in decisions_open; every captain hold remains
# in the bounded queued inventory with its structured classification metadata.
# Before that queued bound is applied, non-captain-actionable rows are selected
# ahead of captain-actionable rows so separately projected live decisions cannot
# crowd Charted-Next-eligible work out of the summary. Each group is ordered by
# filed date newest first, with undated rows stable at the end.
# Structured-home input must declare the current home-summary and hold-classifier
# schemas; a live ledger or cached copy missing either declaration or declaring
# an unsupported version is unavailable even when it contains no captain holds.
Expand Down Expand Up @@ -953,6 +957,16 @@ secondmate_home_summary_json() { # <backlog-json-file> <tasks-json-file>
| def trunc($n):
tostring | gsub("\\s+"; " ")
| if length > $n then .[:$n] + "…" else . end;
def filed_epoch:
(.since // null) as $filed
| if ($filed | type) != "string" then null
elif ($filed | test("T")) then try ($filed | fromdateiso8601) catch null
else try (($filed + "T00:00:00Z") | fromdateiso8601) catch null end;
def newest_filed_first:
to_entries
| sort_by((.value | filed_epoch) as $epoch
| if $epoch == null then [1, 0, .key] else [0, -$epoch, .key] end)
| map(.value);
([ $backlog.records[]?
| select((.state == "in_flight" or .state == "queued") and (.structured | not)) ]) as $unstructured_current
| ([ $backlog.records[]? | select(.state == "in_flight" and .structured) ]) as $owned_in_flight
Expand Down Expand Up @@ -1016,6 +1030,7 @@ secondmate_home_summary_json() { # <backlog-json-file> <tasks-json-file>
| select(.id == $work.id and .current_state.state == "working")
| {id,kind,state:.current_state.state,
repo:(($work.repo // .project // null) | if . == null then null else trunc(120) end),
name:(($work.title // null) | if . == null then null else trunc(70) end),
source:.current_state.source,
doing:((.current_state.detail // "") | trunc(120))} ]) as $active_all
| ($captain_holds_all
Expand Down Expand Up @@ -1082,7 +1097,11 @@ secondmate_home_summary_json() { # <backlog-json-file> <tasks-json-file>
hold_age_days:(.hold_age_days // null),
captain_actionable:(.captain_actionable // false),
repo:((.repo // null) | if . == null then null else trunc(120) end),
kind:((.kind // null) | if . == null then null else trunc(40) end)}][:$queued_n]),
kind:((.kind // null) | if . == null then null else trunc(40) end),
since:((.since // null) | if . == null then null else trunc(40) end)}]
| ((map(select(.captain_actionable != true)) | newest_filed_first)
+ (map(select(.captain_actionable == true)) | newest_filed_first))
| .[:$queued_n]),
landed:(if $landed_n == 0 then $landed_all else $landed_all[:$landed_n] end),
endpoints:([$tasks[] | {id,state:.current_state.state,source:.current_state.source,
endpoint:(.endpoint + {target:((.endpoint.target // null) | if . == null then null else trunc(240) end)})}][:$child_n]),
Expand Down
35 changes: 22 additions & 13 deletions tests/assets/board-render-harness.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
// asserted through the real template rather than by reading its source.
//
// Usage: node board-render-harness.mjs <built-board.html>
// Prints one JSON document: { stats:[{n,label}], charted:[{title,sub,badges,pickable}] }
// Prints one JSON document:
// { stats:[{n,label}], underway:[{title,sub,badges}],
// charted:[{title,sub,badges,pickable}], empty, more, error }
import { readFileSync } from "node:fs";

const html = readFileSync(process.argv[2], "utf8");
Expand Down Expand Up @@ -93,18 +95,24 @@ const stats = strip.children.map((t) => ({
label: t.children.find((c) => c.className.includes("bb-stat__label"))?.textContent,
}));

const rowsOf = (container) =>
container.children
.filter((r) => r.className.split(/\s+/).includes("bb-row"))
.map((row) => {
const main = row.children.find((c) => c.className.includes("bb-row__main"));
return {
title: main?.children.find((c) => c.className.includes("bb-row__title"))?.textContent ?? "",
sub: main?.children.find((c) => c.className.includes("bb-row__sub"))?.textContent ?? "",
badges: badgesOf(row),
pickable: row.children.some((c) => c.className.includes("bb-pick") && !c.className.includes("spacer")),
};
});

const uw = byId.get("bb-underway") || new Node("div");
const underway = rowsOf(uw);

const ch = byId.get("bb-charted") || new Node("div");
const charted = ch.children
.filter((r) => r.className.split(/\s+/).includes("bb-row"))
.map((row) => {
const main = row.children.find((c) => c.className.includes("bb-row__main"));
return {
title: main?.children.find((c) => c.className.includes("bb-row__title"))?.textContent ?? "",
sub: main?.children.find((c) => c.className.includes("bb-row__sub"))?.textContent ?? "",
badges: badgesOf(row),
pickable: row.children.some((c) => c.className.includes("bb-pick") && !c.className.includes("spacer")),
};
});
const charted = rowsOf(ch);
// A fail-closed render replaces the page body instead of the board sections, so
// surface it rather than reporting an empty board as a successful render.
const errorText = [...byId.entries()]
Expand All @@ -114,4 +122,5 @@ const errorText = [...byId.entries()]
const empty = ch.children.filter((c) => c.className.includes("bb-empty")).map((c) => c.textContent);
const more = ch.children.filter((c) => c.className.includes("bb-morechip")).map((c) => c.textContent);

process.stdout.write(JSON.stringify({ stats, charted, empty, more, error: errorText }) + "\n");
process.stdout.write(
JSON.stringify({ stats, underway, charted, empty, more, error: errorText }) + "\n");
Loading
Loading