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/Frame.tsx
DELETED
|
@@ -1,142 +0,0 @@
|
|
|
1
|
-
// The persistent app frame: content on top, a status bar pinned to the bottom
|
|
2
|
-
// of the viewport on every screen — so the chrome stops jumping between phases.
|
|
3
|
-
// (Phase 1 of the TUI redesign; header still lives inside each screen.)
|
|
4
|
-
|
|
5
|
-
import React from "react";
|
|
6
|
-
import { Box, Text } from "ink";
|
|
7
|
-
import { C, useTermRows, useTermCols } from "./components.js";
|
|
8
|
-
import { sessionCost } from "../session-cost.js";
|
|
9
|
-
import { getPrefs } from "./config.js";
|
|
10
|
-
|
|
11
|
-
function truncate(s: string, n: number): string {
|
|
12
|
-
return s.length > n ? s.slice(0, n - 1) + "…" : s;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
/** A short, human label for the current phase — shown faintly in the status bar. */
|
|
16
|
-
const PHASE_LABEL: Record<string, string> = {
|
|
17
|
-
setup: "Setup",
|
|
18
|
-
home: "Home",
|
|
19
|
-
assessing: "Reading your idea",
|
|
20
|
-
setCap: "Budget cap",
|
|
21
|
-
exportMenu: "Export",
|
|
22
|
-
filterEpic: "Filter",
|
|
23
|
-
rename: "Rename",
|
|
24
|
-
confirmDelete: "Delete",
|
|
25
|
-
saveTemplate: "Save template",
|
|
26
|
-
addAsset: "Add asset",
|
|
27
|
-
error: "Error",
|
|
28
|
-
projects: "Projects",
|
|
29
|
-
projectActions: "Project",
|
|
30
|
-
settings: "Settings",
|
|
31
|
-
bakeoff: "Bake-off",
|
|
32
|
-
idea: "New build",
|
|
33
|
-
change: "Change",
|
|
34
|
-
stack: "Stack",
|
|
35
|
-
intake: "Intake",
|
|
36
|
-
planMode: "Plan",
|
|
37
|
-
council: "Council",
|
|
38
|
-
approveEpics: "Epics",
|
|
39
|
-
planning: "Planning",
|
|
40
|
-
plan: "Plan",
|
|
41
|
-
board: "Plan board",
|
|
42
|
-
editBoard: "Edit board",
|
|
43
|
-
kanban: "Board",
|
|
44
|
-
building: "Building",
|
|
45
|
-
done: "Done",
|
|
46
|
-
preview: "Preview",
|
|
47
|
-
deployMenu: "Deploy",
|
|
48
|
-
deploying: "Deploying",
|
|
49
|
-
retro: "Retro",
|
|
50
|
-
burndown: "Burndown",
|
|
51
|
-
history: "History",
|
|
52
|
-
};
|
|
53
|
-
|
|
54
|
-
/** Slim top bar (OpenCode-style header): brand ▌ + project · phase on the left,
|
|
55
|
-
* a faint tagline on the right, with a subtle rule underneath. Full width. */
|
|
56
|
-
export function TopBar({
|
|
57
|
-
projectName,
|
|
58
|
-
phase,
|
|
59
|
-
}: {
|
|
60
|
-
projectName?: string;
|
|
61
|
-
phase?: string;
|
|
62
|
-
}): React.ReactElement {
|
|
63
|
-
const cols = useTermCols();
|
|
64
|
-
const label = phase ? PHASE_LABEL[phase] ?? phase : undefined;
|
|
65
|
-
return (
|
|
66
|
-
<Box
|
|
67
|
-
width={cols}
|
|
68
|
-
borderStyle="single"
|
|
69
|
-
borderColor={C.borderSubtle}
|
|
70
|
-
borderBottom
|
|
71
|
-
borderTop={false}
|
|
72
|
-
borderLeft={false}
|
|
73
|
-
borderRight={false}
|
|
74
|
-
paddingX={1}
|
|
75
|
-
justifyContent="space-between"
|
|
76
|
-
>
|
|
77
|
-
<Box>
|
|
78
|
-
<Text color={C.accent} bold>▌ PROJECTINATOR</Text>
|
|
79
|
-
{projectName ? <Text color={C.textMuted}>{` ${truncate(projectName, Math.max(12, Math.floor(cols * 0.4)))}`}</Text> : null}
|
|
80
|
-
{label ? <Text color={C.textSubtle}>{` · ${label}`}</Text> : null}
|
|
81
|
-
</Box>
|
|
82
|
-
<Box><Text color={C.textSubtle}>your AI build team</Text></Box>
|
|
83
|
-
</Box>
|
|
84
|
-
);
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
/** Bottom status bar: a global shortcut hint on the left, session cost + budget
|
|
88
|
-
* cap on the right, with a subtle rule on top. Full width. */
|
|
89
|
-
export function StatusBar(): React.ReactElement {
|
|
90
|
-
const cols = useTermCols();
|
|
91
|
-
const spent = sessionCost();
|
|
92
|
-
const cap = getPrefs().budgetCapUSD;
|
|
93
|
-
const overHalf = spent >= cap / 2;
|
|
94
|
-
return (
|
|
95
|
-
<Box
|
|
96
|
-
width={cols}
|
|
97
|
-
borderStyle="single"
|
|
98
|
-
borderColor={C.borderSubtle}
|
|
99
|
-
borderTop
|
|
100
|
-
borderBottom={false}
|
|
101
|
-
borderLeft={false}
|
|
102
|
-
borderRight={false}
|
|
103
|
-
paddingX={1}
|
|
104
|
-
justifyContent="space-between"
|
|
105
|
-
>
|
|
106
|
-
<Box>
|
|
107
|
-
<Text color={C.textSubtle}>q</Text>
|
|
108
|
-
<Text color={C.textSubtle}> quit</Text>
|
|
109
|
-
</Box>
|
|
110
|
-
<Box>
|
|
111
|
-
<Text color={C.textSubtle}>session </Text>
|
|
112
|
-
<Text color={overHalf ? C.warn : C.accent} bold>{`$${spent.toFixed(2)}`}</Text>
|
|
113
|
-
<Text color={C.textSubtle}>{` · cap $${cap}`}</Text>
|
|
114
|
-
</Box>
|
|
115
|
-
</Box>
|
|
116
|
-
);
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
/** Wraps every screen in the persistent frame: slim top bar, the screen content
|
|
120
|
-
* (padded, growing to fill the viewport), and the status bar pinned at the
|
|
121
|
-
* bottom edge. Replaces the old per-screen boxed Header. */
|
|
122
|
-
export function AppFrame({
|
|
123
|
-
children,
|
|
124
|
-
projectName,
|
|
125
|
-
phase,
|
|
126
|
-
}: {
|
|
127
|
-
children: React.ReactNode;
|
|
128
|
-
projectName?: string;
|
|
129
|
-
phase?: string;
|
|
130
|
-
}): React.ReactElement {
|
|
131
|
-
const rows = useTermRows();
|
|
132
|
-
return (
|
|
133
|
-
// Fixed height + overflow hidden so overly-tall content clips at the bottom
|
|
134
|
-
// instead of pushing the frame past the viewport (which would scroll the top
|
|
135
|
-
// bar off and unpin the status bar).
|
|
136
|
-
<Box flexDirection="column" height={rows} overflow="hidden">
|
|
137
|
-
<TopBar projectName={projectName} phase={phase} />
|
|
138
|
-
<Box flexGrow={1} flexDirection="column" paddingX={1} paddingTop={1} overflow="hidden">{children}</Box>
|
|
139
|
-
<StatusBar />
|
|
140
|
-
</Box>
|
|
141
|
-
);
|
|
142
|
-
}
|
package/src/tui/Intake.tsx
DELETED
|
@@ -1,111 +0,0 @@
|
|
|
1
|
-
// Intake wizard — walks the PM's clarifying questions one at a time. Single-choice
|
|
2
|
-
// questions use the menu; multi-choice use checkboxes. Every question offers
|
|
3
|
-
// "Something else…" (free text) and can be skipped.
|
|
4
|
-
|
|
5
|
-
import React, { useState } from "react";
|
|
6
|
-
import { Box, Text } from "ink";
|
|
7
|
-
import { MultiSelect } from "@inkjs/ui";
|
|
8
|
-
import { C, Panel, Menu as SelectInput, KeyHint, TextField as TextInput } from "./components.js";
|
|
9
|
-
import type { IntakeQuestion } from "../intake.js";
|
|
10
|
-
|
|
11
|
-
const OTHER = "__other__";
|
|
12
|
-
const SKIP = "__skip__";
|
|
13
|
-
|
|
14
|
-
export interface Answer { question: string; answer: string }
|
|
15
|
-
|
|
16
|
-
export function Intake({
|
|
17
|
-
questions,
|
|
18
|
-
onDone,
|
|
19
|
-
onCancel,
|
|
20
|
-
}: {
|
|
21
|
-
questions: IntakeQuestion[];
|
|
22
|
-
onDone: (answers: Answer[]) => void;
|
|
23
|
-
onCancel: () => void;
|
|
24
|
-
}): React.ReactElement {
|
|
25
|
-
const [i, setI] = useState(0);
|
|
26
|
-
const [answers, setAnswers] = useState<Answer[]>([]);
|
|
27
|
-
const [typingPrefix, setTypingPrefix] = useState<string | null>(null); // non-null = capturing free text
|
|
28
|
-
const [draft, setDraft] = useState("");
|
|
29
|
-
|
|
30
|
-
const q = questions[i]!;
|
|
31
|
-
|
|
32
|
-
const advance = (answer: string) => {
|
|
33
|
-
const next = [...answers, { question: q.question, answer }];
|
|
34
|
-
if (i + 1 < questions.length) {
|
|
35
|
-
setAnswers(next);
|
|
36
|
-
setI(i + 1);
|
|
37
|
-
setTypingPrefix(null);
|
|
38
|
-
setDraft("");
|
|
39
|
-
} else {
|
|
40
|
-
onDone(next);
|
|
41
|
-
}
|
|
42
|
-
};
|
|
43
|
-
|
|
44
|
-
const progress = `Question ${i + 1} of ${questions.length}`;
|
|
45
|
-
|
|
46
|
-
// free-text capture (from "Something else…")
|
|
47
|
-
if (typingPrefix !== null) {
|
|
48
|
-
return (
|
|
49
|
-
<Box flexDirection="column">
|
|
50
|
-
<Text color={C.dim}>{progress}</Text>
|
|
51
|
-
<Text bold wrap="truncate-end">{q.question}</Text>
|
|
52
|
-
<Box marginTop={1}>
|
|
53
|
-
<Text color={C.accent}>{"› "}</Text>
|
|
54
|
-
<TextInput
|
|
55
|
-
value={draft}
|
|
56
|
-
onChange={setDraft}
|
|
57
|
-
onSubmit={() => advance([typingPrefix, draft].filter((s) => s.trim()).join(", "))}
|
|
58
|
-
/>
|
|
59
|
-
</Box>
|
|
60
|
-
<Box flexDirection="column" marginTop={1}>
|
|
61
|
-
<Text color={C.dim}>Type your answer.</Text>
|
|
62
|
-
<KeyHint hints={[{ keys: "Enter", label: "continue" }]} />
|
|
63
|
-
</Box>
|
|
64
|
-
</Box>
|
|
65
|
-
);
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
// multi-choice: checkboxes + then optional free text if "Something else…" picked
|
|
69
|
-
if (q.multi && q.options.length) {
|
|
70
|
-
return (
|
|
71
|
-
<Box flexDirection="column">
|
|
72
|
-
<Text color={C.textSubtle}>{progress}</Text>
|
|
73
|
-
<Panel title={q.question}>
|
|
74
|
-
<Text color={C.textMuted}>Pick any that apply.</Text>
|
|
75
|
-
<MultiSelect
|
|
76
|
-
options={[...q.options.map((o) => ({ label: o, value: o })), { label: "Something else…", value: OTHER }]}
|
|
77
|
-
onSubmit={(vals: string[]) => {
|
|
78
|
-
const picked = vals.filter((v) => v !== OTHER);
|
|
79
|
-
if (vals.includes(OTHER)) setTypingPrefix(picked.join(", "));
|
|
80
|
-
else advance(picked.join(", "));
|
|
81
|
-
}}
|
|
82
|
-
/>
|
|
83
|
-
<Box marginTop={1}><KeyHint hints={[{ keys: "Space", label: "toggle" }, { keys: "Enter", label: "confirm" }]} /></Box>
|
|
84
|
-
</Panel>
|
|
85
|
-
</Box>
|
|
86
|
-
);
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
// single-choice (or free-text-only when no options)
|
|
90
|
-
const items = [
|
|
91
|
-
...q.options.map((o) => ({ label: o, value: o })),
|
|
92
|
-
{ label: "Something else…", value: OTHER },
|
|
93
|
-
{ label: "Skip", value: SKIP },
|
|
94
|
-
];
|
|
95
|
-
return (
|
|
96
|
-
<Box flexDirection="column">
|
|
97
|
-
<Text color={C.dim}>{progress}</Text>
|
|
98
|
-
<Panel title={q.question}>
|
|
99
|
-
<SelectInput
|
|
100
|
-
items={items}
|
|
101
|
-
onSelect={(it) => {
|
|
102
|
-
if (it.value === OTHER) setTypingPrefix("");
|
|
103
|
-
else if (it.value === SKIP) advance("");
|
|
104
|
-
else advance(it.value);
|
|
105
|
-
}}
|
|
106
|
-
/>
|
|
107
|
-
</Panel>
|
|
108
|
-
<KeyHint hints={[{ keys: "Esc", label: "cancel & go back" }]} />
|
|
109
|
-
</Box>
|
|
110
|
-
);
|
|
111
|
-
}
|
package/src/tui/Kanban.tsx
DELETED
|
@@ -1,158 +0,0 @@
|
|
|
1
|
-
// The Scrum board, as swimlanes. Rows = epics; columns = Backlog · Not Started ·
|
|
2
|
-
// In Progress · Done. Used live (during a build) and as the default project view.
|
|
3
|
-
|
|
4
|
-
import React from "react";
|
|
5
|
-
import { Box, Text } from "ink";
|
|
6
|
-
import Spinner from "ink-spinner";
|
|
7
|
-
import { Badge } from "@inkjs/ui";
|
|
8
|
-
import type { Capability } from "../types.js";
|
|
9
|
-
import { C, ROLE_META } from "./components.js";
|
|
10
|
-
|
|
11
|
-
export interface BoardTask {
|
|
12
|
-
id: string;
|
|
13
|
-
capability: Capability;
|
|
14
|
-
title: string;
|
|
15
|
-
epic?: string;
|
|
16
|
-
dependsOn?: string[];
|
|
17
|
-
status: "pending" | "running" | "done" | "failed" | "skipped";
|
|
18
|
-
cost?: number;
|
|
19
|
-
verdict?: "PASS" | "PASS*" | "FAIL";
|
|
20
|
-
/** The teammate (model) working this task, when known. */
|
|
21
|
-
assignee?: string;
|
|
22
|
-
/** Human annotation from the board editor. */
|
|
23
|
-
notes?: string;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
type Col = "backlog" | "notStarted" | "inProgress" | "done";
|
|
27
|
-
|
|
28
|
-
function columnOf(t: BoardTask, done: Set<string>): Col {
|
|
29
|
-
if (t.status === "done" || t.status === "skipped") return "done";
|
|
30
|
-
if (t.status === "running" || t.status === "failed") return "inProgress";
|
|
31
|
-
const ready = (t.dependsOn ?? []).every((d) => done.has(d));
|
|
32
|
-
return ready ? "notStarted" : "backlog";
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
const COLS: { key: Col; label: string; color: string }[] = [
|
|
36
|
-
{ key: "backlog", label: "BACKLOG", color: C.dim },
|
|
37
|
-
{ key: "notStarted", label: "NOT STARTED", color: "cyan" },
|
|
38
|
-
{ key: "inProgress", label: "IN PROGRESS", color: C.accent },
|
|
39
|
-
{ key: "done", label: "DONE", color: C.good },
|
|
40
|
-
];
|
|
41
|
-
|
|
42
|
-
/** Group tasks by epic, preserving first-seen order. */
|
|
43
|
-
export function groupByEpic<T extends { epic?: string }>(tasks: T[]): { epic: string; tasks: T[] }[] {
|
|
44
|
-
const order: string[] = [];
|
|
45
|
-
const map = new Map<string, T[]>();
|
|
46
|
-
for (const t of tasks) {
|
|
47
|
-
const e = t.epic || "General";
|
|
48
|
-
if (!map.has(e)) {
|
|
49
|
-
map.set(e, []);
|
|
50
|
-
order.push(e);
|
|
51
|
-
}
|
|
52
|
-
map.get(e)!.push(t);
|
|
53
|
-
}
|
|
54
|
-
return order.map((epic) => ({ epic, tasks: map.get(epic)! }));
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
function Card({ t }: { t: BoardTask }): React.ReactElement {
|
|
58
|
-
const running = t.status === "running";
|
|
59
|
-
const failed = t.status === "failed";
|
|
60
|
-
return (
|
|
61
|
-
<Box flexDirection="column" marginBottom={1}>
|
|
62
|
-
<Box>
|
|
63
|
-
{running ? <Text color="cyan"><Spinner type="dots" /> </Text> : failed ? <Text color={C.bad}>✗ </Text> : null}
|
|
64
|
-
<Text>{ROLE_META[t.capability].emoji} </Text>
|
|
65
|
-
<Text color={C.dim}>{t.id} </Text>
|
|
66
|
-
<Text color={C.accent}>{t.capability}</Text>
|
|
67
|
-
{t.verdict ? <Text> <Badge color={t.verdict === "FAIL" ? "red" : t.verdict === "PASS*" ? "yellow" : "green"}>{t.verdict}</Badge></Text> : null}
|
|
68
|
-
{t.cost ? <Text color={C.dim}> ${t.cost.toFixed(2)}</Text> : null}
|
|
69
|
-
</Box>
|
|
70
|
-
<Text color={failed ? C.bad : C.text} wrap="truncate-end">{t.title}</Text>
|
|
71
|
-
{t.assignee ? <Text color={C.dim}>{t.assignee}</Text> : null}
|
|
72
|
-
{t.notes ? <Text color={C.dim} wrap="truncate-end">✎ {t.notes}</Text> : null}
|
|
73
|
-
</Box>
|
|
74
|
-
);
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/** One-line card for the compact (live-build) board. */
|
|
78
|
-
function MiniCard({ t }: { t: BoardTask }): React.ReactElement {
|
|
79
|
-
const running = t.status === "running";
|
|
80
|
-
return (
|
|
81
|
-
<Text wrap="truncate-end">
|
|
82
|
-
{running ? <Text color="cyan"><Spinner type="dots" /> </Text> : t.status === "failed" ? <Text color={C.bad}>✗ </Text> : <Text>{ROLE_META[t.capability].emoji} </Text>}
|
|
83
|
-
<Text color={C.dim}>{t.id} </Text>
|
|
84
|
-
<Text color={running ? "cyan" : C.text} wrap="truncate-end">{t.title}</Text>
|
|
85
|
-
{t.cost ? <Text color={C.dim}> ${t.cost.toFixed(2)}</Text> : null}
|
|
86
|
-
</Text>
|
|
87
|
-
);
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
/** Compact 4-column board (no epic swimlanes, one-line cards, per-column cap) —
|
|
91
|
-
* for the live build screen where height must fit the viewport. */
|
|
92
|
-
export function Kanban({ tasks, compact, maxPerCol }: { tasks: BoardTask[]; compact?: boolean; maxPerCol?: number }): React.ReactElement {
|
|
93
|
-
const done = new Set(tasks.filter((t) => t.status === "done" || t.status === "skipped").map((t) => t.id));
|
|
94
|
-
const counts: Record<Col, number> = { backlog: 0, notStarted: 0, inProgress: 0, done: 0 };
|
|
95
|
-
for (const t of tasks) counts[columnOf(t, done)]++;
|
|
96
|
-
|
|
97
|
-
if (compact) {
|
|
98
|
-
const byCol: Record<Col, BoardTask[]> = { backlog: [], notStarted: [], inProgress: [], done: [] };
|
|
99
|
-
for (const t of tasks) byCol[columnOf(t, done)].push(t);
|
|
100
|
-
const cap = Math.max(2, maxPerCol ?? 6);
|
|
101
|
-
return (
|
|
102
|
-
<Box flexDirection="column">
|
|
103
|
-
<Box>
|
|
104
|
-
{COLS.map((col) => (
|
|
105
|
-
<Box key={col.key} flexBasis="25%" flexGrow={1} marginRight={1}>
|
|
106
|
-
<Text color={col.color} bold>{col.label} <Text color={C.dim}>{counts[col.key]}</Text></Text>
|
|
107
|
-
</Box>
|
|
108
|
-
))}
|
|
109
|
-
</Box>
|
|
110
|
-
<Box marginTop={1}>
|
|
111
|
-
{COLS.map((col) => {
|
|
112
|
-
const list = byCol[col.key];
|
|
113
|
-
return (
|
|
114
|
-
<Box key={col.key} flexDirection="column" flexBasis="25%" flexGrow={1} marginRight={1}>
|
|
115
|
-
{list.length === 0 ? <Text color={C.dim}>·</Text> : null}
|
|
116
|
-
{list.slice(0, cap).map((t) => <MiniCard key={t.id} t={t} />)}
|
|
117
|
-
{list.length > cap ? <Text color={C.dim}>+{list.length - cap} more</Text> : null}
|
|
118
|
-
</Box>
|
|
119
|
-
);
|
|
120
|
-
})}
|
|
121
|
-
</Box>
|
|
122
|
-
</Box>
|
|
123
|
-
);
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
const lanes = groupByEpic(tasks);
|
|
127
|
-
|
|
128
|
-
return (
|
|
129
|
-
<Box flexDirection="column">
|
|
130
|
-
{/* column headers */}
|
|
131
|
-
<Box>
|
|
132
|
-
<Box width={12} />
|
|
133
|
-
{COLS.map((col) => (
|
|
134
|
-
<Box key={col.key} flexBasis="25%" flexGrow={1} marginRight={1}>
|
|
135
|
-
<Text color={col.color} bold>{col.label} <Text color={C.dim}>{counts[col.key]}</Text></Text>
|
|
136
|
-
</Box>
|
|
137
|
-
))}
|
|
138
|
-
</Box>
|
|
139
|
-
{lanes.map((lane) => {
|
|
140
|
-
const byCol: Record<Col, BoardTask[]> = { backlog: [], notStarted: [], inProgress: [], done: [] };
|
|
141
|
-
for (const t of lane.tasks) byCol[columnOf(t, done)].push(t);
|
|
142
|
-
return (
|
|
143
|
-
<Box key={lane.epic} flexDirection="column" marginTop={1}>
|
|
144
|
-
<Text color={C.accent}>▊ {lane.epic}</Text>
|
|
145
|
-
<Box>
|
|
146
|
-
<Box width={12} />
|
|
147
|
-
{COLS.map((col) => (
|
|
148
|
-
<Box key={col.key} flexDirection="column" flexBasis="25%" flexGrow={1} marginRight={1}>
|
|
149
|
-
{byCol[col.key].length === 0 ? <Text color={C.dim}>·</Text> : byCol[col.key].map((t) => <Card key={t.id} t={t} />)}
|
|
150
|
-
</Box>
|
|
151
|
-
))}
|
|
152
|
-
</Box>
|
|
153
|
-
</Box>
|
|
154
|
-
);
|
|
155
|
-
})}
|
|
156
|
-
</Box>
|
|
157
|
-
);
|
|
158
|
-
}
|