@mandipadk7/kavi 0.1.3 → 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.
@@ -32,73 +32,22 @@ Runtime model:
32
32
  - The operator surface talks to the daemon over a local control socket under the machine-local state root.
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
-
36
- Development:
37
-
38
- ```bash
39
- # works in an empty folder; Kavi will initialize git and create the
40
- # bootstrap commit it needs for managed worktrees
41
- node bin/kavi.js init --home
42
- node bin/kavi.js doctor --json
43
- node bin/kavi.js paths
44
- node bin/kavi.js start --goal "Build the auth backend"
45
- node bin/kavi.js status
46
- node bin/kavi.js task --agent auto "Design the dashboard shell"
47
- node bin/kavi.js tasks
48
- node bin/kavi.js task-output latest
49
- node bin/kavi.js decisions
50
- node bin/kavi.js claims
51
- node bin/kavi.js reviews
52
- node bin/kavi.js approvals
53
- node bin/kavi.js open
54
- npm test
55
- ```
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.
56
37
 
57
38
  Notes:
58
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.
59
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.
60
41
  - Claude still runs through the installed `claude` CLI with Kavi-managed hooks and approval decisions.
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.
61
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.
62
45
  - The socket is machine-local rather than repo-local to avoid Unix socket path-length issues in deep repos and temp directories.
63
- - 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, `F` queue a fix task, `H` queue a handoff task, `y/n` resolve approvals, and `c` opens the inline task composer.
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.
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.
64
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.
65
50
 
66
- Local install options:
67
-
68
- ```bash
69
- # Run directly from the repo
70
- node bin/kavi.js help
71
-
72
- # Symlink into ~/.local/bin
73
- ./scripts/install-local.sh
74
-
75
- # Or use npm link from this repo
76
- npm link
77
- ```
78
-
79
- Publishing for testers:
80
-
81
- ```bash
82
- # interactive publish flow: prompts for version, defaults to the next patch,
83
- # runs release checks, then publishes the beta tag
84
- npm run publish
85
-
86
- # authenticate once
87
- npm login
88
-
89
- # verify the package before publish
90
- npm run release:check
91
-
92
- # prompt for version and publish beta explicitly
93
- npm run publish:beta
94
-
95
- # prompt for version and publish stable
96
- npm run publish:latest
97
-
98
- # test the publish flow without sending anything to npm
99
- npm run publish -- --dry-run
100
- ```
101
-
102
51
  Install commands for testers:
103
52
 
104
53
  ```bash
@@ -109,13 +58,6 @@ npm install -g @mandipadk7/kavi@beta
109
58
  npx @mandipadk7/kavi@beta help
110
59
  ```
111
60
 
112
- Notes on publish:
113
- - The package name is scoped as `@mandipadk7/kavi` to match the npm user `mandipadk7`.
114
- - The publish flow now builds a compiled `dist/` directory first, and the installed CLI prefers `dist/main.js`. Source mode remains available for local development.
115
- - Hook commands now invoke the compiled entrypoint directly when `dist/` is present, and only use `--experimental-strip-types` in source mode.
116
- - The interactive publish script strips `repository`, `homepage`, and `bugs` from the packaged `package.json`, so the npm page uses the bundled `README.md` instead of private GitHub links.
117
- - `prepublishOnly` runs the release checks automatically during publish.
118
-
119
61
  User-local config example:
120
62
 
121
63
  ```toml
package/dist/config.js CHANGED
@@ -10,6 +10,8 @@ message_limit = 6
10
10
  [routing]
11
11
  frontend_keywords = ["frontend", "ui", "ux", "design", "copy", "react", "css", "html"]
12
12
  backend_keywords = ["backend", "api", "server", "db", "schema", "migration", "auth", "test"]
13
+ codex_paths = []
14
+ claude_paths = []
13
15
 
14
16
  [agents.codex]
15
17
  role = "planning-backend"
@@ -77,7 +79,9 @@ export function defaultConfig() {
77
79
  "migration",
78
80
  "auth",
79
81
  "test"
80
- ]
82
+ ],
83
+ codexPaths: [],
84
+ claudePaths: []
81
85
  },
82
86
  agents: {
83
87
  codex: {
@@ -140,7 +144,9 @@ export async function loadConfig(paths) {
140
144
  messageLimit: asNumber(parsed.message_limit, 6),
141
145
  routing: {
142
146
  frontendKeywords: asStringArray(routing.frontend_keywords, defaultConfig().routing.frontendKeywords),
143
- backendKeywords: asStringArray(routing.backend_keywords, defaultConfig().routing.backendKeywords)
147
+ backendKeywords: asStringArray(routing.backend_keywords, defaultConfig().routing.backendKeywords),
148
+ codexPaths: asStringArray(routing.codex_paths, defaultConfig().routing.codexPaths),
149
+ claudePaths: asStringArray(routing.claude_paths, defaultConfig().routing.claudePaths)
144
150
  },
145
151
  agents: {
146
152
  codex: {
package/dist/daemon.js CHANGED
@@ -6,13 +6,25 @@ 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";
13
13
  import { buildAdHocTask, buildKickoffTasks } from "./router.js";
14
14
  import { loadSessionRecord, readRecentEvents, recordEvent, saveSessionRecord } from "./session.js";
15
15
  import { loadTaskArtifact, saveTaskArtifact } from "./task-artifacts.js";
16
+ function normalizeReviewDisposition(value) {
17
+ if (value === "approve" || value === "concern" || value === "question" || value === "accepted_risk" || value === "wont_fix") {
18
+ return value;
19
+ }
20
+ return "note";
21
+ }
22
+ function normalizeReviewAssignee(value) {
23
+ if (value === "codex" || value === "claude" || value === "operator") {
24
+ return value;
25
+ }
26
+ return null;
27
+ }
16
28
  export class KaviDaemon {
17
29
  paths;
18
30
  session;
@@ -272,6 +284,9 @@ export class KaviDaemon {
272
284
  const taskId = `task-${commandId}`;
273
285
  const task = buildAdHocTask(owner, prompt, taskId, {
274
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 : {},
275
290
  claimedPaths: Array.isArray(params.claimedPaths) ? params.claimedPaths.map((item)=>String(item)) : []
276
291
  });
277
292
  this.session.tasks.push(task);
@@ -284,7 +299,8 @@ export class KaviDaemon {
284
299
  metadata: {
285
300
  strategy: typeof params.routeStrategy === "string" ? params.routeStrategy : "unknown",
286
301
  confidence: typeof params.routeConfidence === "number" ? params.routeConfidence : null,
287
- claimedPaths: task.claimedPaths
302
+ claimedPaths: task.claimedPaths,
303
+ routeMetadata: params.routeMetadata && typeof params.routeMetadata === "object" && !Array.isArray(params.routeMetadata) ? params.routeMetadata : {}
288
304
  }
289
305
  });
290
306
  upsertPathClaim(this.session, {
@@ -391,7 +407,8 @@ export class KaviDaemon {
391
407
  await this.runMutation(async ()=>{
392
408
  const agent = params.agent === "claude" ? "claude" : "codex";
393
409
  const filePath = typeof params.filePath === "string" ? params.filePath.trim() : "";
394
- const disposition = params.disposition === "approve" || params.disposition === "concern" || params.disposition === "question" ? params.disposition : "note";
410
+ const disposition = normalizeReviewDisposition(params.disposition);
411
+ const assignee = normalizeReviewAssignee(params.assignee) ?? agent;
395
412
  const body = typeof params.body === "string" ? params.body.trim() : "";
396
413
  const taskId = typeof params.taskId === "string" ? params.taskId : null;
397
414
  const hunkIndex = typeof params.hunkIndex === "number" ? params.hunkIndex : null;
@@ -404,6 +421,7 @@ export class KaviDaemon {
404
421
  }
405
422
  const note = addReviewNote(this.session, {
406
423
  agent,
424
+ assignee,
407
425
  taskId,
408
426
  filePath,
409
427
  hunkIndex,
@@ -422,6 +440,7 @@ export class KaviDaemon {
422
440
  hunkIndex: note.hunkIndex,
423
441
  hunkHeader: note.hunkHeader,
424
442
  disposition: note.disposition,
443
+ assignee: note.assignee,
425
444
  reviewNoteId: note.id
426
445
  }
427
446
  });
@@ -435,7 +454,8 @@ export class KaviDaemon {
435
454
  taskId: note.taskId,
436
455
  filePath: note.filePath,
437
456
  hunkIndex: note.hunkIndex,
438
- disposition: note.disposition
457
+ disposition: note.disposition,
458
+ assignee: note.assignee
439
459
  });
440
460
  await this.publishSnapshot("review.note_added");
441
461
  });
@@ -443,15 +463,28 @@ export class KaviDaemon {
443
463
  async updateReviewNoteFromRpc(params) {
444
464
  await this.runMutation(async ()=>{
445
465
  const noteId = typeof params.noteId === "string" ? params.noteId : "";
446
- const body = typeof params.body === "string" ? params.body.trim() : "";
466
+ const body = typeof params.body === "string" ? params.body.trim() : undefined;
467
+ const disposition = params.disposition === undefined ? undefined : normalizeReviewDisposition(params.disposition);
468
+ const assignee = params.assignee === undefined ? undefined : normalizeReviewAssignee(params.assignee);
447
469
  if (!noteId) {
448
470
  throw new Error("updateReviewNote requires a noteId.");
449
471
  }
450
- if (!body) {
451
- throw new Error("updateReviewNote requires a note body.");
472
+ if ((body === undefined || body.length === 0) && disposition === undefined && assignee === undefined) {
473
+ throw new Error("updateReviewNote requires at least one body, disposition, or assignee change.");
474
+ }
475
+ if (body !== undefined && body.length === 0) {
476
+ throw new Error("updateReviewNote requires a non-empty note body.");
452
477
  }
453
478
  const note = updateReviewNote(this.session, noteId, {
454
- body
479
+ ...body !== undefined ? {
480
+ body
481
+ } : {},
482
+ ...disposition !== undefined ? {
483
+ disposition
484
+ } : {},
485
+ ...assignee !== undefined ? {
486
+ assignee
487
+ } : {}
455
488
  });
456
489
  if (!note) {
457
490
  throw new Error(`Review note ${noteId} was not found.`);
@@ -465,7 +498,9 @@ export class KaviDaemon {
465
498
  metadata: {
466
499
  reviewNoteId: note.id,
467
500
  filePath: note.filePath,
468
- hunkIndex: note.hunkIndex
501
+ hunkIndex: note.hunkIndex,
502
+ disposition: note.disposition,
503
+ assignee: note.assignee
469
504
  }
470
505
  });
471
506
  await saveSessionRecord(this.paths, this.session);
@@ -476,7 +511,9 @@ export class KaviDaemon {
476
511
  reviewNoteId: note.id,
477
512
  taskId: note.taskId,
478
513
  agent: note.agent,
479
- filePath: note.filePath
514
+ filePath: note.filePath,
515
+ disposition: note.disposition,
516
+ assignee: note.assignee
480
517
  });
481
518
  await this.publishSnapshot("review.note_updated");
482
519
  });
@@ -588,12 +625,19 @@ export class KaviDaemon {
588
625
  promptLines.push(`Focus the change in ${note.filePath} and update the managed worktree accordingly.`);
589
626
  const task = buildAdHocTask(owner, promptLines.join(" "), taskId, {
590
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
+ },
591
635
  claimedPaths: [
592
636
  note.filePath
593
637
  ]
594
638
  });
595
639
  this.session.tasks.push(task);
596
- linkReviewFollowUpTask(this.session, note.id, taskId);
640
+ linkReviewFollowUpTask(this.session, note.id, taskId, owner);
597
641
  upsertPathClaim(this.session, {
598
642
  taskId,
599
643
  agent: owner,
@@ -612,7 +656,8 @@ export class KaviDaemon {
612
656
  owner,
613
657
  mode,
614
658
  filePath: note.filePath,
615
- sourceAgent: note.agent
659
+ sourceAgent: note.agent,
660
+ assignee: owner
616
661
  }
617
662
  });
618
663
  await saveSessionRecord(this.paths, this.session);
@@ -624,7 +669,8 @@ export class KaviDaemon {
624
669
  followUpTaskId: taskId,
625
670
  owner,
626
671
  mode,
627
- filePath: note.filePath
672
+ filePath: note.filePath,
673
+ assignee: owner
628
674
  });
629
675
  await this.publishSnapshot("review.followup_queued");
630
676
  });
@@ -751,6 +797,9 @@ export class KaviDaemon {
751
797
  const taskId = `task-${command.id}`;
752
798
  const task = buildAdHocTask(owner, command.payload.prompt, taskId, {
753
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 : {},
754
803
  claimedPaths: Array.isArray(command.payload.claimedPaths) ? command.payload.claimedPaths.map((item)=>String(item)) : []
755
804
  });
756
805
  this.session.tasks.push(task);
@@ -763,7 +812,8 @@ export class KaviDaemon {
763
812
  metadata: {
764
813
  strategy: typeof command.payload.routeStrategy === "string" ? command.payload.routeStrategy : "unknown",
765
814
  confidence: typeof command.payload.routeConfidence === "number" ? command.payload.routeConfidence : null,
766
- 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 : {}
767
817
  }
768
818
  });
769
819
  upsertPathClaim(this.session, {
@@ -814,18 +864,7 @@ export class KaviDaemon {
814
864
  task.summary = envelope.summary;
815
865
  task.updatedAt = new Date().toISOString();
816
866
  if (task.owner === "codex" || task.owner === "claude") {
817
- const worktree = this.session.worktrees.find((item)=>item.agent === task.owner);
818
- if (worktree) {
819
- const changedPaths = await listWorktreeChangedPaths(worktree.path, this.session.baseCommit);
820
- task.claimedPaths = changedPaths;
821
- upsertPathClaim(this.session, {
822
- taskId: task.id,
823
- agent: task.owner,
824
- source: "diff",
825
- paths: changedPaths,
826
- note: task.summary
827
- });
828
- }
867
+ await this.refreshTaskClaims(task);
829
868
  }
830
869
  addDecisionRecord(this.session, {
831
870
  kind: "task",
@@ -866,6 +905,9 @@ export class KaviDaemon {
866
905
  status: task.status,
867
906
  summary: task.summary,
868
907
  routeReason: task.routeReason,
908
+ routeStrategy: task.routeStrategy,
909
+ routeConfidence: task.routeConfidence,
910
+ routeMetadata: task.routeMetadata,
869
911
  claimedPaths: task.claimedPaths,
870
912
  decisionReplay,
871
913
  rawOutput,
@@ -897,18 +939,7 @@ export class KaviDaemon {
897
939
  task.summary = error instanceof Error ? error.message : String(error);
898
940
  task.updatedAt = new Date().toISOString();
899
941
  if (task.owner === "codex" || task.owner === "claude") {
900
- const worktree = this.session.worktrees.find((item)=>item.agent === task.owner);
901
- if (worktree) {
902
- const changedPaths = await listWorktreeChangedPaths(worktree.path, this.session.baseCommit);
903
- task.claimedPaths = changedPaths;
904
- upsertPathClaim(this.session, {
905
- taskId: task.id,
906
- agent: task.owner,
907
- source: "diff",
908
- paths: changedPaths,
909
- note: task.summary
910
- });
911
- }
942
+ await this.refreshTaskClaims(task);
912
943
  }
913
944
  addDecisionRecord(this.session, {
914
945
  kind: "task",
@@ -933,6 +964,9 @@ export class KaviDaemon {
933
964
  status: task.status,
934
965
  summary: task.summary,
935
966
  routeReason: task.routeReason,
967
+ routeStrategy: task.routeStrategy,
968
+ routeConfidence: task.routeConfidence,
969
+ routeMetadata: task.routeMetadata,
936
970
  claimedPaths: task.claimedPaths,
937
971
  decisionReplay,
938
972
  rawOutput: null,
@@ -959,6 +993,60 @@ export class KaviDaemon {
959
993
  summary
960
994
  };
961
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
+ }
962
1050
  }
963
1051
 
964
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,12 +1,101 @@
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";
6
+ export function parseClaudeAuthStatus(output) {
7
+ try {
8
+ const parsed = JSON.parse(output);
9
+ const loggedIn = parsed.loggedIn === true;
10
+ const authMethod = typeof parsed.authMethod === "string" ? parsed.authMethod : "unknown";
11
+ const apiProvider = typeof parsed.apiProvider === "string" ? parsed.apiProvider : "unknown";
12
+ return {
13
+ loggedIn,
14
+ detail: loggedIn ? `logged in via ${authMethod} (${apiProvider})` : `not logged in (${authMethod}, ${apiProvider})`
15
+ };
16
+ } catch {
17
+ const trimmed = output.trim();
18
+ return {
19
+ loggedIn: false,
20
+ detail: trimmed || "unable to parse claude auth status"
21
+ };
22
+ }
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
+ }
4
92
  function normalizeVersion(output) {
5
93
  return output.trim().split(/\s+/).slice(-1)[0] ?? output.trim();
6
94
  }
7
95
  export async function runDoctor(repoRoot, paths) {
8
96
  const checks = [];
9
97
  const runtime = await resolveSessionRuntime(paths);
98
+ const config = await loadConfig(paths);
10
99
  const nodeVersion = await runCommand(runtime.nodeExecutable, [
11
100
  "--version"
12
101
  ], {
@@ -37,6 +126,21 @@ export async function runDoctor(repoRoot, paths) {
37
126
  ok: claudeVersion.code === 0,
38
127
  detail: claudeVersion.code === 0 ? `${claudeVersion.stdout.trim()} via ${runtime.claudeExecutable}` : claudeVersion.stderr.trim()
39
128
  });
129
+ const claudeAuth = await runCommand(runtime.claudeExecutable, [
130
+ "auth",
131
+ "status"
132
+ ], {
133
+ cwd: repoRoot
134
+ });
135
+ const claudeAuthStatus = claudeAuth.code === 0 ? parseClaudeAuthStatus(claudeAuth.stdout) : {
136
+ loggedIn: false,
137
+ detail: claudeAuth.stderr.trim() || claudeAuth.stdout.trim() || "claude auth status failed"
138
+ };
139
+ checks.push({
140
+ name: "claude-auth",
141
+ ok: claudeAuth.code === 0 && claudeAuthStatus.loggedIn,
142
+ detail: claudeAuth.code === 0 ? claudeAuthStatus.detail : claudeAuthStatus.detail
143
+ });
40
144
  const worktreeCheck = await runCommand("git", [
41
145
  "worktree",
42
146
  "list"
@@ -71,6 +175,12 @@ export async function runDoctor(repoRoot, paths) {
71
175
  ok: true,
72
176
  detail: homeConfigCheck ? "present" : "will be created on demand"
73
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
+ });
74
184
  return checks;
75
185
  }
76
186