@mandipadk7/kavi 0.1.3 → 0.1.4

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
@@ -32,73 +32,19 @@ 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.
56
36
 
57
37
  Notes:
58
38
  - `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
39
  - 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
40
  - Claude still runs through the installed `claude` CLI with Kavi-managed hooks and approval decisions.
41
+ - `kavi doctor` now checks Claude auth readiness with `claude auth status`, and startup blocks if Claude is installed but not authenticated.
61
42
  - 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
43
  - 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.
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.
45
+ - 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
46
  - 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
47
 
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
48
  Install commands for testers:
103
49
 
104
50
  ```bash
@@ -109,13 +55,6 @@ npm install -g @mandipadk7/kavi@beta
109
55
  npx @mandipadk7/kavi@beta help
110
56
  ```
111
57
 
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
58
  User-local config example:
120
59
 
121
60
  ```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
@@ -13,6 +13,18 @@ import { addReviewReply, addReviewNote, autoResolveReviewNotesForCompletedTask,
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;
@@ -391,7 +403,8 @@ export class KaviDaemon {
391
403
  await this.runMutation(async ()=>{
392
404
  const agent = params.agent === "claude" ? "claude" : "codex";
393
405
  const filePath = typeof params.filePath === "string" ? params.filePath.trim() : "";
394
- const disposition = params.disposition === "approve" || params.disposition === "concern" || params.disposition === "question" ? params.disposition : "note";
406
+ const disposition = normalizeReviewDisposition(params.disposition);
407
+ const assignee = normalizeReviewAssignee(params.assignee) ?? agent;
395
408
  const body = typeof params.body === "string" ? params.body.trim() : "";
396
409
  const taskId = typeof params.taskId === "string" ? params.taskId : null;
397
410
  const hunkIndex = typeof params.hunkIndex === "number" ? params.hunkIndex : null;
@@ -404,6 +417,7 @@ export class KaviDaemon {
404
417
  }
405
418
  const note = addReviewNote(this.session, {
406
419
  agent,
420
+ assignee,
407
421
  taskId,
408
422
  filePath,
409
423
  hunkIndex,
@@ -422,6 +436,7 @@ export class KaviDaemon {
422
436
  hunkIndex: note.hunkIndex,
423
437
  hunkHeader: note.hunkHeader,
424
438
  disposition: note.disposition,
439
+ assignee: note.assignee,
425
440
  reviewNoteId: note.id
426
441
  }
427
442
  });
@@ -435,7 +450,8 @@ export class KaviDaemon {
435
450
  taskId: note.taskId,
436
451
  filePath: note.filePath,
437
452
  hunkIndex: note.hunkIndex,
438
- disposition: note.disposition
453
+ disposition: note.disposition,
454
+ assignee: note.assignee
439
455
  });
440
456
  await this.publishSnapshot("review.note_added");
441
457
  });
@@ -443,15 +459,28 @@ export class KaviDaemon {
443
459
  async updateReviewNoteFromRpc(params) {
444
460
  await this.runMutation(async ()=>{
445
461
  const noteId = typeof params.noteId === "string" ? params.noteId : "";
446
- const body = typeof params.body === "string" ? params.body.trim() : "";
462
+ const body = typeof params.body === "string" ? params.body.trim() : undefined;
463
+ const disposition = params.disposition === undefined ? undefined : normalizeReviewDisposition(params.disposition);
464
+ const assignee = params.assignee === undefined ? undefined : normalizeReviewAssignee(params.assignee);
447
465
  if (!noteId) {
448
466
  throw new Error("updateReviewNote requires a noteId.");
449
467
  }
450
- if (!body) {
451
- throw new Error("updateReviewNote requires a note body.");
468
+ if ((body === undefined || body.length === 0) && disposition === undefined && assignee === undefined) {
469
+ throw new Error("updateReviewNote requires at least one body, disposition, or assignee change.");
470
+ }
471
+ if (body !== undefined && body.length === 0) {
472
+ throw new Error("updateReviewNote requires a non-empty note body.");
452
473
  }
453
474
  const note = updateReviewNote(this.session, noteId, {
454
- body
475
+ ...body !== undefined ? {
476
+ body
477
+ } : {},
478
+ ...disposition !== undefined ? {
479
+ disposition
480
+ } : {},
481
+ ...assignee !== undefined ? {
482
+ assignee
483
+ } : {}
455
484
  });
456
485
  if (!note) {
457
486
  throw new Error(`Review note ${noteId} was not found.`);
@@ -465,7 +494,9 @@ export class KaviDaemon {
465
494
  metadata: {
466
495
  reviewNoteId: note.id,
467
496
  filePath: note.filePath,
468
- hunkIndex: note.hunkIndex
497
+ hunkIndex: note.hunkIndex,
498
+ disposition: note.disposition,
499
+ assignee: note.assignee
469
500
  }
470
501
  });
471
502
  await saveSessionRecord(this.paths, this.session);
@@ -476,7 +507,9 @@ export class KaviDaemon {
476
507
  reviewNoteId: note.id,
477
508
  taskId: note.taskId,
478
509
  agent: note.agent,
479
- filePath: note.filePath
510
+ filePath: note.filePath,
511
+ disposition: note.disposition,
512
+ assignee: note.assignee
480
513
  });
481
514
  await this.publishSnapshot("review.note_updated");
482
515
  });
@@ -593,7 +626,7 @@ export class KaviDaemon {
593
626
  ]
594
627
  });
595
628
  this.session.tasks.push(task);
596
- linkReviewFollowUpTask(this.session, note.id, taskId);
629
+ linkReviewFollowUpTask(this.session, note.id, taskId, owner);
597
630
  upsertPathClaim(this.session, {
598
631
  taskId,
599
632
  agent: owner,
@@ -612,7 +645,8 @@ export class KaviDaemon {
612
645
  owner,
613
646
  mode,
614
647
  filePath: note.filePath,
615
- sourceAgent: note.agent
648
+ sourceAgent: note.agent,
649
+ assignee: owner
616
650
  }
617
651
  });
618
652
  await saveSessionRecord(this.paths, this.session);
@@ -624,7 +658,8 @@ export class KaviDaemon {
624
658
  followUpTaskId: taskId,
625
659
  owner,
626
660
  mode,
627
- filePath: note.filePath
661
+ filePath: note.filePath,
662
+ assignee: owner
628
663
  });
629
664
  await this.publishSnapshot("review.followup_queued");
630
665
  });
package/dist/doctor.js CHANGED
@@ -1,6 +1,24 @@
1
1
  import { fileExists } from "./fs.js";
2
2
  import { runCommand } from "./process.js";
3
3
  import { hasSupportedNode, minimumNodeMajor, resolveSessionRuntime } from "./runtime.js";
4
+ export function parseClaudeAuthStatus(output) {
5
+ try {
6
+ const parsed = JSON.parse(output);
7
+ const loggedIn = parsed.loggedIn === true;
8
+ const authMethod = typeof parsed.authMethod === "string" ? parsed.authMethod : "unknown";
9
+ const apiProvider = typeof parsed.apiProvider === "string" ? parsed.apiProvider : "unknown";
10
+ return {
11
+ loggedIn,
12
+ detail: loggedIn ? `logged in via ${authMethod} (${apiProvider})` : `not logged in (${authMethod}, ${apiProvider})`
13
+ };
14
+ } catch {
15
+ const trimmed = output.trim();
16
+ return {
17
+ loggedIn: false,
18
+ detail: trimmed || "unable to parse claude auth status"
19
+ };
20
+ }
21
+ }
4
22
  function normalizeVersion(output) {
5
23
  return output.trim().split(/\s+/).slice(-1)[0] ?? output.trim();
6
24
  }
@@ -37,6 +55,21 @@ export async function runDoctor(repoRoot, paths) {
37
55
  ok: claudeVersion.code === 0,
38
56
  detail: claudeVersion.code === 0 ? `${claudeVersion.stdout.trim()} via ${runtime.claudeExecutable}` : claudeVersion.stderr.trim()
39
57
  });
58
+ const claudeAuth = await runCommand(runtime.claudeExecutable, [
59
+ "auth",
60
+ "status"
61
+ ], {
62
+ cwd: repoRoot
63
+ });
64
+ const claudeAuthStatus = claudeAuth.code === 0 ? parseClaudeAuthStatus(claudeAuth.stdout) : {
65
+ loggedIn: false,
66
+ detail: claudeAuth.stderr.trim() || claudeAuth.stdout.trim() || "claude auth status failed"
67
+ };
68
+ checks.push({
69
+ name: "claude-auth",
70
+ ok: claudeAuth.code === 0 && claudeAuthStatus.loggedIn,
71
+ detail: claudeAuth.code === 0 ? claudeAuthStatus.detail : claudeAuthStatus.detail
72
+ });
40
73
  const worktreeCheck = await runCommand("git", [
41
74
  "worktree",
42
75
  "list"
package/dist/main.js CHANGED
@@ -247,6 +247,7 @@ async function ensureStartupReady(repoRoot, paths) {
247
247
  "node",
248
248
  "codex",
249
249
  "claude",
250
+ "claude-auth",
250
251
  "git-worktree",
251
252
  "codex-app-server",
252
253
  "codex-auth-file"
@@ -543,6 +544,7 @@ async function commandTaskOutput(cwd, args) {
543
544
  } else {
544
545
  for (const note of artifact.reviewNotes){
545
546
  console.log(`${note.createdAt} | ${note.disposition} | ${note.status} | ${note.filePath}${note.hunkIndex === null ? "" : ` | hunk ${note.hunkIndex + 1}`}`);
547
+ console.log(` assignee: ${note.assignee ?? "-"}`);
546
548
  console.log(` comments: ${note.comments.length}`);
547
549
  for (const [index, comment] of note.comments.entries()){
548
550
  console.log(` ${index === 0 ? "root" : `reply-${index}`}: ${comment.body}`);
@@ -615,6 +617,7 @@ async function commandReviews(cwd, args) {
615
617
  for (const note of notes){
616
618
  console.log(`${note.id} | ${note.agent} | ${note.status} | ${note.disposition} | ${note.filePath}${note.hunkIndex === null ? "" : ` | hunk ${note.hunkIndex + 1}`}`);
617
619
  console.log(` task: ${note.taskId ?? "-"}`);
620
+ console.log(` assignee: ${note.assignee ?? "-"}`);
618
621
  console.log(` updated: ${note.updatedAt}`);
619
622
  console.log(` comments: ${note.comments.length}`);
620
623
  console.log(` landed: ${note.landedAt ?? "-"}`);
package/dist/reviews.js CHANGED
@@ -5,11 +5,21 @@ function trimBody(value) {
5
5
  return value.trim();
6
6
  }
7
7
  function summarizeReviewNote(disposition, filePath, hunkHeader, body) {
8
- const label = disposition[0].toUpperCase() + disposition.slice(1);
8
+ const label = reviewDispositionSummaryLabel(disposition);
9
9
  const scope = hunkHeader ? `${filePath} ${hunkHeader}` : filePath;
10
10
  const firstLine = trimBody(body).split("\n")[0]?.trim() ?? "";
11
11
  return firstLine ? `${label} ${scope}: ${firstLine}` : `${label} ${scope}`;
12
12
  }
13
+ function reviewDispositionSummaryLabel(disposition) {
14
+ switch(disposition){
15
+ case "accepted_risk":
16
+ return "Accepted Risk";
17
+ case "wont_fix":
18
+ return "Won't Fix";
19
+ default:
20
+ return disposition[0].toUpperCase() + disposition.slice(1);
21
+ }
22
+ }
13
23
  function createReviewComment(body) {
14
24
  const timestamp = nowIso();
15
25
  return {
@@ -24,6 +34,7 @@ export function addReviewNote(session, input) {
24
34
  const note = {
25
35
  id: randomUUID(),
26
36
  agent: input.agent,
37
+ assignee: input.assignee ?? input.agent,
27
38
  taskId: input.taskId ?? null,
28
39
  filePath: input.filePath,
29
40
  hunkIndex: input.hunkIndex ?? null,
@@ -68,8 +79,10 @@ export function updateReviewNote(session, noteId, input) {
68
79
  }
69
80
  const nextBody = typeof input.body === "string" ? trimBody(input.body) : note.body;
70
81
  const nextDisposition = input.disposition ?? note.disposition;
82
+ const nextAssignee = input.assignee === undefined ? note.assignee : input.assignee;
71
83
  note.body = nextBody;
72
84
  note.disposition = nextDisposition;
85
+ note.assignee = nextAssignee;
73
86
  if (note.comments.length === 0) {
74
87
  note.comments.push(createReviewComment(nextBody));
75
88
  } else if (typeof input.body === "string") {
@@ -96,7 +109,7 @@ export function setReviewNoteStatus(session, noteId, status) {
96
109
  note.updatedAt = nowIso();
97
110
  return note;
98
111
  }
99
- export function linkReviewFollowUpTask(session, noteId, taskId) {
112
+ export function linkReviewFollowUpTask(session, noteId, taskId, assignee) {
100
113
  const note = session.reviewNotes.find((item)=>item.id === noteId) ?? null;
101
114
  if (!note) {
102
115
  return null;
@@ -107,6 +120,9 @@ export function linkReviewFollowUpTask(session, noteId, taskId) {
107
120
  taskId
108
121
  ])
109
122
  ];
123
+ if (assignee !== undefined) {
124
+ note.assignee = assignee;
125
+ }
110
126
  note.updatedAt = nowIso();
111
127
  return note;
112
128
  }
@@ -122,6 +138,19 @@ export function addReviewReply(session, noteId, body) {
122
138
  note.updatedAt = nowIso();
123
139
  return note;
124
140
  }
141
+ export function cycleReviewAssignee(current, noteAgent) {
142
+ const sequence = [
143
+ noteAgent,
144
+ noteAgent === "codex" ? "claude" : "codex",
145
+ "operator",
146
+ null
147
+ ];
148
+ const index = sequence.findIndex((item)=>item === current);
149
+ if (index === -1) {
150
+ return noteAgent;
151
+ }
152
+ return sequence[(index + 1) % sequence.length] ?? null;
153
+ }
125
154
  export function autoResolveReviewNotesForCompletedTask(session, taskId) {
126
155
  const resolved = [];
127
156
  for (const note of session.reviewNotes){
package/dist/router.js CHANGED
@@ -1,3 +1,4 @@
1
+ import path from "node:path";
1
2
  import { CodexAppServerClient } from "./codex-app-server.js";
2
3
  import { findClaimConflicts } from "./decision-ledger.js";
3
4
  import { nowIso } from "./paths.js";
@@ -43,6 +44,55 @@ function normalizeClaimedPaths(paths) {
43
44
  ...new Set(paths.map((item)=>item.trim()).filter(Boolean))
44
45
  ].sort();
45
46
  }
47
+ function normalizePathPattern(value) {
48
+ const trimmed = value.trim().replaceAll("\\", "/");
49
+ const withoutPrefix = trimmed.startsWith("./") ? trimmed.slice(2) : trimmed;
50
+ const normalized = path.posix.normalize(withoutPrefix);
51
+ return normalized === "." ? "" : normalized.replace(/^\/+/, "");
52
+ }
53
+ function globToRegex(pattern) {
54
+ const normalized = normalizePathPattern(pattern);
55
+ const escaped = normalized.replace(/[|\\{}()[\]^$+?.]/g, "\\$&");
56
+ const regexSource = escaped.replaceAll("**", "::double-star::").replaceAll("*", "[^/]*").replaceAll("::double-star::", ".*");
57
+ return new RegExp(`^${regexSource}$`);
58
+ }
59
+ function matchesPattern(filePath, pattern) {
60
+ const normalizedPath = normalizePathPattern(filePath);
61
+ const normalizedPattern = normalizePathPattern(pattern);
62
+ if (!normalizedPath || !normalizedPattern) {
63
+ return false;
64
+ }
65
+ return globToRegex(normalizedPattern).test(normalizedPath);
66
+ }
67
+ function countPathMatches(filePaths, patterns) {
68
+ if (filePaths.length === 0 || patterns.length === 0) {
69
+ return 0;
70
+ }
71
+ return filePaths.filter((filePath)=>patterns.some((pattern)=>matchesPattern(filePath, pattern))).length;
72
+ }
73
+ function buildPathOwnershipDecision(prompt, config) {
74
+ const claimedPaths = extractPromptPathHints(prompt);
75
+ if (claimedPaths.length === 0) {
76
+ return null;
77
+ }
78
+ const codexMatches = countPathMatches(claimedPaths, config.routing.codexPaths);
79
+ const claudeMatches = countPathMatches(claimedPaths, config.routing.claudePaths);
80
+ if (codexMatches === 0 && claudeMatches === 0) {
81
+ return null;
82
+ }
83
+ if (codexMatches === claudeMatches) {
84
+ return null;
85
+ }
86
+ const owner = codexMatches > claudeMatches ? "codex" : "claude";
87
+ const matchedPatterns = owner === "codex" ? config.routing.codexPaths : config.routing.claudePaths;
88
+ return {
89
+ owner,
90
+ strategy: "manual",
91
+ confidence: 0.97,
92
+ reason: `Matched explicit ${owner} path ownership rules for: ${claimedPaths.join(", ")}.`,
93
+ claimedPaths
94
+ };
95
+ }
46
96
  export function extractPromptPathHints(prompt) {
47
97
  const candidates = [];
48
98
  const quotedMatches = prompt.matchAll(/[`'"]([^`'"\n]+)[`'"]/g);
@@ -59,6 +109,10 @@ export function extractPromptPathHints(prompt) {
59
109
  return normalizeClaimedPaths(candidates);
60
110
  }
61
111
  export function routePrompt(prompt, config) {
112
+ const pathDecision = buildPathOwnershipDecision(prompt, config);
113
+ if (pathDecision) {
114
+ return pathDecision.owner;
115
+ }
62
116
  if (containsKeyword(prompt, config.routing.frontendKeywords)) {
63
117
  return "claude";
64
118
  }
@@ -92,6 +146,11 @@ function buildKeywordDecision(prompt, config) {
92
146
  return null;
93
147
  }
94
148
  function buildRouterPrompt(prompt, session) {
149
+ const ownershipRules = [
150
+ ...session.config.routing.codexPaths.map((pattern)=>`- codex: ${pattern}`),
151
+ ...session.config.routing.claudePaths.map((pattern)=>`- claude: ${pattern}`)
152
+ ].join("\n");
153
+ const promptHints = extractPromptPathHints(prompt);
95
154
  const activeClaims = session.pathClaims.filter((claim)=>claim.status === "active").map((claim)=>`- ${claim.agent}: ${claim.paths.join(", ")}`).join("\n");
96
155
  return [
97
156
  "Route this task between Codex and Claude.",
@@ -103,6 +162,12 @@ function buildRouterPrompt(prompt, session) {
103
162
  "Task prompt:",
104
163
  prompt,
105
164
  "",
165
+ "Explicit path ownership rules:",
166
+ ownershipRules || "- none",
167
+ "",
168
+ "Path hints extracted from the prompt:",
169
+ promptHints.length > 0 ? promptHints.map((item)=>`- ${item}`).join("\n") : "- none",
170
+ "",
106
171
  "Active path claims:",
107
172
  activeClaims || "- none"
108
173
  ].join("\n");
@@ -174,6 +239,10 @@ function applyClaimRouting(session, decision) {
174
239
  };
175
240
  }
176
241
  export async function routeTask(prompt, session, _paths) {
242
+ const pathDecision = buildPathOwnershipDecision(prompt, session.config);
243
+ if (pathDecision) {
244
+ return applyClaimRouting(session, pathDecision);
245
+ }
177
246
  const heuristic = buildKeywordDecision(prompt, session.config);
178
247
  if (heuristic) {
179
248
  return applyClaimRouting(session, heuristic);
package/dist/rpc.js CHANGED
@@ -130,13 +130,22 @@ export async function rpcAddReviewNote(paths, params) {
130
130
  hunkIndex: params.hunkIndex,
131
131
  hunkHeader: params.hunkHeader,
132
132
  disposition: params.disposition,
133
+ assignee: params.assignee ?? null,
133
134
  body: params.body
134
135
  });
135
136
  }
136
137
  export async function rpcUpdateReviewNote(paths, params) {
137
138
  await sendRpcRequest(paths, "updateReviewNote", {
138
139
  noteId: params.noteId,
139
- body: params.body
140
+ ...typeof params.body === "string" ? {
141
+ body: params.body
142
+ } : {},
143
+ ...params.disposition ? {
144
+ disposition: params.disposition
145
+ } : {},
146
+ ...params.assignee === undefined ? {} : {
147
+ assignee: params.assignee
148
+ }
140
149
  });
141
150
  }
142
151
  export async function rpcAddReviewReply(paths, params) {
package/dist/session.js CHANGED
@@ -59,9 +59,11 @@ export async function loadSessionRecord(paths) {
59
59
  record.reviewNotes = Array.isArray(record.reviewNotes) ? record.reviewNotes.map((note)=>({
60
60
  ...note,
61
61
  body: typeof note.body === "string" ? note.body : "",
62
+ assignee: note.assignee === "codex" || note.assignee === "claude" || note.assignee === "operator" ? note.assignee : null,
62
63
  taskId: typeof note.taskId === "string" ? note.taskId : null,
63
64
  hunkIndex: typeof note.hunkIndex === "number" ? note.hunkIndex : null,
64
65
  hunkHeader: typeof note.hunkHeader === "string" ? note.hunkHeader : null,
66
+ disposition: note.disposition === "approve" || note.disposition === "concern" || note.disposition === "question" || note.disposition === "accepted_risk" || note.disposition === "wont_fix" ? note.disposition : "note",
65
67
  status: note.status === "resolved" ? "resolved" : "open",
66
68
  comments: Array.isArray(note.comments) ? note.comments.map((comment)=>({
67
69
  id: String(comment.id),
@@ -12,9 +12,11 @@ function normalizeArtifact(artifact) {
12
12
  decisionReplay: Array.isArray(artifact.decisionReplay) ? artifact.decisionReplay.map((item)=>String(item)) : [],
13
13
  reviewNotes: Array.isArray(artifact.reviewNotes) ? artifact.reviewNotes.map((note)=>({
14
14
  ...note,
15
+ assignee: note.assignee === "codex" || note.assignee === "claude" || note.assignee === "operator" ? note.assignee : null,
15
16
  taskId: typeof note.taskId === "string" ? note.taskId : null,
16
17
  hunkIndex: typeof note.hunkIndex === "number" ? note.hunkIndex : null,
17
18
  hunkHeader: typeof note.hunkHeader === "string" ? note.hunkHeader : null,
19
+ disposition: note.disposition === "approve" || note.disposition === "concern" || note.disposition === "question" || note.disposition === "accepted_risk" || note.disposition === "wont_fix" ? note.disposition : "note",
18
20
  status: note.status === "resolved" ? "resolved" : "open",
19
21
  summary: typeof note.summary === "string" ? note.summary : "",
20
22
  body: typeof note.body === "string" ? note.body : "",
package/dist/tui.js CHANGED
@@ -1,6 +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
5
  import { extractPromptPathHints, routeTask } from "./router.js";
5
6
  import { pingRpc, rpcAddReviewNote, rpcAddReviewReply, rpcEnqueueReviewFollowUp, readSnapshot, rpcEnqueueTask, rpcResolveApproval, rpcSetReviewNoteStatus, rpcShutdown, rpcTaskArtifact, rpcUpdateReviewNote, rpcWorktreeDiff, subscribeSnapshotRpc } from "./rpc.js";
6
7
  const RESET = "\u001b[0m";
@@ -530,7 +531,10 @@ function reviewDispositionTone(disposition) {
530
531
  case "concern":
531
532
  return "bad";
532
533
  case "question":
534
+ case "accepted_risk":
533
535
  return "warn";
536
+ case "wont_fix":
537
+ return "muted";
534
538
  default:
535
539
  return "muted";
536
540
  }
@@ -543,12 +547,28 @@ function reviewDispositionLabel(disposition) {
543
547
  return "Concern";
544
548
  case "question":
545
549
  return "Question";
550
+ case "accepted_risk":
551
+ return "Accepted Risk";
552
+ case "wont_fix":
553
+ return "Won't Fix";
546
554
  case "note":
547
555
  return "Note";
548
556
  default:
549
557
  return disposition;
550
558
  }
551
559
  }
560
+ function reviewAssigneeLabel(assignee) {
561
+ switch(assignee){
562
+ case "codex":
563
+ return "codex";
564
+ case "claude":
565
+ return "claude";
566
+ case "operator":
567
+ return "operator";
568
+ default:
569
+ return "unassigned";
570
+ }
571
+ }
552
572
  function activeReviewContext(snapshot, ui) {
553
573
  const agent = reviewAgentForUi(snapshot, ui);
554
574
  if (!agent) {
@@ -605,9 +625,10 @@ function renderReviewNotesSection(notes, selectedNoteId, width) {
605
625
  const prefix = `${note.id === selectedNoteId ? ">" : "-"} [${reviewDispositionLabel(note.disposition)} ${stateLabel}] ${shortTime(note.createdAt)}`;
606
626
  const followUps = note.followUpTaskIds.length > 0 ? ` | follow-ups=${note.followUpTaskIds.length}` : "";
607
627
  const replies = note.comments.length > 1 ? ` | replies=${note.comments.length - 1}` : "";
628
+ const assignee = ` | assignee=${reviewAssigneeLabel(note.assignee)}`;
608
629
  const landed = note.landedAt ? ` | landed=${shortTime(note.landedAt)}` : "";
609
630
  const detail = note.body || note.summary;
610
- return wrapText(`${prefix} ${detail}${followUps}${replies}${landed}`, width).map((line)=>toneLine(line, note.status === "resolved" ? "muted" : reviewDispositionTone(note.disposition), note.id === selectedNoteId));
631
+ return wrapText(`${prefix} ${detail}${assignee}${followUps}${replies}${landed}`, width).map((line)=>toneLine(line, note.status === "resolved" ? "muted" : reviewDispositionTone(note.disposition), note.id === selectedNoteId));
611
632
  });
612
633
  }
613
634
  function renderSelectedReviewNoteSection(note, width) {
@@ -618,6 +639,7 @@ function renderSelectedReviewNoteSection(note, width) {
618
639
  }
619
640
  return [
620
641
  ...wrapText(`Status: ${note.status} | Disposition: ${reviewDispositionLabel(note.disposition)} | Updated: ${shortTime(note.updatedAt)}`, width),
642
+ ...wrapText(`Assignee: ${reviewAssigneeLabel(note.assignee)}`, width),
621
643
  ...wrapText(`Landed: ${note.landedAt ? shortTime(note.landedAt) : "-"}`, width),
622
644
  ...wrapText(`Follow-up tasks: ${note.followUpTaskIds.join(", ") || "-"}`, width),
623
645
  ...note.comments.flatMap((comment, index)=>wrapText(`${index === 0 ? "Root" : `Reply ${index}`}: ${comment.body} (${shortTime(comment.updatedAt)})`, width))
@@ -1156,7 +1178,7 @@ function renderFooter(snapshot, ui, width) {
1156
1178
  }
1157
1179
  return [
1158
1180
  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),
1159
- 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 | F fix task | H handoff | g/G top/bottom | s stop daemon | q quit", 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),
1160
1182
  footerSelectionSummary(snapshot, ui, width),
1161
1183
  fitAnsiLine(toast ? styleLine(toast.message, toast.level === "error" ? "bad" : "good") : styleLine("Operator surface is live over the daemon socket with pushed snapshots.", "muted"), width)
1162
1184
  ];
@@ -1374,6 +1396,34 @@ async function enqueueSelectedReviewFollowUp(paths, snapshot, ui, mode) {
1374
1396
  });
1375
1397
  setToast(ui, "info", `Queued ${mode === "fix" ? "fix" : "handoff"} follow-up for review note ${note.id} to ${owner}.`);
1376
1398
  }
1399
+ async function cycleSelectedReviewNoteAssignee(paths, snapshot, ui) {
1400
+ const note = selectedReviewNote(snapshot, ui);
1401
+ if (!note) {
1402
+ throw new Error("No review note is selected.");
1403
+ }
1404
+ const assignee = cycleReviewAssignee(note.assignee, note.agent);
1405
+ await rpcUpdateReviewNote(paths, {
1406
+ noteId: note.id,
1407
+ assignee
1408
+ });
1409
+ setToast(ui, "info", `Assigned review note ${note.id} to ${reviewAssigneeLabel(assignee)}.`);
1410
+ }
1411
+ async function resolveSelectedReviewNoteWithDisposition(paths, snapshot, ui, disposition) {
1412
+ const note = selectedReviewNote(snapshot, ui);
1413
+ if (!note) {
1414
+ throw new Error("No review note is selected.");
1415
+ }
1416
+ await rpcUpdateReviewNote(paths, {
1417
+ noteId: note.id,
1418
+ disposition,
1419
+ assignee: "operator"
1420
+ });
1421
+ await rpcSetReviewNoteStatus(paths, {
1422
+ noteId: note.id,
1423
+ status: "resolved"
1424
+ });
1425
+ setToast(ui, "info", `Marked review note ${note.id} as ${reviewDispositionLabel(disposition).toLowerCase()}.`);
1426
+ }
1377
1427
  function cycleSelectedReviewNote(snapshot, ui, delta) {
1378
1428
  const notes = reviewNotesForContext(snapshot, activeReviewContext(snapshot, ui));
1379
1429
  if (notes.length === 0) {
@@ -1927,6 +1977,20 @@ export async function attachTui(paths) {
1927
1977
  });
1928
1978
  return;
1929
1979
  }
1980
+ if (input === "a") {
1981
+ runAction(async ()=>{
1982
+ await cycleSelectedReviewNoteAssignee(paths, view.snapshot, ui);
1983
+ await refresh();
1984
+ });
1985
+ return;
1986
+ }
1987
+ if (input === "w" || input === "x") {
1988
+ runAction(async ()=>{
1989
+ await resolveSelectedReviewNoteWithDisposition(paths, view.snapshot, ui, input === "w" ? "wont_fix" : "accepted_risk");
1990
+ await refresh();
1991
+ });
1992
+ return;
1993
+ }
1930
1994
  if (input === "F" || input === "H") {
1931
1995
  runAction(async ()=>{
1932
1996
  await enqueueSelectedReviewFollowUp(paths, view.snapshot, ui, input === "F" ? "fix" : "handoff");
package/package.json CHANGED
@@ -1,9 +1,10 @@
1
1
  {
2
2
  "name": "@mandipadk7/kavi",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "Managed Codex + Claude collaboration TUI",
5
5
  "type": "module",
6
6
  "preferGlobal": true,
7
+ "homepage": "https://www.npmjs.com/package/@mandipadk7/kavi",
7
8
  "publishConfig": {
8
9
  "access": "public"
9
10
  },