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.
- package/README.md +11 -0
- package/cli/src/commands/bootstrap.test.ts +40 -0
- package/cli/src/commands/bootstrap.ts +38 -0
- package/cli/src/commands/extract.ts +217 -0
- package/cli/src/commands/init.test.ts +50 -0
- package/cli/src/commands/init.ts +32 -5
- package/cli/src/commands/learn.test.ts +217 -0
- package/cli/src/commands/learn.ts +104 -0
- package/cli/src/commands/next.ts +30 -10
- package/cli/src/commands/observe.ts +16 -21
- package/cli/src/commands/pause-resume.test.ts +2 -2
- package/cli/src/commands/resume.ts +95 -7
- package/cli/src/commands/setup.ts +144 -0
- package/cli/src/commands/status.ts +2 -0
- package/cli/src/commands/workflow.ts +19 -9
- package/cli/src/config/constants.ts +10 -0
- package/cli/src/config/model-routing.test.ts +190 -0
- package/cli/src/config/model-routing.ts +208 -0
- package/cli/src/config/workflow.ts +5 -5
- package/cli/src/index.ts +70 -5
- package/cli/src/observer/data.ts +132 -9
- package/cli/src/observer/renderer.ts +34 -36
- package/cli/src/observer/watcher.ts +28 -16
- package/cli/src/state/schema.ts +50 -3
- package/cli/src/state/store.ts +39 -4
- package/cli/src/utils/fs.ts +13 -0
- package/cli/src/utils/knowledge.ts +471 -0
- package/package.json +1 -1
- package/skills/auto-kit/SKILL.md +27 -10
- package/skills/full-kit/SKILL.md +25 -8
- package/skills/resume-kit/SKILL.md +44 -8
- package/skills/wk-bootstrap/SKILL.md +6 -0
- package/skills/wk-build/SKILL.md +3 -2
- package/skills/wk-deploy/SKILL.md +1 -0
- package/skills/wk-plan/SKILL.md +3 -2
- package/skills/wk-review/SKILL.md +1 -0
- package/skills/wk-test/SKILL.md +1 -0
- package/skills/wk-test/steps/e2e.md +15 -12
- package/skills/wk-wrap-up/SKILL.md +15 -2
- 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();
|
package/cli/src/observer/data.ts
CHANGED
|
@@ -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(
|
|
260
|
-
|
|
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
|
|
265
|
-
const item = collectWorkItem(
|
|
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
|
|
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 =
|
|
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 =>
|
|
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
|
-
|
|
380
|
-
const
|
|
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
|
-
|
|
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
|
-
//
|
|
406
|
-
const barMaxWidth = Math.max(20, Math.min(40, innerWidth - 20));
|
|
407
|
-
lines.push(" " + renderProgressBar(
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
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
|
|
442
|
+
// Worktree (basename only) and branch
|
|
443
443
|
if (item.worktreePath) {
|
|
444
|
-
|
|
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: () =>
|
|
7
|
+
getWorktrees: () => WorktreeEntry[];
|
|
8
8
|
}
|
|
9
9
|
|
|
10
10
|
export function startWatching(
|
|
11
|
-
|
|
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
|
|
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
|
|
64
|
-
const
|
|
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
|
|
67
|
-
|
|
68
|
-
|
|
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
|
|
71
|
-
watchStateFile(
|
|
80
|
+
for (const e of current) {
|
|
81
|
+
watchStateFile(e.worktree);
|
|
72
82
|
}
|
|
73
|
-
unwatchRemoved(
|
|
83
|
+
unwatchRemoved(seen);
|
|
74
84
|
|
|
75
|
-
|
|
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
|
|
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
|
-
},
|
|
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
|
|
113
|
+
return cachedEntries;
|
|
102
114
|
},
|
|
103
115
|
};
|
|
104
116
|
}
|