@mandipadk7/kavi 0.1.4 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -10,14 +10,16 @@ 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
+ - `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.
17
19
  - `kavi task-output`: inspect the normalized envelope and raw output for a completed task.
18
20
  - `kavi decisions`: inspect the persisted routing, approval, task, and integration decisions.
19
21
  - `kavi claims`: inspect active or historical path claims.
20
- - `kavi reviews`: inspect persisted operator review threads and linked follow-up tasks.
22
+ - `kavi reviews`: inspect persisted operator review threads and linked follow-up tasks, with filters for agent, assignee, disposition, and status.
21
23
  - `kavi approvals`: inspect the approval inbox.
22
24
  - `kavi approve` and `kavi deny`: resolve a pending approval request, optionally with `--remember`.
23
25
  - `kavi events`: inspect recent daemon and task events.
@@ -33,15 +35,21 @@ 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.
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.
36
40
 
37
41
  Notes:
38
42
  - `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
43
  - 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
44
  - Claude still runs through the installed `claude` CLI with Kavi-managed hooks and approval decisions.
41
45
  - `kavi doctor` now checks Claude auth readiness with `claude auth status`, and startup blocks if Claude is installed but not authenticated.
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.
42
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.
43
49
  - The socket is machine-local rather than repo-local to avoid Unix socket path-length issues in deep repos and temp directories.
44
- - 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.
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.
45
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.
46
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.
47
55
 
@@ -4,11 +4,13 @@ function formatDecisionLine(summary, detail) {
4
4
  return detail.trim() ? `- ${summary}: ${detail}` : `- ${summary}`;
5
5
  }
6
6
  export function buildDecisionReplay(session, task, agent) {
7
+ const winningRule = task.routeMetadata?.winningRule && typeof task.routeMetadata.winningRule === "object" && !Array.isArray(task.routeMetadata.winningRule) && typeof task.routeMetadata.winningRule.pattern === "string" ? String(task.routeMetadata.winningRule.pattern) : null;
7
8
  const taskDecisions = session.decisions.filter((decision)=>decision.taskId === task.id).slice(-6).map((decision)=>formatDecisionLine(`[${decision.kind}] ${decision.summary}`, decision.detail));
8
9
  const sharedDecisions = session.decisions.filter((decision)=>decision.taskId !== task.id && (decision.agent === agent || decision.agent === null)).slice(-4).map((decision)=>formatDecisionLine(`[${decision.kind}] ${decision.summary}`, decision.detail));
9
10
  const relevantClaims = session.pathClaims.filter((claim)=>claim.status === "active" && (claim.taskId === task.id || claim.agent !== agent)).slice(-6).map((claim)=>`- ${claim.agent} ${claim.source} claim on ${claim.paths.join(", ")}${claim.note ? `: ${claim.note}` : ""}`);
10
11
  const replay = [
11
12
  `- Current route reason: ${task.routeReason ?? "not recorded"}`,
13
+ `- Winning ownership rule: ${winningRule ?? "none"}`,
12
14
  `- Current claimed paths: ${task.claimedPaths.join(", ") || "none"}`,
13
15
  ...taskDecisions,
14
16
  ...sharedDecisions,
package/dist/daemon.js CHANGED
@@ -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,77 @@ 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 previousClaimedPaths = [
1006
+ ...task.claimedPaths
1007
+ ];
1008
+ const hadClaimedSurface = previousClaimedPaths.length > 0 || this.session.pathClaims.some((claim)=>claim.taskId === task.id && claim.status === "active");
1009
+ task.claimedPaths = changedPaths;
1010
+ const claim = upsertPathClaim(this.session, {
1011
+ taskId: task.id,
1012
+ agent: task.owner,
1013
+ source: "diff",
1014
+ paths: changedPaths,
1015
+ note: task.summary
1016
+ });
1017
+ if (claim && changedPaths.length > 0) {
1018
+ const releasedClaims = releaseSupersededClaims(this.session, {
1019
+ agent: task.owner,
1020
+ taskId: task.id,
1021
+ paths: changedPaths,
1022
+ note: `Superseded by newer ${task.owner} diff claim from task ${task.id}.`
1023
+ });
1024
+ for (const releasedClaim of releasedClaims){
1025
+ addDecisionRecord(this.session, {
1026
+ kind: "route",
1027
+ agent: task.owner,
1028
+ taskId: releasedClaim.taskId,
1029
+ summary: `Released superseded claim ${releasedClaim.id}`,
1030
+ detail: releasedClaim.paths.join(", ") || "No claimed paths.",
1031
+ metadata: {
1032
+ claimId: releasedClaim.id,
1033
+ supersededByTaskId: task.id,
1034
+ supersededByPaths: changedPaths
1035
+ }
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
+ });
1045
+ }
1046
+ return;
1047
+ }
1048
+ if (changedPaths.length === 0 && hadClaimedSurface) {
1049
+ addDecisionRecord(this.session, {
1050
+ kind: "route",
1051
+ agent: task.owner,
1052
+ taskId: task.id,
1053
+ summary: `Released empty claim surface for ${task.id}`,
1054
+ detail: "Task finished without a remaining worktree diff for its claimed paths.",
1055
+ metadata: {
1056
+ releaseReason: "empty-diff-claim"
1057
+ }
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
+ });
1065
+ }
1066
+ }
997
1067
  }
998
1068
 
999
1069
 
@@ -1,11 +1,37 @@
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
+ }
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
+ }
9
35
  export function addDecisionRecord(session, input) {
10
36
  const record = {
11
37
  id: randomUUID(),
@@ -63,12 +89,132 @@ export function upsertPathClaim(session, input) {
63
89
  export function activePathClaims(session) {
64
90
  return session.pathClaims.filter((claim)=>claim.status === "active" && claim.paths.length > 0);
65
91
  }
92
+ export function releasePathClaims(session, input = {}) {
93
+ const taskIds = input.taskIds ? new Set(input.taskIds) : null;
94
+ const released = [];
95
+ for (const claim of session.pathClaims){
96
+ if (claim.status !== "active") {
97
+ continue;
98
+ }
99
+ if (taskIds && !taskIds.has(claim.taskId)) {
100
+ continue;
101
+ }
102
+ claim.status = "released";
103
+ claim.updatedAt = nowIso();
104
+ if (input.note !== undefined) {
105
+ claim.note = input.note;
106
+ }
107
+ released.push(claim);
108
+ }
109
+ return released;
110
+ }
111
+ export function releaseSupersededClaims(session, input) {
112
+ const normalizedPaths = normalizePaths(input.paths);
113
+ if (normalizedPaths.length === 0) {
114
+ return [];
115
+ }
116
+ const released = [];
117
+ for (const claim of session.pathClaims){
118
+ if (claim.status !== "active" || claim.agent !== input.agent || claim.taskId === input.taskId) {
119
+ continue;
120
+ }
121
+ if (!claim.paths.some((item)=>normalizedPaths.some((candidate)=>pathOverlaps(item, candidate)))) {
122
+ continue;
123
+ }
124
+ claim.status = "released";
125
+ claim.updatedAt = nowIso();
126
+ if (input.note !== undefined) {
127
+ claim.note = input.note;
128
+ }
129
+ released.push(claim);
130
+ }
131
+ return released;
132
+ }
66
133
  export function findClaimConflicts(session, owner, claimedPaths) {
67
134
  const normalizedPaths = normalizePaths(claimedPaths);
68
135
  if (normalizedPaths.length === 0) {
69
136
  return [];
70
137
  }
71
- return activePathClaims(session).filter((claim)=>claim.agent !== owner && claim.paths.some((item)=>normalizedPaths.includes(item)));
138
+ return activePathClaims(session).filter((claim)=>claim.agent !== owner && claim.paths.some((item)=>normalizedPaths.some((candidate)=>pathOverlaps(item, candidate))));
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
+ });
72
218
  }
73
219
 
74
220
 
package/dist/doctor.js CHANGED
@@ -1,4 +1,7 @@
1
+ import path from "node:path";
2
+ import { loadConfig } from "./config.js";
1
3
  import { fileExists } from "./fs.js";
4
+ import { findOwnershipRuleConflicts } from "./ownership.js";
2
5
  import { runCommand } from "./process.js";
3
6
  import { hasSupportedNode, minimumNodeMajor, resolveSessionRuntime } from "./runtime.js";
4
7
  export function parseClaudeAuthStatus(output) {
@@ -19,12 +22,85 @@ export function parseClaudeAuthStatus(output) {
19
22
  };
20
23
  }
21
24
  }
25
+ function normalizeRoutingPathRule(value) {
26
+ const trimmed = value.trim().replaceAll("\\", "/");
27
+ const withoutPrefix = trimmed.startsWith("./") ? trimmed.slice(2) : trimmed;
28
+ const normalized = path.posix.normalize(withoutPrefix);
29
+ return normalized === "." ? "" : normalized.replace(/^\/+/, "").replace(/\/+$/, "");
30
+ }
31
+ export function validateRoutingPathRules(config) {
32
+ const issues = [];
33
+ const codexPaths = config.routing.codexPaths.map(normalizeRoutingPathRule);
34
+ const claudePaths = config.routing.claudePaths.map(normalizeRoutingPathRule);
35
+ const rawRules = [
36
+ ...config.routing.codexPaths.map((rule)=>({
37
+ owner: "codex",
38
+ raw: rule,
39
+ normalized: normalizeRoutingPathRule(rule)
40
+ })),
41
+ ...config.routing.claudePaths.map((rule)=>({
42
+ owner: "claude",
43
+ raw: rule,
44
+ normalized: normalizeRoutingPathRule(rule)
45
+ }))
46
+ ];
47
+ const blankRules = [
48
+ ...codexPaths,
49
+ ...claudePaths
50
+ ].filter((rule)=>!rule);
51
+ if (blankRules.length > 0) {
52
+ issues.push("Ownership path rules must not be empty.");
53
+ }
54
+ const absoluteRules = rawRules.filter(({ raw })=>{
55
+ const trimmed = raw.trim();
56
+ return trimmed.startsWith("/") || /^[A-Za-z]:[\\/]/.test(trimmed);
57
+ }).map(({ owner, raw })=>`${owner}:${raw}`);
58
+ if (absoluteRules.length > 0) {
59
+ issues.push(`Ownership path rules must be repo-relative, not absolute: ${absoluteRules.join(", ")}`);
60
+ }
61
+ const parentTraversalRules = rawRules.filter(({ normalized })=>normalized === ".." || normalized.startsWith("../")).map(({ owner, raw })=>`${owner}:${raw}`);
62
+ if (parentTraversalRules.length > 0) {
63
+ issues.push(`Ownership path rules must stay inside the repo root: ${parentTraversalRules.join(", ")}`);
64
+ }
65
+ const duplicateRules = (rules, owner)=>{
66
+ const seen = new Set();
67
+ const duplicates = new Set();
68
+ for (const rule of rules){
69
+ if (!rule) {
70
+ continue;
71
+ }
72
+ if (seen.has(rule)) {
73
+ duplicates.add(rule);
74
+ }
75
+ seen.add(rule);
76
+ }
77
+ if (duplicates.size > 0) {
78
+ issues.push(`${owner} has duplicate ownership rules: ${[
79
+ ...duplicates
80
+ ].join(", ")}`);
81
+ }
82
+ };
83
+ duplicateRules(codexPaths, "codex");
84
+ duplicateRules(claudePaths, "claude");
85
+ const overlappingRules = codexPaths.filter((rule)=>rule && claudePaths.includes(rule));
86
+ if (overlappingRules.length > 0) {
87
+ issues.push(`codex_paths and claude_paths overlap on the same exact rules: ${[
88
+ ...new Set(overlappingRules)
89
+ ].join(", ")}`);
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
+ }
95
+ return issues;
96
+ }
22
97
  function normalizeVersion(output) {
23
98
  return output.trim().split(/\s+/).slice(-1)[0] ?? output.trim();
24
99
  }
25
100
  export async function runDoctor(repoRoot, paths) {
26
101
  const checks = [];
27
102
  const runtime = await resolveSessionRuntime(paths);
103
+ const config = await loadConfig(paths);
28
104
  const nodeVersion = await runCommand(runtime.nodeExecutable, [
29
105
  "--version"
30
106
  ], {
@@ -104,6 +180,12 @@ export async function runDoctor(repoRoot, paths) {
104
180
  ok: true,
105
181
  detail: homeConfigCheck ? "present" : "will be created on demand"
106
182
  });
183
+ const routingRuleIssues = validateRoutingPathRules(config);
184
+ checks.push({
185
+ name: "routing-path-rules",
186
+ ok: routingRuleIssues.length === 0,
187
+ detail: routingRuleIssues.length === 0 ? "valid" : routingRuleIssues.join("; ")
188
+ });
107
189
  return checks;
108
190
  }
109
191