@worca/ui 0.27.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;
@@ -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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@worca/ui",
3
- "version": "0.27.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)
@@ -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
  }
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Dispatch governance migration — JS port of Python
3
+ * _migrate_dispatch_governance() in src/worca/cli/init.py (§10.3).
4
+ *
5
+ * Mutates worcaConfig in place following the same pattern as
6
+ * global-keys.js:extractAndStripGlobalKeys().
7
+ */
8
+
9
+ import { DISPATCH_DEFAULTS } from './dispatch-defaults.js';
10
+
11
+ const _DISPATCH_SECTION_KEYS = new Set(['tools', 'skills', 'subagents']);
12
+
13
+ /**
14
+ * Returns true if `dispatch` has at least one agent-name key directly at the
15
+ * top level (the pre-W-038 legacy flat shape, e.g. `{planner: [...]}`),
16
+ * rather than the W-054 nested shape `{tools, skills, subagents}`.
17
+ */
18
+ function _isLegacyFlatDispatch(dispatch) {
19
+ if (!dispatch || typeof dispatch !== 'object' || Array.isArray(dispatch)) {
20
+ return false;
21
+ }
22
+ for (const key of Object.keys(dispatch)) {
23
+ if (!_DISPATCH_SECTION_KEYS.has(key) && Array.isArray(dispatch[key])) {
24
+ return true;
25
+ }
26
+ }
27
+ return false;
28
+ }
29
+
30
+ /**
31
+ * Strip agent-name keys from `dispatch`, moving their values into
32
+ * `dispatch.subagents.per_agent_allow` so the legacy flat shape is normalized.
33
+ */
34
+ function _absorbFlatDispatchKeys(dispatch) {
35
+ const flatKeys = [];
36
+ for (const key of Object.keys(dispatch)) {
37
+ if (!_DISPATCH_SECTION_KEYS.has(key) && Array.isArray(dispatch[key])) {
38
+ flatKeys.push(key);
39
+ }
40
+ }
41
+ if (flatKeys.length === 0) return false;
42
+
43
+ if (!dispatch.subagents) dispatch.subagents = {};
44
+ if (!dispatch.subagents.per_agent_allow) {
45
+ dispatch.subagents.per_agent_allow = {};
46
+ }
47
+ for (const key of flatKeys) {
48
+ const incoming = dispatch[key];
49
+ const existing = dispatch.subagents.per_agent_allow[key];
50
+ if (!existing || existing.length === 0) {
51
+ dispatch.subagents.per_agent_allow[key] = incoming;
52
+ }
53
+ delete dispatch[key];
54
+ }
55
+ return true;
56
+ }
57
+
58
+ /**
59
+ * Migrate legacy governance.subagent_dispatch and/or legacy flat
60
+ * governance.dispatch (agent-keyed) → governance.dispatch.subagents.per_agent_allow.
61
+ *
62
+ * Seeds _defaults, adds tools/skills defaults, drops _dispatch_legacy.
63
+ * Idempotent — returns [] on already-migrated configs.
64
+ *
65
+ * @param {object} worcaConfig — the `worca` object from settings (mutated)
66
+ * @returns {string[]} list of change descriptions (empty = no-op)
67
+ */
68
+ export function migrateDispatchGovernance(worcaConfig) {
69
+ const changes = [];
70
+ const gov = worcaConfig.governance;
71
+ if (!gov || typeof gov !== 'object') return changes;
72
+
73
+ const hasSubagentDispatch = 'subagent_dispatch' in gov;
74
+ const hasLegacyFlatDispatch = _isLegacyFlatDispatch(gov.dispatch);
75
+
76
+ if (!hasSubagentDispatch && !hasLegacyFlatDispatch) return changes;
77
+
78
+ if (!gov.dispatch || Array.isArray(gov.dispatch)) gov.dispatch = {};
79
+ const dispatch = gov.dispatch;
80
+
81
+ // Absorb legacy flat shape (pre-W-038) first so subagent_dispatch values
82
+ // take precedence below.
83
+ if (hasLegacyFlatDispatch) {
84
+ _absorbFlatDispatchKeys(dispatch);
85
+ changes.push(
86
+ 'governance.dispatch (flat agent-keyed) -> governance.dispatch.subagents (W-054)',
87
+ );
88
+ }
89
+
90
+ if (hasSubagentDispatch) {
91
+ const old = gov.subagent_dispatch;
92
+ delete gov.subagent_dispatch;
93
+ if (!dispatch.subagents) dispatch.subagents = {};
94
+ if (!dispatch.subagents.per_agent_allow) {
95
+ dispatch.subagents.per_agent_allow = {};
96
+ }
97
+ Object.assign(dispatch.subagents.per_agent_allow, old);
98
+ changes.push(
99
+ 'governance.subagent_dispatch -> governance.dispatch.subagents (W-054)',
100
+ );
101
+ }
102
+
103
+ if (!dispatch.subagents) dispatch.subagents = {};
104
+ const subagents = dispatch.subagents;
105
+
106
+ if (!subagents.per_agent_allow) subagents.per_agent_allow = {};
107
+ if (!('_defaults' in subagents.per_agent_allow)) {
108
+ subagents.per_agent_allow._defaults = [
109
+ ...DISPATCH_DEFAULTS.subagents.per_agent_allow._defaults,
110
+ ];
111
+ }
112
+
113
+ if (!subagents.always_disallowed) {
114
+ subagents.always_disallowed = [
115
+ ...DISPATCH_DEFAULTS.subagents.always_disallowed,
116
+ ];
117
+ }
118
+ if (!subagents.default_denied) {
119
+ subagents.default_denied = [...DISPATCH_DEFAULTS.subagents.default_denied];
120
+ }
121
+
122
+ if (!dispatch.tools) {
123
+ dispatch.tools = structuredClone(DISPATCH_DEFAULTS.tools);
124
+ }
125
+ if (!dispatch.skills) {
126
+ dispatch.skills = structuredClone(DISPATCH_DEFAULTS.skills);
127
+ }
128
+
129
+ delete gov._dispatch_legacy;
130
+
131
+ return changes;
132
+ }
@@ -0,0 +1,32 @@
1
+ import { execFileSync } from 'node:child_process';
2
+
3
+ const CACHE_TTL_MS = 30_000;
4
+ const cache = new Map();
5
+
6
+ function _resolveDefaultBranch(projectRoot) {
7
+ try {
8
+ const out = execFileSync(
9
+ 'git',
10
+ ['symbolic-ref', 'refs/remotes/origin/HEAD', '--short'],
11
+ { cwd: projectRoot, encoding: 'utf8', timeout: 5000 },
12
+ );
13
+ const branch = out.trim().replace(/^origin\//, '');
14
+ if (branch) return branch;
15
+ } catch {
16
+ // no symbolic-ref configured — fall through
17
+ }
18
+ return 'main';
19
+ }
20
+
21
+ export function getDefaultBranch(projectRoot) {
22
+ const now = Date.now();
23
+ const hit = cache.get(projectRoot);
24
+ if (hit && hit.expiresAt > now) return hit.value;
25
+ const value = _resolveDefaultBranch(projectRoot);
26
+ cache.set(projectRoot, { value, expiresAt: now + CACHE_TTL_MS });
27
+ return value;
28
+ }
29
+
30
+ export function _clearDefaultBranchCache() {
31
+ cache.clear();
32
+ }