work-kit-cli 0.3.0 → 0.4.0

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.
Files changed (40) hide show
  1. package/README.md +11 -0
  2. package/cli/src/commands/bootstrap.test.ts +40 -0
  3. package/cli/src/commands/bootstrap.ts +38 -0
  4. package/cli/src/commands/extract.ts +217 -0
  5. package/cli/src/commands/init.test.ts +50 -0
  6. package/cli/src/commands/init.ts +32 -5
  7. package/cli/src/commands/learn.test.ts +217 -0
  8. package/cli/src/commands/learn.ts +104 -0
  9. package/cli/src/commands/next.ts +30 -10
  10. package/cli/src/commands/observe.ts +16 -21
  11. package/cli/src/commands/pause-resume.test.ts +2 -2
  12. package/cli/src/commands/resume.ts +95 -7
  13. package/cli/src/commands/setup.ts +144 -0
  14. package/cli/src/commands/status.ts +2 -0
  15. package/cli/src/commands/workflow.ts +19 -9
  16. package/cli/src/config/constants.ts +10 -0
  17. package/cli/src/config/model-routing.test.ts +190 -0
  18. package/cli/src/config/model-routing.ts +208 -0
  19. package/cli/src/config/workflow.ts +5 -5
  20. package/cli/src/index.ts +70 -5
  21. package/cli/src/observer/data.ts +132 -9
  22. package/cli/src/observer/renderer.ts +34 -36
  23. package/cli/src/observer/watcher.ts +28 -16
  24. package/cli/src/state/schema.ts +50 -3
  25. package/cli/src/state/store.ts +39 -4
  26. package/cli/src/utils/fs.ts +13 -0
  27. package/cli/src/utils/knowledge.ts +471 -0
  28. package/package.json +1 -1
  29. package/skills/auto-kit/SKILL.md +27 -10
  30. package/skills/full-kit/SKILL.md +25 -8
  31. package/skills/resume-kit/SKILL.md +44 -8
  32. package/skills/wk-bootstrap/SKILL.md +6 -0
  33. package/skills/wk-build/SKILL.md +3 -2
  34. package/skills/wk-deploy/SKILL.md +1 -0
  35. package/skills/wk-plan/SKILL.md +3 -2
  36. package/skills/wk-review/SKILL.md +1 -0
  37. package/skills/wk-test/SKILL.md +1 -0
  38. package/skills/wk-test/steps/e2e.md +15 -12
  39. package/skills/wk-wrap-up/SKILL.md +15 -2
  40. package/skills/wk-wrap-up/steps/knowledge.md +76 -0
@@ -44,7 +44,7 @@ const WORKFLOW_MATRIX: Record<Classification, Record<string, InclusionRule>> = {
44
44
  "review/self-review": "YES", "review/security": "skip", "review/performance": "skip",
45
45
  "review/compliance": "skip", "review/handoff": "YES",
46
46
  "deploy/merge": "YES", "deploy/monitor": "optional", "deploy/remediate": "optional",
47
- "wrap-up/summary": "YES",
47
+ "wrap-up/summary": "YES", "wrap-up/knowledge": "skip",
48
48
  },
49
49
  "small-change": {
50
50
  "plan/clarify": "YES", "plan/investigate": "skip", "plan/sketch": "skip", "plan/scope": "skip",
@@ -55,7 +55,7 @@ const WORKFLOW_MATRIX: Record<Classification, Record<string, InclusionRule>> = {
55
55
  "review/self-review": "YES", "review/security": "skip", "review/performance": "skip",
56
56
  "review/compliance": "skip", "review/handoff": "YES",
57
57
  "deploy/merge": "YES", "deploy/monitor": "optional", "deploy/remediate": "optional",
58
- "wrap-up/summary": "YES",
58
+ "wrap-up/summary": "YES", "wrap-up/knowledge": "skip",
59
59
  },
60
60
  refactor: {
61
61
  "plan/clarify": "YES", "plan/investigate": "YES", "plan/sketch": "skip", "plan/scope": "skip",
@@ -66,7 +66,7 @@ const WORKFLOW_MATRIX: Record<Classification, Record<string, InclusionRule>> = {
66
66
  "review/self-review": "YES", "review/security": "skip", "review/performance": "YES",
67
67
  "review/compliance": "skip", "review/handoff": "YES",
68
68
  "deploy/merge": "YES", "deploy/monitor": "optional", "deploy/remediate": "optional",
69
- "wrap-up/summary": "YES",
69
+ "wrap-up/summary": "YES", "wrap-up/knowledge": "YES",
70
70
  },
71
71
  feature: {
72
72
  "plan/clarify": "YES", "plan/investigate": "YES", "plan/sketch": "YES", "plan/scope": "YES",
@@ -77,7 +77,7 @@ const WORKFLOW_MATRIX: Record<Classification, Record<string, InclusionRule>> = {
77
77
  "review/self-review": "YES", "review/security": "YES", "review/performance": "skip",
78
78
  "review/compliance": "YES", "review/handoff": "YES",
79
79
  "deploy/merge": "YES", "deploy/monitor": "optional", "deploy/remediate": "optional",
80
- "wrap-up/summary": "YES",
80
+ "wrap-up/summary": "YES", "wrap-up/knowledge": "YES",
81
81
  },
82
82
  "large-feature": {
83
83
  "plan/clarify": "YES", "plan/investigate": "YES", "plan/sketch": "YES", "plan/scope": "YES",
@@ -88,7 +88,7 @@ const WORKFLOW_MATRIX: Record<Classification, Record<string, InclusionRule>> = {
88
88
  "review/self-review": "YES", "review/security": "YES", "review/performance": "YES",
89
89
  "review/compliance": "YES", "review/handoff": "YES",
90
90
  "deploy/merge": "YES", "deploy/monitor": "optional", "deploy/remediate": "optional",
91
- "wrap-up/summary": "YES",
91
+ "wrap-up/summary": "YES", "wrap-up/knowledge": "YES",
92
92
  },
93
93
  };
94
94
 
package/cli/src/index.ts CHANGED
@@ -20,8 +20,11 @@ import { cancelCommand } from "./commands/cancel.js";
20
20
  import { pauseCommand } from "./commands/pause.js";
21
21
  import { resumeCommand } from "./commands/resume.js";
22
22
  import { reportCommand } from "./commands/report.js";
23
+ import { learnCommand } from "./commands/learn.js";
24
+ import { extractCommand } from "./commands/extract.js";
23
25
  import { bold, green, yellow, red } from "./utils/colors.js";
24
- import type { Classification, PhaseName } from "./state/schema.js";
26
+ import type { Classification, ModelPolicy, PhaseName } from "./state/schema.js";
27
+ import { isModelPolicy } from "./state/schema.js";
25
28
 
26
29
  import { createRequire } from "node:module";
27
30
 
@@ -44,14 +47,23 @@ program
44
47
  .requiredOption("--description <text>", "Description of the work")
45
48
  .option("--classification <type>", "Work classification (auto mode): bug-fix, small-change, refactor, feature, large-feature")
46
49
  .option("--gated", "Wait for user approval between phases (default: auto-proceed)")
50
+ .option("--model-policy <policy>", "Session model policy: auto, opus, sonnet, haiku, inherit (default: auto)")
47
51
  .option("--worktree-root <path>", "Override worktree root directory")
48
52
  .action((opts) => {
49
53
  try {
54
+ if (opts.modelPolicy !== undefined && !isModelPolicy(opts.modelPolicy)) {
55
+ console.error(JSON.stringify({
56
+ action: "error",
57
+ message: `Invalid --model-policy "${opts.modelPolicy}". Use one of: auto, opus, sonnet, haiku, inherit.`,
58
+ }));
59
+ process.exit(1);
60
+ }
50
61
  const result = initCommand({
51
62
  mode: opts.mode as "full" | "auto" | undefined,
52
63
  description: opts.description,
53
64
  classification: opts.classification as Classification | undefined,
54
65
  gated: opts.gated,
66
+ modelPolicy: opts.modelPolicy as ModelPolicy | undefined,
55
67
  worktreeRoot: opts.worktreeRoot,
56
68
  });
57
69
  console.log(JSON.stringify(result, null, 2));
@@ -244,8 +256,9 @@ program
244
256
  .command("observe")
245
257
  .description("Real-time dashboard of all active work items")
246
258
  .option("--repo <path>", "Main repository root")
259
+ .option("--all", "Observe every work-kit project found under ~/.claude/projects/")
247
260
  .action(async (opts) => {
248
- await observeCommand({ mainRepo: opts.repo });
261
+ await observeCommand({ mainRepo: opts.repo, all: opts.all });
249
262
  });
250
263
 
251
264
  // ── uninstall ────────────────────────────────────────────────────────
@@ -296,11 +309,12 @@ program
296
309
 
297
310
  program
298
311
  .command("resume")
299
- .description("Resume a paused work-kit session")
300
- .option("--worktree-root <path>", "Override worktree root")
312
+ .description("Resume a paused work-kit session (lists paused sessions when no slug is given)")
313
+ .option("--worktree-root <path>", "Override worktree root (resume session at exact path)")
314
+ .option("--slug <slug>", "Resume the paused session with this slug")
301
315
  .action((opts) => {
302
316
  try {
303
- const result = resumeCommand(opts.worktreeRoot);
317
+ const result = resumeCommand({ worktreeRoot: opts.worktreeRoot, slug: opts.slug });
304
318
  console.log(JSON.stringify(result, null, 2));
305
319
  process.exit(result.action === "error" ? 1 : 0);
306
320
  } catch (e: any) {
@@ -343,4 +357,55 @@ program
343
357
  }
344
358
  });
345
359
 
360
+ // ── learn ───────────────────────────────────────────────────────────
361
+
362
+ program
363
+ .command("learn")
364
+ .description("Append a knowledge entry (lesson/convention/risk/workflow) to .work-kit-knowledge/")
365
+ .requiredOption("--type <type>", "Entry type: lesson, convention, risk, workflow")
366
+ .requiredOption("--text <text>", "Free-form text. Secrets are auto-redacted at write time.")
367
+ .option("--scope <glob>", "Optional path glob (stored, not yet used for filtering)")
368
+ .option("--phase <phase>", "Override session phase auto-fill")
369
+ .option("--step <step>", "Override session step auto-fill")
370
+ .option("--source <source>", "Override entry source label", "explicit-cli")
371
+ .option("--worktree-root <path>", "Override worktree root")
372
+ .action((opts) => {
373
+ try {
374
+ const result = learnCommand({
375
+ type: opts.type,
376
+ text: opts.text,
377
+ scope: opts.scope,
378
+ phase: opts.phase,
379
+ step: opts.step,
380
+ source: opts.source,
381
+ worktreeRoot: opts.worktreeRoot,
382
+ });
383
+ console.log(JSON.stringify(result, null, 2));
384
+ if (result.action === "error") process.exit(1);
385
+ if (result.redacted) {
386
+ console.error(yellow(`! Redacted ${result.redactedKinds?.length ?? 0} secret(s) before writing.`));
387
+ }
388
+ } catch (e: any) {
389
+ console.error(JSON.stringify({ action: "error", message: e.message }));
390
+ process.exit(1);
391
+ }
392
+ });
393
+
394
+ // ── extract ─────────────────────────────────────────────────────────
395
+
396
+ program
397
+ .command("extract")
398
+ .description("Parse current session's state.md + tracker.json and append entries to knowledge files")
399
+ .option("--worktree-root <path>", "Override worktree root")
400
+ .action((opts) => {
401
+ try {
402
+ const result = extractCommand({ worktreeRoot: opts.worktreeRoot });
403
+ console.log(JSON.stringify(result, null, 2));
404
+ if (result.action === "error") process.exit(1);
405
+ } catch (e: any) {
406
+ console.error(JSON.stringify({ action: "error", message: e.message }));
407
+ process.exit(1);
408
+ }
409
+ });
410
+
346
411
  program.parse();
@@ -1,9 +1,10 @@
1
1
  import * as fs from "node:fs";
2
+ import * as os from "node:os";
2
3
  import * as path from "node:path";
3
4
  import { execFileSync } from "node:child_process";
4
5
  import type { WorkKitState, PhaseName } from "../state/schema.js";
5
6
  import { PHASE_NAMES, STEPS_BY_PHASE, MODE_AUTO } from "../state/schema.js";
6
- import { readState, stateExists, STATE_DIR, AWAITING_INPUT_MARKER_FILE, IDLE_MARKER_FILE } from "../state/store.js";
7
+ import { readState, stateExists, STATE_DIR, AWAITING_INPUT_MARKER_FILE, IDLE_MARKER_FILE, gitMainRepoRoot } from "../state/store.js";
7
8
  import { TRACKER_DIR, INDEX_FILE } from "../config/constants.js";
8
9
 
9
10
  const AWAITING_INPUT_MARKER = path.join(STATE_DIR, AWAITING_INPUT_MARKER_FILE);
@@ -11,8 +12,14 @@ const IDLE_MARKER = path.join(STATE_DIR, IDLE_MARKER_FILE);
11
12
 
12
13
  // ── View Types ─────────────────────────────────────────────────────
13
14
 
15
+ export interface WorktreeEntry {
16
+ root: string; // main repo root that owns this worktree
17
+ worktree: string; // worktree path (may equal root)
18
+ }
19
+
14
20
  export interface WorkItemView {
15
21
  slug: string;
22
+ repoName: string;
16
23
  branch: string;
17
24
  mode: string;
18
25
  classification?: string;
@@ -37,6 +44,7 @@ export interface WorkItemView {
37
44
 
38
45
  export interface CompletedItemView {
39
46
  slug: string;
47
+ repoName: string;
40
48
  pr?: string;
41
49
  completedAt: string;
42
50
  phases: string;
@@ -80,9 +88,97 @@ export function discoverWorktrees(mainRepoRoot: string): string[] {
80
88
  return worktrees;
81
89
  }
82
90
 
91
+ // ── Discover All Work-Kit Projects on the System ───────────────────
92
+ //
93
+ // Scans ~/.claude/projects/ for sessions, reads `cwd` from session
94
+ // jsonl files, resolves each to a git toplevel, and keeps only those
95
+ // roots that have at least one work-kit-tracked worktree.
96
+
97
+ const CLAUDE_PROJECTS_DIR = path.join(os.homedir(), ".claude", "projects");
98
+
99
+ function extractCwdFromJsonl(filePath: string): string | null {
100
+ let fd: number | null = null;
101
+ try {
102
+ const buf = Buffer.alloc(16 * 1024);
103
+ fd = fs.openSync(filePath, "r");
104
+ const bytesRead = fs.readSync(fd, buf, 0, buf.length, 0);
105
+ const content = buf.subarray(0, bytesRead).toString("utf-8");
106
+ const lines = content.split("\n");
107
+ for (const line of lines) {
108
+ if (!line.includes('"cwd"')) continue;
109
+ try {
110
+ const obj = JSON.parse(line);
111
+ if (typeof obj.cwd === "string" && obj.cwd.length > 0) return obj.cwd;
112
+ } catch {
113
+ // Likely a truncated final line — skip
114
+ }
115
+ }
116
+ return null;
117
+ } catch {
118
+ return null;
119
+ } finally {
120
+ if (fd !== null) {
121
+ try { fs.closeSync(fd); } catch { /* ignore */ }
122
+ }
123
+ }
124
+ }
125
+
126
+ export function discoverWorkKitProjects(): string[] {
127
+ let entries: string[];
128
+ try {
129
+ entries = fs.readdirSync(CLAUDE_PROJECTS_DIR);
130
+ } catch {
131
+ return [];
132
+ }
133
+
134
+ const cwds = new Set<string>();
135
+ for (const entry of entries) {
136
+ const projDir = path.join(CLAUDE_PROJECTS_DIR, entry);
137
+ let files: string[];
138
+ try {
139
+ files = fs.readdirSync(projDir).filter(f => f.endsWith(".jsonl"));
140
+ } catch {
141
+ continue;
142
+ }
143
+ if (files.length === 0) continue;
144
+
145
+ // Single-pass scan for the most-recently-modified jsonl — avoids
146
+ // re-statting in a sort comparator (which is O(N log N) stats).
147
+ let newestFile: string | null = null;
148
+ let newestMtime = -Infinity;
149
+ for (const f of files) {
150
+ try {
151
+ const m = fs.statSync(path.join(projDir, f)).mtimeMs;
152
+ if (m > newestMtime) {
153
+ newestMtime = m;
154
+ newestFile = f;
155
+ }
156
+ } catch { /* ignore */ }
157
+ }
158
+ if (!newestFile) continue;
159
+
160
+ const cwd = extractCwdFromJsonl(path.join(projDir, newestFile));
161
+ if (cwd) cwds.add(cwd);
162
+ }
163
+
164
+ // Resolve each cwd to its main repo root (works whether the session
165
+ // was run from the main repo or from inside a worktree) and keep
166
+ // only roots that actually have work-kit setup.
167
+ const roots = new Set<string>();
168
+ for (const cwd of cwds) {
169
+ const mainRoot = gitMainRepoRoot(cwd);
170
+ if (!mainRoot || roots.has(mainRoot)) continue;
171
+ if (discoverWorktrees(mainRoot).length > 0) {
172
+ roots.add(mainRoot);
173
+ }
174
+ }
175
+
176
+ return Array.from(roots);
177
+ }
178
+
83
179
  // ── Collect Single Work Item ───────────────────────────────────────
84
180
 
85
- export function collectWorkItem(worktreeRoot: string): WorkItemView | null {
181
+ export function collectWorkItem(worktreeRoot: string, mainRepoRoot?: string): WorkItemView | null {
86
182
  if (!stateExists(worktreeRoot)) return null;
87
183
 
88
184
  let state: WorkKitState;
@@ -182,8 +278,10 @@ export function collectWorkItem(worktreeRoot: string): WorkItemView | null {
182
278
  : undefined,
183
279
  };
184
280
 
281
+ const repoRoot = mainRepoRoot ?? worktreeRoot;
185
282
  return {
186
283
  slug: state.slug,
284
+ repoName: path.basename(repoRoot),
187
285
  branch: state.branch,
188
286
  mode: state.mode,
189
287
  classification: state.classification,
@@ -245,6 +343,7 @@ export function collectCompletedItems(mainRepoRoot: string): CompletedItemView[]
245
343
  if (col1 === "Date" || col1.startsWith("-")) continue;
246
344
  items.push({
247
345
  slug: match[2].trim(),
346
+ repoName: path.basename(mainRepoRoot),
248
347
  pr: match[3].trim() !== "n/a" ? match[3].trim() : undefined,
249
348
  completedAt: col1,
250
349
  phases: match[5].trim(),
@@ -256,13 +355,29 @@ export function collectCompletedItems(mainRepoRoot: string): CompletedItemView[]
256
355
 
257
356
  // ── Collect All Dashboard Data ─────────────────────────────────────
258
357
 
259
- export function collectDashboardData(mainRepoRoot: string, cachedWorktrees?: string[]): DashboardData {
260
- const worktrees = cachedWorktrees ?? discoverWorktrees(mainRepoRoot);
358
+ export function collectDashboardData(
359
+ mainRepoRoots: string[],
360
+ cachedEntries?: WorktreeEntry[]
361
+ ): DashboardData {
362
+ let entries: WorktreeEntry[];
363
+ if (cachedEntries) {
364
+ entries = cachedEntries;
365
+ } else {
366
+ entries = [];
367
+ const seen = new Set<string>();
368
+ for (const root of mainRepoRoots) {
369
+ for (const wt of discoverWorktrees(root)) {
370
+ if (seen.has(wt)) continue;
371
+ seen.add(wt);
372
+ entries.push({ root, worktree: wt });
373
+ }
374
+ }
375
+ }
261
376
  const activeItems: WorkItemView[] = [];
262
377
  const completedFromWorktrees: CompletedItemView[] = [];
263
378
 
264
- for (const wt of worktrees) {
265
- const item = collectWorkItem(wt);
379
+ for (const entry of entries) {
380
+ const item = collectWorkItem(entry.worktree, entry.root);
266
381
  if (!item) continue;
267
382
  if (item.status === "completed") {
268
383
  const phaseNames = item.phases
@@ -271,6 +386,7 @@ export function collectDashboardData(mainRepoRoot: string, cachedWorktrees?: str
271
386
  .join("→");
272
387
  completedFromWorktrees.push({
273
388
  slug: item.slug,
389
+ repoName: item.repoName,
274
390
  completedAt: item.startedAt,
275
391
  phases: phaseNames,
276
392
  });
@@ -288,13 +404,20 @@ export function collectDashboardData(mainRepoRoot: string, cachedWorktrees?: str
288
404
  return new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime();
289
405
  });
290
406
 
291
- // Merge completed items from worktrees and index file, dedup by slug
407
+ // Merge completed items from worktrees and index files, dedup by slug
292
408
  const activeSlugs = new Set(activeItems.map(i => i.slug));
293
- const indexItems = collectCompletedItems(mainRepoRoot);
409
+ const indexItems: CompletedItemView[] = [];
410
+ for (const root of mainRepoRoots) {
411
+ indexItems.push(...collectCompletedItems(root));
412
+ }
294
413
  const seen = new Set(completedFromWorktrees.map(i => i.slug));
295
414
  const completedItems = [
296
415
  ...completedFromWorktrees.filter(i => !activeSlugs.has(i.slug)),
297
- ...indexItems.filter(i => !seen.has(i.slug) && !activeSlugs.has(i.slug)),
416
+ ...indexItems.filter(i => {
417
+ if (activeSlugs.has(i.slug) || seen.has(i.slug)) return false;
418
+ seen.add(i.slug);
419
+ return true;
420
+ }),
298
421
  ];
299
422
 
300
423
  return {
@@ -1,7 +1,8 @@
1
+ import * as path from "node:path";
1
2
  import {
2
3
  bold, dim, green, yellow, red, cyan, magenta,
3
- bgYellow, bgCyan, bgRed, bgMagenta, bgGreen, bgBlue,
4
- boldCyan, boldGreen, boldMagenta,
4
+ bgYellow, bgCyan, bgRed, bgMagenta, bgGreen, bgBlue, bgDim,
5
+ boldCyan, boldGreen, boldMagenta, boldRed,
5
6
  } from "../utils/colors.js";
6
7
  import { formatDurationMs, formatDurationSince } from "../utils/time.js";
7
8
  import { MODE_FULL } from "../state/schema.js";
@@ -376,21 +377,14 @@ function renderStepBox(
376
377
  function renderWorkItem(item: WorkItemView, innerWidth: number, tick: number): string[] {
377
378
  const lines: string[] = [];
378
379
 
379
- // Line 1: status dot + bold slug + elapsed time (right)
380
- const slugText = `${statusDot(item.status)} ${bold(item.slug)}`;
381
- const elapsed = formatTimeAgo(item.startedAt);
382
- const elapsedText = dim(`⏱ ${elapsed}`);
383
- const slugLen = stripAnsi(slugText).length;
384
- const elapsedLen = stripAnsi(elapsedText).length;
385
- const gap1 = Math.max(2, innerWidth - slugLen - elapsedLen);
386
- lines.push(slugText + " ".repeat(gap1) + elapsedText);
380
+ const repoPrefix = item.repoName ? dim(`${item.repoName} `) : "";
381
+ const titleText = `${statusDot(item.status)} ${repoPrefix}${bold(item.slug)}`;
387
382
 
388
- // Line 2: mode badge + gated badge + classification + blocking state
389
383
  let badges = renderModeBadge(item.mode);
390
- if (item.gated) badges += " " + renderGatedBadge();
391
- if (item.status === "paused") badges += " " + bgYellow(" PAUSED ");
392
- if (item.status === "failed") badges += " " + bgRed(" FAILED ");
393
384
  if (item.classification) badges += " " + renderClassificationBadge(item.classification);
385
+ if (item.status === "paused") badges += " " + bgDim(" ⏸ PAUSED ");
386
+ if (item.status === "failed") badges += " " + boldRed("✗ FAILED");
387
+ if (item.gated) badges += " " + renderGatedBadge();
394
388
  if (item.awaitingInput) {
395
389
  // Loud: agent definitely blocked on a permission prompt or AskUserQuestion
396
390
  const arrow = tick % 2 === 0 ? "▶" : "▷";
@@ -399,20 +393,26 @@ function renderWorkItem(item: WorkItemView, innerWidth: number, tick: number): s
399
393
  // Soft: turn ended but step not complete — probably asking a question in prose
400
394
  badges += " " + dim("⏸ idle");
401
395
  }
402
- lines.push(" " + badges);
396
+
397
+ const leftLine = `${titleText} ${badges}`;
398
+ const elapsed = formatTimeAgo(item.startedAt);
399
+ const elapsedText = dim(`⏱ ${elapsed}`);
400
+ const leftLen = stripAnsi(leftLine).length;
401
+ const elapsedLen = stripAnsi(elapsedText).length;
402
+ const gap1 = Math.max(2, innerWidth - leftLen - elapsedLen);
403
+ lines.push(leftLine + " ".repeat(gap1) + elapsedText);
403
404
  lines.push("");
404
405
 
405
- // Line 3: progress bar with animated head
406
- const barMaxWidth = Math.max(20, Math.min(40, innerWidth - 20));
407
- lines.push(" " + renderProgressBar(
408
- item.progress.completed,
409
- item.progress.total,
410
- item.progress.percent,
411
- barMaxWidth,
412
- tick
413
- ));
414
-
415
- // Line 4-5: phase pipeline with connectors, spinner, and timing row
406
+ // TODO(progress-bar): re-enable after redesign hidden per user request
407
+ // const barMaxWidth = Math.max(20, Math.min(40, innerWidth - 20));
408
+ // lines.push(" " + renderProgressBar(
409
+ // item.progress.completed,
410
+ // item.progress.total,
411
+ // item.progress.percent,
412
+ // barMaxWidth,
413
+ // tick
414
+ // ));
415
+
416
416
  const pipelineLines = renderPhasePipeline(item.phases, tick, item.awaitingInput, item.currentPhase);
417
417
  for (const pl of pipelineLines) {
418
418
  lines.push(" " + pl);
@@ -439,14 +439,9 @@ function renderWorkItem(item: WorkItemView, innerWidth: number, tick: number): s
439
439
  lines.push(loopStr);
440
440
  }
441
441
 
442
- // Worktree path, then branch beneath it
442
+ // Worktree (basename only) and branch
443
443
  if (item.worktreePath) {
444
- let displayPath = item.worktreePath;
445
- const maxPathLen = innerWidth - 8;
446
- if (displayPath.length > maxPathLen) {
447
- displayPath = "…" + displayPath.slice(displayPath.length - maxPathLen + 1);
448
- }
449
- lines.push(" " + dim("⌂ " + displayPath));
444
+ lines.push(" " + dim("worktree: " + path.basename(item.worktreePath)));
450
445
  }
451
446
  lines.push(" " + dim("⎇ " + item.branch));
452
447
 
@@ -456,28 +451,31 @@ function renderWorkItem(item: WorkItemView, innerWidth: number, tick: number): s
456
451
  // ── Render Completed Item ───────────────────────────────────────────
457
452
 
458
453
  interface CompletedColumnWidths {
454
+ repo: number;
459
455
  slug: number;
460
456
  pr: number;
461
457
  date: number;
462
458
  }
463
459
 
464
460
  function computeCompletedWidths(items: CompletedItemView[]): CompletedColumnWidths {
465
- let slug = 4, pr = 2, date = 4;
461
+ let repo = 4, slug = 4, pr = 2, date = 4;
466
462
  for (const item of items) {
463
+ repo = Math.max(repo, (item.repoName || "").length);
467
464
  slug = Math.max(slug, item.slug.length);
468
465
  pr = Math.max(pr, (item.pr || "—").length);
469
466
  date = Math.max(date, (item.completedAt || "").length);
470
467
  }
471
- return { slug, pr, date };
468
+ return { repo, slug, pr, date };
472
469
  }
473
470
 
474
471
  function renderCompletedItem(item: CompletedItemView, cols: CompletedColumnWidths): string {
475
472
  const check = green("✓");
473
+ const repo = padRight(dim(item.repoName || ""), cols.repo);
476
474
  const slug = padRight(item.slug, cols.slug);
477
475
  const pr = padRight(dim(item.pr || "—"), cols.pr);
478
476
  const date = padRight(dim(item.completedAt || ""), cols.date);
479
477
  const phases = item.phases ? dim(item.phases) : "";
480
- return `${check} ${slug} ${pr} ${date} ${phases}`;
478
+ return `${check} ${repo} ${slug} ${pr} ${date} ${phases}`;
481
479
  }
482
480
 
483
481
  // ── Main Render Function ────────────────────────────────────────────
@@ -1,21 +1,21 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
- import { discoverWorktrees } from "./data.js";
3
+ import { discoverWorktrees, type WorktreeEntry } from "./data.js";
4
4
 
5
5
  export interface WatcherHandle {
6
6
  stop: () => void;
7
- getWorktrees: () => string[];
7
+ getWorktrees: () => WorktreeEntry[];
8
8
  }
9
9
 
10
10
  export function startWatching(
11
- mainRepoRoot: string,
11
+ mainRepoRoots: string[],
12
12
  onUpdate: () => void
13
13
  ): WatcherHandle {
14
14
  const watchers = new Map<string, fs.FSWatcher>();
15
15
  let debounceTimer: ReturnType<typeof setTimeout> | null = null;
16
16
  let pollTimer: ReturnType<typeof setInterval> | null = null;
17
17
  let stopped = false;
18
- let cachedWorktrees: string[] = [];
18
+ let cachedEntries: WorktreeEntry[] = [];
19
19
 
20
20
  function debouncedUpdate(): void {
21
21
  if (stopped) return;
@@ -60,19 +60,29 @@ export function startWatching(
60
60
 
61
61
  function refreshWorktrees(): void {
62
62
  if (stopped) return;
63
- const current = discoverWorktrees(mainRepoRoot);
64
- const currentSet = new Set(current);
63
+ const seen = new Set<string>();
64
+ const current: WorktreeEntry[] = [];
65
+ for (const root of mainRepoRoots) {
66
+ for (const wt of discoverWorktrees(root)) {
67
+ if (seen.has(wt)) continue;
68
+ seen.add(wt);
69
+ current.push({ root, worktree: wt });
70
+ }
71
+ }
65
72
 
66
- // Only trigger update if worktree list actually changed
67
- const changed = current.length !== cachedWorktrees.length
68
- || current.some((wt, i) => wt !== cachedWorktrees[i]);
73
+ // Only trigger update if the entry list actually changed. Compare
74
+ // both fields so a worktree path reused under a different root is
75
+ // detected as a real change.
76
+ const changed = current.length !== cachedEntries.length
77
+ || current.some((e, i) =>
78
+ e.worktree !== cachedEntries[i].worktree || e.root !== cachedEntries[i].root);
69
79
 
70
- for (const wt of current) {
71
- watchStateFile(wt);
80
+ for (const e of current) {
81
+ watchStateFile(e.worktree);
72
82
  }
73
- unwatchRemoved(currentSet);
83
+ unwatchRemoved(seen);
74
84
 
75
- cachedWorktrees = current;
85
+ cachedEntries = current;
76
86
 
77
87
  if (changed) {
78
88
  debouncedUpdate();
@@ -82,10 +92,12 @@ export function startWatching(
82
92
  // Initial setup
83
93
  refreshWorktrees();
84
94
 
85
- // Poll for new/removed worktrees every 5 seconds
95
+ // Poll for new/removed worktrees. Each tick spawns one `git worktree
96
+ // list` per root, so back off when watching many repos under --all.
97
+ const pollIntervalMs = mainRepoRoots.length > 1 ? 30_000 : 5_000;
86
98
  pollTimer = setInterval(() => {
87
99
  if (!stopped) refreshWorktrees();
88
- }, 5000);
100
+ }, pollIntervalMs);
89
101
 
90
102
  return {
91
103
  stop() {
@@ -98,7 +110,7 @@ export function startWatching(
98
110
  watchers.clear();
99
111
  },
100
112
  getWorktrees() {
101
- return cachedWorktrees;
113
+ return cachedEntries;
102
114
  },
103
115
  };
104
116
  }