From b96dba1babba971cda538751164990a2d8efa623 Mon Sep 17 00:00:00 2001 From: Kun Chen <3233006+kunchenguid@users.noreply.github.com> Date: Thu, 20 Aug 2026 23:28:39 -0700 Subject: [PATCH 01/11] fix(bearings): restore decision options and add close controls (#2707) * fix(bearings): always show decision options and a close/drop control Freeform-only Captain's Call cards hid the option buttons the board was designed around, and there was no way to drop a stale hold without inventing an answer. Require selectable options, keep freeform as a supplement, and route the reserved __drop__ answer through decline so the hold leaves Captain's Call. * no-mistakes(review): Fix drop closure and decision-only option validation * no-mistakes(review): Preserve answerability for non-decision cards * no-mistakes(document): Clarify decision drop documentation --- .agents/skills/bearings/SKILL.md | 8 +- .../bearings/assets/board-template.html | 46 +++-- .../skills/decision-hold-lifecycle/SKILL.md | 1 + bin/fm-bearings-board.sh | 15 +- bin/fm-decision-hold.sh | 98 +++++++---- docs/decision-hold-lifecycle.md | 26 ++- tests/fm-bearings-board.test.sh | 158 ++++++++++++++++++ 7 files changed, 303 insertions(+), 49 deletions(-) diff --git a/.agents/skills/bearings/SKILL.md b/.agents/skills/bearings/SKILL.md index 5f375dab2e..44199d0b55 100644 --- a/.agents/skills/bearings/SKILL.md +++ b/.agents/skills/bearings/SKILL.md @@ -77,6 +77,8 @@ Compose the payload from the same snapshot with the same ranking judgment as the - A Captain's Call decision key is the FULL hold identity from `decisions_open`; a merge card's key is `merge.`; the Charted Next dispatch picker's key is `dispatch.charted`. - Decision cards carry agent-authored copy: a short noun-phrase title, one-line `about` and `decide` context rows, and option labels with hints, with the recommended option marked. +- Every decision card must include at least one selectable option, and the board always renders freeform as a supplementary "something else" input, never the only control. +- Do not use `__drop__` as an option value: that reserved answer is the card's Close / drop control, recognized by the keyed-answer intake as a decline. - 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. @@ -87,7 +89,11 @@ Never run `lavish-axi poll` for the board yourself: the armed source's supervise ### Handling a board wake A board answer arrives as an ordinary `procevent lavish ` check wake. Identify it by comparing the wake source id with `bin/fm-procevent-lavish.sh source-id "$(bin/fm-bearings-board.sh path)"`, regardless of which answer kinds the result contains; then load `process-event-sources` and follow its contract for the result read, adapter classification, and the handled acknowledgement. -Decision answers need no routing from you: the runner feeds the board's any-origin binding into `bin/fm-decision-hold.sh`'s one keyed-answer intake, which closes each full-identity hold at answer time; reconcile any `skipped:` key yourself, using `resolve` when routed work exists. +Decision answers need no routing from you: the runner feeds the board's any-origin binding into `bin/fm-decision-hold.sh`'s one keyed-answer intake, which closes each full-identity hold at answer time. +A reserved `__drop__` answer is the captain closing or dropping that hold, not a substantive choice and not a merge. +The intake declines it through `bin/fm-decision-hold.sh` with a "dropped by captain" decision record, so the hold leaves Captain's Call on the next rebuild. +Existing work routed behind that hold remains independent queued work; dropping does not close those dependents. +Reconcile any other `skipped:` key yourself, using `resolve` when routed work exists. Route the non-decision keys yourself: - `merge.` is the captain's explicit merge order; follow the merge ruling below. diff --git a/.agents/skills/bearings/assets/board-template.html b/.agents/skills/bearings/assets/board-template.html index c768f4d346..f31463f074 100644 --- a/.agents/skills/bearings/assets/board-template.html +++ b/.agents/skills/bearings/assets/board-template.html @@ -91,6 +91,8 @@ .fm-btn--primary:hover { background: var(--rust-600); } .fm-btn--gold { background: var(--gold-500); color: var(--navy-700); border-color: var(--ink-900); box-shadow: var(--shadow-hard-sm); } .fm-btn--gold:hover { background: var(--gold-600); color: var(--white); } +.fm-btn--ghost { background: transparent; color: var(--text-muted); border-color: var(--border-default); box-shadow: none; } +.fm-btn--ghost:hover { background: var(--paper-100); color: var(--text-strong); border-color: var(--ink-300); } .fm-btn[disabled] { opacity: 0.5; cursor: not-allowed; } /* ---- fm-card ---- */ @@ -219,7 +221,9 @@ letter-spacing: 0.07em; color: var(--navy-700); background: var(--gold-300); border: 1px solid var(--gold-600); border-radius: var(--radius-xs); padding: 3px 7px 2px; } .bb-opt:has(input:checked) { border-color: var(--rust-500); background: var(--rust-050); box-shadow: inset 0 0 0 1px var(--rust-500); } -.bb-decision__foot { display: flex; align-items: center; gap: 10px; margin-top: auto; } +.bb-decision__foot { display: flex; align-items: center; gap: 10px; margin-top: auto; flex-wrap: wrap; } +.bb-drop { margin-left: auto; } +.is-queued .bb-drop { display: none; } .bb-queued { display: none; align-items: center; gap: 6px; font-size: var(--fs-2xs); font-weight: 800; text-transform: uppercase; letter-spacing: 0.07em; @@ -517,10 +521,14 @@ }); form.appendChild(opts); - if (item.allow_freeform) { + /* Decision cards always keep a supplementary "something else" box; merge + and credential cards keep the payload's allow_freeform flag. */ + if (item.type === "decision" || item.allow_freeform) { var ff = document.createElement("input"); ff.type = "text"; ff.name = "note"; ff.className = "bb-freeform"; - ff.placeholder = item.freeform_hint || "or answer in your own words…"; + ff.placeholder = item.freeform_hint || (item.type === "decision" + ? "or something else…" + : "or answer in your own words…"); form.appendChild(ff); } @@ -534,16 +542,18 @@ var answerLimit = el("span", "bb-limit"); answerLimit.setAttribute("role", "alert"); foot.appendChild(answerLimit); + var dropBtn = null; + if (item.type === "decision") { + dropBtn = el("button", "fm-btn fm-btn--sm fm-btn--ghost bb-drop", "Close / drop"); + dropBtn.type = "button"; + dropBtn.setAttribute("aria-label", "Close or drop this decision"); + foot.appendChild(dropBtn); + } form.appendChild(foot); - form.addEventListener("submit", function (ev) { - ev.preventDefault(); + function queueAnswer(answer) { + if (card.classList.contains("is-queued")) return; answerLimit.classList.remove("is-visible"); - var fd = new FormData(form); - var value = fd.get("answer"); - var note = (fd.get("note") || "").trim(); - /* picked option, optionally annotated; a bare note is itself the answer */ - var answer = value ? (note ? value + " - " + note : value) : note; if (!answer) return; if (utf8ByteLength(answer) > 512) { answerLimit.textContent = "Answer is too long to queue (512 bytes maximum)."; @@ -560,7 +570,23 @@ card.classList.add("is-queued"); /* deal the next card once this one is answered */ setTimeout(function () { showCard(active < cards.length - 1 ? active + 1 : active); }, 450); + } + + form.addEventListener("submit", function (ev) { + ev.preventDefault(); + var fd = new FormData(form); + var value = fd.get("answer"); + var note = (fd.get("note") || "").trim(); + /* picked option, optionally annotated; a bare note is itself the answer */ + var answer = value ? (note ? value + " - " + note : value) : note; + queueAnswer(answer); }); + if (dropBtn) { + dropBtn.addEventListener("click", function () { + /* reserved close/drop encoding; the keyed-answer intake declines it */ + queueAnswer("__drop__"); + }); + } pad.appendChild(form); card.appendChild(pad); diff --git a/.agents/skills/decision-hold-lifecycle/SKILL.md b/.agents/skills/decision-hold-lifecycle/SKILL.md index dcb1eeb8a8..04a78f6b2a 100644 --- a/.agents/skills/decision-hold-lifecycle/SKILL.md +++ b/.agents/skills/decision-hold-lifecycle/SKILL.md @@ -25,6 +25,7 @@ When the captain's answer authorizes follow-up work, the hold remains the author When the captain's answer routes no follow-up work at all, such as a declined proposal, `bin/fm-decision-hold.sh decline` records that answer and closes the hold; it never substitutes for routing work the captain did authorize. When the captain simply answers a hold that has no follow-up work routed behind it yet, `bin/fm-decision-hold.sh answer` records that answer and closes the hold, so answering is closing rather than a separate later act that can be forgotten. "A keyed answer closes its matching hold" is one capability with one owner, `bin/fm-decision-hold.sh answers`, and every channel that carries a captain answer feeds it the same `` and answer. +The exact answer `__drop__` is the reserved close/drop encoding owned by that script's header: the intake declines the hold with a dropped-by-captain record rather than recording a substantive answer, and closes only that hold while existing dependents remain independent queued work. A channel never maps a key to a hold, records a decision, or closes anything itself, so no channel is special and a new one needs no new closing logic. Chat already feeds it: `bin/fm-send.sh --resolve-key` answers a decision in whichever ledger still holds it open, including a decision already transferred to its durable hold. A captured-answer source feeds it too once bound with `bin/fm-decision-hold.sh bind `, or with `--any-origin` for a source that carries answers across origins, such as the bearings board; bind before arming the source, and key each structured question by the hold's own decision key, or by its full hold identity under an any-origin binding. diff --git a/bin/fm-bearings-board.sh b/bin/fm-bearings-board.sh index 008b714b80..fd336763ae 100755 --- a/bin/fm-bearings-board.sh +++ b/bin/fm-bearings-board.sh @@ -32,8 +32,13 @@ # Captain's Call item explicitly carries `repo`; the composer fills it from the # snapshot and task records wherever known, and uses null or an empty string # only as the deliberate genuinely-no-repo marker. In that exceptional case -# the template may display the routing id. Anything else refuses before the -# existing board is touched. +# the template may display the routing id. Decision cards must include at least +# one selectable option; every other Captain's Call item must either include an +# option or explicitly allow freeform input. Option values cannot +# be `__drop__`: that reserved answer is the board Close / drop encoding, +# recognized by fm-decision-hold.sh's keyed-answer intake as a decline rather +# than a substantive choice. Anything else refuses before the existing board +# is touched. # # 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 @@ -85,10 +90,14 @@ validate_payload() { # and repo_marker and (.title | nonempty_string) and (.options | type == "array") - and ((.options | length) > 0 or .allow_freeform == true) + and (if .type == "decision" + then (.options | length) > 0 + else ((.options | length) > 0 or .allow_freeform == true) + end) and ([.options[] | type == "object" and (.value | slug(128)) + and .value != "__drop__" and (.label | nonempty_string) and optional_string("hint")] | all) and (optional_string("about")) diff --git a/bin/fm-decision-hold.sh b/bin/fm-decision-hold.sh index 1e637de015..794336b232 100755 --- a/bin/fm-decision-hold.sh +++ b/bin/fm-decision-hold.sh @@ -59,20 +59,24 @@ # separate later call nobody is forced to make. It records the captain's answer # on an actively held hold, records `(none)` as the routed identities because no # follow-up work has been routed behind the hold yet, and closes it. It shares -# every guard `decline` has, including the refusal while any task is still -# blocked by the hold, so a decision whose follow-up work is already routed still -# goes through `resolve` and the routed-vs-unrouted distinction survives. It says -# only that the captain answered; `decline` still says the captain answered with -# no follow-up work at all. +# every guard the public `decline` path has, including the refusal while any task +# is still blocked by the hold, so a substantive decision whose follow-up work is +# already routed still goes through `resolve` and the routed-vs-unrouted +# distinction survives. It says only that the captain answered; an ordinary +# `decline` still says the captain answered with no follow-up work at all. The +# reserved keyed-answer drop described below is the sole internal exception. # # ONE KEYED-ANSWER INTAKE, FED BY EVERY CHANNEL. # "A keyed answer closes its matching hold" is a single capability, owned here # and nowhere else. `answers` is its channel-agnostic entry point: it reads # `\t\t