cool-workflow 0.2.4 → 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 (155) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/.gemini-plugin/mcp.json +10 -0
  4. package/.gemini-plugin/plugin.json +40 -0
  5. package/.opencode-plugin/mcp.json +10 -0
  6. package/.opencode-plugin/plugin.json +40 -0
  7. package/README.md +204 -48
  8. package/apps/architecture-review/app.json +1 -1
  9. package/apps/architecture-review-fast/app.json +1 -1
  10. package/apps/end-to-end-golden-path/app.json +1 -1
  11. package/apps/pr-review-fix-ci/app.json +1 -1
  12. package/apps/release-cut/app.json +1 -1
  13. package/apps/research-synthesis/app.json +1 -1
  14. package/dist/cli/dispatch.js +32 -4
  15. package/dist/cli/entry.js +11 -19
  16. package/dist/cli/global-flags.js +113 -0
  17. package/dist/cli/parseargv.js +7 -1
  18. package/dist/core/capability-data.js +337 -8
  19. package/dist/core/format/help.js +73 -3
  20. package/dist/core/format/recovery-hint.js +32 -0
  21. package/dist/core/multi-agent/collaboration.js +35 -6
  22. package/dist/core/multi-agent/runtime.js +7 -0
  23. package/dist/core/multi-agent/trust-policy.js +7 -1
  24. package/dist/core/pipeline/contract.js +7 -0
  25. package/dist/core/pipeline/error-feedback.js +2 -2
  26. package/dist/core/state/run-paths.js +3 -30
  27. package/dist/core/trust/evidence-grounding.js +13 -1
  28. package/dist/core/util/cli-args.js +22 -0
  29. package/dist/core/version.js +1 -1
  30. package/dist/mcp/dispatch.js +22 -2
  31. package/dist/mcp/server.js +215 -36
  32. package/dist/mcp/tool-process.js +181 -0
  33. package/dist/mcp-server.js +28 -1
  34. package/dist/shell/agent-config.js +11 -2
  35. package/dist/shell/audit-cli.js +1 -1
  36. package/dist/shell/commit.js +8 -2
  37. package/dist/shell/coordinator-io.js +73 -1
  38. package/dist/shell/drive.js +195 -128
  39. package/dist/shell/error-feedback-io.js +6 -0
  40. package/dist/shell/execution-backend/agent.js +201 -26
  41. package/dist/shell/execution-backend/container.js +44 -10
  42. package/dist/shell/execution-backend/local.js +33 -0
  43. package/dist/shell/fs-atomic.js +96 -12
  44. package/dist/shell/ledger-cli.js +9 -2
  45. package/dist/shell/metrics-cli.js +1 -1
  46. package/dist/shell/multi-agent-cli.js +9 -2
  47. package/dist/shell/multi-agent-operator-ux.js +1 -1
  48. package/dist/shell/observability.js +7 -4
  49. package/dist/shell/onramp.js +48 -5
  50. package/dist/shell/operator-ux.js +1 -1
  51. package/dist/shell/perf-trace.js +136 -0
  52. package/dist/shell/pipeline.js +3 -2
  53. package/dist/shell/reclamation-io.js +10 -9
  54. package/dist/shell/report-view-cli.js +2 -0
  55. package/dist/shell/run-export-cli.js +5 -2
  56. package/dist/shell/run-export.js +293 -31
  57. package/dist/shell/run-registry-io.js +8 -0
  58. package/dist/shell/run-store.js +183 -3
  59. package/dist/shell/scheduler-io.js +101 -10
  60. package/dist/shell/state-explosion-cli.js +4 -1
  61. package/dist/shell/telemetry-demo.js +1 -1
  62. package/dist/shell/telemetry-ledger-io.js +36 -24
  63. package/dist/shell/trust-audit.js +178 -36
  64. package/dist/shell/workbench-host.js +121 -10
  65. package/dist/shell/workbench.js +83 -6
  66. package/dist/shell/worker-isolation.js +1 -1
  67. package/dist/wiring/capability-table/basics.js +5 -0
  68. package/dist/wiring/capability-table/exec-backend.js +40 -22
  69. package/dist/wiring/capability-table/parity.js +10 -5
  70. package/dist/wiring/capability-table/pipeline.js +32 -0
  71. package/dist/wiring/capability-table/registry-core.js +26 -3
  72. package/dist/wiring/capability-table/reporting.js +7 -1
  73. package/dist/wiring/capability-table/scheduling-registry.js +8 -2
  74. package/dist/wiring/capability-table/trust-ledger.js +54 -12
  75. package/docs/agent-delegation-drive.7.md +17 -0
  76. package/docs/cli-mcp-parity.7.md +46 -5
  77. package/docs/contract-migration-tooling.7.md +4 -0
  78. package/docs/control-plane-scheduling.7.md +38 -0
  79. package/docs/cross-agent-ledger.7.md +20 -8
  80. package/docs/durable-state-and-locking.7.md +4 -0
  81. package/docs/evidence-adoption-reasoning-chain.7.md +4 -0
  82. package/docs/execution-backends.7.md +4 -0
  83. package/docs/fix.7.md +4 -4
  84. package/docs/getting-started.md +40 -32
  85. package/docs/index.md +17 -0
  86. package/docs/launch/demo.tape +4 -3
  87. package/docs/mcp-app-surface.7.md +27 -0
  88. package/docs/multi-agent-cli-mcp-surface.7.md +4 -0
  89. package/docs/multi-agent-eval-replay-harness.7.md +4 -0
  90. package/docs/multi-agent-operator-ux.7.md +4 -0
  91. package/docs/node-snapshot-diff-replay.7.md +4 -0
  92. package/docs/observability-cost-accounting.7.md +4 -0
  93. package/docs/project-index.md +53 -9
  94. package/docs/real-execution-backends.7.md +4 -0
  95. package/docs/release-and-migration.7.md +4 -0
  96. package/docs/release-tooling.7.md +12 -0
  97. package/docs/routine.7.md +22 -0
  98. package/docs/run-registry-control-plane.7.md +37 -9
  99. package/docs/run-retention-reclamation.7.md +12 -4
  100. package/docs/state-explosion-management.7.md +4 -0
  101. package/docs/team-collaboration.7.md +4 -0
  102. package/docs/trust-audit-anchor.7.md +4 -0
  103. package/docs/web-desktop-workbench.7.md +72 -2
  104. package/docs/workflow-app-framework.7.md +31 -0
  105. package/manifest/plugin.manifest.json +1 -1
  106. package/manifest/source-context-profiles.json +4 -3
  107. package/package.json +6 -3
  108. package/scripts/agents/agent-adapter-core.js +26 -0
  109. package/scripts/agents/claude-p-agent.js +4 -1
  110. package/scripts/agents/codex-agent.js +4 -0
  111. package/scripts/agents/cw-attest-wrap.js +1 -1
  112. package/scripts/agents/gemini-agent.js +4 -0
  113. package/scripts/agents/opencode-agent.js +5 -0
  114. package/scripts/block-unapproved-tag.js +160 -0
  115. package/scripts/canonical-apps.js +4 -4
  116. package/scripts/children/batch-delegate-child.js +72 -4
  117. package/scripts/children/http-batch-delegate-child.js +132 -0
  118. package/scripts/children/http-delegate-child.js +8 -0
  119. package/scripts/dogfood-release.js +2 -2
  120. package/scripts/fake-date-for-reproduction.js +1 -1
  121. package/scripts/gen-manifests.js +32 -61
  122. package/scripts/golden-path.js +4 -4
  123. package/scripts/lang-policy-check.js +82 -0
  124. package/scripts/parity-check.js +42 -23
  125. package/scripts/purity-baseline.json +0 -3
  126. package/scripts/purity-gate.js +16 -3
  127. package/scripts/release-check.js +2 -1
  128. package/scripts/release-flow.js +169 -41
  129. package/scripts/release-gate.js +208 -0
  130. package/scripts/release-oneclick.js +38 -7
  131. package/scripts/release-tags.js +47 -0
  132. package/scripts/schema-version-inventory.json +31 -0
  133. package/scripts/validate-run-state-schema.js +95 -4
  134. package/scripts/verdict-keygen.js +2 -2
  135. package/scripts/verify-bump-reproduction.js +193 -0
  136. package/scripts/verify-release-verdict.js +139 -0
  137. package/scripts/verify-verdict-signature.js +1 -1
  138. package/ui/workbench/app.css +50 -10
  139. package/ui/workbench/app.js +269 -34
  140. package/ui/workbench/index.html +4 -2
  141. package/ui/workbench/inspection.js +51 -0
  142. package/ui/workbench/navigation.js +44 -0
  143. package/docs/agent-framework.md +0 -177
  144. package/docs/designs/handoff-ledger.md +0 -145
  145. package/docs/dogfood/resume-drive-real-agent-2026-06-14.md +0 -40
  146. package/docs/launch/launch-kit.md +0 -195
  147. package/docs/launch/pre-launch-checklist.md +0 -53
  148. package/docs/readme-v0.1.87-full.md +0 -301
  149. package/docs/routines.md +0 -101
  150. package/docs/scheduled-tasks.md +0 -80
  151. package/scripts/agents/claude-p-agent.sh +0 -9
  152. package/scripts/block-unapproved-tag.sh +0 -75
  153. package/scripts/release-gate.sh +0 -94
  154. package/scripts/verify-bump-reproduction.sh +0 -148
  155. package/scripts/verify-container-selfref.js +0 -64
@@ -15,7 +15,52 @@ const PANEL_GROUPS = [
15
15
  { key: "collaboration", label: "Review & collaboration", panels: ["review", "comments"] }
16
16
  ];
17
17
 
18
- const state = { activeRunId: null, activeTab: "graph" };
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
+
23
+ // `indexSeq` is a request sequence number: the debounced filter input can
24
+ // start a second /api/index fetch while an older one is still in flight, and
25
+ // only the NEWEST request may render (an old slow response must not
26
+ // overwrite a new fast one). `viewFetchedAt` is when the active run's view
27
+ // was fetched, shown as "as of HH:MM:SS" in the detail header.
28
+ const state = {
29
+ activeRunId: null,
30
+ activeTab: NAV.DEFAULT_TAB,
31
+ currentView: null,
32
+ detailSeq: 0,
33
+ indexSeq: 0,
34
+ viewFetchedAt: null
35
+ };
36
+
37
+ // The host's auth token, read ONCE at startup from the page URL. When
38
+ // CW_WORKBENCH_TOKEN is set on the host, every /api/* request must carry it;
39
+ // the static UI files themselves are served without it, so the page loads
40
+ // and can explain what to do instead of rendering broken.
41
+ const TOKEN = new URLSearchParams(location.search).get("token") || "";
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
+
53
+ // Build a request URL with URLSearchParams so the token composes with any
54
+ // other query params (e.g. the index filter's ?text=).
55
+ function apiUrl(pathname, params = {}) {
56
+ const search = new URLSearchParams();
57
+ for (const [key, value] of Object.entries(params)) {
58
+ if (value) search.set(key, value);
59
+ }
60
+ if (TOKEN) search.set("token", TOKEN);
61
+ const query = search.toString();
62
+ return query ? `${pathname}?${query}` : pathname;
63
+ }
19
64
 
20
65
  async function getJson(url) {
21
66
  const res = await fetch(url, { headers: { Accept: "application/json" } });
@@ -24,7 +69,10 @@ async function getJson(url) {
24
69
  try {
25
70
  body = JSON.parse(text);
26
71
  } catch {
27
- throw new Error(`non-JSON response (${res.status})`);
72
+ throw new Error(`non-JSON response (${res.status}): ${text.slice(0, 120)}`);
73
+ }
74
+ if (res.status === 401) {
75
+ throw new Error("unauthorized — reopen as /?token=<your CW_WORKBENCH_TOKEN value>");
28
76
  }
29
77
  if (!res.ok) throw new Error(body && body.error ? body.error : `HTTP ${res.status}`);
30
78
  return body;
@@ -43,34 +91,71 @@ function el(tag, attrs = {}, children = []) {
43
91
  return node;
44
92
  }
45
93
 
46
- function freshnessBadge(value) {
94
+ function freshnessBadge(value, title) {
47
95
  const v = String(value || "").toLowerCase();
48
- return el("span", { class: `badge ${v || "absent"}`, text: value || "unknown" });
96
+ const attrs = { class: `badge ${v || "absent"}`, text: value || "unknown" };
97
+ if (title) attrs.title = title;
98
+ return el("span", attrs);
49
99
  }
50
100
 
51
101
  async function loadIndex() {
102
+ const seq = ++state.indexSeq;
52
103
  const filter = document.getElementById("filter").value.trim();
53
104
  const list = document.getElementById("run-list");
105
+ // A loading placeholder, not a bare wipe: the same pattern the detail
106
+ // pane uses, so the sidebar never flashes empty while the fetch runs.
54
107
  list.innerHTML = "";
108
+ list.appendChild(el("li", { class: "muted", text: "loading runs…", role: "status" }));
55
109
  let view;
56
110
  try {
57
- view = await getJson(`/api/index${filter ? `?text=${encodeURIComponent(filter)}` : ""}`);
111
+ view = await getJson(apiUrl("/api/index", { text: filter }));
58
112
  } catch (error) {
59
- list.appendChild(el("li", { class: "err", text: `failed to load index: ${error.message}` }));
113
+ if (seq !== state.indexSeq) return;
114
+ list.innerHTML = "";
115
+ list.appendChild(el("li", { class: "err", text: `failed to load index: ${error.message}`, role: "alert" }));
116
+ // Clear the stale "registry valid · scope …" line — leaving the last
117
+ // successful freshness next to a load error is itself a stale badge on
118
+ // the one panel whose whole point is freshness.
119
+ const fresh = document.getElementById("registry-freshness");
120
+ fresh.innerHTML = "";
121
+ fresh.append("registry ", freshnessBadge("unavailable"), " · index unreachable");
60
122
  return;
61
123
  }
124
+ // Only the newest request may render (see state.indexSeq).
125
+ if (seq !== state.indexSeq) return;
126
+ list.innerHTML = "";
62
127
  const reg = view.registry || {};
63
128
  const fresh = document.getElementById("registry-freshness");
64
129
  fresh.innerHTML = "";
65
- fresh.append("registry ", freshnessBadge(reg.freshness && reg.freshness.status), ` · scope ${view.scope}`);
66
- const records = (view.runs && view.runs.records) || [];
130
+ const regStatus = reg.freshness && reg.freshness.status;
131
+ const regTitle = String(regStatus || "").toLowerCase() === "absent"
132
+ ? "no home registry data yet — runs made in this repo still show; `cw registry refresh` builds it"
133
+ : undefined;
134
+ fresh.append("registry ", freshnessBadge(regStatus, regTitle), ` · scope ${view.scope}`);
135
+ const runs = view.runs || {};
136
+ const records = runs.records || [];
67
137
  if (!records.length) {
68
- list.appendChild(el("li", { class: "muted", text: "no runs indexed in this scope" }));
138
+ list.appendChild(el("li", { class: "muted" }, [
139
+ el("div", { text: "no runs indexed in this scope" }),
140
+ el("div", { class: "hint", text: 'create one with: cw quickstart <app> --repo <path> --question "..."' })
141
+ ]));
69
142
  return;
70
143
  }
71
- for (const record of records) {
144
+ // Tell the user when the page is only part of the run set (the server
145
+ // returns the newest page but caps it) — otherwise a scope with more runs
146
+ // than the page size silently hides the rest, and someone checking "did my
147
+ // run get created" is misled into thinking it doesn't exist.
148
+ if (typeof runs.total === "number" && runs.total > records.length) {
149
+ list.appendChild(el("li", { class: "muted hint", text: `showing latest ${records.length} of ${runs.total} runs` }));
150
+ }
151
+ // The server returns the page sorted oldest-first; show newest at the top.
152
+ for (const record of [...records].reverse()) {
72
153
  const lifecycle = record.lifecycle || record.status || "";
73
- const li = el("li", { class: state.activeRunId === record.runId ? "active" : "" }, [
154
+ // A real <button>, not a bare <li> with a click listener: Tab reaches
155
+ // it, Enter/Space activate it, and it gets a focus ring for free — no
156
+ // extra ARIA needed. Same CSS classes as before so the row/card look
157
+ // is unchanged (see app.css .run-list button rules).
158
+ const btn = el("button", { type: "button", "data-runid": record.runId, class: state.activeRunId === record.runId ? "active" : "" }, [
74
159
  el("div", { class: "rid" }, [
75
160
  el("span", { class: `status-dot ${lifecycle}`, title: lifecycle || "unknown" }),
76
161
  document.createTextNode(record.runId)
@@ -80,44 +165,115 @@ async function loadIndex() {
80
165
  text: [record.appId || record.workflowId, lifecycle, record.repo].filter(Boolean).join(" · ")
81
166
  })
82
167
  ]);
83
- li.addEventListener("click", () => selectRun(record.runId));
84
- list.appendChild(li);
168
+ btn.addEventListener("click", () => selectRun(record.runId));
169
+ list.appendChild(el("li", { class: "run-entry" }, [btn]));
170
+ }
171
+ }
172
+
173
+ // Move the "active" highlight to the chosen run's button WITHOUT rebuilding
174
+ // the list. Rebuilding (the old `loadIndex()` call in selectRun) removed the
175
+ // very <button> the user had just clicked/Entered, dropping keyboard focus to
176
+ // <body> and flashing the whole sidebar on every selection.
177
+ function markActiveRow(runId) {
178
+ const list = document.getElementById("run-list");
179
+ for (const btn of list.querySelectorAll("button[data-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");
85
184
  }
86
185
  }
87
186
 
88
- async function selectRun(runId) {
187
+ function selectRun(runId, options = {}) {
89
188
  state.activeRunId = runId;
90
- loadIndex();
189
+ if (!state.currentView || state.currentView.runId !== runId) state.currentView = null;
190
+ markActiveRow(runId);
191
+ if (options.history !== false) writeRoute(runId, state.activeTab);
192
+ return loadRunDetail(runId);
193
+ }
194
+
195
+ async function loadRunDetail(runId) {
196
+ const seq = ++state.detailSeq;
91
197
  const detail = document.getElementById("run-panel");
92
198
  detail.innerHTML = "";
93
- detail.appendChild(el("p", { class: "muted", text: `loading ${runId}…` }));
199
+ detail.appendChild(el("p", { class: "muted", text: `loading ${runId}…`, role: "status" }));
94
200
  let view;
95
201
  try {
96
- view = await getJson(`/api/run/${encodeURIComponent(runId)}`);
202
+ view = await getJson(apiUrl(`/api/run/${encodeURIComponent(runId)}`));
97
203
  } catch (error) {
204
+ if (state.activeRunId !== runId || seq !== state.detailSeq) return;
98
205
  detail.innerHTML = "";
99
- 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" }));
100
207
  return;
101
208
  }
209
+ // The user may have clicked another run while this fetch was in flight;
210
+ // a stale response must not overwrite the newer selection's render.
211
+ if (state.activeRunId !== runId || seq !== state.detailSeq) return;
212
+ state.viewFetchedAt = new Date();
213
+ state.currentView = view;
102
214
  renderRun(view);
103
215
  }
104
216
 
105
- function renderRun(view) {
217
+ // The refresh button re-derives BOTH panes from disk: the sidebar index AND
218
+ // the currently-open run's detail. Refreshing only the index (the old
219
+ // behavior) left the detail header's lifecycle badge showing stale state
220
+ // while the sidebar dot next to it had already changed.
221
+ function refreshAll() {
222
+ loadIndex();
223
+ if (state.activeRunId) loadRunDetail(state.activeRunId);
224
+ }
225
+
226
+ function formatClock(date) {
227
+ return date ? date.toTimeString().slice(0, 8) : "";
228
+ }
229
+
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 = {}) {
106
237
  const detail = document.getElementById("run-panel");
107
238
  detail.innerHTML = "";
108
239
  const header = el("div", { class: "kv" }, [
109
240
  el("span", {}, [el("b", { text: "run " }), document.createTextNode(view.runId)]),
110
241
  el("span", {}, [document.createTextNode("resolved "), freshnessBadge(view.resolved ? "valid" : "missing")])
111
242
  ]);
243
+ if (view.lifecycle) {
244
+ header.appendChild(el("span", {}, [document.createTextNode("lifecycle "), freshnessBadge(view.lifecycle)]));
245
+ }
246
+ if (state.viewFetchedAt) {
247
+ header.appendChild(el("span", { class: "muted", text: `as of ${formatClock(state.viewFetchedAt)}` }));
248
+ }
112
249
  if (view.error) header.appendChild(el("span", { class: "err", text: view.error }));
113
250
  detail.appendChild(header);
251
+ if (view.lifecycle === "blocked" || view.lifecycle === "failed") {
252
+ detail.appendChild(
253
+ el("p", { class: "recovery-hint", text: `${view.lifecycle} — run 'cw run status ${view.runId}' or 'cw doctor' for next steps` })
254
+ );
255
+ }
114
256
 
115
- const tabs = el("div", { class: "tabs" });
257
+ const tabs = el("div", { class: "tabs", role: "tablist" });
116
258
  for (const group of PANEL_GROUPS) {
117
- const btn = el("button", { class: `tab ${state.activeTab === group.key ? "active" : ""}`, text: group.label });
118
- btn.addEventListener("click", () => {
119
- state.activeTab = group.key;
120
- renderRun(view);
259
+ const active = state.activeTab === group.key;
260
+ const btn = el("button", {
261
+ id: `workbench-tab-${group.key}`,
262
+ class: `tab ${active ? "active" : ""}`,
263
+ text: group.label,
264
+ role: "tab",
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 });
271
+ });
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 });
121
277
  });
122
278
  tabs.appendChild(btn);
123
279
  }
@@ -125,10 +281,29 @@ function renderRun(view) {
125
281
 
126
282
  const group = PANEL_GROUPS.find((g) => g.key === state.activeTab) || PANEL_GROUPS[0];
127
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
+ });
289
+ // Some groups declare two panel names that map to the same capability on
290
+ // the server (e.g. graph's compact/criticalPath both come from
291
+ // summary.show), so they carry byte-identical payloads. Render such a pair
292
+ // ONCE under a merged label instead of showing the same JSON twice under
293
+ // two names that promise distinct views.
294
+ const rendered = [];
128
295
  for (const name of group.panels) {
129
296
  const panel = panels[name];
130
- if (panel) detail.appendChild(renderPanel(name, panel));
297
+ if (!panel) continue;
298
+ const twin = rendered.find((entry) => JSON.stringify(entry.panel) === JSON.stringify(panel));
299
+ if (twin) twin.names.push(name);
300
+ else rendered.push({ names: [name], panel });
301
+ }
302
+ for (const entry of rendered) {
303
+ tabPanel.appendChild(renderPanel(entry.names.join(" / "), entry.panel));
131
304
  }
305
+ detail.appendChild(tabPanel);
306
+ if (options.focusTab) document.getElementById(`workbench-tab-${group.key}`).focus();
132
307
  }
133
308
 
134
309
  function renderPanel(name, panel) {
@@ -140,6 +315,8 @@ function renderPanel(name, panel) {
140
315
  card.appendChild(head);
141
316
  card.appendChild(el("div", { class: "kv" }, [el("span", { class: "src", text: panel.cli }), el("span", { class: "src", text: panel.mcp })]));
142
317
  if (panel.status === "present") {
318
+ const actionSummary = renderActionSummary(panel.data);
319
+ if (actionSummary) card.appendChild(actionSummary);
143
320
  card.appendChild(renderStructured(panel.data) || el("pre", { class: "json", text: JSON.stringify(panel.data, null, 2) }));
144
321
  } else {
145
322
  card.appendChild(el("div", { class: "absent-note", text: `absent — ${panel.error || "source unreadable"}` }));
@@ -147,6 +324,23 @@ function renderPanel(name, panel) {
147
324
  return card;
148
325
  }
149
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
+
150
344
  // Purely presentational shape-detection: recognizes the two payload shapes
151
345
  // that recur across several capabilities (a nodes/edges graph, and one or
152
346
  // more TrustAuditEvent[] arrays) and tables them instead of dumping raw
@@ -173,17 +367,27 @@ function renderStructured(data) {
173
367
  return null;
174
368
  }
175
369
 
370
+ // A semantically-correct data table: header cells in a <thead> with
371
+ // scope="col", body rows in a <tbody> — screen readers can then associate
372
+ // each cell with its column header.
373
+ function structTable(headers) {
374
+ const table = el("table", { class: "struct-table" }, [
375
+ el("thead", {}, [el("tr", {}, headers.map((h) => el("th", { scope: "col", text: h })))])
376
+ ]);
377
+ const tbody = el("tbody");
378
+ table.appendChild(tbody);
379
+ return { table, tbody };
380
+ }
381
+
176
382
  function renderGraph(data) {
177
383
  const wrap = el("div");
178
384
  const nodesBlock = el("div", { class: "struct-block" }, [el("div", { class: "struct-title", text: `nodes (${data.nodes.length})` })]);
179
385
  if (data.nodes.length === 0) {
180
386
  nodesBlock.appendChild(el("div", { class: "struct-empty", text: "none" }));
181
387
  } else {
182
- const table = el("table", { class: "struct-table" }, [
183
- el("tr", {}, ["id", "kind", "status", "label"].map((h) => el("th", { text: h })))
184
- ]);
388
+ const { table, tbody } = structTable(["id", "kind", "status", "label"]);
185
389
  for (const node of data.nodes) {
186
- table.appendChild(
390
+ tbody.appendChild(
187
391
  el("tr", {}, [
188
392
  el("td", { text: node.id }),
189
393
  el("td", { text: node.kind }),
@@ -235,11 +439,9 @@ function renderEventGroups(data, confirmedEventKeys) {
235
439
  for (const key of eventKeys) {
236
440
  const events = [...data[key]].sort((a, b) => String(a.createdAt || "").localeCompare(String(b.createdAt || "")));
237
441
  const block = el("div", { class: "struct-block" }, [el("div", { class: "struct-title", text: `${humanizeKey(key)} (${events.length})` })]);
238
- const table = el("table", { class: "struct-table" }, [
239
- el("tr", {}, ["time", "kind", "decision", "source", "actor"].map((h) => el("th", { text: h })))
240
- ]);
442
+ const { table, tbody } = structTable(["time", "kind", "decision", "source", "actor"]);
241
443
  for (const event of events) {
242
- table.appendChild(
444
+ tbody.appendChild(
243
445
  el("tr", {}, [
244
446
  el("td", { text: event.createdAt || "" }),
245
447
  el("td", { text: event.kind || "" }),
@@ -263,8 +465,40 @@ function renderEventGroups(data, confirmedEventKeys) {
263
465
  return wrap;
264
466
  }
265
467
 
266
- document.getElementById("refresh").addEventListener("click", loadIndex);
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
+
499
+ document.getElementById("refresh").addEventListener("click", refreshAll);
267
500
  document.getElementById("filter").addEventListener("input", debounce(loadIndex, 200));
501
+ window.addEventListener("popstate", applyLocationRoute);
268
502
 
269
503
  function debounce(fn, ms) {
270
504
  let timer;
@@ -275,3 +509,4 @@ function debounce(fn, ms) {
275
509
  }
276
510
 
277
511
  loadIndex();
512
+ applyLocationRoute();
@@ -16,10 +16,10 @@
16
16
  <main>
17
17
  <section id="index-panel" class="sidebar">
18
18
  <div class="bar">
19
- <strong>Runs</strong>
19
+ <h2>Runs</h2>
20
20
  <button id="refresh" title="Re-derive from disk">refresh</button>
21
21
  </div>
22
- <input id="filter" type="search" placeholder="filter runs (app/status/text)" />
22
+ <input id="filter" type="search" placeholder="filter runs (app/status/text)" aria-label="filter runs" />
23
23
  <div id="registry-freshness" class="freshness"></div>
24
24
  <ul id="run-list" class="run-list"></ul>
25
25
  </section>
@@ -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
+ });