projectinator 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 +30 -11
- package/bin/projectinator.mjs +56 -11
- package/dist/bakeoff.js +163 -0
- package/dist/bakeoff.js.map +1 -0
- package/dist/build-state.js +26 -0
- package/dist/build-state.js.map +1 -0
- package/dist/burndown.js +19 -0
- package/dist/burndown.js.map +1 -0
- package/dist/calibration.js +86 -0
- package/dist/calibration.js.map +1 -0
- package/dist/cli.js +289 -0
- package/dist/cli.js.map +1 -0
- package/dist/cost.js +35 -0
- package/dist/cost.js.map +1 -0
- package/dist/council.js +147 -0
- package/dist/council.js.map +1 -0
- package/dist/demo.js +93 -0
- package/dist/demo.js.map +1 -0
- package/dist/estimate.js +98 -0
- package/dist/estimate.js.map +1 -0
- package/dist/executor.js +118 -0
- package/dist/executor.js.map +1 -0
- package/dist/git.js +82 -0
- package/dist/git.js.map +1 -0
- package/dist/intake.js +98 -0
- package/dist/intake.js.map +1 -0
- package/dist/models.js +171 -0
- package/dist/models.js.map +1 -0
- package/dist/narrate.js +76 -0
- package/dist/narrate.js.map +1 -0
- package/dist/openrouter.js +112 -0
- package/dist/openrouter.js.map +1 -0
- package/dist/orchestrator.js +273 -0
- package/dist/orchestrator.js.map +1 -0
- package/dist/pm.js +245 -0
- package/dist/pm.js.map +1 -0
- package/dist/preview.js +194 -0
- package/dist/preview.js.map +1 -0
- package/dist/registry-store.js +38 -0
- package/dist/registry-store.js.map +1 -0
- package/dist/registry.js +115 -0
- package/dist/registry.js.map +1 -0
- package/dist/research.js +98 -0
- package/dist/research.js.map +1 -0
- package/dist/retro.js +83 -0
- package/dist/retro.js.map +1 -0
- package/dist/roles.js +357 -0
- package/dist/roles.js.map +1 -0
- package/dist/router.js +95 -0
- package/dist/router.js.map +1 -0
- package/dist/run-bakeoff.js +68 -0
- package/dist/run-bakeoff.js.map +1 -0
- package/dist/run-build.js +193 -0
- package/dist/run-build.js.map +1 -0
- package/{src/run-dev.ts → dist/run-dev.js} +23 -39
- package/dist/run-dev.js.map +1 -0
- package/{src/run-pm.ts → dist/run-pm.js} +34 -46
- package/dist/run-pm.js.map +1 -0
- package/{src/run-research.ts → dist/run-research.js} +19 -30
- package/dist/run-research.js.map +1 -0
- package/{src/run-scout.ts → dist/run-scout.js} +17 -27
- package/dist/run-scout.js.map +1 -0
- package/dist/run-web.js +93 -0
- package/dist/run-web.js.map +1 -0
- package/dist/scout.js +81 -0
- package/dist/scout.js.map +1 -0
- package/dist/session-cost.js +15 -0
- package/dist/session-cost.js.map +1 -0
- package/dist/stack.js +34 -0
- package/dist/stack.js.map +1 -0
- package/dist/stuck.js +21 -0
- package/dist/stuck.js.map +1 -0
- package/dist/tui/App.js +1269 -0
- package/dist/tui/App.js.map +1 -0
- package/dist/tui/BakeOff.js +95 -0
- package/dist/tui/BakeOff.js.map +1 -0
- package/dist/tui/BoardEditor.js +183 -0
- package/dist/tui/BoardEditor.js.map +1 -0
- package/dist/tui/EditableBoard.js +140 -0
- package/dist/tui/EditableBoard.js.map +1 -0
- package/dist/tui/Frame.js +78 -0
- package/dist/tui/Frame.js.map +1 -0
- package/dist/tui/Intake.js +59 -0
- package/dist/tui/Intake.js.map +1 -0
- package/dist/tui/Kanban.js +75 -0
- package/dist/tui/Kanban.js.map +1 -0
- package/dist/tui/Settings.js +299 -0
- package/dist/tui/Settings.js.map +1 -0
- package/dist/tui/StackPick.js +44 -0
- package/dist/tui/StackPick.js.map +1 -0
- package/dist/tui/WebAccounts.js +131 -0
- package/dist/tui/WebAccounts.js.map +1 -0
- package/dist/tui/components.js +178 -0
- package/dist/tui/components.js.map +1 -0
- package/dist/tui/config.js +129 -0
- package/dist/tui/config.js.map +1 -0
- package/dist/tui/deploy.js +114 -0
- package/dist/tui/deploy.js.map +1 -0
- package/dist/tui/engine.js +710 -0
- package/dist/tui/engine.js.map +1 -0
- package/dist/tui/notify.js +47 -0
- package/dist/tui/notify.js.map +1 -0
- package/dist/tui/panels.js +42 -0
- package/dist/tui/panels.js.map +1 -0
- package/dist/tui/templates.js +106 -0
- package/dist/tui/templates.js.map +1 -0
- package/dist/tui/theme.js +42 -0
- package/dist/tui/theme.js.map +1 -0
- package/dist/tui/validate.js +51 -0
- package/dist/tui/validate.js.map +1 -0
- package/{src/tui.tsx → dist/tui.js} +23 -33
- package/dist/tui.js.map +1 -0
- package/dist/types.js +19 -0
- package/dist/types.js.map +1 -0
- package/dist/web/oauth-anthropic.js +179 -0
- package/dist/web/oauth-anthropic.js.map +1 -0
- package/dist/web/session.js +274 -0
- package/dist/web/session.js.map +1 -0
- package/package.json +5 -4
- package/src/bakeoff.ts +0 -214
- package/src/build-state.ts +0 -47
- package/src/burndown.ts +0 -35
- package/src/calibration.ts +0 -88
- package/src/cost.ts +0 -43
- package/src/council.ts +0 -175
- package/src/demo.ts +0 -106
- package/src/estimate.ts +0 -111
- package/src/executor.ts +0 -169
- package/src/git.ts +0 -72
- package/src/intake.ts +0 -125
- package/src/models.ts +0 -175
- package/src/narrate.ts +0 -87
- package/src/openrouter.ts +0 -119
- package/src/orchestrator.ts +0 -310
- package/src/pm.ts +0 -302
- package/src/preview.ts +0 -206
- package/src/registry-store.ts +0 -41
- package/src/registry.ts +0 -132
- package/src/research.ts +0 -123
- package/src/retro.ts +0 -99
- package/src/roles.ts +0 -401
- package/src/router.ts +0 -120
- package/src/run-bakeoff.ts +0 -77
- package/src/run-build.ts +0 -203
- package/src/run-web.ts +0 -87
- package/src/scout.ts +0 -121
- package/src/session-cost.ts +0 -17
- package/src/stack.ts +0 -46
- package/src/tui/App.tsx +0 -1760
- package/src/tui/BakeOff.tsx +0 -190
- package/src/tui/BoardEditor.tsx +0 -260
- package/src/tui/EditableBoard.tsx +0 -179
- package/src/tui/Frame.tsx +0 -142
- package/src/tui/Intake.tsx +0 -111
- package/src/tui/Kanban.tsx +0 -158
- package/src/tui/Settings.tsx +0 -513
- package/src/tui/StackPick.tsx +0 -79
- package/src/tui/WebAccounts.tsx +0 -197
- package/src/tui/components.tsx +0 -340
- package/src/tui/config.ts +0 -150
- package/src/tui/deploy.ts +0 -137
- package/src/tui/engine.ts +0 -749
- package/src/tui/notify.ts +0 -21
- package/src/tui/panels.tsx +0 -89
- package/src/tui/templates.ts +0 -119
- package/src/tui/theme.ts +0 -44
- package/src/tui/validate.ts +0 -53
- package/src/types.ts +0 -208
- package/src/web/oauth-anthropic.ts +0 -206
- package/src/web/session.ts +0 -299
package/src/tui/BakeOff.tsx
DELETED
|
@@ -1,190 +0,0 @@
|
|
|
1
|
-
// Model bake-off — run one task across models, judge quality, compare cost, and
|
|
2
|
-
// optionally save the winner as the model for that role. The founding idea, in
|
|
3
|
-
// the cockpit. v1 covers text roles (design, plan, test-reasoning).
|
|
4
|
-
|
|
5
|
-
import React, { useState } from "react";
|
|
6
|
-
import { Box, Text } from "ink";
|
|
7
|
-
import { Spinner, StatusMessage } from "@inkjs/ui";
|
|
8
|
-
import { C, Panel, Menu as SelectInput, KeyHint, TextField as TextInput } from "./components.js";
|
|
9
|
-
import { setRoleModel, modelLabel } from "./engine.js";
|
|
10
|
-
import { runBakeoff, bakeoffTask, type BakeoffResult, type Candidate } from "../bakeoff.js";
|
|
11
|
-
import type { Capability } from "../types.js";
|
|
12
|
-
|
|
13
|
-
const CAPS: { label: string; value: Capability }[] = [
|
|
14
|
-
{ label: "Design — spec / UI", value: "design" },
|
|
15
|
-
{ label: "Plan — decompose / decide", value: "plan" },
|
|
16
|
-
{ label: "Test — review reasoning", value: "test" },
|
|
17
|
-
];
|
|
18
|
-
|
|
19
|
-
const SAMPLE: Record<string, string> = {
|
|
20
|
-
design: "Design a pricing page with 3 tiers (Free, Pro, Team): layout, components, colors, states",
|
|
21
|
-
plan: "Plan an MVP task backlog for a URL shortener with analytics",
|
|
22
|
-
test: "Review this login flow spec and list the edge cases a tester must check",
|
|
23
|
-
};
|
|
24
|
-
|
|
25
|
-
// v1 compares the three Claude tiers (what most people hold a key for).
|
|
26
|
-
const DEFAULT_CANDIDATES: Candidate[] = [
|
|
27
|
-
{ provider: "anthropic", model: "claude-opus-4-8" },
|
|
28
|
-
{ provider: "anthropic", model: "claude-sonnet-4-6" },
|
|
29
|
-
{ provider: "anthropic", model: "claude-haiku-4-5" },
|
|
30
|
-
];
|
|
31
|
-
|
|
32
|
-
type View =
|
|
33
|
-
| { kind: "pickCap" }
|
|
34
|
-
| { kind: "enterTask" }
|
|
35
|
-
| { kind: "running" }
|
|
36
|
-
| { kind: "results"; result: BakeoffResult }
|
|
37
|
-
| { kind: "error"; msg: string };
|
|
38
|
-
|
|
39
|
-
export function BakeOff({ onExit }: { onExit: () => void }): React.ReactElement {
|
|
40
|
-
const [cap, setCap] = useState<Capability>("design");
|
|
41
|
-
const [task, setTask] = useState("");
|
|
42
|
-
const [view, setView] = useState<View>({ kind: "pickCap" });
|
|
43
|
-
const [log, setLog] = useState<string[]>([]);
|
|
44
|
-
const [notice, setNotice] = useState("");
|
|
45
|
-
|
|
46
|
-
const start = (prompt: string) => {
|
|
47
|
-
setLog([]);
|
|
48
|
-
setNotice("");
|
|
49
|
-
setView({ kind: "running" });
|
|
50
|
-
runBakeoff(bakeoffTask(prompt, cap), DEFAULT_CANDIDATES, {
|
|
51
|
-
onProgress: (m) => setLog((prev) => [...prev.slice(-30), m]),
|
|
52
|
-
})
|
|
53
|
-
.then((result) => setView({ kind: "results", result }))
|
|
54
|
-
.catch((e) => setView({ kind: "error", msg: e instanceof Error ? e.message : String(e) }));
|
|
55
|
-
};
|
|
56
|
-
|
|
57
|
-
// ---------- pick capability ----------
|
|
58
|
-
if (view.kind === "pickCap") {
|
|
59
|
-
return (
|
|
60
|
-
<Box flexDirection="column">
|
|
61
|
-
<Panel title="Model bake-off">
|
|
62
|
-
<Box flexDirection="column" marginBottom={1}>
|
|
63
|
-
<Text color={C.dim}>Run one task across the three Claude tiers, judge quality,</Text>
|
|
64
|
-
<Text color={C.dim}>and compare cost + speed. Save the winner as that role's model.</Text>
|
|
65
|
-
</Box>
|
|
66
|
-
<SelectInput
|
|
67
|
-
items={[...CAPS, { label: "Back", value: "__back" }]}
|
|
68
|
-
onSelect={(i) => {
|
|
69
|
-
if (i.value === "__back") return onExit();
|
|
70
|
-
setCap(i.value as Capability);
|
|
71
|
-
setTask(SAMPLE[i.value] ?? "");
|
|
72
|
-
setView({ kind: "enterTask" });
|
|
73
|
-
}}
|
|
74
|
-
/>
|
|
75
|
-
</Panel>
|
|
76
|
-
</Box>
|
|
77
|
-
);
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
// ---------- enter the task ----------
|
|
81
|
-
if (view.kind === "enterTask") {
|
|
82
|
-
return (
|
|
83
|
-
<Box flexDirection="column">
|
|
84
|
-
<Panel title={`Bake-off task — ${cap}`}>
|
|
85
|
-
<Text color={C.textMuted}>Edit the task, then run it across Opus / Sonnet / Haiku.</Text>
|
|
86
|
-
<Box marginTop={1}>
|
|
87
|
-
<Text color={C.accent}>{"› "}</Text>
|
|
88
|
-
<TextInput
|
|
89
|
-
value={task}
|
|
90
|
-
onChange={setTask}
|
|
91
|
-
onSubmit={() => { if (task.trim()) start(task.trim()); else setView({ kind: "pickCap" }); }}
|
|
92
|
-
/>
|
|
93
|
-
</Box>
|
|
94
|
-
<Box flexDirection="column" marginTop={1}>
|
|
95
|
-
<KeyHint hints={[{ keys: "Enter", label: "run" }]} />
|
|
96
|
-
<Text color={C.textSubtle}>Clear the text and press Enter to cancel.</Text>
|
|
97
|
-
</Box>
|
|
98
|
-
</Panel>
|
|
99
|
-
</Box>
|
|
100
|
-
);
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
// ---------- running ----------
|
|
104
|
-
if (view.kind === "running") {
|
|
105
|
-
return (
|
|
106
|
-
<Box flexDirection="column">
|
|
107
|
-
<Panel title={`Running bake-off — ${cap}`}>
|
|
108
|
-
<Spinner label="Running each model, then judging (real spend)…" />
|
|
109
|
-
<Box marginTop={1} flexDirection="column">
|
|
110
|
-
{log.slice(-8).map((l, i) => <Text key={i} color={C.textSubtle} wrap="truncate-end">{l}</Text>)}
|
|
111
|
-
</Box>
|
|
112
|
-
</Panel>
|
|
113
|
-
</Box>
|
|
114
|
-
);
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
// ---------- error ----------
|
|
118
|
-
if (view.kind === "error") {
|
|
119
|
-
return (
|
|
120
|
-
<Box flexDirection="column">
|
|
121
|
-
<Panel title="Bake-off failed" borderColor={C.bad}>
|
|
122
|
-
<StatusMessage variant="error">{view.msg}</StatusMessage>
|
|
123
|
-
<Box marginTop={1}>
|
|
124
|
-
<SelectInput items={[{ label: "Back", value: "back" }]} onSelect={() => setView({ kind: "pickCap" })} />
|
|
125
|
-
</Box>
|
|
126
|
-
</Panel>
|
|
127
|
-
</Box>
|
|
128
|
-
);
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
// ---------- results ----------
|
|
132
|
-
const { result } = view;
|
|
133
|
-
const scoreOf = new Map(result.scores.map((s) => [s.model, s]));
|
|
134
|
-
const cheapest = result.entries.filter((e) => !e.error).sort((a, b) => a.cost - b.cost)[0];
|
|
135
|
-
const winnerModel = result.winner?.split("/")[1];
|
|
136
|
-
return (
|
|
137
|
-
<Box flexDirection="column">
|
|
138
|
-
{notice ? <Box marginBottom={1}><StatusMessage variant="success">{notice}</StatusMessage></Box> : null}
|
|
139
|
-
<Panel title={`Bake-off results — ${cap}`}>
|
|
140
|
-
<Box flexDirection="column">
|
|
141
|
-
<Text color={C.textSubtle}>{"model".padEnd(22)}{"score".padEnd(7)}{"cost".padEnd(11)}{"time".padEnd(7)}tok</Text>
|
|
142
|
-
{result.entries.map((e) => {
|
|
143
|
-
const key = `${e.provider}/${e.model}`;
|
|
144
|
-
const sc = scoreOf.get(key);
|
|
145
|
-
const win = key === result.winner;
|
|
146
|
-
return (
|
|
147
|
-
<Text key={key} color={win ? C.accent : e.error ? C.warn : C.text}>
|
|
148
|
-
{(win ? "🏆 " : " ") + modelLabel(e.model)}
|
|
149
|
-
{" "}
|
|
150
|
-
{(e.error ? "ERR" : sc ? `${sc.score}/10` : "—").padEnd(7)}
|
|
151
|
-
{(e.error ? "—" : `$${e.cost.toFixed(4)}`).padEnd(11)}
|
|
152
|
-
{(e.error ? "—" : `${(e.ms / 1000).toFixed(1)}s`).padEnd(7)}
|
|
153
|
-
{e.error ? "" : String(e.outputTokens)}
|
|
154
|
-
</Text>
|
|
155
|
-
);
|
|
156
|
-
})}
|
|
157
|
-
</Box>
|
|
158
|
-
{result.winner ? (
|
|
159
|
-
<Box marginTop={1} flexDirection="column">
|
|
160
|
-
<Text color={C.dim}>🏆 Best quality: {modelLabel(winnerModel ?? "")} · 💸 Cheapest: {cheapest ? modelLabel(cheapest.model) : "—"} (judge: {modelLabel(result.judge?.split("/")[1] ?? "")})</Text>
|
|
161
|
-
{result.scores.sort((a, b) => b.score - a.score).map((s) => (
|
|
162
|
-
<Text key={s.model} color={C.dim} wrap="truncate-end"> {s.score}/10 {modelLabel(s.model.split("/")[1] ?? s.model)} — {s.reason}</Text>
|
|
163
|
-
))}
|
|
164
|
-
</Box>
|
|
165
|
-
) : (
|
|
166
|
-
<Box marginTop={1}><Text color={C.warn}>No winner (need ≥2 valid outputs to judge).</Text></Box>
|
|
167
|
-
)}
|
|
168
|
-
<Box marginTop={1}>
|
|
169
|
-
<SelectInput
|
|
170
|
-
items={[
|
|
171
|
-
...(winnerModel ? [{ label: `Use ${modelLabel(winnerModel)} for all ${cap} tasks`, value: "save" }] : []),
|
|
172
|
-
{ label: "Run another", value: "again" },
|
|
173
|
-
{ label: "Back", value: "back" },
|
|
174
|
-
]}
|
|
175
|
-
onSelect={(i) => {
|
|
176
|
-
if (i.value === "save" && winnerModel) {
|
|
177
|
-
setRoleModel(cap, "high", winnerModel);
|
|
178
|
-
setNotice(`Saved: ${cap} → ${modelLabel(winnerModel)} (all tiers).`);
|
|
179
|
-
} else if (i.value === "again") {
|
|
180
|
-
setView({ kind: "pickCap" });
|
|
181
|
-
} else {
|
|
182
|
-
onExit();
|
|
183
|
-
}
|
|
184
|
-
}}
|
|
185
|
-
/>
|
|
186
|
-
</Box>
|
|
187
|
-
</Panel>
|
|
188
|
-
</Box>
|
|
189
|
-
);
|
|
190
|
-
}
|
package/src/tui/BoardEditor.tsx
DELETED
|
@@ -1,260 +0,0 @@
|
|
|
1
|
-
// Interactive planning board with swimlanes (rows = epics). The PM curates before
|
|
2
|
-
// building: pull cards Backlog⇄Ready, add/edit/delete, and break an epic down into
|
|
3
|
-
// more tasks on demand. In Progress / Done fill during the build.
|
|
4
|
-
|
|
5
|
-
import React, { useState } from "react";
|
|
6
|
-
import { Box, Text, useInput } from "ink";
|
|
7
|
-
import { Spinner } from "@inkjs/ui";
|
|
8
|
-
import type { Capability, Difficulty, Task } from "../types.js";
|
|
9
|
-
import { C, KeyHint, TextField as TextInput } from "./components.js";
|
|
10
|
-
import { cleanDeps } from "./engine.js";
|
|
11
|
-
import { estimateTokens } from "../estimate.js";
|
|
12
|
-
import { groupByEpic } from "./Kanban.js";
|
|
13
|
-
|
|
14
|
-
const CAPS: Capability[] = ["plan", "design", "code", "review", "test", "ops"]
|
|
15
|
-
const DIFFS: Difficulty[] = ["trivial", "low", "medium", "high"];
|
|
16
|
-
|
|
17
|
-
interface Card extends Task {
|
|
18
|
-
parked: boolean;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export function BoardEditor({
|
|
22
|
-
tasks,
|
|
23
|
-
onDone,
|
|
24
|
-
onCancel,
|
|
25
|
-
onBreakdown,
|
|
26
|
-
}: {
|
|
27
|
-
tasks: Task[];
|
|
28
|
-
onDone: (readyTasks: Task[]) => void;
|
|
29
|
-
onCancel: () => void;
|
|
30
|
-
/** Break an epic into more tasks (calls the PM). Returns new tasks tagged with the epic. */
|
|
31
|
-
onBreakdown?: (epic: string, current: Task[]) => Promise<Task[]>;
|
|
32
|
-
}): React.ReactElement {
|
|
33
|
-
// New tasks land in the BACKLOG first (Scrum-style); pull into Ready to build them.
|
|
34
|
-
const [items, setItems] = useState<Card[]>(() => tasks.map((t) => ({ ...t, parked: true })));
|
|
35
|
-
const [cursor, setCursor] = useState(0);
|
|
36
|
-
const [editing, setEditing] = useState(false);
|
|
37
|
-
const [editField, setEditField] = useState<"title" | "epic" | "deps" | "notes">("title");
|
|
38
|
-
const [draft, setDraft] = useState("");
|
|
39
|
-
const [warn, setWarn] = useState("");
|
|
40
|
-
const [busy, setBusy] = useState<string>(""); // epic being broken down
|
|
41
|
-
|
|
42
|
-
const lanes = groupByEpic(items);
|
|
43
|
-
const ordered = lanes.flatMap((l) => l.tasks); // flat cursor order (grouped by epic)
|
|
44
|
-
const selected = ordered[Math.min(cursor, ordered.length - 1)];
|
|
45
|
-
|
|
46
|
-
const update = (id: string, patch: Partial<Card>) =>
|
|
47
|
-
setItems((is) => is.map((c) => (c.id === id ? { ...c, ...patch } : c)));
|
|
48
|
-
|
|
49
|
-
const setAllParked = (parked: boolean) => {
|
|
50
|
-
setItems((is) => is.map((c) => ({ ...c, parked })));
|
|
51
|
-
setWarn("");
|
|
52
|
-
};
|
|
53
|
-
|
|
54
|
-
const nextId = () => {
|
|
55
|
-
const ids = new Set(items.map((c) => c.id));
|
|
56
|
-
let n = items.length + 1;
|
|
57
|
-
let id = `T-${String(n).padStart(2, "0")}`;
|
|
58
|
-
while (ids.has(id)) id = `T-${String(++n).padStart(2, "0")}`;
|
|
59
|
-
return id;
|
|
60
|
-
};
|
|
61
|
-
|
|
62
|
-
const finish = () => {
|
|
63
|
-
const ready = items.filter((c) => !c.parked);
|
|
64
|
-
if (ready.length === 0) {
|
|
65
|
-
setWarn("Nothing in Ready yet. Move tasks with → , or press A to pull the whole backlog.");
|
|
66
|
-
return;
|
|
67
|
-
}
|
|
68
|
-
onDone(cleanDeps(ready.map(({ parked, ...t }) => t as Task)));
|
|
69
|
-
};
|
|
70
|
-
|
|
71
|
-
const breakdown = async (epic: string) => {
|
|
72
|
-
if (!onBreakdown || busy) return;
|
|
73
|
-
setBusy(epic);
|
|
74
|
-
setWarn("");
|
|
75
|
-
try {
|
|
76
|
-
const created = await onBreakdown(epic, items.map(({ parked, ...t }) => t as Task));
|
|
77
|
-
const ids = new Set(items.map((c) => c.id));
|
|
78
|
-
const fresh = created.filter((t) => !ids.has(t.id)).map((t) => ({ ...t, epic: t.epic || epic, parked: true }));
|
|
79
|
-
setItems((is) => [...is, ...fresh]);
|
|
80
|
-
} catch {
|
|
81
|
-
setWarn("Couldn't break down that epic. Try again.");
|
|
82
|
-
} finally {
|
|
83
|
-
setBusy("");
|
|
84
|
-
}
|
|
85
|
-
};
|
|
86
|
-
|
|
87
|
-
// Reorder the selected card among its siblings (same epic + same column).
|
|
88
|
-
const reorder = (dir: -1 | 1) => {
|
|
89
|
-
if (!selected) return;
|
|
90
|
-
const idx = items.findIndex((c) => c.id === selected.id);
|
|
91
|
-
let j = idx + dir;
|
|
92
|
-
while (j >= 0 && j < items.length) {
|
|
93
|
-
if ((items[j]!.epic || "General") === (selected.epic || "General") && items[j]!.parked === selected.parked) break;
|
|
94
|
-
j += dir;
|
|
95
|
-
}
|
|
96
|
-
if (j < 0 || j >= items.length) return;
|
|
97
|
-
const next = [...items];
|
|
98
|
-
const [moved] = next.splice(idx, 1);
|
|
99
|
-
next.splice(j, 0, moved!);
|
|
100
|
-
setItems(next);
|
|
101
|
-
const newOrdered = groupByEpic(next).flatMap((l) => l.tasks);
|
|
102
|
-
setCursor(newOrdered.findIndex((c) => c.id === moved!.id));
|
|
103
|
-
};
|
|
104
|
-
|
|
105
|
-
useInput((input, key) => {
|
|
106
|
-
if (editing || busy) return;
|
|
107
|
-
if (key.upArrow) return setCursor((c) => Math.max(0, c - 1));
|
|
108
|
-
if (key.downArrow) return setCursor((c) => Math.min(ordered.length - 1, c + 1));
|
|
109
|
-
if (key.leftArrow) return selected && update(selected.id, { parked: true });
|
|
110
|
-
if (key.rightArrow) return selected && update(selected.id, { parked: false });
|
|
111
|
-
if (input === "[") return reorder(-1);
|
|
112
|
-
if (input === "]") return reorder(1);
|
|
113
|
-
if (input === "A") return setAllParked(false);
|
|
114
|
-
if (input === "Z") return setAllParked(true);
|
|
115
|
-
if (input === "a") {
|
|
116
|
-
const id = nextId();
|
|
117
|
-
const epic = selected?.epic || "General";
|
|
118
|
-
setItems((is) => [...is, { id, title: "New task", capability: "code", difficulty: "low", dependsOn: [], epic, estTokens: estimateTokens("code", "low"), parked: true }]);
|
|
119
|
-
setDraft("New task");
|
|
120
|
-
setEditing(true);
|
|
121
|
-
return;
|
|
122
|
-
}
|
|
123
|
-
if (!selected) return;
|
|
124
|
-
if (input === "b") return void breakdown(selected.epic || "General");
|
|
125
|
-
if (input === "e") {
|
|
126
|
-
setEditField("title");
|
|
127
|
-
setDraft(selected.title);
|
|
128
|
-
setEditing(true);
|
|
129
|
-
} else if (input === "g") {
|
|
130
|
-
setEditField("epic");
|
|
131
|
-
setDraft(selected.epic || "General");
|
|
132
|
-
setEditing(true);
|
|
133
|
-
} else if (input === "D") {
|
|
134
|
-
setEditField("deps");
|
|
135
|
-
setDraft((selected.dependsOn ?? []).join(" "));
|
|
136
|
-
setEditing(true);
|
|
137
|
-
} else if (input === "n") {
|
|
138
|
-
setEditField("notes");
|
|
139
|
-
setDraft(selected.notes ?? "");
|
|
140
|
-
setEditing(true);
|
|
141
|
-
} else if (input === "c") {
|
|
142
|
-
const cap = CAPS[(CAPS.indexOf(selected.capability) + 1) % CAPS.length]!;
|
|
143
|
-
update(selected.id, { capability: cap, estTokens: estimateTokens(cap, selected.difficulty) });
|
|
144
|
-
} else if (input === "f") {
|
|
145
|
-
const diff = DIFFS[(DIFFS.indexOf(selected.difficulty) + 1) % DIFFS.length]!;
|
|
146
|
-
update(selected.id, { difficulty: diff, estTokens: estimateTokens(selected.capability, diff) });
|
|
147
|
-
} else if (input === "d") {
|
|
148
|
-
setItems((is) => is.filter((c) => c.id !== selected.id));
|
|
149
|
-
setCursor((c) => Math.max(0, c - 1));
|
|
150
|
-
} else if (key.return) finish();
|
|
151
|
-
else if (key.escape) onCancel();
|
|
152
|
-
});
|
|
153
|
-
|
|
154
|
-
const readyCount = items.filter((c) => !c.parked).length;
|
|
155
|
-
const backlogCount = items.length - readyCount;
|
|
156
|
-
|
|
157
|
-
const Cell = ({ cards }: { cards: Card[] }) => (
|
|
158
|
-
<Box flexDirection="column" flexBasis="25%" flexGrow={1} marginRight={1}>
|
|
159
|
-
{cards.length === 0 ? <Text color={C.dim}>·</Text> : cards.map((c) => {
|
|
160
|
-
const sel = selected?.id === c.id;
|
|
161
|
-
return (
|
|
162
|
-
<Box key={c.id} flexDirection="column" marginBottom={1}>
|
|
163
|
-
<Box>
|
|
164
|
-
<Text color={C.accent}>{sel ? "› " : " "}</Text>
|
|
165
|
-
<Text color={C.dim}>{c.id} </Text>
|
|
166
|
-
<Text color={C.accent}>{c.capability}/{c.difficulty}</Text>
|
|
167
|
-
</Box>
|
|
168
|
-
<Box>
|
|
169
|
-
<Text> </Text>
|
|
170
|
-
{sel && editing && editField === "title" ? (
|
|
171
|
-
<TextInput value={draft} onChange={setDraft} onSubmit={() => { update(c.id, { title: draft.trim() || c.title }); setEditing(false); }} />
|
|
172
|
-
) : (
|
|
173
|
-
<Text color={sel ? C.text : C.dim} wrap="truncate-end">{c.title}</Text>
|
|
174
|
-
)}
|
|
175
|
-
</Box>
|
|
176
|
-
{(c.dependsOn ?? []).length ? <Text color={C.dim}> ↖ {(c.dependsOn ?? []).join(", ")}</Text> : null}
|
|
177
|
-
{c.notes ? <Text color={C.dim} wrap="truncate-end"> ✎ {c.notes}</Text> : null}
|
|
178
|
-
</Box>
|
|
179
|
-
);
|
|
180
|
-
})}
|
|
181
|
-
</Box>
|
|
182
|
-
);
|
|
183
|
-
|
|
184
|
-
return (
|
|
185
|
-
<Box flexDirection="column">
|
|
186
|
-
<Text bold>Plan board <Text color={C.dim}>({backlogCount} in backlog, {readyCount} ready to build)</Text></Text>
|
|
187
|
-
{warn ? <Text color={C.warn}>{warn}</Text> : null}
|
|
188
|
-
{busy ? <Spinner label={`Breaking down “${busy}”…`} /> : null}
|
|
189
|
-
{editing && editField === "epic" && selected ? (
|
|
190
|
-
<Box>
|
|
191
|
-
<Text color={C.accent}>Epic for {selected.id}: </Text>
|
|
192
|
-
<TextInput value={draft} onChange={setDraft} onSubmit={() => { update(selected.id, { epic: draft.trim() || selected.epic }); setEditing(false); }} />
|
|
193
|
-
</Box>
|
|
194
|
-
) : null}
|
|
195
|
-
{editing && editField === "deps" && selected ? (
|
|
196
|
-
<Box>
|
|
197
|
-
<Text color={C.accent}>{selected.id} depends on (space-separated ids): </Text>
|
|
198
|
-
<TextInput
|
|
199
|
-
value={draft}
|
|
200
|
-
onChange={setDraft}
|
|
201
|
-
onSubmit={() => {
|
|
202
|
-
const ids = new Set(items.map((c) => c.id));
|
|
203
|
-
const deps = draft.split(/[\s,]+/).filter((d) => d && d !== selected.id && ids.has(d));
|
|
204
|
-
update(selected.id, { dependsOn: [...new Set(deps)] });
|
|
205
|
-
setEditing(false);
|
|
206
|
-
}}
|
|
207
|
-
/>
|
|
208
|
-
</Box>
|
|
209
|
-
) : null}
|
|
210
|
-
{editing && editField === "notes" && selected ? (
|
|
211
|
-
<Box>
|
|
212
|
-
<Text color={C.accent}>Note for {selected.id} (yours only, never sent to the model): </Text>
|
|
213
|
-
<TextInput value={draft} onChange={setDraft} onSubmit={() => { update(selected.id, { notes: draft.trim() || undefined }); setEditing(false); }} />
|
|
214
|
-
</Box>
|
|
215
|
-
) : null}
|
|
216
|
-
|
|
217
|
-
{/* column headers */}
|
|
218
|
-
<Box marginTop={1}>
|
|
219
|
-
<Box width={12} />
|
|
220
|
-
<Box flexBasis="25%" flexGrow={1} marginRight={1}><Text color="cyan" bold>BACKLOG {backlogCount}</Text></Box>
|
|
221
|
-
<Box flexBasis="25%" flexGrow={1} marginRight={1}><Text color={C.accent} bold>READY {readyCount}</Text></Box>
|
|
222
|
-
<Box flexBasis="25%" flexGrow={1} marginRight={1}><Text color={C.dim} bold>IN PROGRESS</Text></Box>
|
|
223
|
-
<Box flexBasis="25%" flexGrow={1}><Text color={C.dim} bold>DONE</Text></Box>
|
|
224
|
-
</Box>
|
|
225
|
-
|
|
226
|
-
{lanes.map((lane) => (
|
|
227
|
-
<Box key={lane.epic} flexDirection="column" marginTop={1}>
|
|
228
|
-
<Text color={C.accent}>▊ {lane.epic}</Text>
|
|
229
|
-
<Box>
|
|
230
|
-
<Box width={12} />
|
|
231
|
-
<Cell cards={lane.tasks.filter((c) => c.parked)} />
|
|
232
|
-
<Cell cards={lane.tasks.filter((c) => !c.parked)} />
|
|
233
|
-
<Box flexBasis="25%" flexGrow={1} marginRight={1}><Text color={C.dim}>·</Text></Box>
|
|
234
|
-
<Box flexBasis="25%" flexGrow={1}><Text color={C.dim}>·</Text></Box>
|
|
235
|
-
</Box>
|
|
236
|
-
</Box>
|
|
237
|
-
))}
|
|
238
|
-
<Box marginTop={1}>
|
|
239
|
-
<KeyHint hints={[
|
|
240
|
-
{ keys: "↑↓", label: "pick" },
|
|
241
|
-
{ keys: "→/←", label: "col" },
|
|
242
|
-
{ keys: "[ ]", label: "reorder" },
|
|
243
|
-
{ keys: "a", label: "add" },
|
|
244
|
-
{ keys: "e", label: "edit" },
|
|
245
|
-
{ keys: "g", label: "epic" },
|
|
246
|
-
{ keys: "D", label: "deps" },
|
|
247
|
-
{ keys: "n", label: "note" },
|
|
248
|
-
{ keys: "c", label: "cap" },
|
|
249
|
-
{ keys: "f", label: "diff" },
|
|
250
|
-
{ keys: "b", label: "break" },
|
|
251
|
-
{ keys: "d", label: "del" },
|
|
252
|
-
{ keys: "A", label: "all" },
|
|
253
|
-
{ keys: "Z", label: "none" },
|
|
254
|
-
{ keys: "Enter", label: "build" },
|
|
255
|
-
{ keys: "Esc", label: "back" },
|
|
256
|
-
]} />
|
|
257
|
-
</Box>
|
|
258
|
-
</Box>
|
|
259
|
-
);
|
|
260
|
-
}
|
|
@@ -1,179 +0,0 @@
|
|
|
1
|
-
// Directly edit a project's board: reorganize epics, rename, retag, add or delete
|
|
2
|
-
// tasks. Done tasks are locked (they're already built). Saves back to build-state.
|
|
3
|
-
|
|
4
|
-
import React, { useState } from "react";
|
|
5
|
-
import { Box, Text, useInput } from "ink";
|
|
6
|
-
import type { Capability, Difficulty, Task } from "../types.js";
|
|
7
|
-
import { C, ROLE_META, KeyHint, TextField as TextInput } from "./components.js";
|
|
8
|
-
import { estimateTokens } from "../estimate.js";
|
|
9
|
-
import { groupByEpic } from "./Kanban.js";
|
|
10
|
-
|
|
11
|
-
const CAPS: Capability[] = ["plan", "design", "code", "review", "test", "ops"]
|
|
12
|
-
const DIFFS: Difficulty[] = ["trivial", "low", "medium", "high"];
|
|
13
|
-
|
|
14
|
-
export function EditableBoard({
|
|
15
|
-
tasks,
|
|
16
|
-
doneIds,
|
|
17
|
-
onSave,
|
|
18
|
-
onCancel,
|
|
19
|
-
}: {
|
|
20
|
-
tasks: Task[];
|
|
21
|
-
doneIds: Set<string>;
|
|
22
|
-
onSave: (tasks: Task[]) => void;
|
|
23
|
-
onCancel: () => void;
|
|
24
|
-
}): React.ReactElement {
|
|
25
|
-
const [items, setItems] = useState<Task[]>(() => tasks.map((t) => ({ ...t })));
|
|
26
|
-
const [cursor, setCursor] = useState(0);
|
|
27
|
-
const [editing, setEditing] = useState(false);
|
|
28
|
-
const [field, setField] = useState<"title" | "epic" | "deps" | "notes">("title");
|
|
29
|
-
const [draft, setDraft] = useState("");
|
|
30
|
-
const [warn, setWarn] = useState("");
|
|
31
|
-
|
|
32
|
-
const lanes = groupByEpic(items);
|
|
33
|
-
const ordered = lanes.flatMap((l) => l.tasks);
|
|
34
|
-
const selected = ordered[Math.min(cursor, ordered.length - 1)];
|
|
35
|
-
const isDone = (id: string) => doneIds.has(id);
|
|
36
|
-
|
|
37
|
-
const update = (id: string, patch: Partial<Task>) =>
|
|
38
|
-
setItems((is) => is.map((t) => (t.id === id ? { ...t, ...patch } : t)));
|
|
39
|
-
|
|
40
|
-
const nextId = () => {
|
|
41
|
-
const ids = new Set(items.map((t) => t.id));
|
|
42
|
-
let n = items.length + 1;
|
|
43
|
-
let id = `T-${String(n).padStart(2, "0")}`;
|
|
44
|
-
while (ids.has(id)) id = `T-${String(++n).padStart(2, "0")}`;
|
|
45
|
-
return id;
|
|
46
|
-
};
|
|
47
|
-
|
|
48
|
-
useInput((input, key) => {
|
|
49
|
-
if (editing) return;
|
|
50
|
-
if (key.upArrow) return setCursor((c) => Math.max(0, c - 1));
|
|
51
|
-
if (key.downArrow) return setCursor((c) => Math.min(ordered.length - 1, c + 1));
|
|
52
|
-
if (input === "[" || input === "]") {
|
|
53
|
-
if (!selected) return;
|
|
54
|
-
const dir = input === "[" ? -1 : 1;
|
|
55
|
-
const idx = items.findIndex((t) => t.id === selected.id);
|
|
56
|
-
let j = idx + dir;
|
|
57
|
-
while (j >= 0 && j < items.length) {
|
|
58
|
-
if ((items[j]!.epic || "General") === (selected.epic || "General")) break;
|
|
59
|
-
j += dir;
|
|
60
|
-
}
|
|
61
|
-
if (j < 0 || j >= items.length) return;
|
|
62
|
-
const next = [...items];
|
|
63
|
-
const [moved] = next.splice(idx, 1);
|
|
64
|
-
next.splice(j, 0, moved!);
|
|
65
|
-
setItems(next);
|
|
66
|
-
const newOrdered = groupByEpic(next).flatMap((l) => l.tasks);
|
|
67
|
-
setCursor(newOrdered.findIndex((t) => t.id === moved!.id));
|
|
68
|
-
return;
|
|
69
|
-
}
|
|
70
|
-
if (input === "a") {
|
|
71
|
-
const id = nextId();
|
|
72
|
-
const epic = selected?.epic || "General";
|
|
73
|
-
setItems((is) => [...is, { id, title: "New task", capability: "code", difficulty: "low", dependsOn: [], epic, estTokens: estimateTokens("code", "low") }]);
|
|
74
|
-
setField("title");
|
|
75
|
-
setDraft("New task");
|
|
76
|
-
setEditing(true);
|
|
77
|
-
setWarn("");
|
|
78
|
-
return;
|
|
79
|
-
}
|
|
80
|
-
if (!selected) return;
|
|
81
|
-
if (key.return) return onSave(items);
|
|
82
|
-
if (key.escape) return onCancel();
|
|
83
|
-
if (input === "g") { setField("epic"); setDraft(selected.epic || "General"); setEditing(true); return; }
|
|
84
|
-
if (input === "D") { setField("deps"); setDraft((selected.dependsOn ?? []).join(" ")); setEditing(true); return; }
|
|
85
|
-
if (input === "n") { setField("notes"); setDraft(selected.notes ?? ""); setEditing(true); return; }
|
|
86
|
-
// fields below don't change built work
|
|
87
|
-
if (isDone(selected.id)) {
|
|
88
|
-
setWarn(`${selected.id} is already built — only its epic (g) and notes (n) can be changed.`);
|
|
89
|
-
return;
|
|
90
|
-
}
|
|
91
|
-
if (input === "e") { setField("title"); setDraft(selected.title); setEditing(true); }
|
|
92
|
-
else if (input === "c") {
|
|
93
|
-
const cap = CAPS[(CAPS.indexOf(selected.capability) + 1) % CAPS.length]!;
|
|
94
|
-
update(selected.id, { capability: cap, estTokens: estimateTokens(cap, selected.difficulty) });
|
|
95
|
-
} else if (input === "f") {
|
|
96
|
-
const diff = DIFFS[(DIFFS.indexOf(selected.difficulty) + 1) % DIFFS.length]!;
|
|
97
|
-
update(selected.id, { difficulty: diff, estTokens: estimateTokens(selected.capability, diff) });
|
|
98
|
-
} else if (input === "d") {
|
|
99
|
-
setItems((is) => is.filter((t) => t.id !== selected.id));
|
|
100
|
-
setCursor((c) => Math.max(0, c - 1));
|
|
101
|
-
}
|
|
102
|
-
});
|
|
103
|
-
|
|
104
|
-
return (
|
|
105
|
-
<Box flexDirection="column">
|
|
106
|
-
<Text bold>Edit board <Text color={C.dim}>({items.length} tasks)</Text></Text>
|
|
107
|
-
{warn ? <Text color={C.warn}>{warn}</Text> : null}
|
|
108
|
-
{editing && field === "epic" && selected ? (
|
|
109
|
-
<Box><Text color={C.accent}>Epic for {selected.id}: </Text>
|
|
110
|
-
<TextInput value={draft} onChange={setDraft} onSubmit={() => { update(selected.id, { epic: draft.trim() || selected.epic }); setEditing(false); }} />
|
|
111
|
-
</Box>
|
|
112
|
-
) : null}
|
|
113
|
-
{editing && field === "deps" && selected ? (
|
|
114
|
-
<Box><Text color={C.accent}>{selected.id} depends on (ids): </Text>
|
|
115
|
-
<TextInput
|
|
116
|
-
value={draft}
|
|
117
|
-
onChange={setDraft}
|
|
118
|
-
onSubmit={() => {
|
|
119
|
-
const ids = new Set(items.map((t) => t.id));
|
|
120
|
-
const deps = draft.split(/[\s,]+/).filter((d) => d && d !== selected.id && ids.has(d));
|
|
121
|
-
update(selected.id, { dependsOn: [...new Set(deps)] });
|
|
122
|
-
setEditing(false);
|
|
123
|
-
}}
|
|
124
|
-
/>
|
|
125
|
-
</Box>
|
|
126
|
-
) : null}
|
|
127
|
-
{editing && field === "notes" && selected ? (
|
|
128
|
-
<Box><Text color={C.accent}>Note for {selected.id} (yours only, never sent to the model): </Text>
|
|
129
|
-
<TextInput value={draft} onChange={setDraft} onSubmit={() => { update(selected.id, { notes: draft.trim() || undefined }); setEditing(false); }} />
|
|
130
|
-
</Box>
|
|
131
|
-
) : null}
|
|
132
|
-
|
|
133
|
-
{lanes.map((lane) => (
|
|
134
|
-
<Box key={lane.epic} flexDirection="column" marginTop={1}>
|
|
135
|
-
<Text color={C.accent}>▊ {lane.epic}</Text>
|
|
136
|
-
{lane.tasks.map((t) => {
|
|
137
|
-
const sel = selected?.id === t.id;
|
|
138
|
-
const done = isDone(t.id);
|
|
139
|
-
return (
|
|
140
|
-
<Box key={t.id} flexDirection="column">
|
|
141
|
-
<Box>
|
|
142
|
-
<Box width={2}><Text color={C.accent}>{sel ? "›" : " "}</Text></Box>
|
|
143
|
-
<Box width={2}><Text color={done ? C.good : C.dim}>{done ? "✓" : "○"}</Text></Box>
|
|
144
|
-
<Box width={7}><Text color={C.dim}>{t.id}</Text></Box>
|
|
145
|
-
<Box width={3}><Text>{ROLE_META[t.capability].emoji}</Text></Box>
|
|
146
|
-
<Box width={16}><Text color={sel ? C.accent : C.dim}>{t.capability}/{t.difficulty}</Text></Box>
|
|
147
|
-
<Box flexGrow={1}>
|
|
148
|
-
{sel && editing && field === "title" ? (
|
|
149
|
-
<TextInput value={draft} onChange={setDraft} onSubmit={() => { update(t.id, { title: draft.trim() || t.title }); setEditing(false); }} />
|
|
150
|
-
) : (
|
|
151
|
-
<Text color={sel ? C.text : done ? C.dim : C.text} wrap="truncate-end">{t.title}</Text>
|
|
152
|
-
)}
|
|
153
|
-
</Box>
|
|
154
|
-
</Box>
|
|
155
|
-
{t.notes ? <Box marginLeft={30}><Text color={C.dim} wrap="truncate-end">✎ {t.notes}</Text></Box> : null}
|
|
156
|
-
</Box>
|
|
157
|
-
);
|
|
158
|
-
})}
|
|
159
|
-
</Box>
|
|
160
|
-
))}
|
|
161
|
-
<Box marginTop={1}>
|
|
162
|
-
<KeyHint hints={[
|
|
163
|
-
{ keys: "↑↓", label: "pick" },
|
|
164
|
-
{ keys: "[ ]", label: "reorder" },
|
|
165
|
-
{ keys: "g", label: "epic" },
|
|
166
|
-
{ keys: "D", label: "deps" },
|
|
167
|
-
{ keys: "n", label: "note" },
|
|
168
|
-
{ keys: "e", label: "rename" },
|
|
169
|
-
{ keys: "c", label: "cap" },
|
|
170
|
-
{ keys: "f", label: "diff" },
|
|
171
|
-
{ keys: "a", label: "add" },
|
|
172
|
-
{ keys: "d", label: "delete" },
|
|
173
|
-
{ keys: "Enter", label: "save" },
|
|
174
|
-
{ keys: "Esc", label: "back" },
|
|
175
|
-
]} />
|
|
176
|
-
</Box>
|
|
177
|
-
</Box>
|
|
178
|
-
);
|
|
179
|
-
}
|