diff --git a/.agents/skills/bearings/SKILL.md b/.agents/skills/bearings/SKILL.md
index 3cf043e8e4b..519de7d1eab 100644
--- a/.agents/skills/bearings/SKILL.md
+++ b/.agents/skills/bearings/SKILL.md
@@ -119,6 +119,7 @@ Compose the payload from the same snapshot with the same ranking judgment as the
Include every snapshot `crew` row, including paused people, present people with unconfirmed progress, project leads, and sampled delegated children.
Group by captured project associations, resolving repository paths to known project names; do not infer associations from internal identifiers.
A lead responsible for several projects appears under each; a person whose project is unknown stays visible in the unassigned group.
+ Optionally give each `projects` entry an `id` and include the captain's remembered knowledge as a `knowledge` array, both using the board command's contract; the page then offers a keep / do now / remove choice for each routable row, answered through the board's normal answer path.
Include work on Firstmate itself or other known non-registry work separately, not as invented registry entries.
Give every project and person a human-readable name, a one- or two-sentence status, and explained open questions: the issue, the actual choice, and a recommendation or an explicit statement that none is recorded.
Translate structured questions into plain copy; distinguish a composing recommendation from a recorded recommendation and never invent approval options.
diff --git a/.agents/skills/bearings/assets/board-template.html b/.agents/skills/bearings/assets/board-template.html
index d3c61f074e5..35614672a9d 100644
--- a/.agents/skills/bearings/assets/board-template.html
+++ b/.agents/skills/bearings/assets/board-template.html
@@ -73,6 +73,7 @@
.fm-badge--info { background: var(--ocean-050); color: var(--ocean-600); }
.fm-badge--neutral { background: var(--paper-000); color: var(--text-muted); }
.fm-badge--solid { background: var(--rust-050); color: var(--rust-600); }
+.fm-badge--done { background: var(--sea-700); color: var(--navy-700); }
/* ---- fm-btn ---- */
.fm-btn {
@@ -267,6 +268,23 @@
.bb-dispatch__count { flex: 1 1 auto; font-size: var(--fs-xs); font-weight: 800; color: var(--text-muted);
text-transform: uppercase; letter-spacing: 0.07em; }
+/* ---- keep / do-now / remove choices ---- */
+/* One three-choice group per listed item. The three inputs are one radio
+ group, so exactly one choice can carry per item and an untouched group
+ sends nothing; the sent chip appears once that row's choice was queued. */
+.bb-triage { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 14px; min-width: 0; }
+.bb-triage__opt { display: inline-flex; align-items: center; gap: 6px; cursor: pointer;
+ font-size: var(--fs-xs); font-weight: 700; color: var(--text-body); line-height: 1.3; }
+.bb-triage__opt input { accent-color: var(--rust-500); width: 15px; height: 15px; flex: none; margin: 0; cursor: pointer; }
+.bb-triage__opt:focus-within { outline: 2px solid var(--gold-300); outline-offset: 2px; border-radius: var(--radius-xs); }
+.bb-triage.is-queued .bb-triage__opt { color: var(--text-muted); }
+.bb-triage__sent { display: none; align-items: center; gap: 4px; font-size: var(--fs-2xs); font-weight: 800;
+ text-transform: uppercase; letter-spacing: 0.07em; color: var(--sea-700); }
+.bb-triage__sent svg { width: 12px; height: 12px; }
+.bb-triage.is-queued .bb-triage__sent { display: inline-flex; }
+.bb-project__triage, .bb-person__triage { margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--border-soft); }
+.bb-knowledge .bb-row__title, .bb-knowledge .bb-row__sub { white-space: normal; overflow-wrap: anywhere; }
+
.bb-foot { display: flex; justify-content: flex-end; border-top: 1px solid var(--border-default); padding-top: 14px; }
.bb-main--status .bb-cols { grid-template-columns: 1fr; }
@@ -275,7 +293,8 @@
.bb-main--status .bb-call::before, .bb-main--status .bb-call::after { display: none; }
.bb-requests { width: 100%; border-collapse: collapse; table-layout: fixed; }
.bb-requests th, .bb-requests td { text-align: left; padding: 12px; vertical-align: top; overflow-wrap: anywhere; }
-.bb-requests th:first-child { width: 34%; }
+.bb-requests th:first-child { width: 30%; }
+.bb-requests th:last-child { width: 32%; }
.bb-requests td { border-top: 1px solid var(--border-soft); }
.bb-requests p { margin: 6px 0; }
.bb-requests a { color: #8cc4ff; }
@@ -336,7 +355,12 @@
.bb-row__title, .bb-row__sub { white-space: normal; overflow-wrap: anywhere; }
.bb-decision__title, .bb-ctx__v, .bb-opt__body, .bb-meta-mono { overflow-wrap: anywhere; }
.bb-meta-mono, .bb-decision__repo { white-space: normal; }
- .bb-requests th, .bb-requests td { padding: 9px; }
+ /* Three fixed columns leave each cell too narrow for a badge or a choice
+ label at phone width, so each request stacks its cells full width. */
+ .bb-requests thead { display: none; }
+ .bb-requests tbody, .bb-requests tr, .bb-requests th, .bb-requests td, .bb-requests th:first-child { display: block; width: auto; }
+ .bb-requests tr { border-top: 1px solid var(--border-soft); padding: 4px 0; }
+ .bb-requests th, .bb-requests td { padding: 5px 9px; border-top: 0; }
}
@@ -421,16 +445,52 @@
Projects and people
Delivery plans, current work and open questions, grouped by project.
+ Mark any project or person to leave it as planned, bring it forward now, or remove it completely, then send your choices.
+
+
+
no choice marked
+
+
+ queued
+
+
Send my choices
+
+
+
+
+
+ Knowledge
+ What is remembered between tasks. Mark any entry to keep it as it is, act on it now, or remove it completely, then send your choices.
+
+
+
+
no choice marked
+
+
+ queued
+
+
Send my choices
+
+
Your requests
+ Mark any request to leave it on the backlog, ask for it now, or remove it completely, then send your choices.
- Request Status and next step
+ Request Status and next step Keep, do now or remove
+
+
no choice marked
+
+
+ queued
+
+
Send my choices
+
@@ -496,6 +556,19 @@
Your requests
renderBoardError("The saved page information is incomplete");
return;
}
+ /* knowledge is optional, but a present array must be well formed: its
+ entries ride the keep / do-now / remove triage, so each needs a routing
+ id and readable text. */
+ var knowledgeItemsValid = !Array.isArray(data.knowledge) || data.knowledge.every(function (item) {
+ return item && typeof item === "object" &&
+ typeof item.id === "string" && item.id.length > 0 &&
+ typeof item.title === "string" && item.title.length > 0 &&
+ typeof item.detail === "string" && item.detail.length > 0;
+ });
+ if (!knowledgeItemsValid) {
+ renderBoardError("The saved page information is incomplete");
+ return;
+ }
try {
function el(tag, cls, text) {
@@ -525,6 +598,119 @@ Your requests
function utf8ByteLength(text) { return new TextEncoder().encode(text).length; }
var CHECK_SVG = ' ';
+ /* Keep / do now / remove: one three-choice group per listed item, answered
+ through the board's single Lavish answer path. The three inputs are one
+ radio group, so exactly one choice can carry per item and an untouched
+ group sends nothing. The answer key is namespaced - triage.request.,
+ triage.knowledge., triage.project., triage.crew. - mirroring
+ merge., so a triage mark can never resolve a captain-held task
+ through the keyed-answer intake by accident. */
+ var TRIAGE_CHOICES = [
+ { value: "backlog", label: "Leave on backlog" },
+ { value: "now", label: "Do now" },
+ { value: "remove", label: "Remove completely" }
+ ];
+ var triageSeq = 0;
+ var triagedKeys = {};
+ function triageKey(kind, id) {
+ var prefix = "triage." + kind + ".";
+ if (typeof id !== "string" || !/^[A-Za-z0-9._-]+$/.test(id)) return null;
+ return (prefix + id).length <= 128 ? prefix + id : null;
+ }
+ function triageGroup(key, title) {
+ if (triagedKeys[key]) return null; /* a row listed twice gets one group */
+ triagedKeys[key] = true;
+ var group = el("div", "bb-triage");
+ group.setAttribute("data-triage-key", key);
+ group.setAttribute("data-triage-title", title);
+ group.setAttribute("role", "group");
+ group.setAttribute("aria-label", "Keep on backlog, do now, or remove completely: " + title);
+ var name = "triage-" + (++triageSeq);
+ TRIAGE_CHOICES.forEach(function (c) {
+ var lab = el("label", "bb-triage__opt");
+ var input = document.createElement("input");
+ input.type = "radio";
+ input.className = "bb-triage__pick";
+ input.name = name;
+ input.value = c.value;
+ input.setAttribute("data-label", c.label);
+ lab.appendChild(input);
+ lab.appendChild(el("span", "", c.label));
+ group.appendChild(lab);
+ });
+ var sent = el("span", "bb-triage__sent");
+ sent.innerHTML = CHECK_SVG + " sent";
+ group.appendChild(sent);
+ return group;
+ }
+ function triageInto(parent, kind, id, title) {
+ var key = triageKey(kind, id);
+ if (!key) return false;
+ var group = triageGroup(key, title);
+ if (!group) return false;
+ parent.appendChild(group);
+ return true;
+ }
+ /* One send bar per section: it queues every marked, not-yet-sent group as
+ its own versioned choice annotation, exactly like an option card. A
+ section with nothing to route never touches its bar, so the bar (and the
+ requests choice column) simply stays hidden. */
+ function armTriageBar(barId, scopeId, hintId) {
+ var scope = document.getElementById(scopeId);
+ var groups = scope.querySelectorAll(".bb-triage");
+ if (!groups.length) return false;
+ var bar = document.getElementById(barId);
+ var hint = hintId ? document.getElementById(hintId) : null;
+ if (hint) hint.hidden = false;
+ var card = document.getElementById(barId + "-card");
+ if (card) card.hidden = false;
+ bar.hidden = false;
+ var count = document.getElementById(barId + "-count");
+ var btn = document.getElementById(barId + "-btn");
+ function pendingGroups() {
+ return Array.prototype.filter.call(groups, function (g) {
+ return !g.classList.contains("is-queued") && g.querySelectorAll(".bb-triage__pick:checked").length > 0;
+ });
+ }
+ function refresh() {
+ var n = pendingGroups().length;
+ count.textContent = n ? n + " choice" + (n === 1 ? "" : "s") + " marked to send" : "no choice marked";
+ btn.disabled = !n;
+ }
+ Array.prototype.forEach.call(groups, function (g) {
+ g.addEventListener("change", function () {
+ g.classList.remove("is-queued");
+ bar.classList.remove("is-queued");
+ refresh();
+ });
+ });
+ btn.addEventListener("click", function () {
+ var items = pendingGroups();
+ if (!items.length) return;
+ Array.prototype.forEach.call(items, function (g) {
+ var choice = g.querySelectorAll(".bb-triage__pick:checked")[0];
+ var label = choice.getAttribute("data-label");
+ var title = g.getAttribute("data-triage-title");
+ if (window.lavish && window.lavish.queuePrompt) {
+ window.lavish.queuePrompt(
+ "Keep, do or remove - " + title + ": " + label,
+ { tag: "choice", text: title + " -> " + label, element: g,
+ queueKey: g.getAttribute("data-triage-key"),
+ data: { schema: "fm-bearings-answer.v1",
+ question: g.getAttribute("data-triage-key"),
+ selection: choice.value,
+ note: "" } }
+ );
+ }
+ g.classList.add("is-queued");
+ });
+ bar.classList.add("is-queued");
+ refresh();
+ });
+ refresh();
+ return true;
+ }
+
document.getElementById("bb-provenance").textContent = "Updated " + data.generated;
var statusPage = Array.isArray(data.requests);
if (statusPage) {
@@ -537,7 +723,7 @@ Your requests
title.setAttribute("scope", "row");
row.appendChild(title);
var detail = el("td");
- detail.appendChild(badge("info", t.status));
+ detail.appendChild(badge(statusTone(t.status), t.status));
detail.appendChild(el("p", "", t.detail));
if (t.pr_url) {
var link = el("a", "", "Review the proposed changes");
@@ -545,17 +731,38 @@ Your requests
detail.appendChild(link);
}
row.appendChild(detail);
+ var choices = el("td");
+ if (triageInto(choices, "request", t.id, t.title)) row.appendChild(choices);
requests.appendChild(row);
});
if (!data.requests.length) {
var emptyRow = el("tr"), emptyCell = el("td", "bb-empty", "No requests are recorded.");
- emptyCell.setAttribute("colspan", "2"); emptyRow.appendChild(emptyCell); requests.appendChild(emptyRow);
+ emptyCell.setAttribute("colspan", "3"); emptyRow.appendChild(emptyCell); requests.appendChild(emptyRow);
}
}
(data.coverage || []).forEach(function (notice) {
document.getElementById("bb-coverage").appendChild(el("p", "bb-coverage", notice));
});
+ /* Knowledge entries: remembered facts offered for keep / do-now / remove
+ triage; the section appears only when the payload carries entries. */
+ if (Array.isArray(data.knowledge)) {
+ var knowledgeRows = document.getElementById("bb-knowledge");
+ document.getElementById("bb-knowledge-section").hidden = false;
+ data.knowledge.forEach(function (k) {
+ var row = el("div", "bb-row");
+ var main = el("div", "bb-row__main");
+ main.appendChild(el("div", "bb-row__title", k.title));
+ main.appendChild(el("div", "bb-row__sub", k.detail));
+ row.appendChild(main);
+ triageInto(row, "knowledge", k.id, k.title);
+ knowledgeRows.appendChild(row);
+ });
+ if (!data.knowledge.length) {
+ knowledgeRows.appendChild(el("div", "bb-empty", "No remembered entry is recorded."));
+ }
+ }
+
function renderQuestions(parent, questions) {
if (!questions.length) {
parent.appendChild(el("p", "bb-coverage", "No unanswered question is recorded in the checked information."));
@@ -575,16 +782,20 @@ Your requests
var card = el("article", "fm-card bb-project");
card.appendChild(el("h3", "bb-project__name", project.name));
card.appendChild(el("p", "bb-project__delivery", project.delivery));
- card.appendChild(badge("info", project.status));
+ card.appendChild(badge(statusTone(project.status), project.status));
card.appendChild(el("p", "bb-project__detail", project.detail));
renderQuestions(card, project.questions);
+ var projectChoices = el("div", "bb-project__triage");
+ if (triageInto(projectChoices, "project", project.id, project.name)) card.appendChild(projectChoices);
people.forEach(function (person) {
var row = el("div", "bb-person");
row.appendChild(el("h4", "bb-person__name", person.name));
row.appendChild(el("p", "bb-person__role", person.role));
- row.appendChild(badge("info", person.status));
+ row.appendChild(badge(statusTone(person.status), person.status));
row.appendChild(el("p", "bb-person__detail", person.detail));
renderQuestions(row, person.questions);
+ var personChoices = el("div", "bb-person__triage");
+ if (triageInto(personChoices, "crew", person.id, person.name)) row.appendChild(personChoices);
card.appendChild(row);
});
projects.appendChild(card);
@@ -608,6 +819,7 @@ Your requests
];
if (statusPage) stats = [stats[0], { n: data.requests.length, l: "requests" }];
if (Array.isArray(data.projects)) stats.push({ n: data.projects.length, l: "projects" }, { n: data.crew.length, l: "people" });
+ if (Array.isArray(data.knowledge)) stats.push({ n: data.knowledge.length, l: "knowledge" });
var strip = document.getElementById("bb-stats");
stats.forEach(function (s) {
var t = el("div", "bb-stat" + (s.call ? " bb-stat--call" : ""));
@@ -775,11 +987,19 @@ Your requests
document.getElementById("bb-landed-section").hidden = !data.landed.length;
document.getElementById("bb-landed-label").textContent = "Completed ยท " + data.landed.length;
- function stateTone(state) {
- if (/^(blocked|failed|needs-decision|needs_decision)$/.test(state)) return "danger";
- if (/^(paused|waiting|held|captain_hold|stalled)$/.test(state)) return "warn";
- if (/^(working|running|fixing)$/.test(state)) return "online";
- if (/^(review|reviewing|validating|testing)$/.test(state)) return "info";
+ /* One wording-to-tone map for every status the page shows, request
+ wording and worker state alike, with a neutral fallback so unknown
+ wording still renders. Color is a second signal: the badge always keeps
+ the full status words. */
+ function statusTone(text) {
+ var s = String(text || "").trim().toLowerCase();
+ if (/^(blocked|failed|needs-decision|needs_decision)/.test(s)) return "danger";
+ if (/^waiting on you/.test(s)) return "danger";
+ if (/^(paused|held|stalled|captain_hold)/.test(s)) return "warn";
+ if (/^waiting/.test(s)) return "warn";
+ if (/^(working|running|fixing)/.test(s)) return "online";
+ if (/^(in review|review|reviewing|validating|testing)/.test(s)) return "info";
+ if (/^completed/.test(s)) return "done";
return "neutral";
}
function workRow(tone, title, repo, status, detail) {
@@ -800,7 +1020,7 @@ Your requests
Keep the worker kind and routing identity out of the status line. */
var uw = document.getElementById("bb-underway");
data.underway.forEach(function (t) {
- uw.appendChild(workRow(stateTone(t.state), t.name, t.repo || "", t.state, t.doing));
+ uw.appendChild(workRow(statusTone(t.state), t.name, t.repo || "", t.state, t.doing));
});
/* Landed */
@@ -896,6 +1116,15 @@ Your requests
bar.classList.add("is-queued");
});
}
+
+ /* Keep / do-now / remove send bars: one per section that actually listed
+ triage-routable rows. A section with nothing to route keeps its bar and
+ its choice column hidden. */
+ if (!armTriageBar("bb-requests-triage", "bb-requests", "bb-requests-triage-hint")) {
+ document.getElementById("bb-requests-choice-head").hidden = true;
+ }
+ armTriageBar("bb-knowledge-triage", "bb-knowledge", "bb-knowledge-hint");
+ armTriageBar("bb-projects-triage", "bb-projects", "bb-projects-triage-hint");
} catch (e) {
renderBoardError("The saved page information could not be displayed");
}
diff --git a/bin/fm-bearings-board.sh b/bin/fm-bearings-board.sh
index 7b130e77e13..57564922787 100755
--- a/bin/fm-bearings-board.sh
+++ b/bin/fm-bearings-board.sh
@@ -74,20 +74,48 @@
#
# All-tasks status pages additionally carry requests[] with one row per task:
# {id,title,status,detail,repo,pr_url?}. id is a routing identity, title describes
-# the request, status is plain wording, and detail explains the outcome or wait.
+# the request, status is plain wording the template colors by explicit map
+# (unknown wording stays neutral and the words always stay visible), and
+# detail explains the outcome or wait.
# Ids must be unique; pr_url is HTTPS only. coverage[] is an optional array of
# non-empty plain-language notices about unavailable, cached or omitted state.
# When requests is present, every call requires non-empty about/decide and every
# authored option requires a non-empty hint, including merge/credential cards.
# Optional projects[] lists the entire registered portfolio, with additional
-# managed work allowed: {name,delivery,status,detail,questions:[]}.
-# Optional crew[] lists workers/leads: {id,name,role,status,detail,projects:[],questions:[]}.
+# managed work allowed: {name,id?,delivery,status,detail,questions:[]}; its
+# optional id is the triage routing id for that project. Optional crew[] lists
+# workers/leads: {id,name,role,status,detail,projects:[],questions:[]}.
# Project names and crew ids are unique. Each crew project must name a projects
# entry; an empty projects list is shown as unassigned, never silently dropped.
# Every question is {issue,choice,recommendation}, all non-empty plain strings.
# projects and crew must appear together and require a status-page requests array.
+# Optional knowledge[] lists remembered knowledge entries offered for triage:
+# {id,title,detail}, plain strings with unique ids, rendered as its own section
+# only when the array is present (digest-only payloads may carry it too).
# Older digest-only payloads may omit requests; no second board or listener is used.
#
+# KEEP / DO-NOW / REMOVE CHOICES. The template renders one three-choice group
+# with values backlog, now and remove beside every requests row, knowledge
+# entry, project and person whose routing id can route: requests rows and
+# knowledge entries route on their id, crew rows on their id, projects on the
+# optional project id. The three inputs are one radio group, so a row carries
+# exactly one choice or none, and an untouched row sends nothing. A triage id
+# must match [A-Za-z0-9._-] and keep its full triage key within 128 characters,
+# the keyed-answer key limit bin/fm-procevent-lavish.sh accepts; a row whose id
+# cannot route simply renders without choices. Each section's send bar queues
+# every marked row as its own fm-bearings-answer.v1 choice annotation through
+# the board's single Lavish answer path - the same window.lavish.queuePrompt
+# the option cards use; no second listener exists - with question
+# triage.. and the selection
+# backlog, now or remove. Firstmate reads such an answer from the board's
+# captured result like any other choice: bin/fm-procevent-lavish.sh read prints
+# each annotation (" -> "), while the keyed-answer intake
+# (bin/fm-captain-hold.sh answers) skips the triage.* key because it names a
+# triage row, not a captain-held task, so firstmate applies the choice itself
+# from the result read. backlog leaves the row as planned (a request stays on
+# the backlog untouched), now brings the work forward immediately, and remove
+# permanently deletes the backlog row or remembered knowledge entry.
+#
# 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
@@ -193,9 +221,14 @@ validate_payload() { #
type == "object" and (.issue | nonempty_string)
and (.choice | nonempty_string) and (.recommendation | nonempty_string);
def questions: (.questions | type == "array") and ([.questions[] | question_item] | all);
+ def triage_id($prefix): . | slug(128 - ($prefix | length));
+ def knowledge_item:
+ type == "object" and (.id | triage_id("triage.knowledge."))
+ and (.title | nonempty_string) and (.detail | nonempty_string);
def project_item:
type == "object" and (.name | nonempty_string) and (.delivery | nonempty_string)
- and (.status | nonempty_string) and (.detail | nonempty_string) and questions;
+ and (.status | nonempty_string) and (.detail | nonempty_string) and questions
+ and ((has("id") | not) or (.id | triage_id("triage.project.")));
def crew_item:
type == "object" and (.id | nonempty_string) and (.name | nonempty_string)
and (.role | nonempty_string) and (.status | nonempty_string)
@@ -246,6 +279,10 @@ validate_payload() { #
and (([.crew[].id] | unique | length) == (.crew | length))
and (([.crew[].projects[]] - [.projects[].name]) | length == 0)
else true end)
+ and ((has("knowledge") | not)
+ or ((.knowledge | type == "array")
+ and ([.knowledge[] | knowledge_item] | all)
+ and (([.knowledge[].id] | unique | length) == (.knowledge | length))))
and ([.captains_call[] | call_item] | all)
and ([.underway[] | underway_item] | all)
and ([.landed[] | landed_item] | all)
diff --git a/tests/assets/board-render-harness.mjs b/tests/assets/board-render-harness.mjs
index 6339b385efc..c385646d00b 100644
--- a/tests/assets/board-render-harness.mjs
+++ b/tests/assets/board-render-harness.mjs
@@ -2,10 +2,13 @@
// shim and print what the renderer actually produced, so board behavior is
// asserted through the real template rather than by reading its source.
//
-// Usage: node board-render-harness.mjs [answer]
+// Usage: node board-render-harness.mjs [answer|triage [choice [key-substring [mark]]]]
// Prints rendered stats, task rows (including disclosures and status tones),
-// section visibility, queued answer context, empty/more labels and errors.
-// The optional answer mode submits the first card with its first radio and note.
+// section visibility, keep/do-now/remove triage groups, triage send bars,
+// queued answer context, empty/more labels and errors.
+// The answer mode submits the first card with its first radio and note. The
+// triage mode marks the given choice (default remove) on every matching group
+// and clicks every section's send bar, exercising the choice-answer path.
import { readFileSync } from "node:fs";
const html = readFileSync(process.argv[2], "utf8");
@@ -38,6 +41,7 @@ class Node {
set textContent(v) { this._text = String(v); this.children = []; }
appendChild(n) { n.parentNode = this; this.children.push(n); return n; }
setAttribute(k, v) { this.attributes[k] = v; }
+ getAttribute(k) { return Object.prototype.hasOwnProperty.call(this.attributes, k) ? this.attributes[k] : null; }
addEventListener(event, handler) { this.listeners[event] = handler; }
querySelectorAll(sel) {
const want = sel.replace(/^\./, "").replace(/:checked$/, "");
@@ -99,6 +103,58 @@ const badgesOf = (row) =>
.filter((c) => c.className.includes("fm-badge"))
.map((c) => ({ tone: c.className.replace(/.*fm-badge--/, "").trim(), text: c.textContent }));
+const walkAll = (n) => n.children.flatMap((c) => [c, ...walkAll(c)]);
+const hasClass = (n, cls) => n.className.split(/\s+/).includes(cls);
+// Keep / do-now / remove: one three-radio group per listed row.
+const triageNodesOf = (node) => walkAll(node).filter((c) => hasClass(c, "bb-triage"));
+const triageOf = (node) => {
+ const group = triageNodesOf(node)[0];
+ if (!group) return null;
+ return {
+ key: group.attributes["data-triage-key"] ?? "",
+ title: group.attributes["data-triage-title"] ?? "",
+ choices: walkAll(group).filter((c) => hasClass(c, "bb-triage__pick")).map((i) => ({
+ value: i.value,
+ label: i.attributes["data-label"] ?? "",
+ name: i.name,
+ })),
+ sent: group.classList.contains("is-queued"),
+ };
+};
+
+// Answer modes run before extraction so printed state reflects the submitted
+// answer, not the pre-click page.
+if (process.argv[3] === "answer") {
+ const walk = (n) => n.children.flatMap((c) => [c, ...walk(c)]);
+ const nodes = walk(byId.get("bb-call"));
+ const form = nodes.find((n) => n.tagName === "form");
+ const radio = nodes.find((n) => n.type === "radio");
+ const note = nodes.find((n) => n.name === "note");
+ radio.checked = true;
+ if (note) note.value = "a note";
+ form.listeners.submit({ preventDefault() {} });
+}
+if (process.argv[3] === "triage") {
+ // Mark the given choice (default remove) on every group whose key contains
+ // the optional substring, fire the change listeners, then click every
+ // section's send bar. "mark" as a fourth argument stops before the click, so
+ // a test can assert the pending-count state the bar shows.
+ const want = process.argv[4] ?? "remove";
+ const only = process.argv[5] ?? "";
+ for (const g of ["bb-requests", "bb-knowledge", "bb-projects"].flatMap((id) => triageNodesOf(byId.get(id) ?? new Node("div")))) {
+ if (only && !(g.attributes["data-triage-key"] ?? "").includes(only)) continue;
+ const pick = walkAll(g).find((c) => hasClass(c, "bb-triage__pick") && c.value === want);
+ if (!pick) continue;
+ pick.checked = true;
+ g.listeners.change?.();
+ }
+ if (process.argv[6] !== "mark") {
+ for (const id of ["bb-requests-triage", "bb-knowledge-triage", "bb-projects-triage"]) {
+ byId.get(id + "-btn")?.listeners.click?.();
+ }
+ }
+}
+
const strip = byId.get("bb-stats") || new Node("div");
const stats = strip.children.map((t) => ({
n: Number(t.children.find((c) => c.className.includes("bb-stat__num"))?.textContent),
@@ -141,8 +197,10 @@ const more = ch.children.filter((c) => c.className.includes("bb-morechip")).map(
const requests = (byId.get("bb-requests")?.children || []).map((row) => ({
title: row.children[0]?.textContent,
status: row.children[1]?.children[0]?.textContent,
+ tone: badgesOf(row.children[1] ?? new Node("div"))[0]?.tone ?? "",
detail: row.children[1]?.children[1]?.textContent,
href: row.children[1]?.children.find((c) => c.tagName === "a")?.href ?? "",
+ triage: triageOf(row),
}));
const cards = (byId.get("bb-call")?.children || [])
.filter((c) => c.className.split(/\s+/).includes("bb-decision"))
@@ -156,29 +214,48 @@ const coverage = (byId.get("bb-coverage")?.children || []).map((n) => n.textCont
const projects = (byId.get("bb-projects")?.children || []).map((card) => ({
name: card.querySelectorAll(".bb-project__name")[0]?.textContent,
delivery: card.querySelectorAll(".bb-project__delivery")[0]?.textContent,
+ tone: badgesOf(card)[0]?.tone ?? "",
text: card.textContent,
+ triageKeys: triageNodesOf(card).map((g) => g.attributes["data-triage-key"] ?? ""),
people: card.querySelectorAll(".bb-person").map((person) => ({
name: person.querySelectorAll(".bb-person__name")[0]?.textContent,
role: person.querySelectorAll(".bb-person__role")[0]?.textContent,
+ tone: badgesOf(person)[0]?.tone ?? "",
questions: person.querySelectorAll(".bb-question").map((q) => q.textContent),
+ triage: triageOf(person),
text: person.textContent,
})),
}));
+const knowledge = (byId.get("bb-knowledge")?.children || []).map((row) => ({
+ title: walkAll(row).find((c) => hasClass(c, "bb-row__title"))?.textContent ?? "",
+ detail: walkAll(row).find((c) => hasClass(c, "bb-row__sub"))?.textContent ?? "",
+ triage: triageOf(row),
+}));
+const knowledgeHidden = byId.get("bb-knowledge-section")?.hidden ?? true;
+const triageGroups = ["bb-requests", "bb-knowledge", "bb-projects"]
+ .flatMap((id) => triageNodesOf(byId.get(id) ?? new Node("div")))
+ .map((g) => ({
+ key: g.attributes["data-triage-key"] ?? "",
+ title: g.attributes["data-triage-title"] ?? "",
+ choices: walkAll(g).filter((c) => hasClass(c, "bb-triage__pick")).map((i) => ({ value: i.value, name: i.name })),
+ sent: g.classList.contains("is-queued"),
+ }));
+const triageBars = ["bb-requests-triage", "bb-knowledge-triage", "bb-projects-triage"].map((id) => {
+ const bar = byId.get(id);
+ if (!bar) return { id, missing: true };
+ return {
+ id,
+ hidden: bar.hidden,
+ count: byId.get(id + "-count")?.textContent ?? "",
+ disabled: byId.get(id + "-btn")?.disabled ?? null,
+ queued: bar.classList.contains("is-queued"),
+ };
+});
const sections = Object.fromEntries(["current", "call", "charted", "landed"].map((name) =>
[name, { hidden: byId.get("bb-" + name + "-section")?.hidden ?? false }]));
const idle = !(byId.get("bb-idle")?.hidden ?? true);
const projectsHidden = byId.get("bb-projects-section")?.hidden ?? true;
// Section order is static markup in the built page, so read it from that output.
const order = [...html.matchAll(/id="bb-(\w+)-section"/g)].map((m) => m[1]);
-if (process.argv[3] === "answer") {
- const walk = (n) => n.children.flatMap((c) => [c, ...walk(c)]);
- const nodes = walk(byId.get("bb-call"));
- const form = nodes.find((n) => n.tagName === "form");
- const radio = nodes.find((n) => n.type === "radio");
- const note = nodes.find((n) => n.name === "note");
- radio.checked = true;
- if (note) note.value = "a note";
- form.listeners.submit({ preventDefault() {} });
-}
process.stdout.write(
- JSON.stringify({ stats, underway, charted, sections, order, idle, projectsHidden, queued, empty, more, requests, cards, projects, coverage, error: errorText }) + "\n");
+ JSON.stringify({ stats, underway, charted, sections, order, idle, projectsHidden, knowledgeHidden, queued, empty, more, requests, cards, projects, knowledge, triageGroups, triageBars, coverage, error: errorText }) + "\n");
diff --git a/tests/fm-bearings-board-render.test.sh b/tests/fm-bearings-board-render.test.sh
index 3950ca8cfbc..c64cf6a7851 100755
--- a/tests/fm-bearings-board-render.test.sh
+++ b/tests/fm-bearings-board-render.test.sh
@@ -293,7 +293,7 @@ test_projects_drop_idle_work_and_keep_shared_leads_and_unassigned_people() {
and .projects[1].people[0].role == "Project lead"
and .projects[2].people[0].name == "Assignment to confirm"
and (.projects[0].people[0].questions[0] | contains("What it isReport data is old.") and contains("Your choiceApprove") and contains("RecommendationUse the test-only refresh"))
- and (.projects | tostring | contains("routing-only") | not)
+ and ([.projects[].text, .projects[].people[].text] | tostring | contains("routing-only") | not)
and (.stats | any(.label == "projects" and .n == 3))
and (.stats | any(.label == "people" and .n == 3))
' >/dev/null || fail "project grouping showed an idle project or dropped people or questions: $out"
@@ -353,6 +353,176 @@ test_needs_you_comes_before_open_work_and_the_queue() {
pass "Needs you renders above open work and the waiting queue"
}
+test_request_statuses_read_differently_by_color_while_keeping_their_words() {
+ local home out extra
+ home=$(make_home status-colors)
+ extra=$(jq -n '{requests:[
+ {id:"r-working",repo:"sample",title:"Working one",status:"Working",detail:"Under way today."},
+ {id:"r-you",repo:"sample",title:"Waiting on you",status:"Waiting on you",detail:"You choose the format."},
+ {id:"r-wait",repo:"sample",title:"Waiting task",status:"Waiting for another task",detail:"After the portal check."},
+ {id:"r-until",repo:"sample",title:"Waiting date",status:"Waiting until Friday",detail:"Deferred to Friday."},
+ {id:"r-review",repo:"sample",title:"Review one",status:"In review",detail:"Checks are running."},
+ {id:"r-done",repo:"sample",title:"Done one",status:"Completed",detail:"The change shipped."},
+ {id:"r-unconfirmed",repo:"sample",title:"Unconfirmed",status:"Status not confirmed",detail:"Could not be checked."},
+ {id:"r-queued",repo:"sample",title:"Queued one",status:"Queued",detail:"Not started yet."},
+ {id:"r-paused",repo:"sample",title:"Paused one",status:"Paused",detail:"Held for now."},
+ {id:"r-free",repo:"sample",title:"Free wording",status:"Delivering next week",detail:"Plain words, no mapped tone."}
+ ],coverage:[]}')
+ out=$(render_board "$home" '[]' '[]' 0 0 '[]' '[]' "$extra")
+ printf '%s' "$out" | jq -e '
+ .error == ""
+ and [.requests[].tone] == ["online","danger","warn","warn","info","done","neutral","neutral","warn","neutral"]
+ and [.requests[].status] == ["Working","Waiting on you","Waiting for another task","Waiting until Friday","In review","Completed","Status not confirmed","Queued","Paused","Delivering next week"]
+ ' >/dev/null || fail "request statuses did not read differently at a glance: $out"
+ pass "request statuses color from their wording, unknown wording stays neutral, the words stay visible"
+}
+
+test_project_and_person_statuses_use_the_same_color_map() {
+ local home out extra
+ home=$(make_home project-colors)
+ extra=$(jq -n '{requests:[],coverage:[],projects:[
+ {name:"Urgent portal",delivery:"You decide whether to publish.",status:"Waiting on you",detail:"One choice from you.",questions:[]},
+ {name:"Finished sweep",delivery:"Changes stay local.",status:"Completed",detail:"Nothing left to do.",questions:[]}
+ ],crew:[
+ {id:"w-active",name:"Active worker",role:"Worker",status:"Working",detail:"Fixing the login flow.",projects:["Urgent portal"],questions:[]},
+ {id:"w-unconfirmed",name:"Unconfirmed worker",role:"Worker",status:"Status not confirmed",detail:"Could not be checked.",projects:["Urgent portal"],questions:[]},
+ {id:"w-done",name:"Finished worker",role:"Worker",status:"Completed",detail:"Sweep is done.",projects:["Finished sweep"],questions:[]}
+ ]}')
+ out=$(render_board "$home" '[]' '[]' 0 0 '[]' '[]' "$extra")
+ printf '%s' "$out" | jq -e '
+ .error == ""
+ and [.projects[].tone] == ["danger","done"]
+ and [.projects[0].people[].tone] == ["online","neutral"]
+ and .projects[1].people[0].tone == "done"
+ ' >/dev/null || fail "project and person statuses did not use the shared color map: $out"
+ pass "project and person statuses use the same wording-to-tone map as requests"
+}
+
+test_every_listed_item_carries_one_three_way_triage_choice() {
+ local home out extra
+ home=$(make_home triage-groups)
+ extra=$(jq -n '{requests:[
+ {id:"r-one",repo:"sample",title:"Routable request",status:"Working",detail:"Under way."},
+ {id:"has space",repo:"sample",title:"Legacy request",status:"Status not confirmed",detail:"Id cannot route."}
+ ],coverage:[],knowledge:[
+ {id:"k-one",title:"Deploys go through the canary pipeline",detail:"Never push straight to production."}
+ ],projects:[
+ {name:"Charted",id:"p-one",delivery:"Steady delivery.",status:"Working",detail:"Two people.",questions:[]},
+ {name:"Urgent",delivery:"You decide whether to publish.",status:"Waiting on you",detail:"One lead.",questions:[]}
+ ],crew:[
+ {id:"lead",name:"Shared lead",role:"Project lead",status:"Working",detail:"Leads both projects.",projects:["Charted","Urgent"],questions:[]},
+ {id:"wrk",name:"Charted worker",role:"Worker",status:"Paused",detail:"Waiting for a test refresh.",projects:["Charted"],questions:[]}
+ ]}')
+ out=$(render_board "$home" '[]' '[]' 0 0 '[]' '[]' "$extra")
+ printf '%s' "$out" | jq -e '
+ .error == ""
+ and .requests[0].triage.key == "triage.request.r-one"
+ and .requests[1].triage == null
+ and .knowledgeHidden == false
+ and .knowledge[0].triage.key == "triage.knowledge.k-one"
+ and .projects[0].triageKeys == ["triage.project.p-one","triage.crew.lead","triage.crew.wrk"]
+ and .projects[1].triageKeys == []
+ and .projects[1].people[0].triage == null
+ and (.triageGroups | length) == 5
+ and ([.triageGroups[].key] | sort) == (["triage.crew.lead","triage.crew.wrk","triage.knowledge.k-one","triage.project.p-one","triage.request.r-one"] | sort)
+ and ([.triageGroups[].choices | map(.value)] | all(. == ["backlog","now","remove"]))
+ and ([.triageGroups[].choices | map(.name) | unique | length] == [1,1,1,1,1])
+ and ([.triageGroups[].choices[].name] | unique | length) == 5
+ and (.requests[0].triage.choices | map(.label)) == ["Leave on backlog","Do now","Remove completely"]
+ and ([.triageBars[] | select(.missing)] | length) == 0
+ and ([.triageBars[] | .hidden] | all(. == false))
+ and ([.triageBars[] | .count] | all(. == "no choice marked"))
+ and ([.triageBars[] | .disabled] | all(. == true))
+ ' >/dev/null || fail "listed items did not each carry exactly one three-way choice: $out"
+ pass "every request, knowledge entry, project and person carries one three-choice keep/now/remove group"
+}
+
+test_a_section_with_no_routable_rows_hides_its_choice_column_and_bar() {
+ local home out extra
+ home=$(make_home triage-unroutable)
+ extra=$(jq -n '{requests:[
+ {id:"has space",repo:"sample",title:"Legacy request",status:"Status not confirmed",detail:"Id cannot route."}
+ ],coverage:[],projects:[
+ {name:"Charted",delivery:"Steady delivery.",status:"Working",detail:"No ids anywhere.",questions:[]}
+ ],crew:[
+ {id:"bad/id",name:"Worker",role:"Worker",status:"Working",detail:"Id cannot route.",projects:["Charted"],questions:[]}
+ ]}')
+ out=$(render_board "$home" '[]' '[]' 0 0 '[]' '[]' "$extra")
+ printf '%s' "$out" | jq -e '
+ .error == ""
+ and (.triageGroups | length) == 0
+ and ([.triageBars[] | select(.missing)] | length) == 3
+ and .knowledgeHidden == true
+ and .requests[0].triage == null
+ and .projects[0].triageKeys == []
+ and .projects[0].people[0].triage == null
+ ' >/dev/null || fail "unroutable rows still exposed triage affordances: $out"
+ pass "a row whose id cannot route renders without choices and without a send bar"
+}
+
+test_the_knowledge_section_renders_only_when_the_payload_carries_entries() {
+ local home out extra
+ home=$(make_home knowledge-present)
+ extra=$(jq -n '{requests:[],coverage:[],knowledge:[
+ {id:"k-deploy",title:"Deploys run through the canary pipeline",detail:"Never push straight to production."},
+ {id:"k-review",title:"Reviews land within a day",detail:"Keeps the queue short."}
+ ]}')
+ out=$(render_board "$home" '[]' '[]' 0 0 '[]' '[]' "$extra")
+ printf '%s' "$out" | jq -e '
+ .error == ""
+ and .knowledgeHidden == false
+ and [.knowledge[].title] == ["Deploys run through the canary pipeline","Reviews land within a day"]
+ and [.knowledge[].detail] == ["Never push straight to production.","Keeps the queue short."]
+ and [.knowledge[].triage.key] == ["triage.knowledge.k-deploy","triage.knowledge.k-review"]
+ and (.stats | any(.label == "knowledge" and .n == 2))
+ ' >/dev/null || fail "the knowledge section did not render from the payload: $out"
+ out=$(render "$home" '[]')
+ printf '%s' "$out" | jq -e '
+ .error == "" and .knowledgeHidden == true
+ and (.stats | any(.label == "knowledge") | not)
+ ' >/dev/null || fail "a payload without knowledge still showed a knowledge section: $out"
+ pass "the knowledge section and its stat appear only when the payload carries entries"
+}
+
+test_triage_answers_queue_one_versioned_choice_per_marked_row() {
+ local home out extra
+ home=$(make_home triage-answers)
+ extra=$(jq -n '{requests:[
+ {id:"r-one",repo:"sample",title:"Routable request",status:"Working",detail:"Under way."},
+ {id:"r-two",repo:"sample",title:"Second request",status:"Waiting on you",detail:"You choose."}
+ ],coverage:[],knowledge:[
+ {id:"k-one",title:"Deploys run through the canary pipeline",detail:"Never push straight to production."}
+ ],projects:[
+ {name:"Charted",id:"p-one",delivery:"Steady delivery.",status:"Working",detail:"One lead.",questions:[]}
+ ],crew:[
+ {id:"lead",name:"Shared lead",role:"Project lead",status:"Working",detail:"Leads the project.",projects:["Charted"],questions:[]}
+ ]}')
+ render_board "$home" '[]' '[]' 0 0 '[]' '[]' "$extra" >/dev/null
+ out=$(node "$HARNESS" "$home/.lavish/bearings-board.html" triage now triage.request mark)
+ printf '%s' "$out" | jq -e '
+ .error == "" and (.queued | length) == 0
+ and ([.triageBars[] | select(.id == "bb-requests-triage") | .count] == ["2 choices marked to send"])
+ and ([.triageBars[] | select(.id == "bb-requests-triage") | .disabled] == [false])
+ and ([.triageBars[] | select(.id != "bb-requests-triage") | .count] | all(. == "no choice marked"))
+ ' >/dev/null || fail "marking requests did not update only the requests send bar: $out"
+ out=$(node "$HARNESS" "$home/.lavish/bearings-board.html" triage now triage.request)
+ printf '%s' "$out" | jq -e '
+ .error == "" and (.queued | length) == 2
+ and .queued[0].data == {schema:"fm-bearings-answer.v1",question:"triage.request.r-one",selection:"now",note:""}
+ and .queued[1].data.question == "triage.request.r-two"
+ and (.queued[0].prompt | contains("Routable request") and contains("Do now"))
+ and ([.triageGroups[] | select(.sent) | .key] == ["triage.request.r-one","triage.request.r-two"])
+ and ([.triageBars[] | select(.id == "bb-requests-triage") | .queued] == [true])
+ and ([.triageBars[] | select(.id != "bb-requests-triage") | .queued] | all(. == false))
+ ' >/dev/null || fail "a triage mark did not queue exactly one versioned choice for its row: $out"
+ out=$(node "$HARNESS" "$home/.lavish/bearings-board.html" triage remove triage.knowledge)
+ printf '%s' "$out" | jq -e '
+ .error == "" and (.queued | length) == 1
+ and .queued[0].data == {schema:"fm-bearings-answer.v1",question:"triage.knowledge.k-one",selection:"remove",note:""}
+ ' >/dev/null || fail "a knowledge triage mark did not queue its own choice: $out"
+ pass "a marked row queues exactly one fm-bearings-answer.v1 choice per section bar"
+}
+
test_empty_sections_and_idle_projects_do_not_take_up_space() {
local home out
home=$(make_home empty-sections)
@@ -423,3 +593,9 @@ test_warnings_are_excluded_from_the_charted_next_count
test_a_board_of_only_warnings_still_reports_nothing_queued
test_omitted_warnings_never_count_as_more_queued
test_an_omitted_kind_keeps_queued_status_even_without_a_reason
+test_request_statuses_read_differently_by_color_while_keeping_their_words
+test_project_and_person_statuses_use_the_same_color_map
+test_every_listed_item_carries_one_three_way_triage_choice
+test_a_section_with_no_routable_rows_hides_its_choice_column_and_bar
+test_the_knowledge_section_renders_only_when_the_payload_carries_entries
+test_triage_answers_queue_one_versioned_choice_per_marked_row
diff --git a/tests/fm-bearings-board.test.sh b/tests/fm-bearings-board.test.sh
index 8c618b1c009..3fdd7e3cd2e 100644
--- a/tests/fm-bearings-board.test.sh
+++ b/tests/fm-bearings-board.test.sh
@@ -343,10 +343,66 @@ test_build_refuses_malformed_payloads_before_touching_the_board() {
[ "$rc" -ne 0 ] || fail "invalid project/crew payload was accepted: $invalid_projects"
done
+ # knowledge[] is optional, but a present array must carry routable ids and
+ # readable text; project ids are optional but must route when present.
+ for invalid_knowledge in \
+ '"not-an-array"' \
+ '[{"id":"k1","title":"Title","detail":""}]' \
+ '[{"id":"k1","title":"Title","detail":"Detail"},{"id":"k1","title":"Again","detail":"Detail"}]' \
+ '[{"id":"bad id","title":"Title","detail":"Detail"}]' \
+ '[{"id":42,"title":"Title","detail":"Detail"}]' \
+ '[{"id":"k1","title":"","detail":"Detail"}]'; do
+ write_valid_payload "$data"
+ jq --argjson knowledge "$invalid_knowledge" '.knowledge = $knowledge' "$data" > "$data.tmp" \
+ && mv "$data.tmp" "$data"
+ set +e; out=$(run_board "$home" build "$data" 2>&1); rc=$?; set -e
+ [ "$rc" -ne 0 ] || fail "invalid knowledge payload was accepted: $invalid_knowledge"
+ done
+
+ # A triage id must keep its full triage key within the 128-character keyed-answer
+ # key limit: triage.knowledge. leaves 111 characters for the id.
+ write_valid_payload "$data"
+ jq --arg id "$(printf 'k%.0s' $(seq 1 112))" '.knowledge = [{id:$id,title:"Title",detail:"Detail"}]' "$data" > "$data.tmp" \
+ && mv "$data.tmp" "$data"
+ set +e; out=$(run_board "$home" build "$data" 2>&1); rc=$?; set -e
+ [ "$rc" -ne 0 ] || fail "a knowledge id that breaks the triage key limit was accepted"
+
+ for invalid_project_id in 'bad id' "$(printf 'p%.0s' $(seq 1 114))"; do
+ write_valid_payload "$data"
+ jq --arg id "$invalid_project_id" '.requests=[] | .captains_call=[] |
+ .projects=[{name:"sample",id:$id,delivery:"Changes stay local.",status:"Working",detail:"Still managed.",questions:[]}] |
+ .crew=[{id:"one",name:"A person",role:"Worker",status:"Working",detail:"Checking the report.",projects:["sample"],questions:[]}]' \
+ "$data" > "$data.tmp" && mv "$data.tmp" "$data"
+ set +e; out=$(run_board "$home" build "$data" 2>&1); rc=$?; set -e
+ [ "$rc" -ne 0 ] || fail "an invalid project triage id was accepted: $invalid_project_id"
+ done
+
assert_absent "$board" "a refused payload still produced a board"
pass "build refuses malformed payloads before touching the board"
}
+test_build_accepts_optional_knowledge_and_triage_ids_at_their_limits() {
+ local home data board out
+ home=$(make_home triage-limits)
+ data="$home/payload.json"
+ board="$home/.lavish/bearings-board.html"
+ write_valid_payload "$data"
+ # 111 id characters keep triage.knowledge. exactly at the 128-character
+ # keyed-answer key limit; a project id rides along on a minimal portfolio.
+ jq --arg kid "$(printf 'k%.0s' $(seq 1 111))" --arg pid "p-one" '
+ .requests=[] | .captains_call=[] | .coverage=[]
+ | .knowledge=[{id:$kid,title:"Deploys run through the canary pipeline",detail:"Never push straight to production."}]
+ | .projects=[{name:"sample",id:$pid,delivery:"Changes stay local.",status:"Working",detail:"Still managed.",questions:[]}]
+ | .crew=[{id:"one",name:"A person",role:"Worker",status:"Working",detail:"Checking the report.",projects:["sample"],questions:[]}]' \
+ "$data" > "$data.tmp" && mv "$data.tmp" "$data"
+ out=$(run_board "$home" build "$data") || fail "a payload with optional knowledge and triage ids did not build: $out"
+ assert_present "$board" "an accepted optional-fields payload produced no board"
+ extract_payload "$board" | jq -e --arg kid "$(printf 'k%.0s' $(seq 1 111))" '
+ .knowledge[0].id == $kid and .projects[0].id == "p-one" and .crew[0].id == "one"
+ ' >/dev/null || fail "the built board did not carry the optional knowledge and triage ids"
+ pass "build accepts optional knowledge and triage ids up to the key limit"
+}
+
test_build_injects_binds_then_arms() {
local home data board out sid
home=$(make_home build)
@@ -821,6 +877,7 @@ test_build_refuses_a_nondecision_reconcile_value() {
test_path_is_stable_and_home_scoped
test_build_refuses_malformed_payloads_before_touching_the_board
+test_build_accepts_optional_knowledge_and_triage_ids_at_their_limits
test_charted_kind_is_optional_and_accepts_both_values
test_build_injects_binds_then_arms
test_registration_cannot_consume_before_any_origin_binding