@mandipadk7/kavi 0.1.4 → 0.1.5

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
@@ -10,14 +10,14 @@ Current capabilities:
10
10
  - `kavi start`: start a managed session without attaching the TUI.
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
- - `kavi status`: inspect session health and task counts from any terminal.
13
+ - `kavi status`: inspect session health, task counts, and configured routing ownership rules from any terminal.
14
14
  - `kavi paths`: inspect resolved repo-local, user-local, worktree, and runtime paths.
15
15
  - `kavi task`: enqueue a task for `codex`, `claude`, or `auto` routing.
16
16
  - `kavi tasks`: inspect the session task list with summaries and artifact availability.
17
17
  - `kavi task-output`: inspect the normalized envelope and raw output for a completed task.
18
18
  - `kavi decisions`: inspect the persisted routing, approval, task, and integration decisions.
19
19
  - `kavi claims`: inspect active or historical path claims.
20
- - `kavi reviews`: inspect persisted operator review threads and linked follow-up tasks.
20
+ - `kavi reviews`: inspect persisted operator review threads and linked follow-up tasks, with filters for agent, assignee, disposition, and status.
21
21
  - `kavi approvals`: inspect the approval inbox.
22
22
  - `kavi approve` and `kavi deny`: resolve a pending approval request, optionally with `--remember`.
23
23
  - `kavi events`: inspect recent daemon and task events.
@@ -33,15 +33,18 @@ Runtime model:
33
33
  - SQLite event mirroring is opt-in with `KAVI_ENABLE_SQLITE_HISTORY=1`; the default event log is JSONL under `.kavi/state/events.jsonl`.
34
34
  - 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
35
  - 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.
36
+ - 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.
36
37
 
37
38
  Notes:
38
39
  - `kavi init` and `kavi open` now support the "empty folder to first managed session" path. If no git repo exists, Kavi initializes one; if git exists but no `HEAD` exists yet, Kavi creates the bootstrap commit it needs for worktrees.
39
40
  - Codex runs through `codex app-server` in managed mode, so Codex-side approvals now land in the same Kavi inbox as Claude hook approvals.
40
41
  - Claude still runs through the installed `claude` CLI with Kavi-managed hooks and approval decisions.
41
42
  - `kavi doctor` now checks Claude auth readiness with `claude auth status`, and startup blocks if Claude is installed but not authenticated.
43
+ - `kavi doctor` also validates ownership path rules for duplicates, repo escapes, and absolute-path mistakes before those rules affect routing.
42
44
  - 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.
43
45
  - The socket is machine-local rather than repo-local to avoid Unix socket path-length issues in deep repos and temp directories.
44
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.
47
+ - 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.
45
48
  - 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.
46
49
  - 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.
47
50
 
package/dist/daemon.js CHANGED
@@ -6,7 +6,7 @@ import { buildPeerMessages as buildClaudePeerMessages, runClaudeTask } from "./a
6
6
  import { buildPeerMessages as buildCodexPeerMessages, runCodexTask } from "./adapters/codex.js";
7
7
  import { buildDecisionReplay } from "./adapters/shared.js";
8
8
  import { listApprovalRequests, resolveApprovalRequest } from "./approvals.js";
9
- import { addDecisionRecord, upsertPathClaim } from "./decision-ledger.js";
9
+ import { addDecisionRecord, releaseSupersededClaims, upsertPathClaim } from "./decision-ledger.js";
10
10
  import { getWorktreeDiffReview, listWorktreeChangedPaths } from "./git.js";
11
11
  import { nowIso } from "./paths.js";
12
12
  import { addReviewReply, addReviewNote, autoResolveReviewNotesForCompletedTask, linkReviewFollowUpTask, reviewNotesForTask, setReviewNoteStatus, updateReviewNote } from "./reviews.js";
@@ -284,6 +284,9 @@ export class KaviDaemon {
284
284
  const taskId = `task-${commandId}`;
285
285
  const task = buildAdHocTask(owner, prompt, taskId, {
286
286
  routeReason: typeof params.routeReason === "string" ? params.routeReason : null,
287
+ routeStrategy: params.routeStrategy === "manual" || params.routeStrategy === "keyword" || params.routeStrategy === "ai" || params.routeStrategy === "path-claim" || params.routeStrategy === "fallback" ? params.routeStrategy : null,
288
+ routeConfidence: typeof params.routeConfidence === "number" ? params.routeConfidence : null,
289
+ routeMetadata: params.routeMetadata && typeof params.routeMetadata === "object" && !Array.isArray(params.routeMetadata) ? params.routeMetadata : {},
287
290
  claimedPaths: Array.isArray(params.claimedPaths) ? params.claimedPaths.map((item)=>String(item)) : []
288
291
  });
289
292
  this.session.tasks.push(task);
@@ -296,7 +299,8 @@ export class KaviDaemon {
296
299
  metadata: {
297
300
  strategy: typeof params.routeStrategy === "string" ? params.routeStrategy : "unknown",
298
301
  confidence: typeof params.routeConfidence === "number" ? params.routeConfidence : null,
299
- claimedPaths: task.claimedPaths
302
+ claimedPaths: task.claimedPaths,
303
+ routeMetadata: params.routeMetadata && typeof params.routeMetadata === "object" && !Array.isArray(params.routeMetadata) ? params.routeMetadata : {}
300
304
  }
301
305
  });
302
306
  upsertPathClaim(this.session, {
@@ -621,6 +625,13 @@ export class KaviDaemon {
621
625
  promptLines.push(`Focus the change in ${note.filePath} and update the managed worktree accordingly.`);
622
626
  const task = buildAdHocTask(owner, promptLines.join(" "), taskId, {
623
627
  routeReason: mode === "handoff" ? `Operator handed off review note ${note.id} to ${owner}.` : `Operator created a follow-up task from review note ${note.id}.`,
628
+ routeStrategy: "manual",
629
+ routeConfidence: 1,
630
+ routeMetadata: {
631
+ source: "review-follow-up",
632
+ mode,
633
+ reviewNoteId: note.id
634
+ },
624
635
  claimedPaths: [
625
636
  note.filePath
626
637
  ]
@@ -786,6 +797,9 @@ export class KaviDaemon {
786
797
  const taskId = `task-${command.id}`;
787
798
  const task = buildAdHocTask(owner, command.payload.prompt, taskId, {
788
799
  routeReason: typeof command.payload.routeReason === "string" ? command.payload.routeReason : null,
800
+ routeStrategy: command.payload.routeStrategy === "manual" || command.payload.routeStrategy === "keyword" || command.payload.routeStrategy === "ai" || command.payload.routeStrategy === "path-claim" || command.payload.routeStrategy === "fallback" ? command.payload.routeStrategy : null,
801
+ routeConfidence: typeof command.payload.routeConfidence === "number" ? command.payload.routeConfidence : null,
802
+ routeMetadata: command.payload.routeMetadata && typeof command.payload.routeMetadata === "object" && !Array.isArray(command.payload.routeMetadata) ? command.payload.routeMetadata : {},
789
803
  claimedPaths: Array.isArray(command.payload.claimedPaths) ? command.payload.claimedPaths.map((item)=>String(item)) : []
790
804
  });
791
805
  this.session.tasks.push(task);
@@ -798,7 +812,8 @@ export class KaviDaemon {
798
812
  metadata: {
799
813
  strategy: typeof command.payload.routeStrategy === "string" ? command.payload.routeStrategy : "unknown",
800
814
  confidence: typeof command.payload.routeConfidence === "number" ? command.payload.routeConfidence : null,
801
- claimedPaths: task.claimedPaths
815
+ claimedPaths: task.claimedPaths,
816
+ routeMetadata: command.payload.routeMetadata && typeof command.payload.routeMetadata === "object" && !Array.isArray(command.payload.routeMetadata) ? command.payload.routeMetadata : {}
802
817
  }
803
818
  });
804
819
  upsertPathClaim(this.session, {
@@ -849,18 +864,7 @@ export class KaviDaemon {
849
864
  task.summary = envelope.summary;
850
865
  task.updatedAt = new Date().toISOString();
851
866
  if (task.owner === "codex" || task.owner === "claude") {
852
- const worktree = this.session.worktrees.find((item)=>item.agent === task.owner);
853
- if (worktree) {
854
- const changedPaths = await listWorktreeChangedPaths(worktree.path, this.session.baseCommit);
855
- task.claimedPaths = changedPaths;
856
- upsertPathClaim(this.session, {
857
- taskId: task.id,
858
- agent: task.owner,
859
- source: "diff",
860
- paths: changedPaths,
861
- note: task.summary
862
- });
863
- }
867
+ await this.refreshTaskClaims(task);
864
868
  }
865
869
  addDecisionRecord(this.session, {
866
870
  kind: "task",
@@ -901,6 +905,9 @@ export class KaviDaemon {
901
905
  status: task.status,
902
906
  summary: task.summary,
903
907
  routeReason: task.routeReason,
908
+ routeStrategy: task.routeStrategy,
909
+ routeConfidence: task.routeConfidence,
910
+ routeMetadata: task.routeMetadata,
904
911
  claimedPaths: task.claimedPaths,
905
912
  decisionReplay,
906
913
  rawOutput,
@@ -932,18 +939,7 @@ export class KaviDaemon {
932
939
  task.summary = error instanceof Error ? error.message : String(error);
933
940
  task.updatedAt = new Date().toISOString();
934
941
  if (task.owner === "codex" || task.owner === "claude") {
935
- const worktree = this.session.worktrees.find((item)=>item.agent === task.owner);
936
- if (worktree) {
937
- const changedPaths = await listWorktreeChangedPaths(worktree.path, this.session.baseCommit);
938
- task.claimedPaths = changedPaths;
939
- upsertPathClaim(this.session, {
940
- taskId: task.id,
941
- agent: task.owner,
942
- source: "diff",
943
- paths: changedPaths,
944
- note: task.summary
945
- });
946
- }
942
+ await this.refreshTaskClaims(task);
947
943
  }
948
944
  addDecisionRecord(this.session, {
949
945
  kind: "task",
@@ -968,6 +964,9 @@ export class KaviDaemon {
968
964
  status: task.status,
969
965
  summary: task.summary,
970
966
  routeReason: task.routeReason,
967
+ routeStrategy: task.routeStrategy,
968
+ routeConfidence: task.routeConfidence,
969
+ routeMetadata: task.routeMetadata,
971
970
  claimedPaths: task.claimedPaths,
972
971
  decisionReplay,
973
972
  rawOutput: null,
@@ -994,6 +993,60 @@ export class KaviDaemon {
994
993
  summary
995
994
  };
996
995
  }
996
+ async refreshTaskClaims(task) {
997
+ if (task.owner !== "codex" && task.owner !== "claude") {
998
+ return;
999
+ }
1000
+ const worktree = this.session.worktrees.find((item)=>item.agent === task.owner);
1001
+ if (!worktree) {
1002
+ return;
1003
+ }
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");
1006
+ task.claimedPaths = changedPaths;
1007
+ const claim = upsertPathClaim(this.session, {
1008
+ taskId: task.id,
1009
+ agent: task.owner,
1010
+ source: "diff",
1011
+ paths: changedPaths,
1012
+ note: task.summary
1013
+ });
1014
+ if (claim && changedPaths.length > 0) {
1015
+ const releasedClaims = releaseSupersededClaims(this.session, {
1016
+ agent: task.owner,
1017
+ taskId: task.id,
1018
+ paths: changedPaths,
1019
+ note: `Superseded by newer ${task.owner} diff claim from task ${task.id}.`
1020
+ });
1021
+ for (const releasedClaim of releasedClaims){
1022
+ addDecisionRecord(this.session, {
1023
+ kind: "route",
1024
+ agent: task.owner,
1025
+ taskId: releasedClaim.taskId,
1026
+ summary: `Released superseded claim ${releasedClaim.id}`,
1027
+ detail: releasedClaim.paths.join(", ") || "No claimed paths.",
1028
+ metadata: {
1029
+ claimId: releasedClaim.id,
1030
+ supersededByTaskId: task.id,
1031
+ supersededByPaths: changedPaths
1032
+ }
1033
+ });
1034
+ }
1035
+ return;
1036
+ }
1037
+ if (changedPaths.length === 0 && hadClaimedSurface) {
1038
+ addDecisionRecord(this.session, {
1039
+ kind: "route",
1040
+ agent: task.owner,
1041
+ taskId: task.id,
1042
+ summary: `Released empty claim surface for ${task.id}`,
1043
+ detail: "Task finished without a remaining worktree diff for its claimed paths.",
1044
+ metadata: {
1045
+ releaseReason: "empty-diff-claim"
1046
+ }
1047
+ });
1048
+ }
1049
+ }
997
1050
  }
998
1051
 
999
1052
 
@@ -1,11 +1,26 @@
1
+ import path from "node:path";
1
2
  import { randomUUID } from "node:crypto";
2
3
  import { nowIso } from "./paths.js";
3
4
  const MAX_DECISIONS = 80;
4
5
  function normalizePaths(paths) {
5
6
  return [
6
- ...new Set(paths.map((item)=>item.trim()).filter(Boolean))
7
+ ...new Set(paths.map(normalizePath).filter(Boolean))
7
8
  ].sort();
8
9
  }
10
+ function normalizePath(value) {
11
+ const trimmed = value.trim().replaceAll("\\", "/");
12
+ const withoutPrefix = trimmed.startsWith("./") ? trimmed.slice(2) : trimmed;
13
+ const normalized = path.posix.normalize(withoutPrefix);
14
+ return normalized === "." ? "" : normalized.replace(/^\/+/, "").replace(/\/+$/, "");
15
+ }
16
+ function pathOverlaps(left, right) {
17
+ const leftPath = normalizePath(left);
18
+ const rightPath = normalizePath(right);
19
+ if (!leftPath || !rightPath) {
20
+ return false;
21
+ }
22
+ return leftPath === rightPath || leftPath.startsWith(`${rightPath}/`) || rightPath.startsWith(`${leftPath}/`);
23
+ }
9
24
  export function addDecisionRecord(session, input) {
10
25
  const record = {
11
26
  id: randomUUID(),
@@ -63,12 +78,53 @@ export function upsertPathClaim(session, input) {
63
78
  export function activePathClaims(session) {
64
79
  return session.pathClaims.filter((claim)=>claim.status === "active" && claim.paths.length > 0);
65
80
  }
81
+ export function releasePathClaims(session, input = {}) {
82
+ const taskIds = input.taskIds ? new Set(input.taskIds) : null;
83
+ const released = [];
84
+ for (const claim of session.pathClaims){
85
+ if (claim.status !== "active") {
86
+ continue;
87
+ }
88
+ if (taskIds && !taskIds.has(claim.taskId)) {
89
+ continue;
90
+ }
91
+ claim.status = "released";
92
+ claim.updatedAt = nowIso();
93
+ if (input.note !== undefined) {
94
+ claim.note = input.note;
95
+ }
96
+ released.push(claim);
97
+ }
98
+ return released;
99
+ }
100
+ export function releaseSupersededClaims(session, input) {
101
+ const normalizedPaths = normalizePaths(input.paths);
102
+ if (normalizedPaths.length === 0) {
103
+ return [];
104
+ }
105
+ const released = [];
106
+ for (const claim of session.pathClaims){
107
+ if (claim.status !== "active" || claim.agent !== input.agent || claim.taskId === input.taskId) {
108
+ continue;
109
+ }
110
+ if (!claim.paths.some((item)=>normalizedPaths.some((candidate)=>pathOverlaps(item, candidate)))) {
111
+ continue;
112
+ }
113
+ claim.status = "released";
114
+ claim.updatedAt = nowIso();
115
+ if (input.note !== undefined) {
116
+ claim.note = input.note;
117
+ }
118
+ released.push(claim);
119
+ }
120
+ return released;
121
+ }
66
122
  export function findClaimConflicts(session, owner, claimedPaths) {
67
123
  const normalizedPaths = normalizePaths(claimedPaths);
68
124
  if (normalizedPaths.length === 0) {
69
125
  return [];
70
126
  }
71
- return activePathClaims(session).filter((claim)=>claim.agent !== owner && claim.paths.some((item)=>normalizedPaths.includes(item)));
127
+ return activePathClaims(session).filter((claim)=>claim.agent !== owner && claim.paths.some((item)=>normalizedPaths.some((candidate)=>pathOverlaps(item, candidate))));
72
128
  }
73
129
 
74
130
 
package/dist/doctor.js CHANGED
@@ -1,3 +1,5 @@
1
+ import path from "node:path";
2
+ import { loadConfig } from "./config.js";
1
3
  import { fileExists } from "./fs.js";
2
4
  import { runCommand } from "./process.js";
3
5
  import { hasSupportedNode, minimumNodeMajor, resolveSessionRuntime } from "./runtime.js";
@@ -19,12 +21,81 @@ export function parseClaudeAuthStatus(output) {
19
21
  };
20
22
  }
21
23
  }
24
+ function normalizeRoutingPathRule(value) {
25
+ const trimmed = value.trim().replaceAll("\\", "/");
26
+ const withoutPrefix = trimmed.startsWith("./") ? trimmed.slice(2) : trimmed;
27
+ const normalized = path.posix.normalize(withoutPrefix);
28
+ return normalized === "." ? "" : normalized.replace(/^\/+/, "").replace(/\/+$/, "");
29
+ }
30
+ export function validateRoutingPathRules(config) {
31
+ const issues = [];
32
+ const codexPaths = config.routing.codexPaths.map(normalizeRoutingPathRule);
33
+ const claudePaths = config.routing.claudePaths.map(normalizeRoutingPathRule);
34
+ const rawRules = [
35
+ ...config.routing.codexPaths.map((rule)=>({
36
+ owner: "codex",
37
+ raw: rule,
38
+ normalized: normalizeRoutingPathRule(rule)
39
+ })),
40
+ ...config.routing.claudePaths.map((rule)=>({
41
+ owner: "claude",
42
+ raw: rule,
43
+ normalized: normalizeRoutingPathRule(rule)
44
+ }))
45
+ ];
46
+ const blankRules = [
47
+ ...codexPaths,
48
+ ...claudePaths
49
+ ].filter((rule)=>!rule);
50
+ if (blankRules.length > 0) {
51
+ issues.push("Ownership path rules must not be empty.");
52
+ }
53
+ const absoluteRules = rawRules.filter(({ raw })=>{
54
+ const trimmed = raw.trim();
55
+ return trimmed.startsWith("/") || /^[A-Za-z]:[\\/]/.test(trimmed);
56
+ }).map(({ owner, raw })=>`${owner}:${raw}`);
57
+ if (absoluteRules.length > 0) {
58
+ issues.push(`Ownership path rules must be repo-relative, not absolute: ${absoluteRules.join(", ")}`);
59
+ }
60
+ const parentTraversalRules = rawRules.filter(({ normalized })=>normalized === ".." || normalized.startsWith("../")).map(({ owner, raw })=>`${owner}:${raw}`);
61
+ if (parentTraversalRules.length > 0) {
62
+ issues.push(`Ownership path rules must stay inside the repo root: ${parentTraversalRules.join(", ")}`);
63
+ }
64
+ const duplicateRules = (rules, owner)=>{
65
+ const seen = new Set();
66
+ const duplicates = new Set();
67
+ for (const rule of rules){
68
+ if (!rule) {
69
+ continue;
70
+ }
71
+ if (seen.has(rule)) {
72
+ duplicates.add(rule);
73
+ }
74
+ seen.add(rule);
75
+ }
76
+ if (duplicates.size > 0) {
77
+ issues.push(`${owner} has duplicate ownership rules: ${[
78
+ ...duplicates
79
+ ].join(", ")}`);
80
+ }
81
+ };
82
+ duplicateRules(codexPaths, "codex");
83
+ duplicateRules(claudePaths, "claude");
84
+ const overlappingRules = codexPaths.filter((rule)=>rule && claudePaths.includes(rule));
85
+ if (overlappingRules.length > 0) {
86
+ issues.push(`codex_paths and claude_paths overlap on the same exact rules: ${[
87
+ ...new Set(overlappingRules)
88
+ ].join(", ")}`);
89
+ }
90
+ return issues;
91
+ }
22
92
  function normalizeVersion(output) {
23
93
  return output.trim().split(/\s+/).slice(-1)[0] ?? output.trim();
24
94
  }
25
95
  export async function runDoctor(repoRoot, paths) {
26
96
  const checks = [];
27
97
  const runtime = await resolveSessionRuntime(paths);
98
+ const config = await loadConfig(paths);
28
99
  const nodeVersion = await runCommand(runtime.nodeExecutable, [
29
100
  "--version"
30
101
  ], {
@@ -104,6 +175,12 @@ export async function runDoctor(repoRoot, paths) {
104
175
  ok: true,
105
176
  detail: homeConfigCheck ? "present" : "will be created on demand"
106
177
  });
178
+ const routingRuleIssues = validateRoutingPathRules(config);
179
+ checks.push({
180
+ name: "routing-path-rules",
181
+ ok: routingRuleIssues.length === 0,
182
+ detail: routingRuleIssues.length === 0 ? "valid" : routingRuleIssues.join("; ")
183
+ });
107
184
  return checks;
108
185
  }
109
186
 
package/dist/main.js CHANGED
@@ -6,14 +6,14 @@ 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, upsertPathClaim } from "./decision-ledger.js";
9
+ import { addDecisionRecord, 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 { markReviewNotesLandedForTasks } from "./reviews.js";
16
+ import { filterReviewNotes, markReviewNotesLandedForTasks } from "./reviews.js";
17
17
  import { resolveSessionRuntime } from "./runtime.js";
18
18
  import { buildAdHocTask, extractPromptPathHints, routeTask } from "./router.js";
19
19
  import { createSessionRecord, loadSessionRecord, readRecentEvents, recordEvent, saveSessionRecord, sessionExists, sessionHeartbeatAgeMs } from "./session.js";
@@ -46,6 +46,13 @@ function getGoal(args) {
46
46
  });
47
47
  return filtered.length > 0 ? filtered.join(" ") : null;
48
48
  }
49
+ function getOptionalFilter(args, name) {
50
+ const value = getFlag(args, name);
51
+ if (!value || value.startsWith("--")) {
52
+ return null;
53
+ }
54
+ return value;
55
+ }
49
56
  async function readStdinText() {
50
57
  if (process.stdin.isTTY) {
51
58
  return "";
@@ -72,7 +79,7 @@ function renderUsage() {
72
79
  " kavi task-output <task-id|latest> [--json]",
73
80
  " kavi decisions [--json] [--limit N]",
74
81
  " kavi claims [--json] [--all]",
75
- " kavi reviews [--json] [--all]",
82
+ " kavi reviews [--json] [--all] [--agent codex|claude] [--assignee codex|claude|operator|unassigned] [--status open|resolved] [--disposition approve|concern|question|note|accepted_risk|wont_fix]",
76
83
  " kavi approvals [--json] [--all]",
77
84
  " kavi approve <request-id|latest> [--remember]",
78
85
  " kavi deny <request-id|latest> [--remember]",
@@ -349,6 +356,10 @@ async function commandStatus(cwd, args) {
349
356
  pathClaimCounts: {
350
357
  active: session.pathClaims.filter((claim)=>claim.status === "active").length
351
358
  },
359
+ routingOwnership: {
360
+ codexPaths: session.config.routing.codexPaths,
361
+ claudePaths: session.config.routing.claudePaths
362
+ },
352
363
  worktrees: session.worktrees
353
364
  };
354
365
  if (args.includes("--json")) {
@@ -368,6 +379,7 @@ async function commandStatus(cwd, args) {
368
379
  console.log(`Reviews: open=${payload.reviewCounts.open} total=${payload.reviewCounts.total}`);
369
380
  console.log(`Decisions: total=${payload.decisionCounts.total}`);
370
381
  console.log(`Path claims: active=${payload.pathClaimCounts.active}`);
382
+ console.log(`Routing ownership: codex=${payload.routingOwnership.codexPaths.join(", ") || "-"} | claude=${payload.routingOwnership.claudePaths.join(", ") || "-"}`);
371
383
  for (const worktree of payload.worktrees){
372
384
  console.log(`- ${worktree.agent}: ${worktree.path}`);
373
385
  }
@@ -429,13 +441,18 @@ async function commandTask(cwd, args) {
429
441
  strategy: "manual",
430
442
  confidence: 1,
431
443
  reason: `User explicitly assigned the task to ${requestedAgent}.`,
432
- claimedPaths: extractPromptPathHints(prompt)
444
+ claimedPaths: extractPromptPathHints(prompt),
445
+ metadata: {
446
+ manualAssignment: true,
447
+ requestedAgent
448
+ }
433
449
  } : await routeTask(prompt, session, paths);
434
450
  if (rpcSnapshot) {
435
451
  await rpcEnqueueTask(paths, {
436
452
  owner: routeDecision.owner,
437
453
  prompt,
438
454
  routeReason: routeDecision.reason,
455
+ routeMetadata: routeDecision.metadata,
439
456
  claimedPaths: routeDecision.claimedPaths,
440
457
  routeStrategy: routeDecision.strategy,
441
458
  routeConfidence: routeDecision.confidence
@@ -445,6 +462,7 @@ async function commandTask(cwd, args) {
445
462
  owner: routeDecision.owner,
446
463
  prompt,
447
464
  routeReason: routeDecision.reason,
465
+ routeMetadata: routeDecision.metadata,
448
466
  claimedPaths: routeDecision.claimedPaths,
449
467
  routeStrategy: routeDecision.strategy,
450
468
  routeConfidence: routeDecision.confidence
@@ -455,7 +473,8 @@ async function commandTask(cwd, args) {
455
473
  prompt,
456
474
  strategy: routeDecision.strategy,
457
475
  confidence: routeDecision.confidence,
458
- claimedPaths: routeDecision.claimedPaths
476
+ claimedPaths: routeDecision.claimedPaths,
477
+ routeMetadata: routeDecision.metadata
459
478
  });
460
479
  console.log(`Queued task for ${routeDecision.owner}: ${prompt}\nRoute: ${routeDecision.strategy} (${routeDecision.confidence.toFixed(2)}) ${routeDecision.reason}`);
461
480
  }
@@ -477,6 +496,9 @@ async function commandTasks(cwd, args) {
477
496
  updatedAt: task.updatedAt,
478
497
  summary: task.summary,
479
498
  routeReason: task.routeReason,
499
+ routeStrategy: task.routeStrategy,
500
+ routeConfidence: task.routeConfidence,
501
+ routeMetadata: task.routeMetadata,
480
502
  claimedPaths: task.claimedPaths,
481
503
  hasArtifact: artifactMap.has(task.id)
482
504
  }));
@@ -488,8 +510,11 @@ async function commandTasks(cwd, args) {
488
510
  console.log(`${task.id} | ${task.owner} | ${task.status} | artifact=${task.hasArtifact ? "yes" : "no"}`);
489
511
  console.log(` title: ${task.title}`);
490
512
  console.log(` updated: ${task.updatedAt}`);
491
- console.log(` route: ${task.routeReason ?? "-"}`);
513
+ console.log(` route: ${task.routeStrategy ?? "-"}${task.routeConfidence === null ? "" : ` (${task.routeConfidence.toFixed(2)})`} ${task.routeReason ?? "-"}`);
492
514
  console.log(` paths: ${task.claimedPaths.join(", ") || "-"}`);
515
+ if (Object.keys(task.routeMetadata).length > 0) {
516
+ console.log(` route-meta: ${JSON.stringify(task.routeMetadata)}`);
517
+ }
493
518
  console.log(` summary: ${task.summary ?? "-"}`);
494
519
  }
495
520
  }
@@ -529,7 +554,8 @@ async function commandTaskOutput(cwd, args) {
529
554
  console.log(`Started: ${artifact.startedAt}`);
530
555
  console.log(`Finished: ${artifact.finishedAt}`);
531
556
  console.log(`Summary: ${artifact.summary ?? "-"}`);
532
- console.log(`Route: ${artifact.routeReason ?? "-"}`);
557
+ console.log(`Route: ${artifact.routeStrategy ?? "-"}${artifact.routeConfidence === null ? "" : ` (${artifact.routeConfidence.toFixed(2)})`} ${artifact.routeReason ?? "-"}`);
558
+ console.log(`Route Metadata: ${JSON.stringify(artifact.routeMetadata ?? {})}`);
533
559
  console.log(`Claimed paths: ${artifact.claimedPaths.join(", ") || "-"}`);
534
560
  console.log(`Error: ${artifact.error ?? "-"}`);
535
561
  console.log("Decision Replay:");
@@ -577,6 +603,9 @@ async function commandDecisions(cwd, args) {
577
603
  console.log(`${decision.createdAt} | ${decision.kind} | ${decision.agent ?? "-"} | ${decision.summary}`);
578
604
  console.log(` task: ${decision.taskId ?? "-"}`);
579
605
  console.log(` detail: ${decision.detail}`);
606
+ if (Object.keys(decision.metadata).length > 0) {
607
+ console.log(` metadata: ${JSON.stringify(decision.metadata)}`);
608
+ }
580
609
  }
581
610
  }
582
611
  async function commandClaims(cwd, args) {
@@ -603,15 +632,19 @@ async function commandReviews(cwd, args) {
603
632
  const { paths } = await requireSession(cwd);
604
633
  const rpcSnapshot = await tryRpcSnapshot(paths);
605
634
  const session = rpcSnapshot?.session ?? await loadSessionRecord(paths);
606
- const notes = args.includes("--all") ? [
607
- ...session.reviewNotes
608
- ] : session.reviewNotes.filter((note)=>note.status === "open");
635
+ const filters = {
636
+ agent: getOptionalFilter(args, "--agent"),
637
+ assignee: getOptionalFilter(args, "--assignee"),
638
+ disposition: getOptionalFilter(args, "--disposition"),
639
+ status: getOptionalFilter(args, "--status") ?? (args.includes("--all") ? null : "open")
640
+ };
641
+ const notes = filterReviewNotes(session.reviewNotes, filters);
609
642
  if (args.includes("--json")) {
610
643
  console.log(JSON.stringify(notes, null, 2));
611
644
  return;
612
645
  }
613
646
  if (notes.length === 0) {
614
- console.log("No review notes recorded.");
647
+ console.log("No review notes matched the current filters.");
615
648
  return;
616
649
  }
617
650
  for (const note of notes){
@@ -748,6 +781,12 @@ async function commandLand(cwd) {
748
781
  ].join("\n"), taskId, {
749
782
  title: "Resolve integration overlap",
750
783
  routeReason: "Created by kavi land because multiple agents changed the same paths.",
784
+ routeStrategy: "manual",
785
+ routeConfidence: 1,
786
+ routeMetadata: {
787
+ source: "land-overlap",
788
+ targetBranch
789
+ },
751
790
  claimedPaths: overlappingPaths
752
791
  }));
753
792
  upsertPathClaim(session, {
@@ -788,6 +827,23 @@ async function commandLand(cwd) {
788
827
  snapshotCommits: result.snapshotCommits,
789
828
  commands: result.commandsRun
790
829
  });
830
+ const releasedClaims = releasePathClaims(session, {
831
+ note: `Released after landing into ${targetBranch}.`
832
+ });
833
+ for (const claim of releasedClaims){
834
+ addDecisionRecord(session, {
835
+ kind: "integration",
836
+ agent: claim.agent,
837
+ taskId: claim.taskId,
838
+ summary: `Released path claim ${claim.id}`,
839
+ detail: claim.paths.join(", ") || "No claimed paths.",
840
+ metadata: {
841
+ claimId: claim.id,
842
+ targetBranch,
843
+ releaseReason: "land.completed"
844
+ }
845
+ });
846
+ }
791
847
  const landedReviewNotes = markReviewNotesLandedForTasks(session, session.tasks.filter((task)=>task.status === "completed").map((task)=>task.id));
792
848
  for (const note of landedReviewNotes){
793
849
  addDecisionRecord(session, {
package/dist/reviews.js CHANGED
@@ -72,6 +72,30 @@ export function reviewNotesForPath(session, agent, filePath, hunkIndex) {
72
72
  return note.hunkIndex === hunkIndex;
73
73
  });
74
74
  }
75
+ export function reviewNoteMatchesFilters(note, filters) {
76
+ if (filters.agent && note.agent !== filters.agent) {
77
+ return false;
78
+ }
79
+ if (filters.assignee) {
80
+ if (filters.assignee === "unassigned") {
81
+ if (note.assignee !== null) {
82
+ return false;
83
+ }
84
+ } else if (note.assignee !== filters.assignee) {
85
+ return false;
86
+ }
87
+ }
88
+ if (filters.disposition && note.disposition !== filters.disposition) {
89
+ return false;
90
+ }
91
+ if (filters.status && note.status !== filters.status) {
92
+ return false;
93
+ }
94
+ return true;
95
+ }
96
+ export function filterReviewNotes(notes, filters) {
97
+ return notes.filter((note)=>reviewNoteMatchesFilters(note, filters));
98
+ }
75
99
  export function updateReviewNote(session, noteId, input) {
76
100
  const note = session.reviewNotes.find((item)=>item.id === noteId) ?? null;
77
101
  if (!note) {
package/dist/router.js CHANGED
@@ -44,6 +44,9 @@ function normalizeClaimedPaths(paths) {
44
44
  ...new Set(paths.map((item)=>item.trim()).filter(Boolean))
45
45
  ].sort();
46
46
  }
47
+ function buildRouteMetadata(input = {}) {
48
+ return input;
49
+ }
47
50
  function normalizePathPattern(value) {
48
51
  const trimmed = value.trim().replaceAll("\\", "/");
49
52
  const withoutPrefix = trimmed.startsWith("./") ? trimmed.slice(2) : trimmed;
@@ -84,13 +87,20 @@ function buildPathOwnershipDecision(prompt, config) {
84
87
  return null;
85
88
  }
86
89
  const owner = codexMatches > claudeMatches ? "codex" : "claude";
87
- const matchedPatterns = owner === "codex" ? config.routing.codexPaths : config.routing.claudePaths;
90
+ const ownerPatterns = owner === "codex" ? config.routing.codexPaths : config.routing.claudePaths;
91
+ const matchedPatterns = ownerPatterns.filter((pattern)=>claimedPaths.some((filePath)=>matchesPattern(filePath, pattern)));
88
92
  return {
89
93
  owner,
90
94
  strategy: "manual",
91
95
  confidence: 0.97,
92
96
  reason: `Matched explicit ${owner} path ownership rules for: ${claimedPaths.join(", ")}.`,
93
- claimedPaths
97
+ claimedPaths,
98
+ metadata: buildRouteMetadata({
99
+ ownershipSource: "config-routing-paths",
100
+ matchedPatterns,
101
+ codexMatches,
102
+ claudeMatches
103
+ })
94
104
  };
95
105
  }
96
106
  export function extractPromptPathHints(prompt) {
@@ -131,7 +141,11 @@ function buildKeywordDecision(prompt, config) {
131
141
  strategy: "keyword",
132
142
  confidence: 0.92,
133
143
  reason: "Matched frontend and UX routing keywords.",
134
- claimedPaths
144
+ claimedPaths,
145
+ metadata: buildRouteMetadata({
146
+ matchedKeywordSet: "frontend",
147
+ promptHints: claimedPaths
148
+ })
135
149
  };
136
150
  }
137
151
  if (backend && !frontend) {
@@ -140,7 +154,11 @@ function buildKeywordDecision(prompt, config) {
140
154
  strategy: "keyword",
141
155
  confidence: 0.92,
142
156
  reason: "Matched backend and architecture routing keywords.",
143
- claimedPaths
157
+ claimedPaths,
158
+ metadata: buildRouteMetadata({
159
+ matchedKeywordSet: "backend",
160
+ promptHints: claimedPaths
161
+ })
144
162
  };
145
163
  }
146
164
  return null;
@@ -214,7 +232,11 @@ async function routeWithCodexAi(prompt, session) {
214
232
  strategy: "ai",
215
233
  confidence,
216
234
  reason,
217
- claimedPaths
235
+ claimedPaths,
236
+ metadata: buildRouteMetadata({
237
+ router: "codex-ai",
238
+ promptHints: extractPromptPathHints(prompt)
239
+ })
218
240
  };
219
241
  } finally{
220
242
  await client.close();
@@ -235,7 +257,18 @@ function applyClaimRouting(session, decision) {
235
257
  strategy: "path-claim",
236
258
  confidence: 1,
237
259
  reason: `Re-routed to ${owner} because active path claims overlap: ${overlappingPaths.join(", ")}`,
238
- claimedPaths: decision.claimedPaths.length > 0 ? decision.claimedPaths : overlappingPaths
260
+ claimedPaths: decision.claimedPaths.length > 0 ? decision.claimedPaths : overlappingPaths,
261
+ metadata: buildRouteMetadata({
262
+ ...decision.metadata,
263
+ reroutedFrom: decision.owner,
264
+ conflictingClaims: conflicts.map((claim)=>({
265
+ taskId: claim.taskId,
266
+ agent: claim.agent,
267
+ source: claim.source,
268
+ paths: claim.paths
269
+ })),
270
+ overlappingPaths
271
+ })
239
272
  };
240
273
  }
241
274
  export async function routeTask(prompt, session, _paths) {
@@ -256,7 +289,11 @@ export async function routeTask(prompt, session, _paths) {
256
289
  strategy: "fallback",
257
290
  confidence: 0.4,
258
291
  reason: error instanceof Error ? `AI routing failed, defaulted to Codex: ${error.message}` : "AI routing failed, defaulted to Codex.",
259
- claimedPaths: extractPromptPathHints(prompt)
292
+ claimedPaths: extractPromptPathHints(prompt),
293
+ metadata: buildRouteMetadata({
294
+ router: "fallback",
295
+ error: error instanceof Error ? error.message : String(error)
296
+ })
260
297
  });
261
298
  }
262
299
  }
@@ -273,6 +310,12 @@ export function buildKickoffTasks(goal) {
273
310
  updatedAt: timestamp,
274
311
  summary: null,
275
312
  routeReason: "Kickoff task reserved for Codex planning.",
313
+ routeStrategy: "manual",
314
+ routeConfidence: 1,
315
+ routeMetadata: buildRouteMetadata({
316
+ kickoff: true,
317
+ reservedFor: "codex"
318
+ }),
276
319
  claimedPaths: []
277
320
  },
278
321
  {
@@ -285,6 +328,12 @@ export function buildKickoffTasks(goal) {
285
328
  updatedAt: timestamp,
286
329
  summary: null,
287
330
  routeReason: "Kickoff task reserved for Claude intent and UX interpretation.",
331
+ routeStrategy: "manual",
332
+ routeConfidence: 1,
333
+ routeMetadata: buildRouteMetadata({
334
+ kickoff: true,
335
+ reservedFor: "claude"
336
+ }),
288
337
  claimedPaths: []
289
338
  }
290
339
  ];
@@ -301,6 +350,9 @@ export function buildAdHocTask(owner, prompt, taskId, options = {}) {
301
350
  updatedAt: timestamp,
302
351
  summary: null,
303
352
  routeReason: options.routeReason ?? null,
353
+ routeStrategy: options.routeStrategy ?? null,
354
+ routeConfidence: typeof options.routeConfidence === "number" && Number.isFinite(options.routeConfidence) ? options.routeConfidence : null,
355
+ routeMetadata: options.routeMetadata ?? {},
304
356
  claimedPaths: normalizeClaimedPaths(options.claimedPaths ?? [])
305
357
  };
306
358
  }
package/dist/rpc.js CHANGED
@@ -96,6 +96,7 @@ export async function rpcEnqueueTask(paths, params) {
96
96
  owner: params.owner,
97
97
  prompt: params.prompt,
98
98
  routeReason: params.routeReason,
99
+ routeMetadata: params.routeMetadata,
99
100
  claimedPaths: params.claimedPaths,
100
101
  routeStrategy: params.routeStrategy,
101
102
  routeConfidence: params.routeConfidence
package/dist/session.js CHANGED
@@ -52,6 +52,9 @@ export async function loadSessionRecord(paths) {
52
52
  record.tasks = Array.isArray(record.tasks) ? record.tasks.map((task)=>({
53
53
  ...task,
54
54
  routeReason: typeof task.routeReason === "string" ? task.routeReason : null,
55
+ routeStrategy: task.routeStrategy === "manual" || task.routeStrategy === "keyword" || task.routeStrategy === "ai" || task.routeStrategy === "path-claim" || task.routeStrategy === "fallback" ? task.routeStrategy : null,
56
+ routeConfidence: typeof task.routeConfidence === "number" && Number.isFinite(task.routeConfidence) ? task.routeConfidence : null,
57
+ routeMetadata: task.routeMetadata && typeof task.routeMetadata === "object" && !Array.isArray(task.routeMetadata) ? task.routeMetadata : {},
55
58
  claimedPaths: Array.isArray(task.claimedPaths) ? task.claimedPaths.map((item)=>String(item)) : []
56
59
  })) : [];
57
60
  record.decisions = Array.isArray(record.decisions) ? record.decisions : [];
@@ -8,6 +8,9 @@ function normalizeArtifact(artifact) {
8
8
  return {
9
9
  ...artifact,
10
10
  routeReason: typeof artifact.routeReason === "string" ? artifact.routeReason : null,
11
+ routeStrategy: artifact.routeStrategy === "manual" || artifact.routeStrategy === "keyword" || artifact.routeStrategy === "ai" || artifact.routeStrategy === "path-claim" || artifact.routeStrategy === "fallback" ? artifact.routeStrategy : null,
12
+ routeConfidence: typeof artifact.routeConfidence === "number" && Number.isFinite(artifact.routeConfidence) ? artifact.routeConfidence : null,
13
+ routeMetadata: artifact.routeMetadata && typeof artifact.routeMetadata === "object" && !Array.isArray(artifact.routeMetadata) ? artifact.routeMetadata : {},
11
14
  claimedPaths: Array.isArray(artifact.claimedPaths) ? artifact.claimedPaths.map((item)=>String(item)) : [],
12
15
  decisionReplay: Array.isArray(artifact.decisionReplay) ? artifact.decisionReplay.map((item)=>String(item)) : [],
13
16
  reviewNotes: Array.isArray(artifact.reviewNotes) ? artifact.reviewNotes.map((note)=>({
package/dist/tui.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import path from "node:path";
2
2
  import readline from "node:readline";
3
3
  import process from "node:process";
4
- import { cycleReviewAssignee } from "./reviews.js";
4
+ import { cycleReviewAssignee, reviewNoteMatchesFilters } from "./reviews.js";
5
5
  import { extractPromptPathHints, routeTask } from "./router.js";
6
6
  import { pingRpc, rpcAddReviewNote, rpcAddReviewReply, rpcEnqueueReviewFollowUp, readSnapshot, rpcEnqueueTask, rpcResolveApproval, rpcSetReviewNoteStatus, rpcShutdown, rpcTaskArtifact, rpcUpdateReviewNote, rpcWorktreeDiff, subscribeSnapshotRpc } from "./rpc.js";
7
7
  const RESET = "\u001b[0m";
@@ -569,6 +569,41 @@ function reviewAssigneeLabel(assignee) {
569
569
  return "unassigned";
570
570
  }
571
571
  }
572
+ function reviewFilterLabel(filters) {
573
+ return `status=${filters.status} | assignee=${filters.assignee === "all" ? "all" : reviewAssigneeLabel(filters.assignee)} | disposition=${filters.disposition === "all" ? "all" : reviewDispositionLabel(filters.disposition)}`;
574
+ }
575
+ function cycleReviewFilterAssignee(current) {
576
+ const sequence = [
577
+ "all",
578
+ "codex",
579
+ "claude",
580
+ "operator"
581
+ ];
582
+ const index = sequence.findIndex((item)=>item === current);
583
+ return sequence[(index + 1) % sequence.length] ?? "all";
584
+ }
585
+ function cycleReviewFilterDisposition(current) {
586
+ const sequence = [
587
+ "all",
588
+ "approve",
589
+ "concern",
590
+ "question",
591
+ "note",
592
+ "accepted_risk",
593
+ "wont_fix"
594
+ ];
595
+ const index = sequence.findIndex((item)=>item === current);
596
+ return sequence[(index + 1) % sequence.length] ?? "all";
597
+ }
598
+ function cycleReviewFilterStatus(current) {
599
+ const sequence = [
600
+ "all",
601
+ "open",
602
+ "resolved"
603
+ ];
604
+ const index = sequence.findIndex((item)=>item === current);
605
+ return sequence[(index + 1) % sequence.length] ?? "all";
606
+ }
572
607
  function activeReviewContext(snapshot, ui) {
573
608
  const agent = reviewAgentForUi(snapshot, ui);
574
609
  if (!agent) {
@@ -590,7 +625,7 @@ function activeReviewContext(snapshot, ui) {
590
625
  hunkHeader
591
626
  };
592
627
  }
593
- function reviewNotesForContext(snapshot, context) {
628
+ function reviewNotesForContext(snapshot, context, filters) {
594
629
  if (!snapshot || !context) {
595
630
  return [];
596
631
  }
@@ -600,18 +635,25 @@ function reviewNotesForContext(snapshot, context) {
600
635
  if (note.agent !== context.agent || note.filePath !== context.filePath) {
601
636
  return false;
602
637
  }
603
- if (context.hunkIndex !== null) {
604
- return note.hunkIndex === context.hunkIndex || note.hunkIndex === null;
638
+ if (context.hunkIndex !== null && !(note.hunkIndex === context.hunkIndex || note.hunkIndex === null)) {
639
+ return false;
640
+ }
641
+ if (!reviewNoteMatchesFilters(note, {
642
+ assignee: filters.assignee === "all" ? null : filters.assignee,
643
+ disposition: filters.disposition === "all" ? null : filters.disposition,
644
+ status: filters.status === "all" ? null : filters.status
645
+ })) {
646
+ return false;
605
647
  }
606
648
  return true;
607
649
  }).sort((left, right)=>right.createdAt.localeCompare(left.createdAt));
608
650
  }
609
651
  function syncSelectedReviewNote(snapshot, ui) {
610
- const notes = reviewNotesForContext(snapshot, activeReviewContext(snapshot, ui));
652
+ const notes = reviewNotesForContext(snapshot, activeReviewContext(snapshot, ui), ui.reviewFilters);
611
653
  ui.selectedReviewNoteId = notes.some((note)=>note.id === ui.selectedReviewNoteId) ? ui.selectedReviewNoteId : notes[0]?.id ?? null;
612
654
  }
613
655
  function selectedReviewNote(snapshot, ui) {
614
- const notes = reviewNotesForContext(snapshot, activeReviewContext(snapshot, ui));
656
+ const notes = reviewNotesForContext(snapshot, activeReviewContext(snapshot, ui), ui.reviewFilters);
615
657
  return notes.find((note)=>note.id === ui.selectedReviewNoteId) ?? notes[0] ?? null;
616
658
  }
617
659
  function renderReviewNotesSection(notes, selectedNoteId, width) {
@@ -753,10 +795,13 @@ function renderTaskInspector(task, artifactEntry, loading, diffEntry, loadingDif
753
795
  `Owner: ${task.owner}`,
754
796
  `Status: ${task.status}`,
755
797
  `Updated: ${shortTime(task.updatedAt)}`,
756
- `Route: ${task.routeReason ?? "-"}`,
798
+ `Route: ${task.routeStrategy ?? "-"}${task.routeConfidence === null ? "" : ` (${task.routeConfidence.toFixed(2)})`} ${task.routeReason ?? "-"}`,
757
799
  `Claimed paths: ${task.claimedPaths.join(", ") || "-"}`,
758
800
  `Summary: ${task.summary ?? "-"}`
759
801
  ].flatMap((line)=>wrapText(line, innerWidth))));
802
+ if (Object.keys(task.routeMetadata).length > 0) {
803
+ lines.push(...section("Route Metadata", wrapText(JSON.stringify(task.routeMetadata), innerWidth)));
804
+ }
760
805
  if (loading) {
761
806
  lines.push(...section("Artifact", [
762
807
  "Loading task artifact..."
@@ -809,7 +854,7 @@ function renderTaskInspector(task, artifactEntry, loading, diffEntry, loadingDif
809
854
  lines.push(...section("Task Scope", [
810
855
  `Owner: ${task.owner}`,
811
856
  `Claimed paths: ${task.claimedPaths.join(", ") || "-"}`,
812
- `Route reason: ${task.routeReason ?? "-"}`
857
+ `Route reason: ${task.routeStrategy ?? "-"}${task.routeConfidence === null ? "" : ` (${task.routeConfidence.toFixed(2)})`} ${task.routeReason ?? "-"}`
813
858
  ].flatMap((line)=>wrapText(line, innerWidth))));
814
859
  if (!agent) {
815
860
  lines.push(...section("Diff Review", [
@@ -826,7 +871,7 @@ function renderTaskInspector(task, artifactEntry, loading, diffEntry, loadingDif
826
871
  const hunks = parseDiffHunks(review.patch);
827
872
  const hunkIndex = agent ? selectedHunkIndex(ui, agent, review) : null;
828
873
  const selectedHunk = hunkIndex === null ? null : hunks[hunkIndex] ?? null;
829
- const reviewNotes = reviewNotesForContext(snapshot, activeReviewContext(snapshot, ui));
874
+ const reviewNotes = reviewNotesForContext(snapshot, activeReviewContext(snapshot, ui), ui.reviewFilters);
830
875
  lines.push(...section("Review", [
831
876
  `Agent: ${review.agent}`,
832
877
  `Selected file: ${review.selectedPath ?? "-"}`,
@@ -834,6 +879,7 @@ function renderTaskInspector(task, artifactEntry, loading, diffEntry, loadingDif
834
879
  `Hunks: ${hunks.length}`,
835
880
  `Selected hunk: ${hunkIndex === null ? "-" : `${hunkIndex + 1}/${hunks.length}`}`,
836
881
  `Notes: ${reviewNotes.length}`,
882
+ `Filters: ${reviewFilterLabel(ui.reviewFilters)}`,
837
883
  `Stat: ${review.stat}`
838
884
  ].flatMap((line)=>wrapText(line, innerWidth))));
839
885
  lines.push(...section("Changed Files", review.changedPaths.length ? review.changedPaths.flatMap((filePath)=>wrapText(`${filePath === review.selectedPath ? ">" : "-"} ${filePath}`, innerWidth)) : [
@@ -1024,12 +1070,13 @@ function renderWorktreeInspector(snapshot, worktree, diffEntry, loadingDiff, ui,
1024
1070
  const hunks = parseDiffHunks(diffEntry.review.patch);
1025
1071
  const hunkIndex = selectedHunkIndex(ui, worktree.agent, diffEntry.review);
1026
1072
  const selectedHunk = hunkIndex === null ? null : hunks[hunkIndex] ?? null;
1027
- const reviewNotes = reviewNotesForContext(snapshot, activeReviewContext(snapshot, ui));
1073
+ const reviewNotes = reviewNotesForContext(snapshot, activeReviewContext(snapshot, ui), ui.reviewFilters);
1028
1074
  lines.push(...section("Review", [
1029
1075
  `Selected file: ${diffEntry.review.selectedPath ?? "-"}`,
1030
1076
  `Hunks: ${hunks.length}`,
1031
1077
  `Selected hunk: ${hunkIndex === null ? "-" : `${hunkIndex + 1}/${hunks.length}`}`,
1032
1078
  `Notes: ${reviewNotes.length}`,
1079
+ `Filters: ${reviewFilterLabel(ui.reviewFilters)}`,
1033
1080
  `Stat: ${diffEntry.review.stat}`
1034
1081
  ].flatMap((line)=>wrapText(line, innerWidth))));
1035
1082
  if (selectedHunk) {
@@ -1178,7 +1225,7 @@ function renderFooter(snapshot, ui, width) {
1178
1225
  }
1179
1226
  return [
1180
1227
  fitAnsiLine("Keys: 1-7 tabs | h/l or Tab cycle tabs | j/k move | [ ] task detail | ,/. diff file | { } diff hunk | c compose | r refresh", width),
1181
- fitAnsiLine("Actions: y/Y allow approval | n/N deny approval | A/C/Q/M add note | o/O select note | T reply | E edit | R resolve | a cycle assignee | w won't fix | x accepted risk | F fix task | H handoff | g/G top/bottom | s stop daemon | q quit", width),
1228
+ fitAnsiLine("Actions: y/Y allow approval | n/N deny approval | A/C/Q/M add note | o/O select note | T reply | E edit | R resolve | a cycle assignee | u filter assignee | v filter status | d filter disposition | w won't fix | x accepted risk | F fix task | H handoff | g/G top/bottom | s stop daemon | q quit", width),
1182
1229
  footerSelectionSummary(snapshot, ui, width),
1183
1230
  fitAnsiLine(toast ? styleLine(toast.message, toast.level === "error" ? "bad" : "good") : styleLine("Operator surface is live over the daemon socket with pushed snapshots.", "muted"), width)
1184
1231
  ];
@@ -1311,12 +1358,17 @@ async function queueManualTask(paths, view, ui) {
1311
1358
  strategy: "manual",
1312
1359
  confidence: 1,
1313
1360
  reason: `Operator manually assigned the task to ${composer.owner}.`,
1314
- claimedPaths: extractPromptPathHints(prompt)
1361
+ claimedPaths: extractPromptPathHints(prompt),
1362
+ metadata: {
1363
+ manualAssignment: true,
1364
+ composerOwner: composer.owner
1365
+ }
1315
1366
  };
1316
1367
  await rpcEnqueueTask(paths, {
1317
1368
  owner: routeDecision.owner,
1318
1369
  prompt,
1319
1370
  routeReason: routeDecision.reason,
1371
+ routeMetadata: routeDecision.metadata,
1320
1372
  claimedPaths: routeDecision.claimedPaths,
1321
1373
  routeStrategy: routeDecision.strategy,
1322
1374
  routeConfidence: routeDecision.confidence
@@ -1425,7 +1477,7 @@ async function resolveSelectedReviewNoteWithDisposition(paths, snapshot, ui, dis
1425
1477
  setToast(ui, "info", `Marked review note ${note.id} as ${reviewDispositionLabel(disposition).toLowerCase()}.`);
1426
1478
  }
1427
1479
  function cycleSelectedReviewNote(snapshot, ui, delta) {
1428
- const notes = reviewNotesForContext(snapshot, activeReviewContext(snapshot, ui));
1480
+ const notes = reviewNotesForContext(snapshot, activeReviewContext(snapshot, ui), ui.reviewFilters);
1429
1481
  if (notes.length === 0) {
1430
1482
  ui.selectedReviewNoteId = null;
1431
1483
  return false;
@@ -1589,7 +1641,12 @@ export async function attachTui(paths) {
1589
1641
  codex: 0,
1590
1642
  claude: 0
1591
1643
  },
1592
- selectedReviewNoteId: null
1644
+ selectedReviewNoteId: null,
1645
+ reviewFilters: {
1646
+ assignee: "all",
1647
+ disposition: "all",
1648
+ status: "all"
1649
+ }
1593
1650
  };
1594
1651
  const render = ()=>{
1595
1652
  process.stdout.write(renderScreen(view, ui, paths));
@@ -1984,6 +2041,27 @@ export async function attachTui(paths) {
1984
2041
  });
1985
2042
  return;
1986
2043
  }
2044
+ if (input === "u") {
2045
+ ui.reviewFilters.assignee = cycleReviewFilterAssignee(ui.reviewFilters.assignee);
2046
+ syncSelectedReviewNote(view.snapshot, ui);
2047
+ setToast(ui, "info", `Review assignee filter: ${ui.reviewFilters.assignee === "all" ? "all" : reviewAssigneeLabel(ui.reviewFilters.assignee)}.`);
2048
+ render();
2049
+ return;
2050
+ }
2051
+ if (input === "v") {
2052
+ ui.reviewFilters.status = cycleReviewFilterStatus(ui.reviewFilters.status);
2053
+ syncSelectedReviewNote(view.snapshot, ui);
2054
+ setToast(ui, "info", `Review status filter: ${ui.reviewFilters.status}.`);
2055
+ render();
2056
+ return;
2057
+ }
2058
+ if (input === "d") {
2059
+ ui.reviewFilters.disposition = cycleReviewFilterDisposition(ui.reviewFilters.disposition);
2060
+ syncSelectedReviewNote(view.snapshot, ui);
2061
+ setToast(ui, "info", `Review disposition filter: ${ui.reviewFilters.disposition === "all" ? "all" : reviewDispositionLabel(ui.reviewFilters.disposition)}.`);
2062
+ render();
2063
+ return;
2064
+ }
1987
2065
  if (input === "w" || input === "x") {
1988
2066
  runAction(async ()=>{
1989
2067
  await resolveSelectedReviewNoteWithDisposition(paths, view.snapshot, ui, input === "w" ? "wont_fix" : "accepted_risk");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mandipadk7/kavi",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "description": "Managed Codex + Claude collaboration TUI",
5
5
  "type": "module",
6
6
  "preferGlobal": true,