cool-workflow 0.2.5 → 0.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/.gemini-plugin/plugin.json +1 -1
  4. package/.opencode-plugin/plugin.json +1 -1
  5. package/README.md +5 -3
  6. package/apps/architecture-review/app.json +1 -1
  7. package/apps/architecture-review-fast/app.json +1 -1
  8. package/apps/end-to-end-golden-path/app.json +1 -1
  9. package/apps/pr-review-fix-ci/app.json +1 -1
  10. package/apps/release-cut/app.json +1 -1
  11. package/apps/research-synthesis/app.json +1 -1
  12. package/dist/core/state/run-paths.js +3 -30
  13. package/dist/core/version.js +1 -1
  14. package/dist/mcp/server.js +95 -27
  15. package/dist/mcp/tool-process.js +181 -0
  16. package/dist/mcp-server.js +8 -1
  17. package/dist/shell/agent-config.js +11 -2
  18. package/dist/shell/audit-cli.js +1 -1
  19. package/dist/shell/drive.js +99 -89
  20. package/dist/shell/execution-backend/agent.js +18 -15
  21. package/dist/shell/execution-backend/local.js +1 -0
  22. package/dist/shell/fs-atomic.js +3 -0
  23. package/dist/shell/metrics-cli.js +1 -1
  24. package/dist/shell/multi-agent-cli.js +4 -1
  25. package/dist/shell/multi-agent-operator-ux.js +1 -1
  26. package/dist/shell/observability.js +7 -4
  27. package/dist/shell/operator-ux.js +1 -1
  28. package/dist/shell/perf-trace.js +136 -0
  29. package/dist/shell/pipeline.js +1 -1
  30. package/dist/shell/report-view-cli.js +2 -0
  31. package/dist/shell/run-export-cli.js +5 -2
  32. package/dist/shell/run-export.js +241 -27
  33. package/dist/shell/run-registry-io.js +8 -0
  34. package/dist/shell/run-store.js +27 -3
  35. package/dist/shell/state-explosion-cli.js +4 -1
  36. package/dist/shell/telemetry-demo.js +1 -1
  37. package/dist/shell/trust-audit.js +91 -43
  38. package/dist/shell/workbench.js +4 -1
  39. package/dist/wiring/capability-table/parity.js +10 -5
  40. package/docs/agent-delegation-drive.7.md +2 -0
  41. package/docs/cli-mcp-parity.7.md +21 -5
  42. package/docs/contract-migration-tooling.7.md +2 -0
  43. package/docs/control-plane-scheduling.7.md +2 -0
  44. package/docs/durable-state-and-locking.7.md +2 -0
  45. package/docs/evidence-adoption-reasoning-chain.7.md +2 -0
  46. package/docs/execution-backends.7.md +2 -0
  47. package/docs/mcp-app-surface.7.md +21 -0
  48. package/docs/multi-agent-cli-mcp-surface.7.md +2 -0
  49. package/docs/multi-agent-eval-replay-harness.7.md +2 -0
  50. package/docs/multi-agent-operator-ux.7.md +2 -0
  51. package/docs/node-snapshot-diff-replay.7.md +2 -0
  52. package/docs/observability-cost-accounting.7.md +2 -0
  53. package/docs/project-index.md +18 -4
  54. package/docs/real-execution-backends.7.md +2 -0
  55. package/docs/release-and-migration.7.md +2 -0
  56. package/docs/release-tooling.7.md +10 -0
  57. package/docs/run-registry-control-plane.7.md +35 -9
  58. package/docs/run-retention-reclamation.7.md +2 -0
  59. package/docs/state-explosion-management.7.md +2 -0
  60. package/docs/team-collaboration.7.md +2 -0
  61. package/docs/trust-audit-anchor.7.md +2 -0
  62. package/docs/web-desktop-workbench.7.md +48 -0
  63. package/manifest/plugin.manifest.json +1 -1
  64. package/package.json +3 -2
  65. package/scripts/canonical-apps.js +4 -4
  66. package/scripts/dogfood-release.js +1 -1
  67. package/scripts/gen-manifests.js +32 -61
  68. package/scripts/golden-path.js +4 -4
  69. package/scripts/parity-check.js +42 -23
  70. package/scripts/purity-baseline.json +0 -3
  71. package/scripts/release-flow.js +43 -13
  72. package/scripts/release-gate.js +30 -6
  73. package/scripts/schema-version-inventory.json +31 -0
  74. package/scripts/validate-run-state-schema.js +95 -4
  75. package/scripts/verify-release-verdict.js +139 -0
  76. package/ui/workbench/app.css +15 -7
  77. package/ui/workbench/app.js +118 -16
  78. package/ui/workbench/index.html +2 -0
  79. package/ui/workbench/inspection.js +51 -0
  80. package/ui/workbench/navigation.js +44 -0
@@ -0,0 +1,139 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ // verify-release-verdict.js — the one true "find a signed, committed release
4
+ // verdict for this commit" check. Both CI workflows call it:
5
+ // .github/workflows/release-gate.yml (cwd = repo root)
6
+ // .github/workflows/npm-publish.yml (cwd = plugins/cool-workflow)
7
+ // Before this file, each workflow kept its own ~35-line bash copy of this
8
+ // same loop, and the two copies could drift apart. Now the logic lives here
9
+ // only, and each workflow step is one line.
10
+ //
11
+ // One more win: a GitHub Actions `run:` step prints its FULL script text to
12
+ // the log before it runs. With the old inline bash, the "::error::" lines of
13
+ // the fail branch were printed on EVERY run — even a fully green one — and
14
+ // GitHub's log UI marked them red, which made a good release look bad. With
15
+ // a one-line `run:`, the "::error::" text only ever gets printed when this
16
+ // script truly fails.
17
+ //
18
+ // WHY the check is shaped this way (was: near-same comments in both YAMLs):
19
+ // The tag commit itself bumps version/changelog; the reviewed sha is its
20
+ // parent. So a verdict for HEAD or HEAD~1 is accepted. But a validly-signed
21
+ // verdict for the PARENT alone does not prove HEAD is the same mechanical
22
+ // bump release-flow.js's cut() would have made on top of the approved
23
+ // parent — it could be an attacker's own commit smuggling in changes on top
24
+ // of an old, truly-approved parent, replaying that parent's already-public
25
+ // verdict + signature (public git objects; no secret is needed to copy
26
+ // them). So when the match is at HEAD~1, HEAD must ALSO reproduce exactly
27
+ // as the deterministic bump (verify-bump-reproduction.js).
28
+ //
29
+ // The pubkey is read from origin/main, NOT from the checked-out tree. The
30
+ // checked-out tree IS the commit under test — an attacker's tag could just
31
+ // delete .cw-release/verdict-signing.pub and fall back to a no-signature
32
+ // check. origin/main is a ref the tag being judged cannot rewrite. Until a
33
+ // pubkey is committed there, this stays a first-line-only text check
34
+ // (opt-in, backward compatible — see scripts/verdict-keygen.js).
35
+ //
36
+ // cwd does not matter: everything is anchored on
37
+ // `git rev-parse --show-toplevel`, and the two helper scripts are loaded
38
+ // from this script's own directory (never from the repo under test).
39
+ //
40
+ // Usage: verify-release-verdict.js [--context "<one sentence for the fail message>"]
41
+ // Exit 0: a valid verdict was found. Exit 1: none found — the two
42
+ // "::error::" lines are printed here and nowhere else.
43
+
44
+ const fs = require("node:fs");
45
+ const os = require("node:os");
46
+ const path = require("node:path");
47
+ const { spawnSync } = require("node:child_process");
48
+
49
+ // Where THIS SCRIPT lives — the helper scripts are siblings of the installed
50
+ // script, never files of the (possibly throwaway) repo being checked. Same
51
+ // rule as in verify-bump-reproduction.js.
52
+ const VERIFY_SIG = path.join(__dirname, "verify-verdict-signature.js");
53
+ const VERIFY_BUMP = path.join(__dirname, "verify-bump-reproduction.js");
54
+
55
+ function git(args) {
56
+ const r = spawnSync("git", args, { encoding: "utf8" });
57
+ return r.status === 0 ? r.stdout.trim() : "";
58
+ }
59
+
60
+ function main() {
61
+ let context = "The release flow was bypassed.";
62
+ const argv = process.argv.slice(2);
63
+ for (let i = 0; i < argv.length; i++) {
64
+ if (argv[i] === "--context" && argv[i + 1]) context = argv[++i];
65
+ }
66
+
67
+ const REPO_ROOT = git(["rev-parse", "--show-toplevel"]);
68
+ if (!REPO_ROOT) {
69
+ process.stderr.write("verify-release-verdict: not inside a git repo\n");
70
+ return 1;
71
+ }
72
+
73
+ const SHA = git(["-C", REPO_ROOT, "rev-parse", "HEAD^{commit}"]);
74
+ if (!SHA) {
75
+ process.stderr.write("verify-release-verdict: could not resolve HEAD\n");
76
+ return 1;
77
+ }
78
+ const PARENT = git(["-C", REPO_ROOT, "rev-parse", "HEAD~1"]) || "none";
79
+
80
+ let PUBKEY = "";
81
+ const pk = spawnSync("git", ["-C", REPO_ROOT, "show", "origin/main:.cw-release/verdict-signing.pub"], {
82
+ encoding: "utf8",
83
+ });
84
+ if (pk.status === 0 && pk.stdout.trim() !== "") {
85
+ PUBKEY = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "cw-verdict-pub-")), "verdict-signing.pub");
86
+ fs.writeFileSync(PUBKEY, pk.stdout);
87
+ }
88
+
89
+ for (const C of [SHA, PARENT]) {
90
+ if (C === "none") continue;
91
+ const VERDICT_REL = `.cw-release/review-${C}.verdict`;
92
+ const VERDICT = path.join(REPO_ROOT, VERDICT_REL);
93
+ if (!fs.existsSync(VERDICT)) continue;
94
+ // The first line must say EXACTLY "APPROVED <this candidate's sha>". A
95
+ // starts-with check is not enough: the signature binds the BYTES, never
96
+ // the file NAME, so a real signed verdict for some other sha could be
97
+ // parked under this candidate's filename and still verify.
98
+ const FIRST_LINE = fs.readFileSync(VERDICT, "utf8").split("\n", 1)[0];
99
+ if (FIRST_LINE !== `APPROVED ${C}`) continue;
100
+
101
+ if (PUBKEY) {
102
+ const SIG_REL = `${VERDICT_REL}.sig`;
103
+ const SIG = path.join(REPO_ROOT, SIG_REL);
104
+ const sigOk =
105
+ fs.existsSync(SIG) &&
106
+ spawnSync(process.execPath, [VERIFY_SIG, VERDICT, SIG, PUBKEY], { stdio: "inherit" }).status === 0;
107
+ if (sigOk) {
108
+ if (C !== SHA) {
109
+ const bump = spawnSync(process.execPath, [VERIFY_BUMP, C, SHA, VERDICT_REL, SIG_REL], {
110
+ cwd: REPO_ROOT,
111
+ stdio: "inherit",
112
+ });
113
+ if (bump.status !== 0) {
114
+ console.log(
115
+ `verdict for ${C} has a valid signature but HEAD does not reproduce as its deterministic bump — trying the other candidate`
116
+ );
117
+ continue;
118
+ }
119
+ }
120
+ console.log(`verdict found + signature verified for ${C}`);
121
+ return 0;
122
+ }
123
+ console.log(`verdict for ${C} has no valid signature — trying the other candidate`);
124
+ continue;
125
+ }
126
+ console.log(`verdict found for ${C} (no verdict-signing.pub committed yet — signature not required)`);
127
+ return 0;
128
+ }
129
+
130
+ console.log(
131
+ `::error::No committed APPROVED verdict for this tag (with a valid signature, if .cw-release/verdict-signing.pub is committed). ${context}`
132
+ );
133
+ console.log(
134
+ "::error::Expected: .cw-release/review-<sha>.verdict with first line 'APPROVED <sha>' — auto-created by release-flow.js --cut"
135
+ );
136
+ return 1;
137
+ }
138
+
139
+ process.exit(main());
@@ -63,24 +63,26 @@ input[type="search"] {
63
63
  }
64
64
  .freshness { font-size: 12px; color: var(--muted); margin-bottom: 8px; }
65
65
  .run-list { list-style: none; margin: 0; padding: 0; }
66
- .run-list li,
67
- .run-list button {
66
+ .run-list > li { margin-bottom: 6px; }
67
+ .run-list > li:not(.run-entry),
68
+ .run-list .run-entry > button {
68
69
  padding: 8px; border: 1px solid var(--line); border-radius: 6px;
69
- margin-bottom: 6px; cursor: pointer; background: var(--panel);
70
+ cursor: pointer; background: var(--panel);
70
71
  }
71
72
  /* Only the run-row <button> needs a plain-element reset: a <button> has
72
73
  its own default display/width/font/color, a plain <li> (used only for
73
74
  the error and empty-state rows) does not and must keep its own color
74
75
  (.err red, .muted gray) — see app.js. */
75
- .run-list button {
76
+ .run-list .run-entry > button {
76
77
  display: block; width: 100%; text-align: left; font: inherit; color: inherit;
77
78
  }
78
- .run-list li:hover, .run-list button:hover { border-color: var(--accent); }
79
- .run-list li.active, .run-list button.active { border-color: var(--accent); background: var(--panel-2); }
79
+ .run-list > li:not(.run-entry):hover, .run-list .run-entry > button:hover { border-color: var(--accent); }
80
+ .run-list .run-entry > button.active { border-color: var(--accent); background: var(--panel-2); }
80
81
  /* Run rows are now real <button> elements (for Tab/Enter/Space); make the
81
82
  keyboard focus ring visible — there is no :focus-visible rule for this
82
83
  element otherwise. */
83
- .run-list button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
84
+ .run-list button:focus-visible,
85
+ .tabs button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
84
86
  .run-list .rid { font-family: ui-monospace, monospace; font-size: 12px; display: flex; align-items: center; gap: 6px; }
85
87
  .run-list .meta { color: var(--muted); font-size: 12px; margin-top: 2px; }
86
88
  .status-dot {
@@ -126,6 +128,12 @@ pre.json {
126
128
  color: var(--ink); white-space: pre; max-height: 460px;
127
129
  }
128
130
  .panel-card .absent-note { padding: 10px 12px; color: var(--absent); font-size: 12px; }
131
+ .what-matters { padding: 10px 12px; border-top: 1px solid var(--line); background: var(--panel-2); }
132
+ .what-matters-title { margin: 0 0 8px; font-size: 12px; text-transform: uppercase; letter-spacing: 0.04em; color: var(--muted); }
133
+ .action-fact { display: grid; grid-template-columns: minmax(100px, 140px) 1fr; gap: 10px; margin-top: 6px; }
134
+ .action-fact:first-of-type { margin-top: 0; }
135
+ .action-label { color: var(--muted); font-size: 12px; }
136
+ .action-items { margin: 0; padding-left: 18px; font: 12px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; white-space: pre-wrap; overflow-wrap: anywhere; }
129
137
  .kv { display: flex; gap: 16px; flex-wrap: wrap; padding: 8px 12px; font-size: 12px; color: var(--muted); }
130
138
  .kv b { color: var(--ink); font-weight: 600; }
131
139
  .err { color: var(--bad); }
@@ -15,12 +15,24 @@ const PANEL_GROUPS = [
15
15
  { key: "collaboration", label: "Review & collaboration", panels: ["review", "comments"] }
16
16
  ];
17
17
 
18
+ const NAV = globalThis.CWWorkbenchNavigation;
19
+ if (!NAV) throw new Error("Workbench navigation helper is not available");
20
+ const INSPECTION = globalThis.CWWorkbenchInspection;
21
+ if (!INSPECTION) throw new Error("Workbench inspection helper is not available");
22
+
18
23
  // `indexSeq` is a request sequence number: the debounced filter input can
19
24
  // start a second /api/index fetch while an older one is still in flight, and
20
25
  // only the NEWEST request may render (an old slow response must not
21
26
  // overwrite a new fast one). `viewFetchedAt` is when the active run's view
22
27
  // was fetched, shown as "as of HH:MM:SS" in the detail header.
23
- const state = { activeRunId: null, activeTab: "graph", indexSeq: 0, viewFetchedAt: null };
28
+ const state = {
29
+ activeRunId: null,
30
+ activeTab: NAV.DEFAULT_TAB,
31
+ currentView: null,
32
+ detailSeq: 0,
33
+ indexSeq: 0,
34
+ viewFetchedAt: null
35
+ };
24
36
 
25
37
  // The host's auth token, read ONCE at startup from the page URL. When
26
38
  // CW_WORKBENCH_TOKEN is set on the host, every /api/* request must carry it;
@@ -28,6 +40,16 @@ const state = { activeRunId: null, activeTab: "graph", indexSeq: 0, viewFetchedA
28
40
  // and can explain what to do instead of rendering broken.
29
41
  const TOKEN = new URLSearchParams(location.search).get("token") || "";
30
42
 
43
+ function routeUrl(runId, tab) {
44
+ return `${location.pathname}${location.search}${NAV.formatFragment(runId, tab)}`;
45
+ }
46
+
47
+ function writeRoute(runId, tab, mode = "push") {
48
+ const fragment = NAV.formatFragment(runId, tab);
49
+ if (location.hash === fragment && mode === "push") return;
50
+ history[mode === "replace" ? "replaceState" : "pushState"](null, "", routeUrl(runId, tab));
51
+ }
52
+
31
53
  // Build a request URL with URLSearchParams so the token composes with any
32
54
  // other query params (e.g. the index filter's ?text=).
33
55
  function apiUrl(pathname, params = {}) {
@@ -83,14 +105,14 @@ async function loadIndex() {
83
105
  // A loading placeholder, not a bare wipe: the same pattern the detail
84
106
  // pane uses, so the sidebar never flashes empty while the fetch runs.
85
107
  list.innerHTML = "";
86
- list.appendChild(el("li", { class: "muted", text: "loading runs…" }));
108
+ list.appendChild(el("li", { class: "muted", text: "loading runs…", role: "status" }));
87
109
  let view;
88
110
  try {
89
111
  view = await getJson(apiUrl("/api/index", { text: filter }));
90
112
  } catch (error) {
91
113
  if (seq !== state.indexSeq) return;
92
114
  list.innerHTML = "";
93
- list.appendChild(el("li", { class: "err", text: `failed to load index: ${error.message}` }));
115
+ list.appendChild(el("li", { class: "err", text: `failed to load index: ${error.message}`, role: "alert" }));
94
116
  // Clear the stale "registry valid · scope …" line — leaving the last
95
117
  // successful freshness next to a load error is itself a stale badge on
96
118
  // the one panel whose whole point is freshness.
@@ -144,7 +166,7 @@ async function loadIndex() {
144
166
  })
145
167
  ]);
146
168
  btn.addEventListener("click", () => selectRun(record.runId));
147
- list.appendChild(btn);
169
+ list.appendChild(el("li", { class: "run-entry" }, [btn]));
148
170
  }
149
171
  }
150
172
 
@@ -155,33 +177,40 @@ async function loadIndex() {
155
177
  function markActiveRow(runId) {
156
178
  const list = document.getElementById("run-list");
157
179
  for (const btn of list.querySelectorAll("button[data-runid]")) {
158
- btn.classList.toggle("active", btn.getAttribute("data-runid") === runId);
180
+ const active = btn.getAttribute("data-runid") === runId;
181
+ btn.classList.toggle("active", active);
182
+ if (active) btn.setAttribute("aria-current", "true");
183
+ else btn.removeAttribute("aria-current");
159
184
  }
160
185
  }
161
186
 
162
- function selectRun(runId) {
187
+ function selectRun(runId, options = {}) {
163
188
  state.activeRunId = runId;
189
+ if (!state.currentView || state.currentView.runId !== runId) state.currentView = null;
164
190
  markActiveRow(runId);
191
+ if (options.history !== false) writeRoute(runId, state.activeTab);
165
192
  return loadRunDetail(runId);
166
193
  }
167
194
 
168
195
  async function loadRunDetail(runId) {
196
+ const seq = ++state.detailSeq;
169
197
  const detail = document.getElementById("run-panel");
170
198
  detail.innerHTML = "";
171
- detail.appendChild(el("p", { class: "muted", text: `loading ${runId}…` }));
199
+ detail.appendChild(el("p", { class: "muted", text: `loading ${runId}…`, role: "status" }));
172
200
  let view;
173
201
  try {
174
202
  view = await getJson(apiUrl(`/api/run/${encodeURIComponent(runId)}`));
175
203
  } catch (error) {
176
- if (state.activeRunId !== runId) return;
204
+ if (state.activeRunId !== runId || seq !== state.detailSeq) return;
177
205
  detail.innerHTML = "";
178
- detail.appendChild(el("p", { class: "err", text: `failed to load run: ${error.message}` }));
206
+ detail.appendChild(el("p", { class: "err", text: `failed to load run: ${error.message}`, role: "alert" }));
179
207
  return;
180
208
  }
181
209
  // The user may have clicked another run while this fetch was in flight;
182
210
  // a stale response must not overwrite the newer selection's render.
183
- if (state.activeRunId !== runId) return;
211
+ if (state.activeRunId !== runId || seq !== state.detailSeq) return;
184
212
  state.viewFetchedAt = new Date();
213
+ state.currentView = view;
185
214
  renderRun(view);
186
215
  }
187
216
 
@@ -198,7 +227,13 @@ function formatClock(date) {
198
227
  return date ? date.toTimeString().slice(0, 8) : "";
199
228
  }
200
229
 
201
- function renderRun(view) {
230
+ function selectTab(tab, options = {}) {
231
+ state.activeTab = NAV.TAB_KEYS.includes(tab) ? tab : NAV.DEFAULT_TAB;
232
+ if (options.history !== false && state.activeRunId) writeRoute(state.activeRunId, state.activeTab);
233
+ if (state.currentView) renderRun(state.currentView, { focusTab: options.focus === true });
234
+ }
235
+
236
+ function renderRun(view, options = {}) {
202
237
  const detail = document.getElementById("run-panel");
203
238
  detail.innerHTML = "";
204
239
  const header = el("div", { class: "kv" }, [
@@ -223,14 +258,22 @@ function renderRun(view) {
223
258
  for (const group of PANEL_GROUPS) {
224
259
  const active = state.activeTab === group.key;
225
260
  const btn = el("button", {
261
+ id: `workbench-tab-${group.key}`,
226
262
  class: `tab ${active ? "active" : ""}`,
227
263
  text: group.label,
228
264
  role: "tab",
229
- "aria-selected": active ? "true" : "false"
265
+ "aria-controls": `workbench-panel-${group.key}`,
266
+ "aria-selected": active ? "true" : "false",
267
+ tabindex: active ? "0" : "-1"
268
+ });
269
+ btn.addEventListener("click", (event) => {
270
+ selectTab(group.key, { focus: event.detail === 0 });
230
271
  });
231
- btn.addEventListener("click", () => {
232
- state.activeTab = group.key;
233
- renderRun(view);
272
+ btn.addEventListener("keydown", (event) => {
273
+ const next = NAV.moveTab(state.activeTab, event.key);
274
+ if (next === state.activeTab && !["Home", "End", "ArrowLeft", "ArrowRight"].includes(event.key)) return;
275
+ event.preventDefault();
276
+ selectTab(next, { focus: true });
234
277
  });
235
278
  tabs.appendChild(btn);
236
279
  }
@@ -238,6 +281,11 @@ function renderRun(view) {
238
281
 
239
282
  const group = PANEL_GROUPS.find((g) => g.key === state.activeTab) || PANEL_GROUPS[0];
240
283
  const panels = (view.panels && view.panels[group.key]) || {};
284
+ const tabPanel = el("section", {
285
+ id: `workbench-panel-${group.key}`,
286
+ role: "tabpanel",
287
+ "aria-labelledby": `workbench-tab-${group.key}`
288
+ });
241
289
  // Some groups declare two panel names that map to the same capability on
242
290
  // the server (e.g. graph's compact/criticalPath both come from
243
291
  // summary.show), so they carry byte-identical payloads. Render such a pair
@@ -252,8 +300,10 @@ function renderRun(view) {
252
300
  else rendered.push({ names: [name], panel });
253
301
  }
254
302
  for (const entry of rendered) {
255
- detail.appendChild(renderPanel(entry.names.join(" / "), entry.panel));
303
+ tabPanel.appendChild(renderPanel(entry.names.join(" / "), entry.panel));
256
304
  }
305
+ detail.appendChild(tabPanel);
306
+ if (options.focusTab) document.getElementById(`workbench-tab-${group.key}`).focus();
257
307
  }
258
308
 
259
309
  function renderPanel(name, panel) {
@@ -265,6 +315,8 @@ function renderPanel(name, panel) {
265
315
  card.appendChild(head);
266
316
  card.appendChild(el("div", { class: "kv" }, [el("span", { class: "src", text: panel.cli }), el("span", { class: "src", text: panel.mcp })]));
267
317
  if (panel.status === "present") {
318
+ const actionSummary = renderActionSummary(panel.data);
319
+ if (actionSummary) card.appendChild(actionSummary);
268
320
  card.appendChild(renderStructured(panel.data) || el("pre", { class: "json", text: JSON.stringify(panel.data, null, 2) }));
269
321
  } else {
270
322
  card.appendChild(el("div", { class: "absent-note", text: `absent — ${panel.error || "source unreadable"}` }));
@@ -272,6 +324,23 @@ function renderPanel(name, panel) {
272
324
  return card;
273
325
  }
274
326
 
327
+ function renderActionSummary(data) {
328
+ const facts = INSPECTION.actionFacts(data);
329
+ if (facts.length === 0) return null;
330
+ const summary = el("section", { class: "what-matters", "aria-label": "What matters" }, [
331
+ el("h3", { class: "what-matters-title", text: "What matters" })
332
+ ]);
333
+ for (const fact of facts) {
334
+ summary.appendChild(
335
+ el("div", { class: `action-fact ${fact.key}` }, [
336
+ el("div", { class: "action-label", text: fact.label }),
337
+ el("ul", { class: "action-items" }, fact.items.map((item) => el("li", { text: item })))
338
+ ])
339
+ );
340
+ }
341
+ return summary;
342
+ }
343
+
275
344
  // Purely presentational shape-detection: recognizes the two payload shapes
276
345
  // that recur across several capabilities (a nodes/edges graph, and one or
277
346
  // more TrustAuditEvent[] arrays) and tables them instead of dumping raw
@@ -396,8 +465,40 @@ function renderEventGroups(data, confirmedEventKeys) {
396
465
  return wrap;
397
466
  }
398
467
 
468
+ function showIndexOnly() {
469
+ ++state.detailSeq;
470
+ state.currentView = null;
471
+ const detail = document.getElementById("run-panel");
472
+ detail.innerHTML = "";
473
+ detail.appendChild(
474
+ el("p", {
475
+ class: "empty",
476
+ text: "Select a run to inspect its graph, blackboard, worker logs, candidate compare, and audit timeline."
477
+ })
478
+ );
479
+ }
480
+
481
+ function applyLocationRoute() {
482
+ const route = NAV.parseFragment(location.hash);
483
+ state.activeRunId = route.runId;
484
+ state.activeTab = route.tab;
485
+ if (route.replace) writeRoute(route.runId, route.tab, "replace");
486
+ markActiveRow(route.runId);
487
+ if (!route.runId) {
488
+ showIndexOnly();
489
+ return;
490
+ }
491
+ if (state.currentView && state.currentView.runId === route.runId) {
492
+ renderRun(state.currentView);
493
+ return;
494
+ }
495
+ state.currentView = null;
496
+ loadRunDetail(route.runId);
497
+ }
498
+
399
499
  document.getElementById("refresh").addEventListener("click", refreshAll);
400
500
  document.getElementById("filter").addEventListener("input", debounce(loadIndex, 200));
501
+ window.addEventListener("popstate", applyLocationRoute);
401
502
 
402
503
  function debounce(fn, ms) {
403
504
  let timer;
@@ -408,3 +509,4 @@ function debounce(fn, ms) {
408
509
  }
409
510
 
410
511
  loadIndex();
512
+ applyLocationRoute();
@@ -27,6 +27,8 @@
27
27
  <p class="empty">Select a run to inspect its graph, blackboard, worker logs, candidate compare, and audit timeline.</p>
28
28
  </section>
29
29
  </main>
30
+ <script src="/ui/navigation.js"></script>
31
+ <script src="/ui/inspection.js"></script>
30
32
  <script src="/ui/app.js"></script>
31
33
  </body>
32
34
  </html>
@@ -0,0 +1,51 @@
1
+ "use strict";
2
+ // Pure action-first projection. It copies a small set of facts from one
3
+ // capability payload for display; it makes no new state, rank, or action.
4
+
5
+ (function expose(root, factory) {
6
+ const inspection = factory();
7
+ if (typeof module === "object" && module.exports) module.exports = inspection;
8
+ if (root) root.CWWorkbenchInspection = inspection;
9
+ })(typeof globalThis === "object" ? globalThis : this, function buildInspection() {
10
+ function compactValue(value) {
11
+ if (typeof value === "string") return value;
12
+ const encoded = JSON.stringify(value);
13
+ return typeof encoded === "string" ? encoded : null;
14
+ }
15
+
16
+ function arrayFact(data, key, label, emptyMeansNone) {
17
+ if (!Array.isArray(data[key])) return null;
18
+ const items = data[key].map(compactValue).filter((item) => item !== null);
19
+ if (items.length === 0 && emptyMeansNone) items.push("none");
20
+ return items.length > 0 ? { key, label, items } : null;
21
+ }
22
+
23
+ function actionFacts(data) {
24
+ if (!data || typeof data !== "object" || Array.isArray(data)) return [];
25
+ const facts = [];
26
+ const integrity = data.integrity;
27
+ if (integrity && typeof integrity === "object" && !Array.isArray(integrity)) {
28
+ const items = [];
29
+ if (typeof integrity.verified === "boolean") items.push(`verified: ${integrity.verified}`);
30
+ if (typeof integrity.eventCount === "number" && Number.isFinite(integrity.eventCount)) items.push(`event count: ${integrity.eventCount}`);
31
+ if (typeof integrity.corruptLines === "number" && Number.isFinite(integrity.corruptLines)) items.push(`corrupt lines: ${integrity.corruptLines}`);
32
+ if (items.length > 0) facts.push({ key: "integrity", label: "integrity", items });
33
+ }
34
+
35
+ const problems = arrayFact(data, "problems", "problems", true);
36
+ if (problems) facts.push(problems);
37
+ const missing = arrayFact(data, "missingEvidence", "missing evidence", true);
38
+ if (missing) facts.push(missing);
39
+
40
+ if (typeof data.nextAction === "string" && data.nextAction.length > 0) {
41
+ facts.push({ key: "nextAction", label: "next action", items: [data.nextAction] });
42
+ }
43
+ if (Array.isArray(data.nextActions)) {
44
+ const items = data.nextActions.filter((item) => typeof item === "string" && item.length > 0);
45
+ if (items.length > 0) facts.push({ key: "nextActions", label: "next actions", items });
46
+ }
47
+ return facts;
48
+ }
49
+
50
+ return { actionFacts };
51
+ });
@@ -0,0 +1,44 @@
1
+ "use strict";
2
+ // Pure Workbench navigation policy. The browser page and Node unit test both
3
+ // use this file; it holds no run data and does no file or network work.
4
+
5
+ (function expose(root, factory) {
6
+ const navigation = factory();
7
+ if (typeof module === "object" && module.exports) module.exports = navigation;
8
+ if (root) root.CWWorkbenchNavigation = navigation;
9
+ })(typeof globalThis === "object" ? globalThis : this, function buildNavigation() {
10
+ const TAB_KEYS = ["graph", "blackboard", "worker", "candidate", "audit", "metrics", "collaboration"];
11
+ const DEFAULT_TAB = TAB_KEYS[0];
12
+
13
+ function knownTab(value) {
14
+ return TAB_KEYS.includes(value) ? value : DEFAULT_TAB;
15
+ }
16
+
17
+ function parseFragment(fragment) {
18
+ const source = typeof fragment === "string" && fragment.startsWith("#") ? fragment.slice(1) : fragment || "";
19
+ const params = new URLSearchParams(source);
20
+ const run = params.get("run");
21
+ const requestedTab = params.get("tab");
22
+ return {
23
+ runId: run ? run : null,
24
+ tab: knownTab(requestedTab),
25
+ replace: requestedTab !== null && !TAB_KEYS.includes(requestedTab),
26
+ };
27
+ }
28
+
29
+ function formatFragment(runId, tab) {
30
+ if (!runId) return "";
31
+ return `#run=${encodeURIComponent(runId)}&tab=${encodeURIComponent(knownTab(tab))}`;
32
+ }
33
+
34
+ function moveTab(current, key) {
35
+ const index = Math.max(0, TAB_KEYS.indexOf(knownTab(current)));
36
+ if (key === "Home") return TAB_KEYS[0];
37
+ if (key === "End") return TAB_KEYS[TAB_KEYS.length - 1];
38
+ if (key === "ArrowLeft") return TAB_KEYS[(index - 1 + TAB_KEYS.length) % TAB_KEYS.length];
39
+ if (key === "ArrowRight") return TAB_KEYS[(index + 1) % TAB_KEYS.length];
40
+ return TAB_KEYS[index];
41
+ }
42
+
43
+ return { DEFAULT_TAB, TAB_KEYS, formatFragment, moveTab, parseFragment };
44
+ });