@mandipadk7/kavi 0.1.2 → 0.1.3

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
@@ -3,11 +3,12 @@
3
3
  Kavi is a local terminal control plane for managed Codex and Claude collaboration.
4
4
 
5
5
  Current capabilities:
6
- - `kavi init`: create repo-local `.kavi` config, prompt files, and ignore rules.
6
+ - `kavi init`: create repo-local `.kavi` config, prompt files, ignore rules, and bootstrap git if the folder is not already a repository.
7
7
  - `kavi init --home`: also scaffold the user-local config file used for binary overrides.
8
+ - `kavi init --no-commit`: skip the bootstrap commit and let `kavi open` or `kavi start` create the first base commit later.
8
9
  - `kavi doctor`: verify Node, Codex, Claude, git worktree support, and local readiness.
9
10
  - `kavi start`: start a managed session without attaching the TUI.
10
- - `kavi open`: create a managed session with separate Codex and Claude worktrees and open the full-screen operator console.
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.
11
12
  - `kavi resume`: reopen the operator console for the current repo session.
12
13
  - `kavi status`: inspect session health and task counts from any terminal.
13
14
  - `kavi paths`: inspect resolved repo-local, user-local, worktree, and runtime paths.
@@ -16,6 +17,7 @@ Current capabilities:
16
17
  - `kavi task-output`: inspect the normalized envelope and raw output for a completed task.
17
18
  - `kavi decisions`: inspect the persisted routing, approval, task, and integration decisions.
18
19
  - `kavi claims`: inspect active or historical path claims.
20
+ - `kavi reviews`: inspect persisted operator review threads and linked follow-up tasks.
19
21
  - `kavi approvals`: inspect the approval inbox.
20
22
  - `kavi approve` and `kavi deny`: resolve a pending approval request, optionally with `--remember`.
21
23
  - `kavi events`: inspect recent daemon and task events.
@@ -29,11 +31,13 @@ Runtime model:
29
31
  - User-local runtime overrides live in `~/.config/kavi/config.toml` and can point Kavi at custom `node`, `codex`, and `claude` binaries.
30
32
  - The operator surface talks to the daemon over a local control socket under the machine-local state root.
31
33
  - SQLite event mirroring is opt-in with `KAVI_ENABLE_SQLITE_HISTORY=1`; the default event log is JSONL under `.kavi/state/events.jsonl`.
32
- - The operator console exposes a task board, dual agent lanes, a live inspector pane, approval actions, inline task composition, and worktree diff review with file and hunk navigation.
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.
33
35
 
34
36
  Development:
35
37
 
36
38
  ```bash
39
+ # works in an empty folder; Kavi will initialize git and create the
40
+ # bootstrap commit it needs for managed worktrees
37
41
  node bin/kavi.js init --home
38
42
  node bin/kavi.js doctor --json
39
43
  node bin/kavi.js paths
@@ -44,17 +48,20 @@ node bin/kavi.js tasks
44
48
  node bin/kavi.js task-output latest
45
49
  node bin/kavi.js decisions
46
50
  node bin/kavi.js claims
51
+ node bin/kavi.js reviews
47
52
  node bin/kavi.js approvals
48
53
  node bin/kavi.js open
49
54
  npm test
50
55
  ```
51
56
 
52
57
  Notes:
58
+ - `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.
53
59
  - Codex runs through `codex app-server` in managed mode, so Codex-side approvals now land in the same Kavi inbox as Claude hook approvals.
54
60
  - Claude still runs through the installed `claude` CLI with Kavi-managed hooks and approval decisions.
55
61
  - 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.
56
62
  - The socket is machine-local rather than repo-local to avoid Unix socket path-length issues in deep repos and temp directories.
57
- - 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, `y/n` resolve approvals, and `c` opens the inline task composer.
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.
64
+ - 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.
58
65
 
59
66
  Local install options:
60
67
 
package/dist/daemon.js CHANGED
@@ -9,6 +9,7 @@ import { listApprovalRequests, resolveApprovalRequest } from "./approvals.js";
9
9
  import { addDecisionRecord, upsertPathClaim } from "./decision-ledger.js";
10
10
  import { getWorktreeDiffReview, listWorktreeChangedPaths } from "./git.js";
11
11
  import { nowIso } from "./paths.js";
12
+ import { addReviewReply, addReviewNote, autoResolveReviewNotesForCompletedTask, linkReviewFollowUpTask, reviewNotesForTask, setReviewNoteStatus, updateReviewNote } from "./reviews.js";
12
13
  import { buildAdHocTask, buildKickoffTasks } from "./router.js";
13
14
  import { loadSessionRecord, readRecentEvents, recordEvent, saveSessionRecord } from "./session.js";
14
15
  import { loadTaskArtifact, saveTaskArtifact } from "./task-artifacts.js";
@@ -191,6 +192,41 @@ export class KaviDaemon {
191
192
  ok: true
192
193
  }
193
194
  };
195
+ case "addReviewNote":
196
+ await this.addReviewNoteFromRpc(params);
197
+ return {
198
+ result: {
199
+ ok: true
200
+ }
201
+ };
202
+ case "updateReviewNote":
203
+ await this.updateReviewNoteFromRpc(params);
204
+ return {
205
+ result: {
206
+ ok: true
207
+ }
208
+ };
209
+ case "addReviewReply":
210
+ await this.addReviewReplyFromRpc(params);
211
+ return {
212
+ result: {
213
+ ok: true
214
+ }
215
+ };
216
+ case "setReviewNoteStatus":
217
+ await this.setReviewNoteStatusFromRpc(params);
218
+ return {
219
+ result: {
220
+ ok: true
221
+ }
222
+ };
223
+ case "enqueueReviewFollowUp":
224
+ await this.enqueueReviewFollowUpFromRpc(params);
225
+ return {
226
+ result: {
227
+ ok: true
228
+ }
229
+ };
194
230
  default:
195
231
  throw new Error(`Unknown RPC method: ${method}`);
196
232
  }
@@ -351,6 +387,264 @@ export class KaviDaemon {
351
387
  }
352
388
  return await getWorktreeDiffReview(agent, worktree.path, this.session.baseCommit, filePath);
353
389
  }
390
+ async addReviewNoteFromRpc(params) {
391
+ await this.runMutation(async ()=>{
392
+ const agent = params.agent === "claude" ? "claude" : "codex";
393
+ const filePath = typeof params.filePath === "string" ? params.filePath.trim() : "";
394
+ const disposition = params.disposition === "approve" || params.disposition === "concern" || params.disposition === "question" ? params.disposition : "note";
395
+ const body = typeof params.body === "string" ? params.body.trim() : "";
396
+ const taskId = typeof params.taskId === "string" ? params.taskId : null;
397
+ const hunkIndex = typeof params.hunkIndex === "number" ? params.hunkIndex : null;
398
+ const hunkHeader = typeof params.hunkHeader === "string" ? params.hunkHeader : null;
399
+ if (!filePath) {
400
+ throw new Error("addReviewNote requires a filePath.");
401
+ }
402
+ if (!body) {
403
+ throw new Error("addReviewNote requires a note body.");
404
+ }
405
+ const note = addReviewNote(this.session, {
406
+ agent,
407
+ taskId,
408
+ filePath,
409
+ hunkIndex,
410
+ hunkHeader,
411
+ disposition,
412
+ body
413
+ });
414
+ addDecisionRecord(this.session, {
415
+ kind: "review",
416
+ agent,
417
+ taskId,
418
+ summary: note.summary,
419
+ detail: note.body,
420
+ metadata: {
421
+ filePath: note.filePath,
422
+ hunkIndex: note.hunkIndex,
423
+ hunkHeader: note.hunkHeader,
424
+ disposition: note.disposition,
425
+ reviewNoteId: note.id
426
+ }
427
+ });
428
+ await saveSessionRecord(this.paths, this.session);
429
+ if (taskId) {
430
+ await this.refreshTaskArtifactReviewNotes(taskId);
431
+ }
432
+ await recordEvent(this.paths, this.session.id, "review.note_added", {
433
+ reviewNoteId: note.id,
434
+ agent: note.agent,
435
+ taskId: note.taskId,
436
+ filePath: note.filePath,
437
+ hunkIndex: note.hunkIndex,
438
+ disposition: note.disposition
439
+ });
440
+ await this.publishSnapshot("review.note_added");
441
+ });
442
+ }
443
+ async updateReviewNoteFromRpc(params) {
444
+ await this.runMutation(async ()=>{
445
+ const noteId = typeof params.noteId === "string" ? params.noteId : "";
446
+ const body = typeof params.body === "string" ? params.body.trim() : "";
447
+ if (!noteId) {
448
+ throw new Error("updateReviewNote requires a noteId.");
449
+ }
450
+ if (!body) {
451
+ throw new Error("updateReviewNote requires a note body.");
452
+ }
453
+ const note = updateReviewNote(this.session, noteId, {
454
+ body
455
+ });
456
+ if (!note) {
457
+ throw new Error(`Review note ${noteId} was not found.`);
458
+ }
459
+ addDecisionRecord(this.session, {
460
+ kind: "review",
461
+ agent: note.agent,
462
+ taskId: note.taskId,
463
+ summary: `Edited review note ${note.id}`,
464
+ detail: note.body,
465
+ metadata: {
466
+ reviewNoteId: note.id,
467
+ filePath: note.filePath,
468
+ hunkIndex: note.hunkIndex
469
+ }
470
+ });
471
+ await saveSessionRecord(this.paths, this.session);
472
+ if (note.taskId) {
473
+ await this.refreshTaskArtifactReviewNotes(note.taskId);
474
+ }
475
+ await recordEvent(this.paths, this.session.id, "review.note_updated", {
476
+ reviewNoteId: note.id,
477
+ taskId: note.taskId,
478
+ agent: note.agent,
479
+ filePath: note.filePath
480
+ });
481
+ await this.publishSnapshot("review.note_updated");
482
+ });
483
+ }
484
+ async addReviewReplyFromRpc(params) {
485
+ await this.runMutation(async ()=>{
486
+ const noteId = typeof params.noteId === "string" ? params.noteId : "";
487
+ const body = typeof params.body === "string" ? params.body.trim() : "";
488
+ if (!noteId) {
489
+ throw new Error("addReviewReply requires a noteId.");
490
+ }
491
+ if (!body) {
492
+ throw new Error("addReviewReply requires a reply body.");
493
+ }
494
+ const note = addReviewReply(this.session, noteId, body);
495
+ if (!note) {
496
+ throw new Error(`Review note ${noteId} was not found.`);
497
+ }
498
+ addDecisionRecord(this.session, {
499
+ kind: "review",
500
+ agent: note.agent,
501
+ taskId: note.taskId,
502
+ summary: `Replied to review note ${note.id}`,
503
+ detail: body,
504
+ metadata: {
505
+ reviewNoteId: note.id,
506
+ filePath: note.filePath,
507
+ hunkIndex: note.hunkIndex,
508
+ replyCount: note.comments.length
509
+ }
510
+ });
511
+ await saveSessionRecord(this.paths, this.session);
512
+ if (note.taskId) {
513
+ await this.refreshTaskArtifactReviewNotes(note.taskId);
514
+ }
515
+ await recordEvent(this.paths, this.session.id, "review.reply_added", {
516
+ reviewNoteId: note.id,
517
+ taskId: note.taskId,
518
+ agent: note.agent,
519
+ filePath: note.filePath,
520
+ replyCount: note.comments.length
521
+ });
522
+ await this.publishSnapshot("review.reply_added");
523
+ });
524
+ }
525
+ async setReviewNoteStatusFromRpc(params) {
526
+ await this.runMutation(async ()=>{
527
+ const noteId = typeof params.noteId === "string" ? params.noteId : "";
528
+ const status = params.status === "resolved" ? "resolved" : "open";
529
+ if (!noteId) {
530
+ throw new Error("setReviewNoteStatus requires a noteId.");
531
+ }
532
+ const note = setReviewNoteStatus(this.session, noteId, status);
533
+ if (!note) {
534
+ throw new Error(`Review note ${noteId} was not found.`);
535
+ }
536
+ addDecisionRecord(this.session, {
537
+ kind: "review",
538
+ agent: note.agent,
539
+ taskId: note.taskId,
540
+ summary: `${status === "resolved" ? "Resolved" : "Reopened"} review note ${note.id}`,
541
+ detail: note.summary,
542
+ metadata: {
543
+ reviewNoteId: note.id,
544
+ status,
545
+ filePath: note.filePath,
546
+ hunkIndex: note.hunkIndex
547
+ }
548
+ });
549
+ await saveSessionRecord(this.paths, this.session);
550
+ if (note.taskId) {
551
+ await this.refreshTaskArtifactReviewNotes(note.taskId);
552
+ }
553
+ await recordEvent(this.paths, this.session.id, "review.note_status_changed", {
554
+ reviewNoteId: note.id,
555
+ taskId: note.taskId,
556
+ agent: note.agent,
557
+ filePath: note.filePath,
558
+ status: note.status
559
+ });
560
+ await this.publishSnapshot("review.note_status_changed");
561
+ });
562
+ }
563
+ async enqueueReviewFollowUpFromRpc(params) {
564
+ await this.runMutation(async ()=>{
565
+ const noteId = typeof params.noteId === "string" ? params.noteId : "";
566
+ const owner = params.owner === "claude" ? "claude" : "codex";
567
+ const mode = params.mode === "handoff" ? "handoff" : "fix";
568
+ if (!noteId) {
569
+ throw new Error("enqueueReviewFollowUp requires a noteId.");
570
+ }
571
+ const note = this.session.reviewNotes.find((item)=>item.id === noteId) ?? null;
572
+ if (!note) {
573
+ throw new Error(`Review note ${noteId} was not found.`);
574
+ }
575
+ const taskId = `task-review-${Date.now()}`;
576
+ const scope = note.hunkHeader ? `${note.filePath} ${note.hunkHeader}` : note.filePath;
577
+ const promptLines = [
578
+ `${mode === "handoff" ? "Handle a review handoff" : "Address a review note"} for ${scope}.`,
579
+ `Disposition: ${note.disposition}.`,
580
+ `Review note: ${note.body}`
581
+ ];
582
+ if (note.taskId) {
583
+ promptLines.push(`Originating task: ${note.taskId}.`);
584
+ }
585
+ if (mode === "handoff") {
586
+ promptLines.push(`This was handed off from ${note.agent} work to ${owner}.`);
587
+ }
588
+ promptLines.push(`Focus the change in ${note.filePath} and update the managed worktree accordingly.`);
589
+ const task = buildAdHocTask(owner, promptLines.join(" "), taskId, {
590
+ routeReason: mode === "handoff" ? `Operator handed off review note ${note.id} to ${owner}.` : `Operator created a follow-up task from review note ${note.id}.`,
591
+ claimedPaths: [
592
+ note.filePath
593
+ ]
594
+ });
595
+ this.session.tasks.push(task);
596
+ linkReviewFollowUpTask(this.session, note.id, taskId);
597
+ upsertPathClaim(this.session, {
598
+ taskId,
599
+ agent: owner,
600
+ source: "route",
601
+ paths: task.claimedPaths,
602
+ note: task.routeReason
603
+ });
604
+ addDecisionRecord(this.session, {
605
+ kind: "review",
606
+ agent: owner,
607
+ taskId,
608
+ summary: `Queued ${mode} follow-up from review note ${note.id}`,
609
+ detail: note.body,
610
+ metadata: {
611
+ reviewNoteId: note.id,
612
+ owner,
613
+ mode,
614
+ filePath: note.filePath,
615
+ sourceAgent: note.agent
616
+ }
617
+ });
618
+ await saveSessionRecord(this.paths, this.session);
619
+ if (note.taskId) {
620
+ await this.refreshTaskArtifactReviewNotes(note.taskId);
621
+ }
622
+ await recordEvent(this.paths, this.session.id, "review.followup_queued", {
623
+ reviewNoteId: note.id,
624
+ followUpTaskId: taskId,
625
+ owner,
626
+ mode,
627
+ filePath: note.filePath
628
+ });
629
+ await this.publishSnapshot("review.followup_queued");
630
+ });
631
+ }
632
+ async refreshTaskArtifactReviewNotes(taskId) {
633
+ const artifact = await loadTaskArtifact(this.paths, taskId);
634
+ if (!artifact) {
635
+ return;
636
+ }
637
+ artifact.reviewNotes = reviewNotesForTask(this.session, taskId);
638
+ await saveTaskArtifact(this.paths, artifact);
639
+ }
640
+ async refreshReviewArtifactsForNotes(notes) {
641
+ const taskIds = [
642
+ ...new Set(notes.map((note)=>note.taskId).filter((value)=>Boolean(value)))
643
+ ];
644
+ for (const taskId of taskIds){
645
+ await this.refreshTaskArtifactReviewNotes(taskId);
646
+ }
647
+ }
354
648
  async publishSnapshot(reason) {
355
649
  if (this.subscribers.size === 0) {
356
650
  return;
@@ -545,6 +839,22 @@ export class KaviDaemon {
545
839
  claimedPaths: task.claimedPaths
546
840
  }
547
841
  });
842
+ const autoResolvedNotes = autoResolveReviewNotesForCompletedTask(this.session, task.id);
843
+ for (const note of autoResolvedNotes){
844
+ addDecisionRecord(this.session, {
845
+ kind: "review",
846
+ agent: note.agent,
847
+ taskId: note.taskId,
848
+ summary: `Auto-resolved review note ${note.id}`,
849
+ detail: `Closed because linked follow-up task ${task.id} completed successfully.`,
850
+ metadata: {
851
+ reviewNoteId: note.id,
852
+ filePath: note.filePath,
853
+ followUpTaskId: task.id,
854
+ reason: "follow-up-task-completed"
855
+ }
856
+ });
857
+ }
548
858
  this.session.peerMessages.push(...peerMessages);
549
859
  await saveSessionRecord(this.paths, this.session);
550
860
  const decisionReplay = buildDecisionReplay(this.session, task, task.owner === "claude" ? "claude" : "codex");
@@ -561,15 +871,26 @@ export class KaviDaemon {
561
871
  rawOutput,
562
872
  error: null,
563
873
  envelope,
874
+ reviewNotes: reviewNotesForTask(this.session, task.id),
564
875
  startedAt,
565
876
  finishedAt: task.updatedAt
566
877
  });
878
+ await this.refreshReviewArtifactsForNotes(autoResolvedNotes);
567
879
  await recordEvent(this.paths, this.session.id, "task.completed", {
568
880
  taskId: task.id,
569
881
  owner: task.owner,
570
882
  status: task.status,
571
883
  peerMessages: peerMessages.length
572
884
  });
885
+ for (const note of autoResolvedNotes){
886
+ await recordEvent(this.paths, this.session.id, "review.note_auto_resolved", {
887
+ reviewNoteId: note.id,
888
+ taskId: note.taskId,
889
+ followUpTaskId: task.id,
890
+ agent: note.agent,
891
+ filePath: note.filePath
892
+ });
893
+ }
573
894
  await this.publishSnapshot("task.completed");
574
895
  } catch (error) {
575
896
  task.status = "failed";
@@ -617,6 +938,7 @@ export class KaviDaemon {
617
938
  rawOutput: null,
618
939
  error: task.summary,
619
940
  envelope: null,
941
+ reviewNotes: reviewNotesForTask(this.session, task.id),
620
942
  startedAt,
621
943
  finishedAt: task.updatedAt
622
944
  });
package/dist/git.js CHANGED
@@ -3,7 +3,7 @@ import path from "node:path";
3
3
  import { ensureDir } from "./fs.js";
4
4
  import { buildSessionId } from "./paths.js";
5
5
  import { runCommand } from "./process.js";
6
- export async function detectRepoRoot(cwd) {
6
+ export async function findRepoRoot(cwd) {
7
7
  const result = await runCommand("git", [
8
8
  "rev-parse",
9
9
  "--show-toplevel"
@@ -11,10 +11,17 @@ export async function detectRepoRoot(cwd) {
11
11
  cwd
12
12
  });
13
13
  if (result.code !== 0) {
14
- throw new Error(result.stderr.trim() || "Not inside a git repository.");
14
+ return null;
15
15
  }
16
16
  return result.stdout.trim();
17
17
  }
18
+ export async function detectRepoRoot(cwd) {
19
+ const repoRoot = await findRepoRoot(cwd);
20
+ if (!repoRoot) {
21
+ throw new Error("Not inside a git repository.");
22
+ }
23
+ return repoRoot;
24
+ }
18
25
  export async function getHeadCommit(repoRoot) {
19
26
  const result = await runCommand("git", [
20
27
  "rev-parse",
@@ -27,6 +34,98 @@ export async function getHeadCommit(repoRoot) {
27
34
  }
28
35
  return result.stdout.trim();
29
36
  }
37
+ async function initializeRepository(repoRoot) {
38
+ const preferred = await runCommand("git", [
39
+ "init",
40
+ "--initial-branch=main"
41
+ ], {
42
+ cwd: repoRoot
43
+ });
44
+ if (preferred.code === 0) {
45
+ return;
46
+ }
47
+ const fallback = await runCommand("git", [
48
+ "init"
49
+ ], {
50
+ cwd: repoRoot
51
+ });
52
+ if (fallback.code !== 0) {
53
+ throw new Error(fallback.stderr.trim() || preferred.stderr.trim() || `Unable to initialize git in ${repoRoot}.`);
54
+ }
55
+ }
56
+ export async function ensureGitRepository(cwd) {
57
+ const existing = await findRepoRoot(cwd);
58
+ if (existing) {
59
+ return {
60
+ repoRoot: existing,
61
+ createdRepository: false
62
+ };
63
+ }
64
+ await initializeRepository(cwd);
65
+ return {
66
+ repoRoot: await detectRepoRoot(cwd),
67
+ createdRepository: true
68
+ };
69
+ }
70
+ export async function hasHeadCommit(repoRoot) {
71
+ const result = await runCommand("git", [
72
+ "rev-parse",
73
+ "--verify",
74
+ "HEAD"
75
+ ], {
76
+ cwd: repoRoot
77
+ });
78
+ return result.code === 0;
79
+ }
80
+ export async function ensureBootstrapCommit(repoRoot, message = "kavi: bootstrap project") {
81
+ if (await hasHeadCommit(repoRoot)) {
82
+ return {
83
+ createdCommit: false,
84
+ commit: await getHeadCommit(repoRoot),
85
+ stagedPaths: []
86
+ };
87
+ }
88
+ const add = await runCommand("git", [
89
+ "add",
90
+ "-A"
91
+ ], {
92
+ cwd: repoRoot
93
+ });
94
+ if (add.code !== 0) {
95
+ throw new Error(add.stderr.trim() || `Unable to stage bootstrap files in ${repoRoot}.`);
96
+ }
97
+ const staged = await runCommand("git", [
98
+ "diff",
99
+ "--cached",
100
+ "--name-only",
101
+ "--diff-filter=ACMRTUXB"
102
+ ], {
103
+ cwd: repoRoot
104
+ });
105
+ if (staged.code !== 0) {
106
+ throw new Error(staged.stderr.trim() || `Unable to inspect staged bootstrap files in ${repoRoot}.`);
107
+ }
108
+ const commit = await runCommand("git", [
109
+ "-c",
110
+ "user.name=Kavi",
111
+ "-c",
112
+ "user.email=kavi@local.invalid",
113
+ "commit",
114
+ "--allow-empty",
115
+ "-m",
116
+ message
117
+ ], {
118
+ cwd: repoRoot
119
+ });
120
+ if (commit.code !== 0) {
121
+ throw new Error(commit.stderr.trim() || `Unable to create bootstrap commit in ${repoRoot}.`);
122
+ }
123
+ return {
124
+ createdCommit: true,
125
+ commit: await getHeadCommit(repoRoot),
126
+ stagedPaths: parsePathList(staged.stdout)
127
+ };
128
+ }
30
129
  export async function getCurrentBranch(repoRoot) {
31
130
  const result = await runCommand("git", [
32
131
  "branch",