@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 +4 -65
- package/dist/config.js +8 -2
- package/dist/daemon.js +46 -11
- package/dist/doctor.js +33 -0
- package/dist/main.js +3 -0
- package/dist/reviews.js +31 -2
- package/dist/router.js +69 -0
- package/dist/rpc.js +10 -1
- package/dist/session.js +2 -0
- package/dist/task-artifacts.js +2 -0
- package/dist/tui.js +66 -2
- package/package.json +2 -1
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
|
|
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 (
|
|
451
|
-
throw new Error("updateReviewNote requires
|
|
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 =
|
|
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
|
-
|
|
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),
|
package/dist/task-artifacts.js
CHANGED
|
@@ -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
|
+
"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
|
},
|