@worca/ui 0.35.0 → 0.37.0

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
@@ -2202,7 +2202,6 @@ sl-details.dispatch-section-details::part(content) {
2202
2202
 
2203
2203
  .pricing-model-name {
2204
2204
  font-weight: 500;
2205
- text-transform: uppercase;
2206
2205
  }
2207
2206
 
2208
2207
  .pricing-table sl-input {
@@ -2226,6 +2225,28 @@ sl-input.pricing-input::part(input) {
2226
2225
  margin-bottom: 8px;
2227
2226
  }
2228
2227
 
2228
+ /* Inline explainer that distinguishes alt-endpoint overrides from default
2229
+ Anthropic runs (where Claude CLI's total_cost_usd remains authoritative). */
2230
+ .pricing-source-note {
2231
+ display: block;
2232
+ margin: 12px 0;
2233
+ font-size: 13px;
2234
+ line-height: 1.5;
2235
+ }
2236
+
2237
+ .pricing-source-note strong {
2238
+ display: block;
2239
+ margin-bottom: 4px;
2240
+ }
2241
+
2242
+ .pricing-source-note code {
2243
+ font-family: var(--sl-font-mono, ui-monospace, SFMono-Regular, monospace);
2244
+ font-size: 0.92em;
2245
+ padding: 1px 4px;
2246
+ background: var(--sl-color-neutral-100, rgba(127, 127, 127, 0.12));
2247
+ border-radius: 3px;
2248
+ }
2249
+
2229
2250
  /* Settings toast overlay */
2230
2251
  .settings-toast {
2231
2252
  position: fixed;
@@ -2295,6 +2316,12 @@ sl-input.pricing-input::part(input) {
2295
2316
  font-size: 13px;
2296
2317
  }
2297
2318
 
2319
+ /* CRG invocation badge tooltip lists one tool per line (one <div> each, via
2320
+ the tooltip's HTML content slot) — just left-align them. */
2321
+ sl-tooltip.crg-tool-tooltip::part(body) {
2322
+ text-align: left;
2323
+ }
2324
+
2298
2325
  .iteration-tags-sep {
2299
2326
  color: var(--fg);
2300
2327
  opacity: 0.7;
@@ -5302,6 +5329,10 @@ sl-tooltip.bead-tooltip::part(body) {
5302
5329
  transition: border-color 0.15s ease, box-shadow 0.15s ease;
5303
5330
  }
5304
5331
 
5332
+ .model-card .settings-card-title {
5333
+ text-transform: none;
5334
+ }
5335
+
5305
5336
  .model-card.is-dirty {
5306
5337
  border-color: var(--status-running, #3b82f6);
5307
5338
  box-shadow: 0 0 0 1px var(--status-running, #3b82f6);
@@ -6211,6 +6242,17 @@ sl-tooltip.bead-tooltip::part(body) {
6211
6242
  color: var(--fg-muted);
6212
6243
  }
6213
6244
 
6245
+ /* W-061: plan revision selector inside the plan dialog */
6246
+ .plan-iter-selector {
6247
+ display: flex;
6248
+ flex-wrap: wrap;
6249
+ align-items: center;
6250
+ gap: 6px;
6251
+ margin: 0 0 12px;
6252
+ padding-bottom: 12px;
6253
+ border-bottom: 1px solid var(--border, var(--bg-secondary));
6254
+ }
6255
+
6214
6256
  /* --- sl-dialog: wider markdown-body dialogs (plan, guide) --- */
6215
6257
 
6216
6258
  sl-dialog.markdown-dialog::part(panel) {
@@ -6616,3 +6658,74 @@ sl-dialog.markdown-dialog::part(body) {
6616
6658
  .graphify-not-installed .settings-tab-description {
6617
6659
  color: var(--status-paused);
6618
6660
  }
6661
+
6662
+ /* Code Review Graph (CRG) — mirrors graphify-* classes above */
6663
+ .crg-codebox {
6664
+ display: block;
6665
+ margin: 0.25rem 0 0.75rem;
6666
+ padding: 0.4rem 0.6rem;
6667
+ background: var(--bg-secondary);
6668
+ border: 1px solid var(--border-subtle);
6669
+ border-radius: var(--radius);
6670
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
6671
+ font-size: 0.85rem;
6672
+ color: var(--fg);
6673
+ word-break: break-all;
6674
+ user-select: all;
6675
+ }
6676
+
6677
+ .crg-copy-row {
6678
+ display: flex;
6679
+ align-items: center;
6680
+ gap: 0.4rem;
6681
+ margin: 0.25rem 0 0.75rem;
6682
+ }
6683
+
6684
+ .crg-copy-row .crg-codebox {
6685
+ flex: 1;
6686
+ min-width: 0;
6687
+ margin: 0;
6688
+ }
6689
+
6690
+ .crg-not-installed {
6691
+ margin: 0 0 0.75rem;
6692
+ }
6693
+
6694
+ .crg-not-installed .settings-tab-description {
6695
+ color: var(--status-paused);
6696
+ }
6697
+
6698
+ .crg-coming-soon {
6699
+ color: var(--muted);
6700
+ font-style: italic;
6701
+ }
6702
+
6703
+ .crg-stage-tools {
6704
+ margin: 1rem 0;
6705
+ }
6706
+
6707
+ .crg-stage-tools-grid {
6708
+ display: grid;
6709
+ gap: 0.4rem;
6710
+ margin-top: 0.5rem;
6711
+ }
6712
+
6713
+ .crg-stage-tools-entry {
6714
+ display: flex;
6715
+ gap: 0.75rem;
6716
+ align-items: baseline;
6717
+ font-size: 0.85rem;
6718
+ }
6719
+
6720
+ .crg-stage-role {
6721
+ font-weight: 600;
6722
+ min-width: 6rem;
6723
+ color: var(--fg);
6724
+ }
6725
+
6726
+ .crg-stage-tool-list {
6727
+ color: var(--muted);
6728
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
6729
+ font-size: 0.8rem;
6730
+ word-break: break-all;
6731
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@worca/ui",
3
- "version": "0.35.0",
3
+ "version": "0.37.0",
4
4
  "description": "Pipeline monitoring UI for worca-cc",
5
5
  "license": "MIT",
6
6
  "author": "Sinisha Djukic",
package/server/app.js CHANGED
@@ -15,6 +15,10 @@ import { fileURLToPath } from 'node:url';
15
15
  import express from 'express';
16
16
 
17
17
  import { dbExists, getIssue, listIssues } from './beads-reader.js';
18
+ import {
19
+ _effectiveCrgConfig,
20
+ createCrgStatus,
21
+ } from './code-review-graph-status.js';
18
22
  import { createFleetRouter } from './fleet-routes.js';
19
23
  import {
20
24
  _effectiveConfig,
@@ -98,6 +102,23 @@ export function buildWorkspaceArgs(workspace_root, workspace_id, manifest) {
98
102
  return args;
99
103
  }
100
104
 
105
+ /**
106
+ * Returns true when `host` resolves to a loopback bind — undefined/null are
107
+ * treated as loopback because the production default in
108
+ * `worca-ui/server/index.js` is `127.0.0.1`. Used by the outbound-send route
109
+ * to refuse exposing user-addressable chat from a non-loopback bind.
110
+ *
111
+ * @param {string|undefined|null} host
112
+ * @returns {boolean}
113
+ */
114
+ export function isLoopbackHost(host) {
115
+ if (host === undefined || host === null || host === '') return true;
116
+ if (host === 'localhost' || host === '::1') return true;
117
+ if (host === '::ffff:127.0.0.1') return true;
118
+ if (host.startsWith('127.')) return true;
119
+ return false;
120
+ }
121
+
101
122
  export function createApp(options = {}) {
102
123
  const app = express();
103
124
  const appDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'app');
@@ -861,6 +882,68 @@ export function createApp(options = {}) {
861
882
  res.json(integrations.status());
862
883
  });
863
884
 
885
+ // POST /api/integrations/send — fan out a NormalizedMessage to one or
886
+ // more chat platforms through the same allowlist + rate-limiter pipeline
887
+ // the worca event fan-out uses. Drives the worca-notify skill.
888
+ //
889
+ // Body shape:
890
+ // {
891
+ // platforms?: string[], // omit → all enabled chat adapters
892
+ // message: NormalizedMessage, // { title, body: MessageSegment[], severity }
893
+ // chat_id?: string // override the configured chat_id
894
+ // }
895
+ //
896
+ // Returns 200 with `{ results: [{ platform, ok, error? }] }` for both
897
+ // total-success and partial-success cases; 4xx only for malformed input.
898
+ //
899
+ // Auth model: the endpoint is INTENTIONALLY restricted to loopback binds
900
+ // (127.0.0.0/8, ::1, localhost). The CSRF middleware above lets through
901
+ // requests with no Origin header (so webhooks work); without this guard,
902
+ // a UI server started with HOST=0.0.0.0 or --host <public-ip> would expose
903
+ // unauthenticated send-to-user's-chat to anything that can reach the port.
904
+ // 503 (subsystem disabled) and 403 (non-loopback bind) are terminal —
905
+ // retrying won't help.
906
+ app.post('/api/integrations/send', async (req, res) => {
907
+ if (!isLoopbackHost(serverHost)) {
908
+ return res.status(403).json({
909
+ error:
910
+ 'send endpoint is restricted to loopback binds; ' +
911
+ 'restart the UI server on 127.0.0.1 / ::1 / localhost ' +
912
+ 'to send notifications',
913
+ });
914
+ }
915
+ const integrations = app.locals.integrations;
916
+ if (!integrations) {
917
+ return res
918
+ .status(503)
919
+ .json({ error: 'integrations subsystem not initialized' });
920
+ }
921
+ if (integrations.status?.().enabled === false) {
922
+ return res
923
+ .status(503)
924
+ .json({ error: 'integrations subsystem disabled in config' });
925
+ }
926
+ const { platforms, message, chat_id: chatIdOverride } = req.body ?? {};
927
+ if (!message || typeof message !== 'object') {
928
+ return res
929
+ .status(400)
930
+ .json({ error: 'message is required and must be an object' });
931
+ }
932
+ if (platforms !== undefined && !Array.isArray(platforms)) {
933
+ return res.status(400).json({ error: 'platforms must be an array' });
934
+ }
935
+ try {
936
+ const out = await integrations.sendOutbound({
937
+ platforms,
938
+ message,
939
+ chatIdOverride,
940
+ });
941
+ res.json(out);
942
+ } catch (err) {
943
+ res.status(400).json({ error: String(err?.message ?? err) });
944
+ }
945
+ });
946
+
864
947
  // GET /api/integrations/config — return saved config (secrets redacted)
865
948
  app.get('/api/integrations/config', (_req, res) => {
866
949
  const configPath = join(prefsDir, 'integrations', 'config.json');
@@ -1165,6 +1248,150 @@ export function createApp(options = {}) {
1165
1248
  res.sendFile(htmlPath);
1166
1249
  });
1167
1250
 
1251
+ // ─── CRG (code-review-graph) endpoints ─────────────────────────────────
1252
+ if (!app.locals.crgStatus) {
1253
+ app.locals.crgStatus = createCrgStatus({});
1254
+ }
1255
+
1256
+ function readCrgSettings(projectId) {
1257
+ const readJson = (p) => {
1258
+ if (!p) return {};
1259
+ try {
1260
+ return JSON.parse(readFileSync(p, 'utf-8'));
1261
+ } catch {
1262
+ return {};
1263
+ }
1264
+ };
1265
+ const globalSettingsPath = prefsDir
1266
+ ? join(prefsDir, 'settings.json')
1267
+ : settingsPath;
1268
+
1269
+ let projectSettingsPath = settingsPath;
1270
+ let root = projectRoot || process.cwd();
1271
+ if (projectId && prefsDir) {
1272
+ const proj = readProjects(prefsDir).find((p) => p.name === projectId);
1273
+ if (proj) {
1274
+ projectSettingsPath =
1275
+ proj.settingsPath || join(proj.path, '.claude', 'settings.json');
1276
+ root = proj.path;
1277
+ }
1278
+ } else if (!projectSettingsPath && projectRoot) {
1279
+ projectSettingsPath = join(projectRoot, '.claude', 'settings.json');
1280
+ }
1281
+
1282
+ return {
1283
+ globalSettings: readJson(globalSettingsPath),
1284
+ projectSettings: readJson(projectSettingsPath),
1285
+ root,
1286
+ };
1287
+ }
1288
+
1289
+ async function crgStatusPayload(projectId) {
1290
+ const { globalSettings, projectSettings, root } =
1291
+ readCrgSettings(projectId);
1292
+ const result = await app.locals.crgStatus.getStatus({
1293
+ globalSettings,
1294
+ projectSettings,
1295
+ projectRoot: root,
1296
+ });
1297
+ return { ...result, building: Boolean(app.locals.crgBuilding) };
1298
+ }
1299
+
1300
+ app.get('/api/crg/status', async (req, res) => {
1301
+ try {
1302
+ res.json(await crgStatusPayload(req.query.project));
1303
+ } catch (err) {
1304
+ res.status(500).json({ ok: false, error: err.message });
1305
+ }
1306
+ });
1307
+
1308
+ app.post('/api/crg/recheck', async (req, res) => {
1309
+ try {
1310
+ app.locals.crgStatus.invalidate();
1311
+ res.json(await crgStatusPayload(req.query.project));
1312
+ } catch (err) {
1313
+ res.status(500).json({ ok: false, error: err.message });
1314
+ }
1315
+ });
1316
+
1317
+ app.post('/api/crg/build', async (req, res) => {
1318
+ try {
1319
+ const { globalSettings, projectSettings, root } = readCrgSettings(
1320
+ req.query.project,
1321
+ );
1322
+ const effective = _effectiveCrgConfig(globalSettings, projectSettings);
1323
+ if (!effective.enabled) {
1324
+ return res
1325
+ .status(400)
1326
+ .json({ ok: false, error: 'Code-review-graph is not enabled' });
1327
+ }
1328
+ const detection = await app.locals.crgStatus.detect();
1329
+ if (
1330
+ !detection.installed ||
1331
+ !detection.compatible ||
1332
+ !detection.fastmcp_ok
1333
+ ) {
1334
+ return res.status(400).json({
1335
+ ok: false,
1336
+ error:
1337
+ detection.error ||
1338
+ 'Code-review-graph is not installed or not compatible',
1339
+ });
1340
+ }
1341
+ if (app.locals.crgBuilding) {
1342
+ return res.json({ ok: true, status: 'building' });
1343
+ }
1344
+ const snap = snapshotDir(root);
1345
+ if (snap) {
1346
+ try {
1347
+ rmSync(snap, { recursive: true, force: true });
1348
+ } catch {}
1349
+ }
1350
+ app.locals.crgBuilding = true;
1351
+ const child = spawn(
1352
+ 'python3',
1353
+ [
1354
+ '-c',
1355
+ 'from worca.scripts.crg_preflight import run_crg_preflight as r; r()',
1356
+ ],
1357
+ { cwd: root, stdio: 'ignore' },
1358
+ );
1359
+ const done = () => {
1360
+ app.locals.crgBuilding = false;
1361
+ app.locals.crgStatus.invalidate();
1362
+ };
1363
+ child.on('exit', done);
1364
+ child.on('error', done);
1365
+ res.json({ ok: true, status: 'building' });
1366
+ } catch (err) {
1367
+ app.locals.crgBuilding = false;
1368
+ res.status(500).json({ ok: false, error: err.message });
1369
+ }
1370
+ });
1371
+
1372
+ app.post('/api/crg/clear', async (req, res) => {
1373
+ try {
1374
+ const { root } = readCrgSettings(req.query.project);
1375
+ const cleared = clearRepoCache(root);
1376
+ app.locals.crgStatus.invalidate();
1377
+ res.json({ ok: true, cleared });
1378
+ } catch (err) {
1379
+ res.status(500).json({ ok: false, error: err.message });
1380
+ }
1381
+ });
1382
+
1383
+ app.get('/api/crg/graph.html', (req, res) => {
1384
+ const { root } = readCrgSettings(req.query.project);
1385
+ const snap = snapshotDir(root);
1386
+ const htmlPath = snap
1387
+ ? join(snap, 'code-review-graph', 'graph.html')
1388
+ : null;
1389
+ if (!htmlPath || !existsSync(htmlPath)) {
1390
+ return res.status(404).json({ ok: false, error: 'graph.html not found' });
1391
+ }
1392
+ res.sendFile(htmlPath);
1393
+ });
1394
+
1168
1395
  // ─── Dynamic favicon ──────────────────────────────────────────────────
1169
1396
  // Serve mode-specific favicon before express.static so it takes precedence.
1170
1397
  app.get('/favicon.svg', (_req, res) => {
@@ -0,0 +1,206 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { existsSync, statSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+
5
+ import { repoCacheDir, snapshotDir } from './graphify-status.js';
6
+
7
+ // Mirror of _CRG_DEFAULTS in src/worca/utils/code_review_graph.py — keep in sync.
8
+ const CRG_DEFAULTS = {
9
+ enabled: false,
10
+ embeddings: false,
11
+ update_on: {
12
+ preflight: true,
13
+ post_implement: true,
14
+ guardian_post_commit: true,
15
+ },
16
+ freshness: 'clean_only',
17
+ min_repo_files: 100,
18
+ version_range: '>=2,<3',
19
+ fastmcp_min: '3.2.4',
20
+ preflight_timeout_seconds: 300,
21
+ stage_tools: null,
22
+ };
23
+
24
+ // Mirror of effective_crg_config() in src/worca/utils/code_review_graph.py.
25
+ // Enablement is project-level: the project opts in via code_review_graph.enabled.
26
+ // Global code_review_graph.enabled is purely a kill-switch — an EXPLICIT global
27
+ // `false` disables everywhere; `true`/unset defer to the project. These rules
28
+ // MUST match the Python implementation; the parity is guarded by
29
+ // code-review-graph-status.test.js ("_effectiveCrgConfig parity with Python").
30
+ // Update both together.
31
+ export function _effectiveCrgConfig(globalSettings, projectSettings) {
32
+ const gCrg = globalSettings?.worca?.code_review_graph ?? {};
33
+ const pCrg = projectSettings?.worca?.code_review_graph ?? {};
34
+
35
+ if (gCrg.enabled === false) {
36
+ return _disabledConfig('global-off');
37
+ }
38
+
39
+ const projectEnabled = pCrg.enabled ?? false;
40
+ if (!projectEnabled) {
41
+ return _disabledConfig('project-off');
42
+ }
43
+
44
+ const merged = { ...CRG_DEFAULTS };
45
+ for (const [k, v] of Object.entries(gCrg)) {
46
+ if (v != null || k === 'enabled') merged[k] = v;
47
+ }
48
+ for (const [k, v] of Object.entries(pCrg)) {
49
+ if (v != null || k === 'enabled') merged[k] = v;
50
+ }
51
+
52
+ const defaultsUpdateOn = { ...CRG_DEFAULTS.update_on };
53
+ if (gCrg.update_on && typeof gCrg.update_on === 'object') {
54
+ Object.assign(defaultsUpdateOn, gCrg.update_on);
55
+ }
56
+ if (pCrg.update_on && typeof pCrg.update_on === 'object') {
57
+ Object.assign(defaultsUpdateOn, pCrg.update_on);
58
+ }
59
+
60
+ return {
61
+ enabled: true,
62
+ embeddings: merged.embeddings,
63
+ update_on_preflight: defaultsUpdateOn.preflight ?? true,
64
+ update_on_post_implement: defaultsUpdateOn.post_implement ?? true,
65
+ update_on_guardian_post_commit:
66
+ defaultsUpdateOn.guardian_post_commit ?? true,
67
+ min_repo_files: merged.min_repo_files,
68
+ version_range: merged.version_range,
69
+ fastmcp_min: merged.fastmcp_min,
70
+ preflight_timeout_seconds: merged.preflight_timeout_seconds,
71
+ freshness: merged.freshness,
72
+ stage_tools: merged.stage_tools,
73
+ reason: null,
74
+ };
75
+ }
76
+
77
+ function _disabledConfig(reason) {
78
+ const d = CRG_DEFAULTS;
79
+ const u = d.update_on;
80
+ return {
81
+ enabled: false,
82
+ embeddings: d.embeddings,
83
+ update_on_preflight: u.preflight,
84
+ update_on_post_implement: u.post_implement,
85
+ update_on_guardian_post_commit: u.guardian_post_commit,
86
+ min_repo_files: d.min_repo_files,
87
+ version_range: d.version_range,
88
+ fastmcp_min: d.fastmcp_min,
89
+ preflight_timeout_seconds: d.preflight_timeout_seconds,
90
+ freshness: d.freshness,
91
+ stage_tools: d.stage_tools,
92
+ reason,
93
+ };
94
+ }
95
+
96
+ // ─── Per-commit CRG graph stats ─────────────────────────────────────────
97
+
98
+ const _CRG_SUBDIR = 'code-review-graph';
99
+
100
+ export function _crgGraphStats(snapDir) {
101
+ if (!snapDir) return null;
102
+ const dbPath = join(snapDir, _CRG_SUBDIR, 'graph.db');
103
+ if (!existsSync(dbPath)) return null;
104
+
105
+ const stat = statSync(dbPath);
106
+ const ageSeconds = Math.max(0, (Date.now() - stat.mtimeMs) / 1000);
107
+ const htmlPath = join(snapDir, _CRG_SUBDIR, 'graph.html');
108
+
109
+ return {
110
+ db_path: dbPath,
111
+ snapshot_dir: snapDir,
112
+ age_seconds: ageSeconds,
113
+ size_bytes: stat.size,
114
+ has_html: existsSync(htmlPath),
115
+ };
116
+ }
117
+
118
+ // ─── Detection (delegates to Python detect_code_review_graph) ───────────
119
+
120
+ function defaultDetect() {
121
+ return new Promise((resolve) => {
122
+ const child = spawn(
123
+ 'python3',
124
+ [
125
+ '-c',
126
+ 'import json; from worca.utils.code_review_graph import detect_code_review_graph; d = detect_code_review_graph(); print(json.dumps({"installed": d.installed, "version": d.version, "compatible": d.compatible, "fastmcp_ok": d.fastmcp_ok, "error": d.error}))',
127
+ ],
128
+ { stdio: ['ignore', 'pipe', 'pipe'] },
129
+ );
130
+
131
+ let stdout = '';
132
+ let stderr = '';
133
+ child.stdout.on('data', (chunk) => {
134
+ stdout += chunk.toString();
135
+ });
136
+ child.stderr.on('data', (chunk) => {
137
+ stderr += chunk.toString();
138
+ });
139
+ child.on('error', () => {
140
+ resolve({
141
+ installed: false,
142
+ version: null,
143
+ compatible: false,
144
+ fastmcp_ok: false,
145
+ error: 'python3 not available',
146
+ });
147
+ });
148
+ child.on('exit', (code) => {
149
+ if (code === 0 && stdout.trim()) {
150
+ try {
151
+ resolve(JSON.parse(stdout.trim()));
152
+ return;
153
+ } catch {
154
+ // fall through
155
+ }
156
+ }
157
+ resolve({
158
+ installed: false,
159
+ version: null,
160
+ compatible: false,
161
+ fastmcp_ok: false,
162
+ error: stderr.trim() || `detect exited ${code}`,
163
+ });
164
+ });
165
+ });
166
+ }
167
+
168
+ const DEFAULT_TTL_MS = 60_000;
169
+
170
+ export function createCrgStatus(opts = {}) {
171
+ const detectFn = opts.detectFn || defaultDetect;
172
+ const ttlMs = opts.ttlMs ?? DEFAULT_TTL_MS;
173
+
174
+ let cached = null;
175
+ let cachedAt = 0;
176
+
177
+ async function detect() {
178
+ const now = Date.now();
179
+ if (cached && now - cachedAt < ttlMs) return cached;
180
+ cached = await detectFn();
181
+ cachedAt = Date.now();
182
+ return cached;
183
+ }
184
+
185
+ function invalidate() {
186
+ cached = null;
187
+ cachedAt = 0;
188
+ }
189
+
190
+ async function getStatus({ globalSettings, projectSettings, projectRoot }) {
191
+ const effective = _effectiveCrgConfig(globalSettings, projectSettings);
192
+ const detection = await detect();
193
+ const graphStats = effective.enabled
194
+ ? _crgGraphStats(snapshotDir(projectRoot))
195
+ : null;
196
+ return {
197
+ ok: true,
198
+ effective,
199
+ detection,
200
+ graph_stats: graphStats,
201
+ cache_path: repoCacheDir(projectRoot),
202
+ };
203
+ }
204
+
205
+ return { detect, invalidate, getStatus };
206
+ }