@worca/ui 0.26.0 → 0.28.0-rc.1

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.
package/app/styles.css CHANGED
@@ -1928,6 +1928,25 @@ sl-input [slot="prefix"] {
1928
1928
  cursor: not-allowed;
1929
1929
  }
1930
1930
 
1931
+ .dispatch-suggestions .item.warn {
1932
+ background: var(--sl-color-warning-50);
1933
+ border-left: 3px solid var(--sl-color-warning-300);
1934
+ padding-left: 9px;
1935
+ }
1936
+
1937
+ .dispatch-suggestions .item.warn:hover,
1938
+ .dispatch-suggestions .item.warn.active {
1939
+ background: var(--sl-color-warning-100);
1940
+ }
1941
+
1942
+ .dispatch-suggestions .item-hint {
1943
+ font-size: var(--sl-font-size-x-small);
1944
+ color: var(--sl-color-warning-700);
1945
+ margin-left: auto;
1946
+ padding-left: 6px;
1947
+ font-style: italic;
1948
+ }
1949
+
1931
1950
  .dispatch-suggestions .item-label {
1932
1951
  font-size: 11px;
1933
1952
  color: var(--sl-color-neutral-500);
@@ -1951,6 +1970,128 @@ sl-input [slot="prefix"] {
1951
1970
  display: inline-block;
1952
1971
  }
1953
1972
 
1973
+ /* Dispatch chip variants */
1974
+ .dispatch-chip-wildcard {
1975
+ --sl-color-neutral-200: var(--sl-color-primary-200);
1976
+ background: var(--sl-color-primary-100);
1977
+ font-weight: 600;
1978
+ }
1979
+
1980
+ .dispatch-chip-locked {
1981
+ text-decoration: line-through;
1982
+ opacity: 0.6;
1983
+ }
1984
+
1985
+ /* Auto-included meta-tool chips (Skill, Agent) — locked but not crossed-out;
1986
+ * the line-through reads as "blocked", but these are present-and-required. */
1987
+ .dispatch-chip-auto {
1988
+ text-decoration: none;
1989
+ opacity: 0.75;
1990
+ font-style: italic;
1991
+ border-style: dashed;
1992
+ }
1993
+
1994
+ .dispatch-chip-auto::part(base) {
1995
+ border-style: dashed;
1996
+ }
1997
+
1998
+ /* Lockdown placeholder — semantically distinct from a real chip. */
1999
+ .dispatch-chip-lockdown {
2000
+ text-decoration: none;
2001
+ opacity: 0.7;
2002
+ font-style: italic;
2003
+ text-transform: uppercase;
2004
+ letter-spacing: 0.5px;
2005
+ font-size: 10px;
2006
+ }
2007
+
2008
+ .dispatch-chip-warn {
2009
+ --sl-color-neutral-200: var(--sl-color-warning-200);
2010
+ background: var(--sl-color-warning-100);
2011
+ }
2012
+
2013
+ /* Dispatch section layout */
2014
+ .dispatch-section {
2015
+ margin-bottom: 16px;
2016
+ }
2017
+
2018
+ .dispatch-section-title {
2019
+ font-size: 14px;
2020
+ font-weight: 600;
2021
+ margin: 12px 0 4px;
2022
+ }
2023
+
2024
+ .dispatch-section-hint {
2025
+ font-size: 12px;
2026
+ color: var(--sl-color-neutral-600);
2027
+ line-height: 1.4;
2028
+ margin: 0 0 8px;
2029
+ max-width: 720px;
2030
+ }
2031
+
2032
+ sl-alert.dispatch-wildcard-deny-warning {
2033
+ margin: 0 0 12px;
2034
+ font-size: 12px;
2035
+ max-width: 720px;
2036
+ }
2037
+
2038
+ sl-alert.dispatch-wildcard-deny-warning code {
2039
+ font-size: 12px;
2040
+ padding: 0 4px;
2041
+ border-radius: 3px;
2042
+ background: var(--sl-color-neutral-100);
2043
+ }
2044
+
2045
+ /* Collapsible wrapper for each dispatch section in the Governance tab.
2046
+ * Mirrors .model-env-details so users get a consistent collapse pattern. */
2047
+ sl-details.dispatch-section-details {
2048
+ margin-bottom: 0.5rem;
2049
+ }
2050
+
2051
+ sl-details.dispatch-section-details::part(base) {
2052
+ border: none;
2053
+ background: transparent;
2054
+ box-shadow: none;
2055
+ }
2056
+
2057
+ sl-details.dispatch-section-details::part(header) {
2058
+ padding: 0.4rem 0.5rem;
2059
+ border-radius: 6px;
2060
+ }
2061
+
2062
+ sl-details.dispatch-section-details::part(header):hover {
2063
+ background: var(--hover, rgba(0, 0, 0, 0.04));
2064
+ }
2065
+
2066
+ sl-details.dispatch-section-details::part(content) {
2067
+ padding: 0.25rem 0 0.5rem;
2068
+ }
2069
+
2070
+ .dispatch-section-details-summary {
2071
+ display: flex;
2072
+ align-items: center;
2073
+ justify-content: space-between;
2074
+ width: 100%;
2075
+ gap: 0.75rem;
2076
+ }
2077
+
2078
+ .dispatch-tier {
2079
+ margin-bottom: 12px;
2080
+ }
2081
+
2082
+ .dispatch-tier-label {
2083
+ font-size: 12px;
2084
+ font-weight: 500;
2085
+ color: var(--sl-color-neutral-600);
2086
+ margin-bottom: 4px;
2087
+ }
2088
+
2089
+ .dispatch-tier-chips {
2090
+ display: flex;
2091
+ flex-wrap: wrap;
2092
+ gap: 4px;
2093
+ }
2094
+
1954
2095
  /* Permissions list */
1955
2096
  .settings-permissions {
1956
2097
  display: flex;
@@ -2031,7 +2172,7 @@ sl-input [slot="prefix"] {
2031
2172
 
2032
2173
  .pricing-model-name {
2033
2174
  font-weight: 500;
2034
- text-transform: capitalize;
2175
+ text-transform: uppercase;
2035
2176
  }
2036
2177
 
2037
2178
  .pricing-table sl-input {
@@ -2130,6 +2271,95 @@ sl-input.pricing-input::part(input) {
2130
2271
  margin: 0 2px;
2131
2272
  }
2132
2273
 
2274
+ /* PR B — wider subagent fanout under wildcard defaults. The badge row needs
2275
+ * to wrap gracefully when an iteration dispatches 5+ distinct subagents.
2276
+ * Inherits flex-wrap from .iteration-tags-row; adds a small visual cue so
2277
+ * wildcard dispatches read differently from explicit ones. */
2278
+ /* One row with both sections: "Skills: <badges> Subagents: <badges>".
2279
+ * row-gap covers the case where chips wrap to a second line; column-gap
2280
+ * spaces the two section groups apart so the eye finds the boundary. */
2281
+ .dispatch-events-row {
2282
+ row-gap: 4px;
2283
+ column-gap: 16px;
2284
+ }
2285
+
2286
+ .dispatch-badge::part(base) {
2287
+ font-variant-numeric: tabular-nums;
2288
+ display: inline-flex;
2289
+ align-items: center;
2290
+ gap: 4px;
2291
+ }
2292
+
2293
+ /* CircleCheck (allowed) / X (blocked) glyph sits inline before the badge
2294
+ * label, with the badge's own foreground colour. */
2295
+ .dispatch-badge-icon {
2296
+ display: inline-flex;
2297
+ align-items: center;
2298
+ line-height: 0;
2299
+ }
2300
+
2301
+ .dispatch-badge-icon svg {
2302
+ display: block;
2303
+ }
2304
+
2305
+ .dispatch-events-section {
2306
+ display: inline-flex;
2307
+ flex-wrap: wrap;
2308
+ align-items: center;
2309
+ gap: 4px;
2310
+ }
2311
+
2312
+ /* Empty-section placeholder: keeps the label visible so layout stays
2313
+ * stable across iterations. Muted on purpose — explicit absence. */
2314
+ .dispatch-events-empty {
2315
+ font-size: 12px;
2316
+ color: var(--sl-color-neutral-500);
2317
+ }
2318
+
2319
+ .meta-value-muted {
2320
+ color: var(--sl-color-neutral-500);
2321
+ }
2322
+
2323
+ /* Overflow control: when an iteration dispatches more than _DISPATCH_VISIBLE_LIMIT
2324
+ * candidates (typical under PR B's wildcard default), the tail goes inside a
2325
+ * compact sl-details. */
2326
+ sl-details.dispatch-events-overflow {
2327
+ display: inline-block;
2328
+ }
2329
+
2330
+ sl-details.dispatch-events-overflow::part(base) {
2331
+ border: none;
2332
+ background: transparent;
2333
+ box-shadow: none;
2334
+ }
2335
+
2336
+ sl-details.dispatch-events-overflow::part(header) {
2337
+ padding: 0 0.4rem;
2338
+ border-radius: 6px;
2339
+ font-size: 12px;
2340
+ color: var(--sl-color-neutral-700);
2341
+ }
2342
+
2343
+ sl-details.dispatch-events-overflow::part(header):hover {
2344
+ background: var(--hover, rgba(0, 0, 0, 0.04));
2345
+ }
2346
+
2347
+ sl-details.dispatch-events-overflow::part(content) {
2348
+ padding: 0.25rem 0 0;
2349
+ }
2350
+
2351
+ .dispatch-events-overflow-summary {
2352
+ font-size: 12px;
2353
+ font-weight: 500;
2354
+ }
2355
+
2356
+ .dispatch-events-overflow-content {
2357
+ display: flex;
2358
+ flex-wrap: wrap;
2359
+ gap: 4px;
2360
+ margin-top: 4px;
2361
+ }
2362
+
2133
2363
  /* --- Agent Prompt Section --- */
2134
2364
  sl-details.agent-prompt-section {
2135
2365
  margin-top: 12px;
@@ -5084,6 +5314,56 @@ sl-tooltip.bead-tooltip::part(body) {
5084
5314
  border-color: var(--status-failed, #ef4444);
5085
5315
  }
5086
5316
 
5317
+ .rename-model-input.is-invalid::part(base) {
5318
+ border-color: var(--status-failed, #ef4444);
5319
+ }
5320
+
5321
+ .rename-model-error {
5322
+ color: var(--status-failed, #ef4444);
5323
+ font-size: 12px;
5324
+ margin: 0.4rem 0 0;
5325
+ }
5326
+
5327
+ .model-env-details::part(base) {
5328
+ border: none;
5329
+ background: transparent;
5330
+ box-shadow: none;
5331
+ }
5332
+
5333
+ .model-env-details::part(header) {
5334
+ padding: 0.25rem 0.4rem;
5335
+ border-radius: 6px;
5336
+ }
5337
+
5338
+ .model-env-details::part(header):hover {
5339
+ background: var(--hover, rgba(0, 0, 0, 0.04));
5340
+ }
5341
+
5342
+ .model-env-details::part(content) {
5343
+ padding: 0.5rem 0 0;
5344
+ }
5345
+
5346
+ .model-env-summary {
5347
+ display: flex;
5348
+ align-items: center;
5349
+ justify-content: space-between;
5350
+ width: 100%;
5351
+ gap: 0.75rem;
5352
+ }
5353
+
5354
+ .model-env-invalid-chip {
5355
+ color: var(--status-failed, #ef4444);
5356
+ font-weight: 600;
5357
+ font-size: 11px;
5358
+ text-transform: uppercase;
5359
+ letter-spacing: 0.5px;
5360
+ }
5361
+
5362
+ .model-env-details.has-invalid::part(header) {
5363
+ border-left: 3px solid var(--status-failed, #ef4444);
5364
+ padding-left: 0.5rem;
5365
+ }
5366
+
5087
5367
  .model-env-value::part(input) {
5088
5368
  font-size: 12px;
5089
5369
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@worca/ui",
3
- "version": "0.26.0",
3
+ "version": "0.28.0-rc.1",
4
4
  "description": "Pipeline monitoring UI for worca-cc",
5
5
  "license": "MIT",
6
6
  "author": "Sinisha Djukic",
@@ -26,6 +26,8 @@
26
26
  "server/**/*.js",
27
27
  "server/schemas/keys.json",
28
28
  "server/reserved-env-keys.json",
29
+ "server/known-tools.json",
30
+ "server/known-skills.json",
29
31
  "!server/**/*.test.js",
30
32
  "!server/test/**",
31
33
  "!server/**/test/**",
package/server/app.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // server/app.js
2
2
 
3
- import { execFileSync, spawn } from 'node:child_process';
3
+ import { execFile, execFileSync, spawn } from 'node:child_process';
4
4
  import { createHmac, randomUUID } from 'node:crypto';
5
5
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
6
6
  import { homedir } from 'node:os';
@@ -183,6 +183,52 @@ export function createApp(options = {}) {
183
183
  }
184
184
  });
185
185
 
186
+ // GET /api/tools — static known-tools list for autocomplete.
187
+ const knownToolsPath = join(
188
+ dirname(fileURLToPath(import.meta.url)),
189
+ 'known-tools.json',
190
+ );
191
+ const knownTools = JSON.parse(readFileSync(knownToolsPath, 'utf8'));
192
+
193
+ app.get('/api/tools', (_req, res) => {
194
+ res.json({ ok: true, tools: knownTools });
195
+ });
196
+
197
+ // GET /api/skills — try `claude --list-skills --json`, fallback to static list.
198
+ const knownSkillsPath = join(
199
+ dirname(fileURLToPath(import.meta.url)),
200
+ 'known-skills.json',
201
+ );
202
+ const knownSkills = JSON.parse(readFileSync(knownSkillsPath, 'utf8'));
203
+
204
+ app.get('/api/skills', (_req, res) => {
205
+ execFile(
206
+ 'claude',
207
+ ['--list-skills', '--json'],
208
+ { timeout: 5000 },
209
+ (err, stdout) => {
210
+ if (!err && stdout) {
211
+ try {
212
+ const parsed = JSON.parse(stdout);
213
+ const skills = Array.isArray(parsed)
214
+ ? parsed.map((s) => ({
215
+ name: typeof s === 'string' ? s : s.name,
216
+ group:
217
+ typeof s === 'string'
218
+ ? 'Discovered'
219
+ : s.group || 'Discovered',
220
+ }))
221
+ : knownSkills;
222
+ return res.json({ ok: true, skills, source: 'live' });
223
+ } catch {
224
+ // JSON parse failed — fall through to fallback
225
+ }
226
+ }
227
+ res.json({ ok: true, skills: knownSkills, source: 'fallback' });
228
+ },
229
+ );
230
+ });
231
+
186
232
  // GET /api/beads/issues
187
233
  app.get('/api/beads/issues', (_req, res) => {
188
234
  if (!worcaDir)
@@ -59,14 +59,21 @@ export async function listIssues(beadsDb) {
59
59
  }
60
60
 
61
61
  export async function listIssuesByLabel(beadsDb, label) {
62
- try {
62
+ const attempt = async () => {
63
63
  const issues = await runBd(
64
64
  ['list', '--label-any', label, '--all', '--limit', '0'],
65
65
  beadsDb,
66
66
  );
67
67
  return await enrichWithDeps(issues, beadsDb);
68
+ };
69
+ try {
70
+ return await attempt();
68
71
  } catch {
69
- return [];
72
+ // bd/SQLite contention during active runs is usually sub-second — one
73
+ // retry covers the observed window. If it still fails, propagate so the
74
+ // WS handler can return an error rather than masquerading as "no beads."
75
+ await new Promise((r) => setTimeout(r, 150));
76
+ return await attempt();
70
77
  }
71
78
  }
72
79
 
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Dispatch governance defaults — JS mirror of Python _DISPATCH_DEFAULTS
3
+ * in src/worca/hooks/tracking.py.
4
+ *
5
+ * Single source of truth for the JS side; used by dispatch-migration.js
6
+ * and the settings editor.
7
+ */
8
+
9
+ export const DISPATCH_DEFAULTS = {
10
+ tools: {
11
+ always_disallowed: ['EnterPlanMode', 'EnterWorktree', 'TodoWrite'],
12
+ default_denied: [],
13
+ per_agent_allow: { _defaults: ['*'] },
14
+ },
15
+ skills: {
16
+ always_disallowed: [
17
+ 'batch',
18
+ 'fewer-permission-prompts',
19
+ 'loop',
20
+ 'schedule',
21
+ 'worca-*',
22
+ 'update-config',
23
+ 'hookify:hookify',
24
+ 'hookify:configure',
25
+ 'hookify:list',
26
+ 'hookify:writing-rules',
27
+ 'init',
28
+ ],
29
+ default_denied: [
30
+ 'claude-api',
31
+ 'debug',
32
+ 'review',
33
+ 'security-review',
34
+ 'simplify',
35
+ 'feature-dev:feature-dev',
36
+ 'claude-md-management:revise-claude-md',
37
+ 'claude-md-management:claude-md-improver',
38
+ ],
39
+ per_agent_allow: {
40
+ _defaults: ['*'],
41
+ implementer: ['*', 'simplify', 'claude-api'],
42
+ tester: ['*', 'debug'],
43
+ reviewer: ['*', 'review', 'security-review'],
44
+ learner: [
45
+ '*',
46
+ 'claude-md-management:revise-claude-md',
47
+ 'claude-md-management:claude-md-improver',
48
+ ],
49
+ },
50
+ },
51
+ subagents: {
52
+ always_disallowed: ['general-purpose'],
53
+ default_denied: [],
54
+ per_agent_allow: { _defaults: ['*'] },
55
+ },
56
+ };
57
+
58
+ /**
59
+ * Check if candidate matches any pattern in the list.
60
+ *
61
+ * Supported: exact match, trailing-* prefix glob, bare '*' (matches all).
62
+ * JS port of Python _matches_any() in src/worca/hooks/tracking.py (§11).
63
+ *
64
+ * @param {string} candidate
65
+ * @param {string[]} patterns
66
+ * @returns {boolean}
67
+ */
68
+ export function matchesAny(candidate, patterns) {
69
+ for (const pattern of patterns) {
70
+ if (pattern === candidate) return true;
71
+ if (pattern === '*') return true;
72
+ if (
73
+ pattern.endsWith('*') &&
74
+ pattern.length > 1 &&
75
+ candidate.startsWith(pattern.slice(0, -1))
76
+ ) {
77
+ return true;
78
+ }
79
+ }
80
+ return false;
81
+ }
@@ -6,14 +6,14 @@
6
6
  * Works for both live and completed runs because it reads only persisted data
7
7
  * (events.jsonl is append-only and survives the pipeline process exiting).
8
8
  *
9
- * Aggregation: events are deduplicated per iteration by (type, subagent_type).
10
- * A `count` field tracks how many times the same (type, subagent_type) fired
11
- * in that iteration. The `reason` from the first occurrence is kept (reasons
12
- * for the same key are deterministic — derived from the denylist/rule check).
9
+ * Aggregation: events are deduplicated per iteration by (type, section, candidate).
10
+ * A `count` field tracks how many times the same key fired in that iteration.
11
+ * The `reason` from the first occurrence is kept (reasons for the same key are
12
+ * deterministic — derived from the denylist/rule check).
13
13
  *
14
14
  * Output shape per iteration:
15
15
  * dispatch_events: [
16
- * { type, subagent_type, reason?, count }
16
+ * { type, section, candidate, via?, reason?, count }
17
17
  * ]
18
18
  */
19
19
 
@@ -29,7 +29,7 @@ const DISPATCH_EVENT_TYPES = new Set([
29
29
  * Malformed lines are silently skipped so a corrupt event doesn't break the run view.
30
30
  *
31
31
  * @param {string} eventsPath — absolute path to events.jsonl
32
- * @returns {Array<{type, subagent_type, reason?, timestamp}>}
32
+ * @returns {Array<{type, section, candidate, via?, reason?, timestamp}>}
33
33
  */
34
34
  export function readDispatchEventsFromJsonl(eventsPath) {
35
35
  if (!eventsPath || !existsSync(eventsPath)) return [];
@@ -50,13 +50,17 @@ export function readDispatchEventsFromJsonl(eventsPath) {
50
50
  }
51
51
  if (!DISPATCH_EVENT_TYPES.has(e.event_type)) continue;
52
52
  const payload = e.payload || {};
53
- if (!payload.subagent_type) continue;
54
- out.push({
53
+ const candidate = payload.candidate;
54
+ if (!candidate) continue;
55
+ const entry = {
55
56
  type: e.event_type,
56
- subagent_type: payload.subagent_type,
57
- reason: payload.reason,
57
+ section: payload.section || 'subagents',
58
+ candidate,
58
59
  timestamp: e.timestamp,
59
- });
60
+ };
61
+ if (payload.reason) entry.reason = payload.reason;
62
+ if (payload.via) entry.via = payload.via;
63
+ out.push(entry);
60
64
  }
61
65
  return out;
62
66
  }
@@ -64,13 +68,13 @@ export function readDispatchEventsFromJsonl(eventsPath) {
64
68
  /**
65
69
  * Given a list of dispatch events and a stages map from status.json, return
66
70
  * a new stages map where each iteration that overlaps an event's timestamp
67
- * is enriched with a `dispatch_events` array (deduplicated by type+subagent_type
71
+ * is enriched with a `dispatch_events` array (deduplicated by type+section+candidate
68
72
  * with a count).
69
73
  *
70
74
  * Non-destructive: input stages object is shallow-copied; iterations get new
71
75
  * objects with the extra field. Existing iteration fields are preserved.
72
76
  *
73
- * @param {Array<{type, subagent_type, reason?, timestamp}>} events
77
+ * @param {Array<{type, section, candidate, via?, reason?, timestamp}>} events
74
78
  * @param {object} stages — status.stages
75
79
  * @returns {object} enriched stages
76
80
  */
@@ -134,26 +138,28 @@ export function assignEventsToIterations(events, stages) {
134
138
  }
135
139
 
136
140
  /**
137
- * Deduplicate an array of dispatch events by (type, subagent_type) and count
138
- * occurrences. First reason wins for blocked events.
141
+ * Deduplicate an array of dispatch events by (type, section, candidate) and
142
+ * count occurrences. First reason wins for blocked events.
139
143
  *
140
- * @param {Array<{type, subagent_type, reason?}>} events
141
- * @returns {Array<{type, subagent_type, reason?, count}>}
144
+ * @param {Array<{type, section, candidate, via?, reason?}>} events
145
+ * @returns {Array<{type, section, candidate, via?, reason?, count}>}
142
146
  */
143
147
  function aggregate(events) {
144
148
  const map = new Map();
145
149
  for (const ev of events) {
146
- const key = `${ev.type}|${ev.subagent_type}`;
150
+ const key = `${ev.type}|${ev.section}|${ev.candidate}`;
147
151
  const existing = map.get(key);
148
152
  if (existing) {
149
153
  existing.count += 1;
150
154
  } else {
151
155
  const entry = {
152
156
  type: ev.type,
153
- subagent_type: ev.subagent_type,
157
+ section: ev.section,
158
+ candidate: ev.candidate,
154
159
  count: 1,
155
160
  };
156
161
  if (ev.reason) entry.reason = ev.reason;
162
+ if (ev.via) entry.via = ev.via;
157
163
  map.set(key, entry);
158
164
  }
159
165
  }