@mandipadk7/kavi 0.1.5 → 0.1.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.
package/README.md CHANGED
@@ -11,6 +11,8 @@ Current capabilities:
11
11
  - `kavi open`: create a managed session with separate Codex and Claude worktrees and open the full-screen operator console, even from an empty folder or a repo with no `HEAD` yet.
12
12
  - `kavi resume`: reopen the operator console for the current repo session.
13
13
  - `kavi status`: inspect session health, task counts, and configured routing ownership rules from any terminal.
14
+ - `kavi route`: preview how Kavi would route a prompt before enqueuing it.
15
+ - `kavi routes`: inspect recent task routing decisions with strategy, confidence, and metadata.
14
16
  - `kavi paths`: inspect resolved repo-local, user-local, worktree, and runtime paths.
15
17
  - `kavi task`: enqueue a task for `codex`, `claude`, or `auto` routing.
16
18
  - `kavi tasks`: inspect the session task list with summaries and artifact availability.
@@ -33,6 +35,7 @@ Runtime model:
33
35
  - SQLite event mirroring is opt-in with `KAVI_ENABLE_SQLITE_HISTORY=1`; the default event log is JSONL under `.kavi/state/events.jsonl`.
34
36
  - The operator console exposes a task board, dual agent lanes, a live inspector pane, approval actions, inline task composition, worktree diff review with file and hunk navigation, and persisted operator review threads on files or hunks.
35
37
  - Routing can now use explicit path ownership rules from `.kavi/config.toml` via `[routing].codex_paths` and `[routing].claude_paths`, so known parts of the tree can bypass looser keyword or AI routing.
38
+ - Ownership routing now prefers the strongest matching rule, not just the side with the most raw glob hits, and route metadata records the winning rule when one exists.
36
39
  - Diff-based path claims now release older overlapping same-agent claims automatically, so the active claim set stays closer to each managed worktree's current unlanded surface.
37
40
 
38
41
  Notes:
@@ -41,10 +44,12 @@ Notes:
41
44
  - Claude still runs through the installed `claude` CLI with Kavi-managed hooks and approval decisions.
42
45
  - `kavi doctor` now checks Claude auth readiness with `claude auth status`, and startup blocks if Claude is installed but not authenticated.
43
46
  - `kavi doctor` also validates ownership path rules for duplicates, repo escapes, and absolute-path mistakes before those rules affect routing.
47
+ - `kavi doctor` now also flags overlapping cross-agent ownership rules that do not produce a clear specificity winner.
44
48
  - The dashboard and operator commands now use the daemon's local RPC socket instead of editing session files directly, and the TUI stays updated from pushed daemon snapshots rather than polling.
45
49
  - The socket is machine-local rather than repo-local to avoid Unix socket path-length issues in deep repos and temp directories.
46
- - The console is keyboard-driven: `1-7` switch views, `j/k` move selection, `[` and `]` cycle task detail sections, `,` and `.` cycle changed files, `{` and `}` cycle patch hunks, `A/C/Q/M` add review notes, `o/O` cycle existing threads, `T` reply, `E` edit, `R` resolve or reopen, `a` cycle thread assignee, `w` mark a thread as won't-fix, `x` mark it as accepted-risk, `F` queue a fix task, `H` queue a handoff task, `y/n` resolve approvals, and `c` opens the inline task composer.
50
+ - The console is keyboard-driven: `1-7` switch views, `j/k` move selection, `[` and `]` cycle task detail sections, `,` and `.` cycle changed files, `{` and `}` cycle patch hunks, `A/C/Q/M` add review notes, `o/O` cycle existing threads, `T` reply, `E` edit, `R` resolve or reopen, `a` cycle thread assignee, `w` mark a thread as won't-fix, `x` mark it as accepted-risk, `F` queue a fix task, `H` queue a handoff task, `y/n` resolve approvals, and `c` opens the inline task composer with live route preview diagnostics.
47
51
  - Review filters are available both in the CLI and the TUI: `kavi reviews --assignee operator --status open`, and inside the console use `u`, `v`, and `d` to cycle assignee, status, and disposition filters for the active diff context.
52
+ - The claims inspector now shows active overlap hotspots and ownership-rule conflicts so routing pressure points are visible directly in the operator surface.
48
53
  - Review threads now carry explicit assignees and richer dispositions, including `accepted risk` and `won't fix`, instead of relying only on free-form note text.
49
54
  - Successful follow-up tasks now auto-resolve linked open review threads, landed follow-up work marks those resolved threads as landed, and replying to a resolved thread reopens it.
50
55
 
@@ -4,11 +4,13 @@ function formatDecisionLine(summary, detail) {
4
4
  return detail.trim() ? `- ${summary}: ${detail}` : `- ${summary}`;
5
5
  }
6
6
  export function buildDecisionReplay(session, task, agent) {
7
+ const winningRule = task.routeMetadata?.winningRule && typeof task.routeMetadata.winningRule === "object" && !Array.isArray(task.routeMetadata.winningRule) && typeof task.routeMetadata.winningRule.pattern === "string" ? String(task.routeMetadata.winningRule.pattern) : null;
7
8
  const taskDecisions = session.decisions.filter((decision)=>decision.taskId === task.id).slice(-6).map((decision)=>formatDecisionLine(`[${decision.kind}] ${decision.summary}`, decision.detail));
8
9
  const sharedDecisions = session.decisions.filter((decision)=>decision.taskId !== task.id && (decision.agent === agent || decision.agent === null)).slice(-4).map((decision)=>formatDecisionLine(`[${decision.kind}] ${decision.summary}`, decision.detail));
9
10
  const relevantClaims = session.pathClaims.filter((claim)=>claim.status === "active" && (claim.taskId === task.id || claim.agent !== agent)).slice(-6).map((claim)=>`- ${claim.agent} ${claim.source} claim on ${claim.paths.join(", ")}${claim.note ? `: ${claim.note}` : ""}`);
10
11
  const replay = [
11
12
  `- Current route reason: ${task.routeReason ?? "not recorded"}`,
13
+ `- Winning ownership rule: ${winningRule ?? "none"}`,
12
14
  `- Current claimed paths: ${task.claimedPaths.join(", ") || "none"}`,
13
15
  ...taskDecisions,
14
16
  ...sharedDecisions,
package/dist/daemon.js CHANGED
@@ -1002,7 +1002,10 @@ export class KaviDaemon {
1002
1002
  return;
1003
1003
  }
1004
1004
  const changedPaths = await listWorktreeChangedPaths(worktree.path, this.session.baseCommit);
1005
- const hadClaimedSurface = task.claimedPaths.length > 0 || this.session.pathClaims.some((claim)=>claim.taskId === task.id && claim.status === "active");
1005
+ const previousClaimedPaths = [
1006
+ ...task.claimedPaths
1007
+ ];
1008
+ const hadClaimedSurface = previousClaimedPaths.length > 0 || this.session.pathClaims.some((claim)=>claim.taskId === task.id && claim.status === "active");
1006
1009
  task.claimedPaths = changedPaths;
1007
1010
  const claim = upsertPathClaim(this.session, {
1008
1011
  taskId: task.id,
@@ -1031,6 +1034,14 @@ export class KaviDaemon {
1031
1034
  supersededByPaths: changedPaths
1032
1035
  }
1033
1036
  });
1037
+ await recordEvent(this.paths, this.session.id, "claim.superseded", {
1038
+ claimId: releasedClaim.id,
1039
+ taskId: releasedClaim.taskId,
1040
+ agent: releasedClaim.agent,
1041
+ paths: releasedClaim.paths,
1042
+ supersededByTaskId: task.id,
1043
+ supersededByPaths: changedPaths
1044
+ });
1034
1045
  }
1035
1046
  return;
1036
1047
  }
@@ -1045,6 +1056,12 @@ export class KaviDaemon {
1045
1056
  releaseReason: "empty-diff-claim"
1046
1057
  }
1047
1058
  });
1059
+ await recordEvent(this.paths, this.session.id, "claim.released", {
1060
+ taskId: task.id,
1061
+ agent: task.owner,
1062
+ paths: previousClaimedPaths,
1063
+ reason: "empty-diff-claim"
1064
+ });
1048
1065
  }
1049
1066
  }
1050
1067
  }
@@ -21,6 +21,17 @@ function pathOverlaps(left, right) {
21
21
  }
22
22
  return leftPath === rightPath || leftPath.startsWith(`${rightPath}/`) || rightPath.startsWith(`${leftPath}/`);
23
23
  }
24
+ function overlapPath(left, right) {
25
+ const leftPath = normalizePath(left);
26
+ const rightPath = normalizePath(right);
27
+ if (!pathOverlaps(leftPath, rightPath)) {
28
+ return null;
29
+ }
30
+ if (leftPath === rightPath) {
31
+ return leftPath;
32
+ }
33
+ return leftPath.startsWith(`${rightPath}/`) ? leftPath : rightPath;
34
+ }
24
35
  export function addDecisionRecord(session, input) {
25
36
  const record = {
26
37
  id: randomUUID(),
@@ -126,6 +137,85 @@ export function findClaimConflicts(session, owner, claimedPaths) {
126
137
  }
127
138
  return activePathClaims(session).filter((claim)=>claim.agent !== owner && claim.paths.some((item)=>normalizedPaths.some((candidate)=>pathOverlaps(item, candidate))));
128
139
  }
140
+ export function buildClaimHotspots(session) {
141
+ const hotspots = new Map();
142
+ const claims = activePathClaims(session);
143
+ for(let index = 0; index < claims.length; index += 1){
144
+ const left = claims[index];
145
+ if (!left) {
146
+ continue;
147
+ }
148
+ for(let inner = index + 1; inner < claims.length; inner += 1){
149
+ const right = claims[inner];
150
+ if (!right || left.taskId === right.taskId) {
151
+ continue;
152
+ }
153
+ const overlappingPaths = left.paths.flatMap((leftPath)=>right.paths.map((rightPath)=>overlapPath(leftPath, rightPath)).filter((value)=>Boolean(value)));
154
+ if (overlappingPaths.length === 0) {
155
+ continue;
156
+ }
157
+ for (const hotspotPath of overlappingPaths){
158
+ const existing = hotspots.get(hotspotPath);
159
+ if (existing) {
160
+ existing.overlapCount += 1;
161
+ existing.agents = [
162
+ ...new Set([
163
+ ...existing.agents,
164
+ left.agent,
165
+ right.agent
166
+ ])
167
+ ];
168
+ existing.taskIds = [
169
+ ...new Set([
170
+ ...existing.taskIds,
171
+ left.taskId,
172
+ right.taskId
173
+ ])
174
+ ];
175
+ existing.claimIds = [
176
+ ...new Set([
177
+ ...existing.claimIds,
178
+ left.id,
179
+ right.id
180
+ ])
181
+ ];
182
+ continue;
183
+ }
184
+ hotspots.set(hotspotPath, {
185
+ path: hotspotPath,
186
+ agents: [
187
+ ...new Set([
188
+ left.agent,
189
+ right.agent
190
+ ])
191
+ ],
192
+ taskIds: [
193
+ ...new Set([
194
+ left.taskId,
195
+ right.taskId
196
+ ])
197
+ ],
198
+ claimIds: [
199
+ ...new Set([
200
+ left.id,
201
+ right.id
202
+ ])
203
+ ],
204
+ overlapCount: 1
205
+ });
206
+ }
207
+ }
208
+ }
209
+ return [
210
+ ...hotspots.values()
211
+ ].sort((left, right)=>{
212
+ const overlapDelta = right.overlapCount - left.overlapCount;
213
+ if (overlapDelta !== 0) {
214
+ return overlapDelta;
215
+ }
216
+ return left.path.localeCompare(right.path);
217
+ });
218
+ }
129
219
 
130
220
 
131
221
  //# sourceURL=decision-ledger.ts
package/dist/doctor.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import path from "node:path";
2
2
  import { loadConfig } from "./config.js";
3
3
  import { fileExists } from "./fs.js";
4
+ import { findOwnershipRuleConflicts } from "./ownership.js";
4
5
  import { runCommand } from "./process.js";
5
6
  import { hasSupportedNode, minimumNodeMajor, resolveSessionRuntime } from "./runtime.js";
6
7
  export function parseClaudeAuthStatus(output) {
@@ -87,6 +88,10 @@ export function validateRoutingPathRules(config) {
87
88
  ...new Set(overlappingRules)
88
89
  ].join(", ")}`);
89
90
  }
91
+ const ambiguousConflicts = findOwnershipRuleConflicts(config).filter((conflict)=>conflict.kind === "ambiguous-overlap").map((conflict)=>`${conflict.leftOwner}:${conflict.leftPattern} <> ${conflict.rightOwner}:${conflict.rightPattern}`);
92
+ if (ambiguousConflicts.length > 0) {
93
+ issues.push(`Ownership rules have ambiguous overlaps without a specificity winner: ${ambiguousConflicts.join(", ")}`);
94
+ }
90
95
  return issues;
91
96
  }
92
97
  function normalizeVersion(output) {
package/dist/main.js CHANGED
@@ -6,16 +6,17 @@ import { createApprovalRequest, describeToolUse, findApprovalRule, listApprovalR
6
6
  import { appendCommand } from "./command-queue.js";
7
7
  import { ensureHomeConfig, ensureProjectScaffold, loadConfig } from "./config.js";
8
8
  import { KaviDaemon } from "./daemon.js";
9
- import { addDecisionRecord, releasePathClaims, upsertPathClaim } from "./decision-ledger.js";
9
+ import { addDecisionRecord, buildClaimHotspots, releasePathClaims, upsertPathClaim } from "./decision-ledger.js";
10
10
  import { runDoctor } from "./doctor.js";
11
11
  import { writeJson } from "./fs.js";
12
12
  import { createGitignoreEntries, detectRepoRoot, ensureBootstrapCommit, ensureGitRepository, ensureWorktrees, findRepoRoot, findOverlappingWorktreePaths, landBranches, resolveTargetBranch } from "./git.js";
13
13
  import { buildSessionId, resolveAppPaths } from "./paths.js";
14
14
  import { isProcessAlive, spawnDetachedNode } from "./process.js";
15
15
  import { pingRpc, readSnapshot, rpcEnqueueTask, rpcNotifyExternalUpdate, rpcKickoff, rpcRecentEvents, rpcResolveApproval, rpcShutdown, rpcTaskArtifact } from "./rpc.js";
16
+ import { findOwnershipRuleConflicts } from "./ownership.js";
16
17
  import { filterReviewNotes, markReviewNotesLandedForTasks } from "./reviews.js";
17
18
  import { resolveSessionRuntime } from "./runtime.js";
18
- import { buildAdHocTask, extractPromptPathHints, routeTask } from "./router.js";
19
+ import { buildAdHocTask, extractPromptPathHints, previewRouteDecision, routeTask } from "./router.js";
19
20
  import { createSessionRecord, loadSessionRecord, readRecentEvents, recordEvent, saveSessionRecord, sessionExists, sessionHeartbeatAgeMs } from "./session.js";
20
21
  import { listTaskArtifacts, loadTaskArtifact, saveTaskArtifact } from "./task-artifacts.js";
21
22
  import { attachTui } from "./tui.js";
@@ -73,6 +74,8 @@ function renderUsage() {
73
74
  " kavi open [--goal \"...\"]",
74
75
  " kavi resume",
75
76
  " kavi status [--json]",
77
+ " kavi route [--json] [--no-ai] <prompt>",
78
+ " kavi routes [--json] [--limit N]",
76
79
  " kavi paths [--json]",
77
80
  " kavi task [--agent codex|claude|auto] <prompt>",
78
81
  " kavi tasks [--json]",
@@ -291,6 +294,25 @@ async function notifyOperatorSurface(paths, reason) {
291
294
  await rpcNotifyExternalUpdate(paths, reason);
292
295
  } catch {}
293
296
  }
297
+ function buildRouteAnalytics(tasks) {
298
+ const byOwner = {
299
+ codex: 0,
300
+ claude: 0
301
+ };
302
+ const byStrategy = {};
303
+ for (const task of tasks){
304
+ if (task.owner === "codex" || task.owner === "claude") {
305
+ byOwner[task.owner] += 1;
306
+ }
307
+ if (task.routeStrategy) {
308
+ byStrategy[task.routeStrategy] = (byStrategy[task.routeStrategy] ?? 0) + 1;
309
+ }
310
+ }
311
+ return {
312
+ byOwner,
313
+ byStrategy
314
+ };
315
+ }
294
316
  async function commandOpen(cwd, args) {
295
317
  const goal = getGoal(args);
296
318
  await startOrAttachSession(cwd, goal);
@@ -323,6 +345,9 @@ async function commandStatus(cwd, args) {
323
345
  const session = rpcSnapshot?.session ?? await loadSessionRecord(paths);
324
346
  const pendingApprovals = rpcSnapshot?.approvals.filter((item)=>item.status === "pending") ?? await listApprovalRequests(paths);
325
347
  const heartbeatAgeMs = sessionHeartbeatAgeMs(session);
348
+ const routeAnalytics = buildRouteAnalytics(session.tasks);
349
+ const ownershipConflicts = findOwnershipRuleConflicts(session.config);
350
+ const claimHotspots = buildClaimHotspots(session);
326
351
  const payload = {
327
352
  id: session.id,
328
353
  status: session.status,
@@ -356,6 +381,9 @@ async function commandStatus(cwd, args) {
356
381
  pathClaimCounts: {
357
382
  active: session.pathClaims.filter((claim)=>claim.status === "active").length
358
383
  },
384
+ routeCounts: routeAnalytics,
385
+ ownershipConflicts: ownershipConflicts.length,
386
+ claimHotspots: claimHotspots.length,
359
387
  routingOwnership: {
360
388
  codexPaths: session.config.routing.codexPaths,
361
389
  claudePaths: session.config.routing.claudePaths
@@ -379,11 +407,83 @@ async function commandStatus(cwd, args) {
379
407
  console.log(`Reviews: open=${payload.reviewCounts.open} total=${payload.reviewCounts.total}`);
380
408
  console.log(`Decisions: total=${payload.decisionCounts.total}`);
381
409
  console.log(`Path claims: active=${payload.pathClaimCounts.active}`);
410
+ console.log(`Routes: codex=${payload.routeCounts.byOwner.codex} claude=${payload.routeCounts.byOwner.claude} | strategies=${Object.entries(payload.routeCounts.byStrategy).map(([strategy, count])=>`${strategy}:${count}`).join(", ") || "-"}`);
411
+ console.log(`Ownership conflicts: ${payload.ownershipConflicts} | Claim hotspots: ${payload.claimHotspots}`);
382
412
  console.log(`Routing ownership: codex=${payload.routingOwnership.codexPaths.join(", ") || "-"} | claude=${payload.routingOwnership.claudePaths.join(", ") || "-"}`);
383
413
  for (const worktree of payload.worktrees){
384
414
  console.log(`- ${worktree.agent}: ${worktree.path}`);
385
415
  }
386
416
  }
417
+ async function commandRoute(cwd, args) {
418
+ const prompt = getGoal(args);
419
+ if (!prompt) {
420
+ throw new Error("A route preview prompt is required. Example: kavi route \"Refactor src/ui/App.tsx\"");
421
+ }
422
+ const repoRoot = await findRepoRoot(cwd) ?? cwd;
423
+ const paths = resolveAppPaths(repoRoot);
424
+ const allowAi = !args.includes("--no-ai");
425
+ const hasSession = await sessionExists(paths);
426
+ const session = hasSession ? await loadSessionRecord(paths) : null;
427
+ const config = session?.config ?? await loadConfig(paths);
428
+ const routeDecision = allowAi && session ? await routeTask(prompt, session, paths) : previewRouteDecision(prompt, config, session);
429
+ const payload = {
430
+ prompt,
431
+ mode: allowAi && session ? "live" : "preview",
432
+ route: routeDecision
433
+ };
434
+ if (args.includes("--json")) {
435
+ console.log(JSON.stringify(payload, null, 2));
436
+ return;
437
+ }
438
+ console.log(`Mode: ${payload.mode}`);
439
+ console.log(`Owner: ${routeDecision.owner}`);
440
+ console.log(`Strategy: ${routeDecision.strategy}`);
441
+ console.log(`Confidence: ${routeDecision.confidence.toFixed(2)}`);
442
+ console.log(`Reason: ${routeDecision.reason}`);
443
+ console.log(`Claimed paths: ${routeDecision.claimedPaths.join(", ") || "-"}`);
444
+ if (Object.keys(routeDecision.metadata).length > 0) {
445
+ console.log(`Metadata: ${JSON.stringify(routeDecision.metadata)}`);
446
+ }
447
+ }
448
+ async function commandRoutes(cwd, args) {
449
+ const { paths } = await requireSession(cwd);
450
+ const rpcSnapshot = await tryRpcSnapshot(paths);
451
+ const session = rpcSnapshot?.session ?? await loadSessionRecord(paths);
452
+ const limitArg = getFlag(args, "--limit");
453
+ const limit = limitArg ? Number(limitArg) : 20;
454
+ const routes = [
455
+ ...session.tasks
456
+ ].filter((task)=>task.routeStrategy !== null).sort((left, right)=>right.updatedAt.localeCompare(left.updatedAt)).slice(0, Math.max(1, Number.isFinite(limit) ? limit : 20)).map((task)=>({
457
+ taskId: task.id,
458
+ title: task.title,
459
+ owner: task.owner,
460
+ status: task.status,
461
+ updatedAt: task.updatedAt,
462
+ routeStrategy: task.routeStrategy,
463
+ routeConfidence: task.routeConfidence,
464
+ routeReason: task.routeReason,
465
+ routeMetadata: task.routeMetadata,
466
+ claimedPaths: task.claimedPaths
467
+ }));
468
+ if (args.includes("--json")) {
469
+ console.log(JSON.stringify(routes, null, 2));
470
+ return;
471
+ }
472
+ if (routes.length === 0) {
473
+ console.log("No routed tasks recorded.");
474
+ return;
475
+ }
476
+ for (const route of routes){
477
+ console.log(`${route.taskId} | ${route.owner} | ${route.status} | ${route.routeStrategy ?? "-"}${route.routeConfidence === null ? "" : ` (${route.routeConfidence.toFixed(2)})`}`);
478
+ console.log(` title: ${route.title}`);
479
+ console.log(` updated: ${route.updatedAt}`);
480
+ console.log(` reason: ${route.routeReason ?? "-"}`);
481
+ console.log(` paths: ${route.claimedPaths.join(", ") || "-"}`);
482
+ if (Object.keys(route.routeMetadata).length > 0) {
483
+ console.log(` metadata: ${JSON.stringify(route.routeMetadata)}`);
484
+ }
485
+ }
486
+ }
387
487
  async function commandPaths(cwd, args) {
388
488
  const repoRoot = await findRepoRoot(cwd) ?? cwd;
389
489
  const paths = resolveAppPaths(repoRoot);
@@ -843,6 +943,14 @@ async function commandLand(cwd) {
843
943
  releaseReason: "land.completed"
844
944
  }
845
945
  });
946
+ await recordEvent(paths, session.id, "claim.released", {
947
+ claimId: claim.id,
948
+ taskId: claim.taskId,
949
+ agent: claim.agent,
950
+ paths: claim.paths,
951
+ reason: "land.completed",
952
+ targetBranch
953
+ });
846
954
  }
847
955
  const landedReviewNotes = markReviewNotesLandedForTasks(session, session.tasks.filter((task)=>task.status === "completed").map((task)=>task.id));
848
956
  for (const note of landedReviewNotes){
@@ -1028,6 +1136,12 @@ async function main() {
1028
1136
  case "status":
1029
1137
  await commandStatus(cwd, args);
1030
1138
  break;
1139
+ case "route":
1140
+ await commandRoute(cwd, args);
1141
+ break;
1142
+ case "routes":
1143
+ await commandRoutes(cwd, args);
1144
+ break;
1031
1145
  case "paths":
1032
1146
  await commandPaths(cwd, args);
1033
1147
  break;
@@ -0,0 +1,276 @@
1
+ import path from "node:path";
2
+ export function normalizeOwnershipPattern(value) {
3
+ const trimmed = value.trim().replaceAll("\\", "/");
4
+ const withoutPrefix = trimmed.startsWith("./") ? trimmed.slice(2) : trimmed;
5
+ const normalized = path.posix.normalize(withoutPrefix);
6
+ return normalized === "." ? "" : normalized.replace(/^\/+/, "").replace(/\/+$/, "");
7
+ }
8
+ export function globToRegex(pattern) {
9
+ const normalized = normalizeOwnershipPattern(pattern);
10
+ const escaped = normalized.replace(/[|\\{}()[\]^$+?.]/g, "\\$&");
11
+ const regexSource = escaped.replaceAll("**", "::double-star::").replaceAll("*", "[^/]*").replaceAll("::double-star::", ".*");
12
+ return new RegExp(`^${regexSource}$`);
13
+ }
14
+ export function matchesOwnershipPattern(filePath, pattern) {
15
+ const normalizedPath = normalizeOwnershipPattern(filePath);
16
+ const normalizedPattern = normalizeOwnershipPattern(pattern);
17
+ if (!normalizedPath || !normalizedPattern) {
18
+ return false;
19
+ }
20
+ return globToRegex(normalizedPattern).test(normalizedPath);
21
+ }
22
+ function ownershipStaticPrefix(pattern) {
23
+ const normalized = normalizeOwnershipPattern(pattern);
24
+ const wildcardIndex = normalized.indexOf("*");
25
+ const prefix = wildcardIndex === -1 ? normalized : normalized.slice(0, wildcardIndex);
26
+ return prefix.replace(/\/+$/, "");
27
+ }
28
+ function wildcardCount(pattern) {
29
+ return (pattern.match(/\*/g) ?? []).length;
30
+ }
31
+ function literalLength(pattern) {
32
+ return pattern.replaceAll("*", "").length;
33
+ }
34
+ function segmentCount(pattern) {
35
+ return pattern.split("/").filter(Boolean).length;
36
+ }
37
+ function exactCoverage(pattern, matchedPaths) {
38
+ const normalizedPattern = normalizeOwnershipPattern(pattern);
39
+ if (normalizedPattern.includes("*")) {
40
+ return 0;
41
+ }
42
+ return matchedPaths.filter((filePath)=>normalizeOwnershipPattern(filePath) === normalizedPattern).length;
43
+ }
44
+ function buildOwnershipRuleCandidate(owner, pattern, declaredIndex, claimedPaths) {
45
+ const normalizedPattern = normalizeOwnershipPattern(pattern);
46
+ if (!normalizedPattern) {
47
+ return null;
48
+ }
49
+ const matchedPaths = claimedPaths.filter((filePath)=>matchesOwnershipPattern(filePath, normalizedPattern));
50
+ if (matchedPaths.length === 0) {
51
+ return null;
52
+ }
53
+ return {
54
+ owner,
55
+ pattern,
56
+ normalizedPattern,
57
+ declaredIndex,
58
+ matchedPaths,
59
+ coverage: matchedPaths.length,
60
+ exactCoverage: exactCoverage(normalizedPattern, matchedPaths),
61
+ staticPrefixLength: ownershipStaticPrefix(normalizedPattern).length,
62
+ literalLength: literalLength(normalizedPattern),
63
+ segmentCount: segmentCount(normalizedPattern),
64
+ wildcardCount: wildcardCount(normalizedPattern)
65
+ };
66
+ }
67
+ function compareRulePriority(left, right) {
68
+ const comparisons = [
69
+ [
70
+ left.coverage,
71
+ right.coverage,
72
+ false
73
+ ],
74
+ [
75
+ left.exactCoverage,
76
+ right.exactCoverage,
77
+ false
78
+ ],
79
+ [
80
+ left.staticPrefixLength,
81
+ right.staticPrefixLength,
82
+ false
83
+ ],
84
+ [
85
+ left.literalLength,
86
+ right.literalLength,
87
+ false
88
+ ],
89
+ [
90
+ left.segmentCount,
91
+ right.segmentCount,
92
+ false
93
+ ],
94
+ [
95
+ left.wildcardCount,
96
+ right.wildcardCount,
97
+ true
98
+ ]
99
+ ];
100
+ for (const [leftValue, rightValue, preferLower] of comparisons){
101
+ if (leftValue === rightValue) {
102
+ continue;
103
+ }
104
+ if (preferLower) {
105
+ return leftValue < rightValue ? 1 : -1;
106
+ }
107
+ return leftValue > rightValue ? 1 : -1;
108
+ }
109
+ return 0;
110
+ }
111
+ function compareRuleCandidates(left, right) {
112
+ const priority = compareRulePriority(left, right);
113
+ if (priority !== 0) {
114
+ return priority;
115
+ }
116
+ if (left.owner === right.owner && left.declaredIndex !== right.declaredIndex) {
117
+ return left.declaredIndex < right.declaredIndex ? 1 : -1;
118
+ }
119
+ return 0;
120
+ }
121
+ function pathAncestorsOverlap(left, right) {
122
+ return left === right || left.startsWith(`${right}/`) || right.startsWith(`${left}/`);
123
+ }
124
+ function patternsMayOverlap(leftPattern, rightPattern) {
125
+ const left = normalizeOwnershipPattern(leftPattern);
126
+ const right = normalizeOwnershipPattern(rightPattern);
127
+ if (!left || !right) {
128
+ return false;
129
+ }
130
+ if (left === right) {
131
+ return true;
132
+ }
133
+ const leftPrefix = ownershipStaticPrefix(left);
134
+ const rightPrefix = ownershipStaticPrefix(right);
135
+ if (!leftPrefix || !rightPrefix) {
136
+ return false;
137
+ }
138
+ return pathAncestorsOverlap(leftPrefix, rightPrefix);
139
+ }
140
+ function summarizeCandidate(candidate) {
141
+ return {
142
+ owner: candidate.owner,
143
+ pattern: candidate.pattern,
144
+ declaredIndex: candidate.declaredIndex,
145
+ matchedPaths: candidate.matchedPaths,
146
+ coverage: candidate.coverage,
147
+ exactCoverage: candidate.exactCoverage,
148
+ staticPrefixLength: candidate.staticPrefixLength,
149
+ literalLength: candidate.literalLength,
150
+ segmentCount: candidate.segmentCount,
151
+ wildcardCount: candidate.wildcardCount
152
+ };
153
+ }
154
+ export function analyzeOwnershipRules(claimedPaths, config) {
155
+ const candidates = [
156
+ ...config.routing.codexPaths.map((pattern, index)=>buildOwnershipRuleCandidate("codex", pattern, index, claimedPaths)).filter((candidate)=>candidate !== null),
157
+ ...config.routing.claudePaths.map((pattern, index)=>buildOwnershipRuleCandidate("claude", pattern, index, claimedPaths)).filter((candidate)=>candidate !== null)
158
+ ].sort((left, right)=>compareRuleCandidates(right, left));
159
+ const winningCandidate = candidates[0] ?? null;
160
+ if (!winningCandidate) {
161
+ return {
162
+ claimedPaths,
163
+ candidates,
164
+ winningCandidate: null,
165
+ ambiguousCandidates: []
166
+ };
167
+ }
168
+ const ambiguousCandidates = candidates.filter((candidate)=>candidate.owner !== winningCandidate.owner && compareRulePriority(candidate, winningCandidate) === 0);
169
+ return {
170
+ claimedPaths,
171
+ candidates,
172
+ winningCandidate: ambiguousCandidates.length > 0 ? null : winningCandidate,
173
+ ambiguousCandidates
174
+ };
175
+ }
176
+ export function ownershipMetadataFromAnalysis(analysis) {
177
+ const metadata = {};
178
+ if (analysis.winningCandidate) {
179
+ metadata.winningRule = summarizeCandidate(analysis.winningCandidate);
180
+ metadata.matchedRules = analysis.candidates.slice(0, 4).map(summarizeCandidate);
181
+ }
182
+ if (analysis.ambiguousCandidates.length > 0 && analysis.candidates[0]) {
183
+ metadata.ownershipAmbiguity = {
184
+ claimedPaths: analysis.claimedPaths,
185
+ contenders: [
186
+ analysis.candidates[0],
187
+ ...analysis.ambiguousCandidates
188
+ ].map(summarizeCandidate)
189
+ };
190
+ }
191
+ return metadata;
192
+ }
193
+ export function buildOwnershipRouteDecision(claimedPaths, config) {
194
+ const analysis = analyzeOwnershipRules(claimedPaths, config);
195
+ if (!analysis.winningCandidate) {
196
+ return null;
197
+ }
198
+ const candidate = analysis.winningCandidate;
199
+ return {
200
+ owner: candidate.owner,
201
+ strategy: "manual",
202
+ confidence: 0.97,
203
+ reason: `Matched explicit ${candidate.owner} ownership rule ${candidate.pattern} for: ${candidate.matchedPaths.join(", ")}.`,
204
+ claimedPaths,
205
+ metadata: {
206
+ ownershipSource: "config-routing-paths",
207
+ ...ownershipMetadataFromAnalysis(analysis)
208
+ }
209
+ };
210
+ }
211
+ export function findOwnershipRuleConflicts(config) {
212
+ const conflicts = [];
213
+ const leftRules = config.routing.codexPaths.map((pattern, index)=>({
214
+ owner: "codex",
215
+ pattern,
216
+ normalizedPattern: normalizeOwnershipPattern(pattern),
217
+ declaredIndex: index
218
+ }));
219
+ const rightRules = config.routing.claudePaths.map((pattern, index)=>({
220
+ owner: "claude",
221
+ pattern,
222
+ normalizedPattern: normalizeOwnershipPattern(pattern),
223
+ declaredIndex: index
224
+ }));
225
+ for (const left of leftRules){
226
+ if (!left.normalizedPattern) {
227
+ continue;
228
+ }
229
+ for (const right of rightRules){
230
+ if (!right.normalizedPattern || !patternsMayOverlap(left.pattern, right.pattern)) {
231
+ continue;
232
+ }
233
+ if (left.normalizedPattern === right.normalizedPattern) {
234
+ conflicts.push({
235
+ leftOwner: left.owner,
236
+ leftPattern: left.pattern,
237
+ rightOwner: right.owner,
238
+ rightPattern: right.pattern,
239
+ kind: "exact",
240
+ detail: `Both agents claim the same ownership rule ${left.pattern}.`
241
+ });
242
+ continue;
243
+ }
244
+ const syntheticPath = [
245
+ ownershipStaticPrefix(left.normalizedPattern),
246
+ ownershipStaticPrefix(right.normalizedPattern)
247
+ ].filter(Boolean).sort((a, b)=>b.length - a.length)[0];
248
+ if (!syntheticPath) {
249
+ continue;
250
+ }
251
+ const leftCandidate = buildOwnershipRuleCandidate(left.owner, left.pattern, left.declaredIndex, [
252
+ syntheticPath
253
+ ]);
254
+ const rightCandidate = buildOwnershipRuleCandidate(right.owner, right.pattern, right.declaredIndex, [
255
+ syntheticPath
256
+ ]);
257
+ if (!leftCandidate || !rightCandidate) {
258
+ continue;
259
+ }
260
+ if (compareRulePriority(leftCandidate, rightCandidate) === 0) {
261
+ conflicts.push({
262
+ leftOwner: left.owner,
263
+ leftPattern: left.pattern,
264
+ rightOwner: right.owner,
265
+ rightPattern: right.pattern,
266
+ kind: "ambiguous-overlap",
267
+ detail: `Ownership rules ${left.pattern} and ${right.pattern} can overlap without a specificity winner.`
268
+ });
269
+ }
270
+ }
271
+ }
272
+ return conflicts;
273
+ }
274
+
275
+
276
+ //# sourceURL=ownership.ts
package/dist/router.js CHANGED
@@ -1,6 +1,6 @@
1
- import path from "node:path";
2
1
  import { CodexAppServerClient } from "./codex-app-server.js";
3
2
  import { findClaimConflicts } from "./decision-ledger.js";
3
+ import { analyzeOwnershipRules, buildOwnershipRouteDecision, ownershipMetadataFromAnalysis } from "./ownership.js";
4
4
  import { nowIso } from "./paths.js";
5
5
  const ROUTER_OUTPUT_SCHEMA = {
6
6
  type: "object",
@@ -47,61 +47,19 @@ function normalizeClaimedPaths(paths) {
47
47
  function buildRouteMetadata(input = {}) {
48
48
  return input;
49
49
  }
50
- function normalizePathPattern(value) {
51
- const trimmed = value.trim().replaceAll("\\", "/");
52
- const withoutPrefix = trimmed.startsWith("./") ? trimmed.slice(2) : trimmed;
53
- const normalized = path.posix.normalize(withoutPrefix);
54
- return normalized === "." ? "" : normalized.replace(/^\/+/, "");
55
- }
56
- function globToRegex(pattern) {
57
- const normalized = normalizePathPattern(pattern);
58
- const escaped = normalized.replace(/[|\\{}()[\]^$+?.]/g, "\\$&");
59
- const regexSource = escaped.replaceAll("**", "::double-star::").replaceAll("*", "[^/]*").replaceAll("::double-star::", ".*");
60
- return new RegExp(`^${regexSource}$`);
61
- }
62
- function matchesPattern(filePath, pattern) {
63
- const normalizedPath = normalizePathPattern(filePath);
64
- const normalizedPattern = normalizePathPattern(pattern);
65
- if (!normalizedPath || !normalizedPattern) {
66
- return false;
67
- }
68
- return globToRegex(normalizedPattern).test(normalizedPath);
69
- }
70
- function countPathMatches(filePaths, patterns) {
71
- if (filePaths.length === 0 || patterns.length === 0) {
72
- return 0;
50
+ function ownershipAnalysisMetadata(prompt, config) {
51
+ const claimedPaths = extractPromptPathHints(prompt);
52
+ if (claimedPaths.length === 0) {
53
+ return {};
73
54
  }
74
- return filePaths.filter((filePath)=>patterns.some((pattern)=>matchesPattern(filePath, pattern))).length;
55
+ return ownershipMetadataFromAnalysis(analyzeOwnershipRules(claimedPaths, config));
75
56
  }
76
57
  function buildPathOwnershipDecision(prompt, config) {
77
58
  const claimedPaths = extractPromptPathHints(prompt);
78
59
  if (claimedPaths.length === 0) {
79
60
  return null;
80
61
  }
81
- const codexMatches = countPathMatches(claimedPaths, config.routing.codexPaths);
82
- const claudeMatches = countPathMatches(claimedPaths, config.routing.claudePaths);
83
- if (codexMatches === 0 && claudeMatches === 0) {
84
- return null;
85
- }
86
- if (codexMatches === claudeMatches) {
87
- return null;
88
- }
89
- const owner = codexMatches > claudeMatches ? "codex" : "claude";
90
- const ownerPatterns = owner === "codex" ? config.routing.codexPaths : config.routing.claudePaths;
91
- const matchedPatterns = ownerPatterns.filter((pattern)=>claimedPaths.some((filePath)=>matchesPattern(filePath, pattern)));
92
- return {
93
- owner,
94
- strategy: "manual",
95
- confidence: 0.97,
96
- reason: `Matched explicit ${owner} path ownership rules for: ${claimedPaths.join(", ")}.`,
97
- claimedPaths,
98
- metadata: buildRouteMetadata({
99
- ownershipSource: "config-routing-paths",
100
- matchedPatterns,
101
- codexMatches,
102
- claudeMatches
103
- })
104
- };
62
+ return buildOwnershipRouteDecision(claimedPaths, config);
105
63
  }
106
64
  export function extractPromptPathHints(prompt) {
107
65
  const candidates = [];
@@ -131,10 +89,36 @@ export function routePrompt(prompt, config) {
131
89
  }
132
90
  return "codex";
133
91
  }
92
+ export function previewRouteDecision(prompt, config, session = null) {
93
+ const pathDecision = buildPathOwnershipDecision(prompt, config);
94
+ if (pathDecision) {
95
+ return session ? applyClaimRouting(session, pathDecision) : pathDecision;
96
+ }
97
+ const heuristic = buildKeywordDecision(prompt, config);
98
+ if (heuristic) {
99
+ return session ? applyClaimRouting(session, heuristic) : heuristic;
100
+ }
101
+ const claimedPaths = extractPromptPathHints(prompt);
102
+ const previewDecision = {
103
+ owner: "codex",
104
+ strategy: "fallback",
105
+ confidence: 0.35,
106
+ reason: "No deterministic ownership or keyword route matched yet. The AI router would decide on submit.",
107
+ claimedPaths,
108
+ metadata: buildRouteMetadata({
109
+ router: "preview",
110
+ promptHints: claimedPaths,
111
+ aiPending: true,
112
+ ...ownershipAnalysisMetadata(prompt, config)
113
+ })
114
+ };
115
+ return session ? applyClaimRouting(session, previewDecision) : previewDecision;
116
+ }
134
117
  function buildKeywordDecision(prompt, config) {
135
118
  const frontend = containsKeyword(prompt, config.routing.frontendKeywords);
136
119
  const backend = containsKeyword(prompt, config.routing.backendKeywords);
137
120
  const claimedPaths = extractPromptPathHints(prompt);
121
+ const ownershipMetadata = ownershipAnalysisMetadata(prompt, config);
138
122
  if (frontend && !backend) {
139
123
  return {
140
124
  owner: "claude",
@@ -144,7 +128,8 @@ function buildKeywordDecision(prompt, config) {
144
128
  claimedPaths,
145
129
  metadata: buildRouteMetadata({
146
130
  matchedKeywordSet: "frontend",
147
- promptHints: claimedPaths
131
+ promptHints: claimedPaths,
132
+ ...ownershipMetadata
148
133
  })
149
134
  };
150
135
  }
@@ -157,7 +142,8 @@ function buildKeywordDecision(prompt, config) {
157
142
  claimedPaths,
158
143
  metadata: buildRouteMetadata({
159
144
  matchedKeywordSet: "backend",
160
- promptHints: claimedPaths
145
+ promptHints: claimedPaths,
146
+ ...ownershipMetadata
161
147
  })
162
148
  };
163
149
  }
@@ -235,7 +221,8 @@ async function routeWithCodexAi(prompt, session) {
235
221
  claimedPaths,
236
222
  metadata: buildRouteMetadata({
237
223
  router: "codex-ai",
238
- promptHints: extractPromptPathHints(prompt)
224
+ promptHints: extractPromptPathHints(prompt),
225
+ ...ownershipAnalysisMetadata(prompt, session.config)
239
226
  })
240
227
  };
241
228
  } finally{
@@ -292,7 +279,8 @@ export async function routeTask(prompt, session, _paths) {
292
279
  claimedPaths: extractPromptPathHints(prompt),
293
280
  metadata: buildRouteMetadata({
294
281
  router: "fallback",
295
- error: error instanceof Error ? error.message : String(error)
282
+ error: error instanceof Error ? error.message : String(error),
283
+ ...ownershipAnalysisMetadata(prompt, session.config)
296
284
  })
297
285
  });
298
286
  }
package/dist/tui.js CHANGED
@@ -1,8 +1,10 @@
1
1
  import path from "node:path";
2
2
  import readline from "node:readline";
3
3
  import process from "node:process";
4
+ import { buildClaimHotspots } from "./decision-ledger.js";
5
+ import { findOwnershipRuleConflicts } from "./ownership.js";
4
6
  import { cycleReviewAssignee, reviewNoteMatchesFilters } from "./reviews.js";
5
- import { extractPromptPathHints, routeTask } from "./router.js";
7
+ import { extractPromptPathHints, previewRouteDecision, routeTask } from "./router.js";
6
8
  import { pingRpc, rpcAddReviewNote, rpcAddReviewReply, rpcEnqueueReviewFollowUp, readSnapshot, rpcEnqueueTask, rpcResolveApproval, rpcSetReviewNoteStatus, rpcShutdown, rpcTaskArtifact, rpcUpdateReviewNote, rpcWorktreeDiff, subscribeSnapshotRpc } from "./rpc.js";
7
9
  const RESET = "\u001b[0m";
8
10
  const ANSI_PATTERN = /\u001b\[[0-9;]*m/g;
@@ -648,6 +650,26 @@ function reviewNotesForContext(snapshot, context, filters) {
648
650
  return true;
649
651
  }).sort((left, right)=>right.createdAt.localeCompare(left.createdAt));
650
652
  }
653
+ function composerRoutePreview(snapshot, composer) {
654
+ if (!snapshot) {
655
+ return null;
656
+ }
657
+ const prompt = composer.prompt.trim();
658
+ if (!prompt) {
659
+ return null;
660
+ }
661
+ return composer.owner === "auto" ? previewRouteDecision(prompt, snapshot.session.config, snapshot.session) : {
662
+ owner: composer.owner,
663
+ strategy: "manual",
664
+ confidence: 1,
665
+ reason: `Operator manually assigned the task to ${composer.owner}.`,
666
+ claimedPaths: extractPromptPathHints(prompt),
667
+ metadata: {
668
+ manualAssignment: true,
669
+ composerOwner: composer.owner
670
+ }
671
+ };
672
+ }
651
673
  function syncSelectedReviewNote(snapshot, ui) {
652
674
  const notes = reviewNotesForContext(snapshot, activeReviewContext(snapshot, ui), ui.reviewFilters);
653
675
  ui.selectedReviewNoteId = notes.some((note)=>note.id === ui.selectedReviewNoteId) ? ui.selectedReviewNoteId : notes[0]?.id ?? null;
@@ -943,15 +965,13 @@ function renderApprovalInspector(approval, width, height) {
943
965
  focused: true
944
966
  });
945
967
  }
946
- function renderClaimInspector(claim, width, height) {
947
- if (!claim) {
948
- return renderPanel("Inspector | Claim", width, height, [
949
- styleLine("No claim selected.", "muted")
950
- ]);
951
- }
968
+ function renderClaimInspector(snapshot, claim, width, height) {
952
969
  const innerWidth = Math.max(16, width - 2);
953
- const lines = [
954
- ...section("Claim", [
970
+ const hotspots = snapshot ? buildClaimHotspots(snapshot.session) : [];
971
+ const ownershipConflicts = snapshot ? findOwnershipRuleConflicts(snapshot.session.config) : [];
972
+ const lines = [];
973
+ if (claim) {
974
+ lines.push(...section("Claim", [
955
975
  `Id: ${claim.id}`,
956
976
  `Task: ${claim.taskId}`,
957
977
  `Agent: ${claim.agent}`,
@@ -960,11 +980,19 @@ function renderClaimInspector(claim, width, height) {
960
980
  `Created: ${shortTime(claim.createdAt)}`,
961
981
  `Updated: ${shortTime(claim.updatedAt)}`,
962
982
  `Note: ${claim.note ?? "-"}`
963
- ].flatMap((line)=>wrapText(line, innerWidth))),
964
- ...section("Paths", claim.paths.length > 0 ? claim.paths.flatMap((filePath)=>wrapText(`- ${filePath}`, innerWidth)) : [
983
+ ].flatMap((line)=>wrapText(line, innerWidth))), ...section("Paths", claim.paths.length > 0 ? claim.paths.flatMap((filePath)=>wrapText(`- ${filePath}`, innerWidth)) : [
965
984
  "- none"
966
- ])
967
- ];
985
+ ]));
986
+ } else {
987
+ lines.push(...section("Claim", [
988
+ "No claim selected."
989
+ ]));
990
+ }
991
+ lines.push(...section("Hotspots", hotspots.length > 0 ? hotspots.slice(0, 6).flatMap((hotspot)=>wrapText(`- ${hotspot.path} | agents=${hotspot.agents.join(", ")} | tasks=${hotspot.taskIds.length} | overlaps=${hotspot.overlapCount}`, innerWidth)) : [
992
+ "- none"
993
+ ]), ...section("Ownership Conflicts", ownershipConflicts.length > 0 ? ownershipConflicts.slice(0, 6).flatMap((conflict)=>wrapText(`- [${conflict.kind}] ${conflict.leftOwner}:${conflict.leftPattern} <> ${conflict.rightOwner}:${conflict.rightPattern}`, innerWidth)) : [
994
+ "- none"
995
+ ]));
968
996
  return renderPanel("Inspector | Claim", width, height, lines, {
969
997
  focused: true
970
998
  });
@@ -1108,7 +1136,7 @@ function renderInspector(snapshot, ui, width, height) {
1108
1136
  case "approvals":
1109
1137
  return renderApprovalInspector(selectedApproval(snapshot, ui), width, height);
1110
1138
  case "claims":
1111
- return renderClaimInspector(selectedClaim(snapshot, ui), width, height);
1139
+ return renderClaimInspector(snapshot, selectedClaim(snapshot, ui), width, height);
1112
1140
  case "decisions":
1113
1141
  return renderDecisionInspector(selectedDecision(snapshot, ui), width, height);
1114
1142
  case "events":
@@ -1170,7 +1198,7 @@ function renderHeader(view, ui, width) {
1170
1198
  const repoName = path.basename(session?.repoRoot ?? process.cwd());
1171
1199
  const line1 = fitAnsiLine(`${toneForPanel("Kavi Operator", true)} | session=${session?.id ?? "-"} | repo=${repoName} | rpc=${view.connected ? "connected" : "disconnected"}`, width);
1172
1200
  const line2 = fitAnsiLine(`Goal: ${session?.goal ?? "-"} | status=${session?.status ?? "-"} | refresh=${shortTime(view.refreshedAt)}`, width);
1173
- const line3 = fitAnsiLine(session ? `Tasks P:${countTasks(session.tasks, "pending")} R:${countTasks(session.tasks, "running")} B:${countTasks(session.tasks, "blocked")} C:${countTasks(session.tasks, "completed")} F:${countTasks(session.tasks, "failed")} | approvals=${snapshot?.approvals.filter((approval)=>approval.status === "pending").length ?? 0} | reviews=${countOpenReviewNotes(snapshot)} | claims=${session.pathClaims.filter((claim)=>claim.status === "active").length} | decisions=${session.decisions.length}` : "Waiting for session snapshot...", width);
1201
+ const line3 = fitAnsiLine(session ? `Tasks P:${countTasks(session.tasks, "pending")} R:${countTasks(session.tasks, "running")} B:${countTasks(session.tasks, "blocked")} C:${countTasks(session.tasks, "completed")} F:${countTasks(session.tasks, "failed")} | approvals=${snapshot?.approvals.filter((approval)=>approval.status === "pending").length ?? 0} | reviews=${countOpenReviewNotes(snapshot)} | claims=${session.pathClaims.filter((claim)=>claim.status === "active").length} | hotspots=${buildClaimHotspots(session).length} | ownership-conflicts=${findOwnershipRuleConflicts(session.config).length}` : "Waiting for session snapshot...", width);
1174
1202
  const tabs = OPERATOR_TABS.map((tab, index)=>{
1175
1203
  const count = buildTabItems(snapshot, tab).length;
1176
1204
  const label = `[${index + 1}] ${tabLabel(tab)} ${count}`;
@@ -1213,12 +1241,19 @@ function renderFooter(snapshot, ui, width) {
1213
1241
  ];
1214
1242
  }
1215
1243
  if (ui.composer) {
1244
+ const preview = composerRoutePreview(snapshot, ui.composer);
1245
+ const previewMetadata = preview && preview.metadata && typeof preview.metadata === "object" ? preview.metadata : {};
1246
+ const winningRule = previewMetadata.winningRule && typeof previewMetadata.winningRule === "object" ? previewMetadata.winningRule : null;
1216
1247
  const composerHeader = fitAnsiLine(styleLine("Compose Task", "accent", "strong"), width);
1217
1248
  const composerLine = fitAnsiLine(`Route: ${ui.composer.owner} | 1 auto 2 codex 3 claude | Enter submit | Esc cancel | Ctrl+U clear`, width);
1249
+ const previewLine = fitAnsiLine(preview ? `Preview: ${preview.owner} via ${preview.strategy} (${preview.confidence.toFixed(2)}) | ${preview.reason}` : "Preview: waiting for prompt", width);
1250
+ const diagnosticsLine = fitAnsiLine(preview ? `Hints: ${preview.claimedPaths.join(", ") || "-"} | rule=${typeof winningRule?.pattern === "string" ? winningRule.pattern : "-"}` : "Hints: -", width);
1218
1251
  const promptLine = fitAnsiLine(`> ${ui.composer.prompt}`, width);
1219
1252
  return [
1220
1253
  composerHeader,
1221
1254
  composerLine,
1255
+ previewLine,
1256
+ diagnosticsLine,
1222
1257
  promptLine,
1223
1258
  fitAnsiLine(toast ? styleLine(toast.message, toast.level === "error" ? "bad" : "good") : styleLine("Composer accepts free text; tasks are routed or assigned when you press Enter.", "muted"), width)
1224
1259
  ];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mandipadk7/kavi",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
4
4
  "description": "Managed Codex + Claude collaboration TUI",
5
5
  "type": "module",
6
6
  "preferGlobal": true,