@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 +6 -1
- package/dist/adapters/shared.js +2 -0
- package/dist/daemon.js +18 -1
- package/dist/decision-ledger.js +90 -0
- package/dist/doctor.js +5 -0
- package/dist/main.js +116 -2
- package/dist/ownership.js +276 -0
- package/dist/router.js +41 -53
- package/dist/tui.js +50 -15
- package/package.json +1 -1
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
|
|
package/dist/adapters/shared.js
CHANGED
|
@@ -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
|
|
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
|
}
|
package/dist/decision-ledger.js
CHANGED
|
@@ -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
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
954
|
-
|
|
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} |
|
|
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
|
];
|