@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 +9 -67
- package/dist/config.js +8 -2
- package/dist/daemon.js +126 -38
- package/dist/decision-ledger.js +58 -2
- package/dist/doctor.js +110 -0
- package/dist/main.js +70 -11
- package/dist/reviews.js +55 -2
- package/dist/router.js +126 -5
- package/dist/rpc.js +11 -1
- package/dist/session.js +5 -0
- package/dist/task-artifacts.js +5 -0
- package/dist/tui.js +156 -14
- package/package.json +2 -1
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
|
|
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
|
-
|
|
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
|
|
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 (
|
|
451
|
-
throw new Error("updateReviewNote requires
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
package/dist/decision-ledger.js
CHANGED
|
@@ -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(
|
|
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.
|
|
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
|
|