generated from coulomb/repo-seed
Add WSJF triage dashboard review page
This commit is contained in:
@@ -71,6 +71,7 @@ export default {
|
||||
{ name: "Interventions", path: "/interventions" },
|
||||
{ name: "Tasks", path: "/tasks" },
|
||||
{ name: "UI Feedback", path: "/ui-feedback" },
|
||||
{ name: "WSJF Triage", path: "/wsjf-triage" },
|
||||
],
|
||||
},
|
||||
// ── Reference (always last) ───────────────────────────────────────────────
|
||||
@@ -110,6 +111,7 @@ export default {
|
||||
{ name: "Workstream Health", path: "/docs/workstream-health-index" },
|
||||
{ name: "Workstream Lifecycle", path: "/docs/workstream-lifecycle" },
|
||||
{ name: "Workstreams", path: "/docs/workstreams" },
|
||||
{ name: "WSJF Triage", path: "/docs/wsjf-triage" },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
216
dashboard/src/components/wsjf-triage.js
Normal file
216
dashboard/src/components/wsjf-triage.js
Normal file
@@ -0,0 +1,216 @@
|
||||
export const ACTION_META = {
|
||||
"work-next": {
|
||||
tone: "good",
|
||||
label: "work-next",
|
||||
description: "Best next executable task.",
|
||||
},
|
||||
"close-out": {
|
||||
tone: "good",
|
||||
label: "close-out",
|
||||
description: "Finish closure review and mark done when appropriate.",
|
||||
},
|
||||
revisit: {
|
||||
tone: "warn",
|
||||
label: "revisit",
|
||||
description: "Re-read and refresh before execution.",
|
||||
},
|
||||
split: {
|
||||
tone: "warn",
|
||||
label: "split",
|
||||
description: "Break an oversized workplan into smaller plans.",
|
||||
},
|
||||
park: {
|
||||
tone: "muted",
|
||||
label: "park",
|
||||
description: "Move out of active focus.",
|
||||
},
|
||||
"needs-human": {
|
||||
tone: "bad",
|
||||
label: "needs-human",
|
||||
description: "Human decision or approval needed.",
|
||||
},
|
||||
"needs-cross-agent": {
|
||||
tone: "bad",
|
||||
label: "needs-cross-agent",
|
||||
description: "Another repo or agent is the right owner.",
|
||||
},
|
||||
"needs-consistency-sync": {
|
||||
tone: "bad",
|
||||
label: "needs-consistency-sync",
|
||||
description: "File, DB, or index state should be reconciled.",
|
||||
},
|
||||
};
|
||||
|
||||
const WORKPLAN_ID_RE = /([a-z0-9]+-wp-\d+[a-z]?)/i;
|
||||
const ADHOC_ID_RE = /(adhoc-\d{4}-\d{2}-\d{2})/i;
|
||||
|
||||
export function normalizeCandidate(value) {
|
||||
return String(value ?? "")
|
||||
.trim()
|
||||
.replace(/\.md$/i, "")
|
||||
.replace(/^workplans\//i, "")
|
||||
.replace(/^archived\//i, "")
|
||||
.replace(/_/g, "-")
|
||||
.replace(/\s+/g, "-")
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function filenameStem(filename) {
|
||||
const base = String(filename ?? "").split("/").pop() ?? "";
|
||||
return base.replace(/\.md$/i, "").replace(/^\d{6}-/, "");
|
||||
}
|
||||
|
||||
export function candidateKeysForWorkplan(item = {}) {
|
||||
const stem = filenameStem(item.filename ?? item.relative_path);
|
||||
const keys = new Set();
|
||||
const normalizedStem = normalizeCandidate(stem);
|
||||
if (normalizedStem) keys.add(normalizedStem);
|
||||
|
||||
const idMatch = stem.match(WORKPLAN_ID_RE) ?? stem.match(ADHOC_ID_RE);
|
||||
if (idMatch) {
|
||||
const idKey = normalizeCandidate(idMatch[1]);
|
||||
keys.add(idKey);
|
||||
|
||||
const suffix = normalizeCandidate(stem.slice(idMatch.index + idMatch[1].length).replace(/^-/, ""));
|
||||
if (suffix) keys.add(suffix);
|
||||
}
|
||||
|
||||
return [...keys].filter(Boolean);
|
||||
}
|
||||
|
||||
export function buildCandidateIndex(workplanIndex = {}) {
|
||||
const byCandidate = new Map();
|
||||
const workstreams = workplanIndex.workstreams ?? {};
|
||||
for (const [id, item] of Object.entries(workstreams)) {
|
||||
const resolved = {id, ...item};
|
||||
byCandidate.set(normalizeCandidate(id), resolved);
|
||||
for (const key of candidateKeysForWorkplan(item)) {
|
||||
if (!byCandidate.has(key)) byCandidate.set(key, resolved);
|
||||
}
|
||||
}
|
||||
return byCandidate;
|
||||
}
|
||||
|
||||
export function resolveCandidate(candidate, candidateIndex) {
|
||||
if (!candidateIndex) return null;
|
||||
const key = normalizeCandidate(candidate);
|
||||
return candidateIndex.get(key) ?? null;
|
||||
}
|
||||
|
||||
export function actionMeta(action) {
|
||||
return ACTION_META[normalizeCandidate(action)] ?? {
|
||||
tone: "muted",
|
||||
label: String(action ?? "unknown"),
|
||||
description: "Unrecognized recommendation action.",
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeTriageReports(events = []) {
|
||||
return events
|
||||
.map((event, index) => {
|
||||
const detail = event?.detail ?? {};
|
||||
const report = detail.report ?? {};
|
||||
const recommendations = Array.isArray(report.recommendations)
|
||||
? report.recommendations.map((rec, recIndex) => ({
|
||||
rank: recIndex + 1,
|
||||
candidate: String(rec?.candidate ?? "").trim(),
|
||||
action: normalizeCandidate(rec?.action) || "unknown",
|
||||
confidence: String(rec?.confidence ?? "").trim() || "unknown",
|
||||
why: String(rec?.why ?? "").trim(),
|
||||
}))
|
||||
: [];
|
||||
const scheduledFor = detail.scheduled_for ?? null;
|
||||
const runId = detail.activity_core_run_id ?? null;
|
||||
const dateSource = scheduledFor ?? event?.created_at ?? "";
|
||||
const date = String(dateSource).slice(0, 10) || null;
|
||||
|
||||
return {
|
||||
id: String(event?.id ?? `${event?.created_at ?? "report"}-${index}`),
|
||||
created_at: event?.created_at ?? null,
|
||||
date,
|
||||
summary: String(report.summary ?? event?.summary ?? "").trim(),
|
||||
recommendations,
|
||||
scheduled_for: scheduledFor,
|
||||
activity_core_run_id: runId,
|
||||
instruction_id: detail.instruction_id ?? null,
|
||||
activity_id: detail.activity_id ?? null,
|
||||
author: event?.author ?? null,
|
||||
memory_path: detail.memory_path ?? detail.working_memory_path ?? deriveMemoryPath(date, runId),
|
||||
};
|
||||
})
|
||||
.sort((a, b) => String(b.created_at ?? "").localeCompare(String(a.created_at ?? "")));
|
||||
}
|
||||
|
||||
export function deriveMemoryPath(date, runId) {
|
||||
if (!date || !runId) return null;
|
||||
return `the-custodian/memory/working/daily-triage-${date}-${String(runId).slice(0, 8)}.md`;
|
||||
}
|
||||
|
||||
export function truncateSummary(summary, maxLength = 120) {
|
||||
const text = String(summary ?? "").trim();
|
||||
if (text.length <= maxLength) return text;
|
||||
return `${text.slice(0, Math.max(0, maxLength - 3)).trimEnd()}...`;
|
||||
}
|
||||
|
||||
export function topAction(recommendations = []) {
|
||||
const counts = new Map();
|
||||
for (const rec of recommendations) {
|
||||
const action = normalizeCandidate(rec?.action) || "unknown";
|
||||
counts.set(action, (counts.get(action) ?? 0) + 1);
|
||||
}
|
||||
const [action, count] = [...counts.entries()].sort((a, b) => {
|
||||
const countCompare = b[1] - a[1];
|
||||
return countCompare || a[0].localeCompare(b[0]);
|
||||
})[0] ?? [];
|
||||
return action ? {action, count} : null;
|
||||
}
|
||||
|
||||
export function formatActionCount(row) {
|
||||
return row ? `${row.action} x${row.count}` : "-";
|
||||
}
|
||||
|
||||
export function isWithinDays(iso, days, now = new Date()) {
|
||||
if (!iso) return false;
|
||||
const ts = new Date(iso).getTime();
|
||||
if (Number.isNaN(ts)) return false;
|
||||
return now.getTime() - ts <= days * 24 * 60 * 60 * 1000;
|
||||
}
|
||||
|
||||
export function buildPatternRows(reports = []) {
|
||||
const rows = new Map();
|
||||
for (const report of reports) {
|
||||
for (const rec of report.recommendations ?? []) {
|
||||
const key = normalizeCandidate(rec.candidate);
|
||||
if (!key) continue;
|
||||
const row = rows.get(key) ?? {
|
||||
candidate: rec.candidate,
|
||||
candidateKey: key,
|
||||
count: 0,
|
||||
actionCounts: new Map(),
|
||||
};
|
||||
row.count += 1;
|
||||
const action = normalizeCandidate(rec.action) || "unknown";
|
||||
row.actionCounts.set(action, (row.actionCounts.get(action) ?? 0) + 1);
|
||||
rows.set(key, row);
|
||||
}
|
||||
}
|
||||
|
||||
return [...rows.values()]
|
||||
.map(row => {
|
||||
const top = [...row.actionCounts.entries()].sort((a, b) => {
|
||||
const countCompare = b[1] - a[1];
|
||||
return countCompare || a[0].localeCompare(b[0]);
|
||||
})[0];
|
||||
return {
|
||||
candidate: row.candidate,
|
||||
candidateKey: row.candidateKey,
|
||||
count: row.count,
|
||||
action: top?.[0] ?? "unknown",
|
||||
actionCount: top?.[1] ?? 0,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const countCompare = b.count - a.count;
|
||||
return countCompare || a.candidateKey.localeCompare(b.candidateKey);
|
||||
});
|
||||
}
|
||||
@@ -172,6 +172,7 @@ withDocHelp(_liveEl, "/docs/live-data");
|
||||
|
||||
const _h1 = document.querySelector("#observablehq-main h1");
|
||||
if (_h1) { _h1.style.position = "relative"; withDocHelp(_h1, "/docs/decisions"); }
|
||||
display(html`<p class="dim" style="margin-top:-0.25rem"><a href="/wsjf-triage">Daily WSJF triage</a> surfaces recurring decision and human-approval recommendations.</p>`);
|
||||
|
||||
// ── Inject into TOC sidebar: KPI first (prepend → bottom), live last (→ top) ─
|
||||
const _toc = document.querySelector("#observablehq-toc");
|
||||
|
||||
@@ -10,6 +10,11 @@ belongs to exactly one project domain. The Workstreams page gives you a
|
||||
filtered, visual overview of active work, derived blocked state, and the
|
||||
dependency graph between workstreams.
|
||||
|
||||
The [Daily WSJF Triage](/wsjf-triage) page is a companion review surface for
|
||||
activity-core's daily recommendations. It links recommendation candidates back
|
||||
to workstream detail pages when the candidate can be resolved through the
|
||||
workplan index.
|
||||
|
||||
---
|
||||
|
||||
## Workstation Distribution chart
|
||||
|
||||
57
dashboard/src/docs/wsjf-triage.md
Normal file
57
dashboard/src/docs/wsjf-triage.md
Normal file
@@ -0,0 +1,57 @@
|
||||
---
|
||||
title: WSJF Triage
|
||||
---
|
||||
|
||||
# WSJF Triage
|
||||
|
||||
The Daily WSJF Triage page reviews the append-only reports produced by the
|
||||
Custodian daily triage runner. The producer is tracked by `CUST-WP-0044`; the
|
||||
dashboard page is only a read surface for `daily_triage` progress events.
|
||||
|
||||
The page loads the latest reports from:
|
||||
|
||||
```text
|
||||
GET /progress/?event_type=daily_triage&limit=14
|
||||
```
|
||||
|
||||
Each event carries the report under `detail.report`, with a summary and a list
|
||||
of recommendations. Candidate values are resolved through
|
||||
`/workstreams/workplan-index` so file-backed workplans can link to their
|
||||
workstream detail pages.
|
||||
|
||||
## How to read recommendations
|
||||
|
||||
Recommendations are advisory, not authoritative. They help decide where to look
|
||||
first, but they do not change workplan status, task status, ownership, or
|
||||
priority. Acting on a recommendation still belongs to the operator, the
|
||||
workplan owner, or a follow-up implementation workplan.
|
||||
|
||||
Confidence labels mean:
|
||||
|
||||
| Confidence | Meaning |
|
||||
|---|---|
|
||||
| high | State Hub summary, workplan file, and recent progress agree |
|
||||
| medium | Summary data is supported by at least one corroborating source |
|
||||
| low | The signal is stale, incomplete, or mostly inferred |
|
||||
|
||||
## Action vocabulary
|
||||
|
||||
| Action | Meaning |
|
||||
|---|---|
|
||||
| `work-next` | Best next executable task |
|
||||
| `close-out` | Finish closure review and mark done when appropriate |
|
||||
| `revisit` | Re-read and refresh before execution |
|
||||
| `split` | Break an oversized workplan into smaller plans |
|
||||
| `park` | Move out of active focus |
|
||||
| `needs-human` | Human decision or approval needed |
|
||||
| `needs-cross-agent` | Another repo or agent is the right owner |
|
||||
| `needs-consistency-sync` | File, DB, or index state should be reconciled |
|
||||
|
||||
## Pattern view
|
||||
|
||||
The pattern table aggregates recommendations in the loaded 14-day window. It is
|
||||
useful for spotting recurring human gates, stale revisit signals, or workstreams
|
||||
that keep surfacing as the next best piece of work.
|
||||
|
||||
No write controls live on this page. It is intentionally a review page so the
|
||||
daily runner remains a focus surface, not an execution loop.
|
||||
@@ -38,6 +38,7 @@ convention used in the Custodian State Hub.
|
||||
| [Todo](/docs/todo) | Internal/Ecosystem/Third-party classification, data sources |
|
||||
| [Workstream Health](/docs/workstream-health-index) | WHI formula, six base metrics, per-domain breakdown |
|
||||
| [Workstreams](/docs/workstreams) | Workstream statuses, dependency edges, WHI KPI card |
|
||||
| [WSJF Triage](/docs/wsjf-triage) | Daily triage reports, action vocabulary, advisory review workflow |
|
||||
|
||||
---
|
||||
|
||||
|
||||
398
dashboard/src/wsjf-triage.md
Normal file
398
dashboard/src/wsjf-triage.md
Normal file
@@ -0,0 +1,398 @@
|
||||
---
|
||||
title: Daily WSJF Triage
|
||||
---
|
||||
|
||||
```js
|
||||
import {POLL_HEAVY, apiFetch, pollDelay, waitForVisible} from "./components/config.js";
|
||||
import {injectTocTop} from "./components/toc-sidebar.js";
|
||||
import {withDocHelp} from "./components/doc-overlay.js";
|
||||
import {
|
||||
ACTION_META,
|
||||
actionMeta,
|
||||
buildCandidateIndex,
|
||||
buildPatternRows,
|
||||
isWithinDays,
|
||||
normalizeTriageReports,
|
||||
resolveCandidate,
|
||||
topAction,
|
||||
truncateSummary,
|
||||
} from "./components/wsjf-triage.js";
|
||||
```
|
||||
|
||||
```js
|
||||
const triageState = (async function*() {
|
||||
let failures = 0;
|
||||
while (true) {
|
||||
let events = [], workplanIndex = {workstreams: {}}, ok = false;
|
||||
try {
|
||||
const [reportsResp, indexResp] = await Promise.all([
|
||||
apiFetch("/progress/?event_type=daily_triage&limit=14"),
|
||||
apiFetch("/workstreams/workplan-index"),
|
||||
]);
|
||||
ok = reportsResp.ok && indexResp.ok;
|
||||
events = reportsResp.ok ? await reportsResp.json() : [];
|
||||
workplanIndex = indexResp.ok ? await indexResp.json() : {workstreams: {}};
|
||||
} catch {}
|
||||
failures = ok ? 0 : failures + 1;
|
||||
yield {events, workplanIndex, ok, ts: new Date()};
|
||||
await waitForVisible(pollDelay({ok, base: POLL_HEAVY, failures}));
|
||||
}
|
||||
})();
|
||||
```
|
||||
|
||||
```js
|
||||
const reports = normalizeTriageReports(triageState.events ?? []);
|
||||
const candidateIndex = buildCandidateIndex(triageState.workplanIndex ?? {workstreams: {}});
|
||||
const _ok = triageState.ok ?? false;
|
||||
const _ts = triageState.ts;
|
||||
const latestReport = reports[0] ?? null;
|
||||
```
|
||||
|
||||
# Daily WSJF Triage
|
||||
|
||||
```js
|
||||
function fmtDateTime(iso) {
|
||||
if (!iso) return "-";
|
||||
const d = new Date(iso);
|
||||
return Number.isNaN(d.getTime()) ? String(iso) : d.toLocaleString();
|
||||
}
|
||||
|
||||
function fmtDate(iso) {
|
||||
if (!iso) return "-";
|
||||
const d = new Date(iso);
|
||||
return Number.isNaN(d.getTime()) ? String(iso).slice(0, 10) : d.toLocaleDateString(undefined, {year: "numeric", month: "short", day: "numeric"});
|
||||
}
|
||||
|
||||
function candidateNode(candidate, index) {
|
||||
const resolved = resolveCandidate(candidate, index);
|
||||
return resolved
|
||||
? html`<a href="/workstreams/${resolved.id}" title=${resolved.filename ?? resolved.id}>${candidate}</a>`
|
||||
: html`<span>${candidate || "-"}</span>`;
|
||||
}
|
||||
|
||||
function actionBadge(action, extraText = "") {
|
||||
const meta = actionMeta(action);
|
||||
return html`<span class="triage-action triage-action-${meta.tone}" title=${meta.description}>${meta.label}${extraText}</span>`;
|
||||
}
|
||||
|
||||
function recommendationTable(report, index) {
|
||||
const recommendations = report.recommendations ?? [];
|
||||
if (recommendations.length === 0) {
|
||||
return html`<p class="triage-muted">No recommendations were recorded for this report.</p>`;
|
||||
}
|
||||
return html`<div>
|
||||
<table class="triage-table triage-recommendations">
|
||||
<thead><tr><th>#</th><th>Candidate</th><th>Action</th><th>Confidence</th><th>Why</th></tr></thead>
|
||||
<tbody>${recommendations.map(rec => html`<tr>
|
||||
<td>${rec.rank}</td>
|
||||
<td>${candidateNode(rec.candidate, index)}</td>
|
||||
<td>${actionBadge(rec.action)}</td>
|
||||
<td><span class="triage-confidence">${rec.confidence}</span></td>
|
||||
<td>${rec.why || "-"}</td>
|
||||
</tr>`)}</tbody>
|
||||
</table>
|
||||
<div class="triage-legend">
|
||||
${Object.values(ACTION_META).map(meta => html`<span>${actionBadge(meta.label)} ${meta.description}</span>`)}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function reportMetadata(report) {
|
||||
return html`<div class="triage-metadata">
|
||||
<div><span>Scheduled for</span><strong>${fmtDateTime(report.scheduled_for)}</strong></div>
|
||||
<div><span>Created</span><strong>${fmtDateTime(report.created_at)}</strong></div>
|
||||
<div><span>Instruction</span><strong>${report.instruction_id ?? "-"}</strong></div>
|
||||
<div><span>Activity run</span><strong>${report.activity_core_run_id ?? "-"}</strong></div>
|
||||
<div><span>Activity</span><strong>${report.activity_id ?? "-"}</strong></div>
|
||||
<div><span>Memory note</span><strong>${report.memory_path ?? "-"}</strong></div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderReportDetail(report, index) {
|
||||
return html`<div>
|
||||
<div class="triage-section-heading">
|
||||
<h2>Report Detail</h2>
|
||||
<span>${fmtDate(report.scheduled_for ?? report.created_at)}</span>
|
||||
</div>
|
||||
<section class="triage-detail-block">
|
||||
<h3>Summary</h3>
|
||||
<p>${report.summary || "-"}</p>
|
||||
</section>
|
||||
<section class="triage-detail-block">
|
||||
<h3>Recommendations</h3>
|
||||
${recommendationTable(report, index)}
|
||||
</section>
|
||||
<section class="triage-detail-block">
|
||||
<h3>Run Metadata</h3>
|
||||
${reportMetadata(report)}
|
||||
</section>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderPatterns(reports, index) {
|
||||
const windowReports = reports.filter(report => isWithinDays(report.created_at, 14));
|
||||
const rows = buildPatternRows(windowReports);
|
||||
return html`<section class="triage-section">
|
||||
<div class="triage-section-heading">
|
||||
<h2>Patterns</h2>
|
||||
<span>Last 14 days</span>
|
||||
</div>
|
||||
${rows.length === 0
|
||||
? html`<p class="triage-muted">No repeated recommendations are visible in the loaded 14-day window.</p>`
|
||||
: html`<table class="triage-table">
|
||||
<thead><tr><th>Workstream</th><th>Times Recommended</th><th>Most Frequent Action</th></tr></thead>
|
||||
<tbody>${rows.map(row => html`<tr>
|
||||
<td>${candidateNode(row.candidate, index)}</td>
|
||||
<td>${row.count} / ${Math.max(1, windowReports.length)} reports</td>
|
||||
<td>${actionBadge(row.action, ` x${row.actionCount}`)}</td>
|
||||
</tr>`)}</tbody>
|
||||
</table>`}
|
||||
</section>`;
|
||||
}
|
||||
|
||||
function renderExplorer(reports, index) {
|
||||
const root = html`<div class="triage-explorer"></div>`;
|
||||
const detail = html`<section id="triage-report-detail" class="triage-section"></section>`;
|
||||
const tableBody = html`<tbody></tbody>`;
|
||||
const rows = [];
|
||||
let selectedId = reports[0]?.id;
|
||||
|
||||
function selectReport(report, {scroll = true} = {}) {
|
||||
selectedId = report.id;
|
||||
for (const row of rows) {
|
||||
row.classList.toggle("is-selected", row.dataset.reportId === selectedId);
|
||||
row.setAttribute("aria-selected", row.dataset.reportId === selectedId ? "true" : "false");
|
||||
}
|
||||
detail.replaceChildren(renderReportDetail(report, index));
|
||||
if (scroll) detail.scrollIntoView({behavior: "smooth", block: "start"});
|
||||
}
|
||||
|
||||
for (const report of reports) {
|
||||
const top = topAction(report.recommendations);
|
||||
const row = html`<tr class="triage-report-row" tabindex="0" role="button" data-report-id=${report.id} aria-selected="false">
|
||||
<td>${fmtDate(report.scheduled_for ?? report.created_at)}</td>
|
||||
<td>${truncateSummary(report.summary)}</td>
|
||||
<td>${report.recommendations.length}</td>
|
||||
<td>${top ? actionBadge(top.action, ` x${top.count}`) : "-"}</td>
|
||||
</tr>`;
|
||||
row.addEventListener("click", () => selectReport(report));
|
||||
row.addEventListener("keydown", event => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
selectReport(report);
|
||||
}
|
||||
});
|
||||
rows.push(row);
|
||||
}
|
||||
tableBody.append(...rows);
|
||||
|
||||
root.append(
|
||||
html`<section class="triage-section">
|
||||
<div class="triage-section-heading">
|
||||
<h2>Recent Reports</h2>
|
||||
<span>${reports.length} loaded</span>
|
||||
</div>
|
||||
<table class="triage-table triage-reports">
|
||||
<thead><tr><th>Date</th><th>Summary</th><th># Recs</th><th>Top Action</th></tr></thead>
|
||||
${tableBody}
|
||||
</table>
|
||||
</section>`,
|
||||
detail,
|
||||
renderPatterns(reports, index),
|
||||
);
|
||||
|
||||
if (reports[0]) selectReport(reports[0], {scroll: false});
|
||||
return root;
|
||||
}
|
||||
```
|
||||
|
||||
```js
|
||||
const _liveEl = html`<div class="live-indicator">
|
||||
<span class="live-dot" style="background:${_ok ? "var(--theme-foreground-focus)" : "red"}"></span>
|
||||
${_ok
|
||||
? `Live - updated ${_ts?.toLocaleTimeString()} - ${reports.length} triage reports`
|
||||
: html`<span style="color:red">Offline - run: <code>make api</code></span>`}
|
||||
</div>`;
|
||||
withDocHelp(_liveEl, "/docs/live-data");
|
||||
injectTocTop("live-indicator", _liveEl);
|
||||
|
||||
const _h1 = document.querySelector("#observablehq-main h1");
|
||||
if (_h1) { _h1.style.position = "relative"; withDocHelp(_h1, "/docs/wsjf-triage"); }
|
||||
|
||||
display(html`<p class="triage-subtitle">Daily State Hub triage from activity-core. Recommendations are advisory; the operator and workplan owners decide what to act on.</p>`);
|
||||
display(html`<div class="triage-latest">
|
||||
<span>Last updated</span>
|
||||
<strong>${latestReport ? fmtDateTime(latestReport.created_at) : "No daily_triage events yet"}</strong>
|
||||
</div>`);
|
||||
|
||||
if (reports.length === 0) {
|
||||
const empty = html`<div class="triage-empty">
|
||||
<h2>No daily triage reports yet.</h2>
|
||||
<p>The next run is scheduled for 07:20 Europe/Berlin (activity-core <code>daily-statehub-wsjf-triage</code>).</p>
|
||||
</div>`;
|
||||
withDocHelp(empty, "/docs/wsjf-triage");
|
||||
display(empty);
|
||||
} else {
|
||||
display(renderExplorer(reports, candidateIndex));
|
||||
}
|
||||
```
|
||||
|
||||
<style>
|
||||
.live-indicator {
|
||||
color: gray;
|
||||
font-size: 0.8rem;
|
||||
margin-bottom: 0.75rem;
|
||||
padding: 0.55rem 1.8rem 0.55rem 0.7rem;
|
||||
position: relative;
|
||||
}
|
||||
.live-dot {
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
height: 0.5rem;
|
||||
margin-right: 0.35rem;
|
||||
width: 0.5rem;
|
||||
}
|
||||
.triage-subtitle {
|
||||
color: var(--theme-foreground-muted, #666);
|
||||
margin-top: -0.2rem;
|
||||
max-width: 820px;
|
||||
}
|
||||
.triage-latest {
|
||||
align-items: baseline;
|
||||
border: 1px solid var(--theme-foreground-faint, #ddd);
|
||||
border-radius: 6px;
|
||||
display: inline-flex;
|
||||
gap: 0.45rem;
|
||||
margin: 0.2rem 0 1rem;
|
||||
padding: 0.35rem 0.55rem;
|
||||
}
|
||||
.triage-latest span,
|
||||
.triage-section-heading span,
|
||||
.triage-metadata span {
|
||||
color: var(--theme-foreground-muted, #666);
|
||||
font-size: 0.72rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.triage-empty {
|
||||
border: 1px solid var(--theme-foreground-faint, #ddd);
|
||||
border-radius: 6px;
|
||||
margin-top: 1rem;
|
||||
max-width: 760px;
|
||||
padding: 1rem;
|
||||
position: relative;
|
||||
}
|
||||
.triage-empty h2 {
|
||||
margin-top: 0;
|
||||
}
|
||||
.triage-section {
|
||||
margin: 1.4rem 0 1.8rem;
|
||||
}
|
||||
.triage-section-heading {
|
||||
align-items: baseline;
|
||||
display: flex;
|
||||
gap: 0.65rem;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.45rem;
|
||||
}
|
||||
.triage-section-heading h2 {
|
||||
margin: 0;
|
||||
}
|
||||
.triage-detail-block {
|
||||
margin: 0.9rem 0 1.2rem;
|
||||
}
|
||||
.triage-detail-block h3 {
|
||||
font-size: 0.95rem;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
.triage-table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
}
|
||||
.triage-table th,
|
||||
.triage-table td {
|
||||
border-bottom: 1px solid var(--theme-foreground-faint, #ddd);
|
||||
padding: 0.45rem 0.5rem;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
.triage-reports td:nth-child(2),
|
||||
.triage-recommendations td:nth-child(5) {
|
||||
max-width: 520px;
|
||||
}
|
||||
.triage-report-row {
|
||||
cursor: pointer;
|
||||
}
|
||||
.triage-report-row:hover,
|
||||
.triage-report-row:focus {
|
||||
background: var(--theme-background-alt, #f7f7f7);
|
||||
outline: none;
|
||||
}
|
||||
.triage-report-row.is-selected {
|
||||
background: color-mix(in srgb, var(--theme-foreground-focus, steelblue) 9%, transparent);
|
||||
}
|
||||
.triage-action {
|
||||
border-radius: 4px;
|
||||
display: inline-block;
|
||||
font-size: 0.74rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
padding: 0.14rem 0.4rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.triage-action-good {
|
||||
background: #dcfce7;
|
||||
color: #166534;
|
||||
}
|
||||
.triage-action-warn {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
.triage-action-muted {
|
||||
background: #f3f4f6;
|
||||
color: #4b5563;
|
||||
}
|
||||
.triage-action-bad {
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
}
|
||||
.triage-confidence {
|
||||
color: var(--theme-foreground-muted, #666);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.triage-legend {
|
||||
color: var(--theme-foreground-muted, #666);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.45rem 0.8rem;
|
||||
margin-top: 0.6rem;
|
||||
}
|
||||
.triage-legend span {
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
.triage-metadata {
|
||||
display: grid;
|
||||
gap: 0.65rem 1rem;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
.triage-metadata div {
|
||||
min-width: 0;
|
||||
}
|
||||
.triage-metadata strong {
|
||||
display: block;
|
||||
font-size: 0.86rem;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.triage-muted {
|
||||
color: var(--theme-foreground-muted, #666);
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.triage-section-heading,
|
||||
.triage-latest {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
.triage-metadata {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
117
dashboard/test/wsjf-triage.test.mjs
Normal file
117
dashboard/test/wsjf-triage.test.mjs
Normal file
@@ -0,0 +1,117 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
buildCandidateIndex,
|
||||
buildPatternRows,
|
||||
candidateKeysForWorkplan,
|
||||
formatActionCount,
|
||||
normalizeTriageReports,
|
||||
resolveCandidate,
|
||||
topAction,
|
||||
truncateSummary,
|
||||
} from "../src/components/wsjf-triage.js";
|
||||
|
||||
test("candidate keys resolve workplan ids and filename slugs", () => {
|
||||
assert.deepEqual(
|
||||
candidateKeysForWorkplan({filename: "CUST-WP-0003-whi-kpi-card.md"}).sort(),
|
||||
["cust-wp-0003", "cust-wp-0003-whi-kpi-card", "whi-kpi-card"],
|
||||
);
|
||||
assert.deepEqual(
|
||||
candidateKeysForWorkplan({filename: "260501-CUST-WP-0028-e2e-sandbox-framework.md"}).sort(),
|
||||
["cust-wp-0028", "cust-wp-0028-e2e-sandbox-framework", "e2e-sandbox-framework"],
|
||||
);
|
||||
});
|
||||
|
||||
test("candidate index resolves daily report candidates to workstream ids", () => {
|
||||
const index = buildCandidateIndex({
|
||||
workstreams: {
|
||||
"d9d9a3ec-f736-4041-beac-bb92c7ad314e": {
|
||||
filename: "CUST-WP-0045-activity-core-daily-triage-runner.md",
|
||||
},
|
||||
"9cc32158-2f5c-4ef6-9713-aacce4623d5e": {
|
||||
filename: "CUST-WP-0003-whi-kpi-card.md",
|
||||
},
|
||||
"36162ff0-9b47-47c4-8602-56767f9b7a1c": {
|
||||
filename: "ADHOC-2026-06-01.md",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(resolveCandidate("cust-wp-0045", index).id, "d9d9a3ec-f736-4041-beac-bb92c7ad314e");
|
||||
assert.equal(resolveCandidate("whi-kpi-card", index).id, "9cc32158-2f5c-4ef6-9713-aacce4623d5e");
|
||||
assert.equal(resolveCandidate("adhoc-2026-06-01", index).id, "36162ff0-9b47-47c4-8602-56767f9b7a1c");
|
||||
assert.equal(resolveCandidate("missing-wp-0001", index), null);
|
||||
});
|
||||
|
||||
test("triage events normalize reports and action summaries", () => {
|
||||
const [report] = normalizeTriageReports([
|
||||
{
|
||||
id: "935244fa-b438-488c-a11a-42e1a84e3d59",
|
||||
summary: "event summary",
|
||||
created_at: "2026-06-02T12:52:14.460214Z",
|
||||
author: "activity-core",
|
||||
detail: {
|
||||
scheduled_for: "2026-06-02T12:52:01.690214+00:00",
|
||||
instruction_id: "daily-triage-report",
|
||||
activity_core_run_id: "f9b97749-c1d0-5746-ab18-89932bef47c1",
|
||||
report: {
|
||||
summary: "daily summary",
|
||||
recommendations: [
|
||||
{candidate: "cust-wp-0045", action: "work-next", confidence: "high", why: "runner"},
|
||||
{candidate: "cust-wp-0046", action: "needs-human", confidence: "high", why: "blocked"},
|
||||
{candidate: "whi-kpi-card", action: "work-next", confidence: "medium", why: "health"},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
assert.equal(report.summary, "daily summary");
|
||||
assert.equal(report.date, "2026-06-02");
|
||||
assert.equal(report.recommendations.length, 3);
|
||||
assert.equal(report.memory_path, "the-custodian/memory/working/daily-triage-2026-06-02-f9b97749.md");
|
||||
assert.deepEqual(topAction(report.recommendations), {action: "work-next", count: 2});
|
||||
assert.equal(formatActionCount(topAction(report.recommendations)), "work-next x2");
|
||||
});
|
||||
|
||||
test("pattern rows aggregate repeated candidates by action", () => {
|
||||
const reports = normalizeTriageReports([
|
||||
{
|
||||
id: "one",
|
||||
created_at: "2026-06-03T06:00:00Z",
|
||||
detail: {
|
||||
report: {
|
||||
recommendations: [
|
||||
{candidate: "cust-wp-0046", action: "needs-human"},
|
||||
{candidate: "cust-wp-0045", action: "work-next"},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "two",
|
||||
created_at: "2026-06-02T06:00:00Z",
|
||||
detail: {
|
||||
report: {
|
||||
recommendations: [
|
||||
{candidate: "cust-wp-0046", action: "needs-human"},
|
||||
{candidate: "cust-wp-0046", action: "revisit"},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const [first, second] = buildPatternRows(reports);
|
||||
assert.equal(first.candidateKey, "cust-wp-0046");
|
||||
assert.equal(first.count, 3);
|
||||
assert.equal(first.action, "needs-human");
|
||||
assert.equal(first.actionCount, 2);
|
||||
assert.equal(second.candidateKey, "cust-wp-0045");
|
||||
});
|
||||
|
||||
test("summary truncation keeps compact table rows", () => {
|
||||
assert.equal(truncateSummary("short", 120), "short");
|
||||
assert.equal(truncateSummary("abcdefghijklmnopqrstuvwxyz", 10), "abcdefg...");
|
||||
});
|
||||
@@ -4,11 +4,11 @@ type: workplan
|
||||
title: "WSJF Triage Review Page (Workstreams section)"
|
||||
domain: custodian
|
||||
repo: state-hub
|
||||
status: proposed
|
||||
status: finished
|
||||
owner: custodian
|
||||
topic_slug: custodian
|
||||
created: "2026-06-02"
|
||||
updated: "2026-06-02"
|
||||
updated: "2026-06-03"
|
||||
state_hub_workstream_id: "0cca23a9-9640-491a-92db-6414db891019"
|
||||
---
|
||||
|
||||
@@ -182,7 +182,7 @@ dashboard); fall back to plain text if the slug is not indexed.
|
||||
|
||||
```task
|
||||
id: STATE-WP-0053-T01
|
||||
status: todo
|
||||
status: done
|
||||
priority: medium
|
||||
state_hub_task_id: "6d33aeed-03d2-4a04-95f4-d28fe35e22c7"
|
||||
```
|
||||
@@ -198,7 +198,7 @@ state in a dev preview.
|
||||
|
||||
```task
|
||||
id: STATE-WP-0053-T02
|
||||
status: todo
|
||||
status: done
|
||||
priority: medium
|
||||
depends_on: [STATE-WP-0053-T01]
|
||||
state_hub_task_id: "b3e569df-861f-4a65-8aa5-b011547e1fdb"
|
||||
@@ -215,7 +215,7 @@ in the table, summary truncated to ~120 characters.
|
||||
|
||||
```task
|
||||
id: STATE-WP-0053-T03
|
||||
status: todo
|
||||
status: done
|
||||
priority: medium
|
||||
depends_on: [STATE-WP-0053-T02]
|
||||
state_hub_task_id: "fcbaca4d-3186-4192-aa79-a2ebb12074ed"
|
||||
@@ -233,7 +233,7 @@ workstream detail page on the existing dashboard.
|
||||
|
||||
```task
|
||||
id: STATE-WP-0053-T04
|
||||
status: todo
|
||||
status: done
|
||||
priority: low
|
||||
depends_on: [STATE-WP-0053-T03]
|
||||
state_hub_task_id: "45441829-6f01-4b53-8ca7-f4d6f644f77c"
|
||||
@@ -252,7 +252,7 @@ and the legend matches the action vocabulary documented in
|
||||
|
||||
```task
|
||||
id: STATE-WP-0053-T05
|
||||
status: todo
|
||||
status: done
|
||||
priority: medium
|
||||
depends_on: [STATE-WP-0053-T02]
|
||||
state_hub_task_id: "8403b3b5-9f37-4ae7-a141-6fc7afbe8053"
|
||||
@@ -269,7 +269,7 @@ three or more days surfaces near the top of the pattern table.
|
||||
|
||||
```task
|
||||
id: STATE-WP-0053-T06
|
||||
status: todo
|
||||
status: done
|
||||
priority: low
|
||||
depends_on: [STATE-WP-0053-T05]
|
||||
state_hub_task_id: "ce0abbbd-9418-4016-9679-3b3abe0efd6b"
|
||||
|
||||
Reference in New Issue
Block a user