@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 +11 -3
- package/dist/adapters/shared.js +2 -0
- package/dist/daemon.js +97 -27
- package/dist/decision-ledger.js +148 -2
- package/dist/doctor.js +82 -0
- package/dist/main.js +182 -12
- package/dist/ownership.js +276 -0
- package/dist/reviews.js +24 -0
- package/dist/router.js +86 -46
- package/dist/rpc.js +1 -0
- package/dist/session.js +3 -0
- package/dist/task-artifacts.js +3 -0
- package/dist/tui.js +142 -29
- package/package.json +1 -1
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
|
|
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
|
|
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
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
package/dist/decision-ledger.js
CHANGED
|
@@ -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(
|
|
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.
|
|
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
|
|