projectinator 0.1.5 → 0.2.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.
@@ -16,9 +16,11 @@ export interface BoardTask {
16
16
  dependsOn?: string[];
17
17
  status: "pending" | "running" | "done" | "failed" | "skipped";
18
18
  cost?: number;
19
- verdict?: "PASS" | "FAIL";
19
+ verdict?: "PASS" | "PASS*" | "FAIL";
20
20
  /** The teammate (model) working this task, when known. */
21
21
  assignee?: string;
22
+ /** Human annotation from the board editor. */
23
+ notes?: string;
22
24
  }
23
25
 
24
26
  type Col = "backlog" | "notStarted" | "inProgress" | "done";
@@ -62,11 +64,12 @@ function Card({ t }: { t: BoardTask }): React.ReactElement {
62
64
  <Text>{ROLE_META[t.capability].emoji} </Text>
63
65
  <Text color={C.dim}>{t.id} </Text>
64
66
  <Text color={C.accent}>{t.capability}</Text>
65
- {t.verdict ? <Text> <Badge color={t.verdict === "PASS" ? "green" : "red"}>{t.verdict}</Badge></Text> : null}
67
+ {t.verdict ? <Text> <Badge color={t.verdict === "FAIL" ? "red" : t.verdict === "PASS*" ? "yellow" : "green"}>{t.verdict}</Badge></Text> : null}
66
68
  {t.cost ? <Text color={C.dim}> ${t.cost.toFixed(2)}</Text> : null}
67
69
  </Box>
68
70
  <Text color={failed ? C.bad : C.text} wrap="truncate-end">{t.title}</Text>
69
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}
70
73
  </Box>
71
74
  );
72
75
  }
@@ -10,7 +10,7 @@ import { WebAccounts } from "./WebAccounts.js";
10
10
  import { connectedProviders } from "../web/session.js";
11
11
  import { estimateAccuracy } from "../estimate.js";
12
12
  import { availableProviders, effectiveRoster, allModels, setRoleModel, PROVIDER_LABEL } from "./engine.js";
13
- import { setKey, getPrefs, setPrefs, loadConfig, setPreferredProvider, getDefaultMode, setDefaultMode, getNotify, setNotify, getPreferredStack, setPreferredStack, ENV_VAR } from "./config.js";
13
+ import { setKey, getPrefs, setPrefs, loadConfig, setPreferredProvider, getDefaultMode, setDefaultMode, getNotify, setNotify, getPreferredStack, setPreferredStack, ENV_VAR, type Prefs } from "./config.js";
14
14
  import { validateKey } from "./validate.js";
15
15
  import { openRouterModels, refreshOpenRouterModels } from "../openrouter.js";
16
16
 
@@ -428,25 +428,35 @@ function PrefsEditor({
428
428
  onDone,
429
429
  onCancel,
430
430
  }: {
431
- initial: { budgetCapUSD: number; concurrency: number; budgetAlertPct: number };
432
- onDone: (p: { budgetCapUSD: number; concurrency: number; budgetAlertPct: number }) => void;
431
+ initial: Prefs;
432
+ onDone: (p: Prefs) => void;
433
433
  onCancel: () => void;
434
434
  }): React.ReactElement {
435
435
  const [cap, setCap] = useState(String(initial.budgetCapUSD));
436
436
  const [conc, setConc] = useState(String(initial.concurrency));
437
437
  const [pct, setPct] = useState(String(initial.budgetAlertPct));
438
- const [field, setField] = useState<"cap" | "conc" | "pct">("cap");
438
+ const [tmo, setTmo] = useState(String(initial.taskTimeoutMin));
439
+ const [tcap, setTcap] = useState(String(initial.taskCostCapUSD));
440
+ const [field, setField] = useState<"cap" | "conc" | "pct" | "tmo" | "tcap">("cap");
439
441
 
440
442
  const commit = () => {
441
443
  const b = Math.max(1, parseFloat(cap) || initial.budgetCapUSD);
442
444
  const c = Math.max(1, Math.floor(parseFloat(conc) || initial.concurrency));
443
445
  const p = Math.min(99, Math.max(1, Math.round(parseFloat(pct) || initial.budgetAlertPct)));
444
- onDone({ budgetCapUSD: b, concurrency: c, budgetAlertPct: p });
446
+ // "0" is a valid value here (unlimited), so only a non-number falls back.
447
+ const parse0 = (s: string, fallback: number) => { const n = parseFloat(s); return Number.isFinite(n) ? Math.max(0, n) : fallback; };
448
+ onDone({
449
+ budgetCapUSD: b,
450
+ concurrency: c,
451
+ budgetAlertPct: p,
452
+ taskTimeoutMin: parse0(tmo, initial.taskTimeoutMin),
453
+ taskCostCapUSD: parse0(tcap, initial.taskCostCapUSD),
454
+ });
445
455
  };
446
456
 
447
457
  return (
448
458
  <Box flexDirection="column">
449
- <Panel title="Budget, speed & alerts">
459
+ <Panel title="Budget, speed, alerts & task limits">
450
460
  <Box flexDirection="column">
451
461
  <Box>
452
462
  <Box width={22}><Text color={field === "cap" ? C.accent : C.text}>Budget cap (USD)</Text></Box>
@@ -471,14 +481,30 @@ function PrefsEditor({
471
481
  <Box>
472
482
  <Box width={22}><Text color={field === "pct" ? C.accent : C.text}>Alert at % of cap</Text></Box>
473
483
  {field === "pct" ? (
474
- <TextInput value={pct} onChange={setPct} onSubmit={commit} />
484
+ <TextInput value={pct} onChange={setPct} onSubmit={() => setField("tmo")} />
475
485
  ) : (
476
486
  <Text>{pct}%</Text>
477
487
  )}
478
488
  </Box>
489
+ <Box>
490
+ <Box width={22}><Text color={field === "tmo" ? C.accent : C.text}>Task timeout (min)</Text></Box>
491
+ {field === "tmo" ? (
492
+ <TextInput value={tmo} onChange={setTmo} onSubmit={() => setField("tcap")} />
493
+ ) : (
494
+ <Text>{tmo === "0" ? "unlimited" : tmo}</Text>
495
+ )}
496
+ </Box>
497
+ <Box>
498
+ <Box width={22}><Text color={field === "tcap" ? C.accent : C.text}>Task cost cap (USD)</Text></Box>
499
+ {field === "tcap" ? (
500
+ <TextInput value={tcap} onChange={setTcap} onSubmit={commit} />
501
+ ) : (
502
+ <Text>{tcap === "0" ? "unlimited" : tcap}</Text>
503
+ )}
504
+ </Box>
479
505
  </Box>
480
506
  <Box flexDirection="column" marginTop={1}>
481
- <Text color={C.textSubtle}>Enter moves to the next field, then saves.</Text>
507
+ <Text color={C.textSubtle}>Enter moves to the next field, then saves. A task over its limit is aborted and the build halts (resumable); 0 = unlimited.</Text>
482
508
  <KeyHint hints={[{ keys: "Enter", label: "next / save" }, { keys: "Ctrl+C", label: "cancel" }]} />
483
509
  </Box>
484
510
  </Panel>
@@ -261,11 +261,12 @@ export interface TaskView {
261
261
  title: string;
262
262
  cost?: number;
263
263
  model?: string;
264
- verdict?: "PASS" | "FAIL";
264
+ /** "PASS*" = passed, but the tester never ran the app (no Chromium). */
265
+ verdict?: "PASS" | "PASS*" | "FAIL";
265
266
  }
266
267
 
267
268
  const CAP_LABEL: Record<Capability, string> = {
268
- plan: "plan", design: "design", code: "code", test: "test", ops: "ops",
269
+ plan: "plan", design: "design", code: "code", review: "review", test: "test", ops: "ops",
269
270
  };
270
271
 
271
272
  /** The AI team: each capability is a role with a friendly name + icon. */
@@ -273,6 +274,7 @@ export const ROLE_META: Record<Capability, { emoji: string; label: string }> = {
273
274
  plan: { emoji: "🧭", label: "Project manager" },
274
275
  design: { emoji: "🎨", label: "Designer" },
275
276
  code: { emoji: "🧠", label: "Developer" },
277
+ review: { emoji: "🔍", label: "Reviewer" },
276
278
  test: { emoji: "🔎", label: "Tester" },
277
279
  ops: { emoji: "🚀", label: "Runner" },
278
280
  };
@@ -311,7 +313,7 @@ export function TaskRow({ t }: { t: TaskView }): React.ReactElement {
311
313
  </Box>
312
314
  <Box width={10} justifyContent="flex-end">
313
315
  {t.verdict ? (
314
- <Text color={t.verdict === "PASS" ? C.good : C.bad}>{t.verdict}</Text>
316
+ <Text color={t.verdict === "FAIL" ? C.bad : t.verdict === "PASS*" ? C.warn : C.good}>{t.verdict}</Text>
315
317
  ) : t.cost !== undefined ? (
316
318
  <Text color={C.dim}>${t.cost.toFixed(2)}</Text>
317
319
  ) : (
package/src/tui/config.ts CHANGED
@@ -15,6 +15,9 @@ export interface AppConfig {
15
15
  concurrency?: number;
16
16
  /** Warn (not halt) once spend crosses this % of the cap. Default 80. */
17
17
  budgetAlertPct?: number;
18
+ /** Per-task limits; 0 = unlimited. Defaults: 10 min, $3. */
19
+ taskTimeoutMin?: number;
20
+ taskCostCapUSD?: number;
18
21
  /** If set, always route to this provider (when it has a key), ignoring the others. */
19
22
  preferredProvider?: Provider;
20
23
  /** Default workflow for new builds: auto-run, or require PM approval before building. */
@@ -117,19 +120,31 @@ export function setKey(provider: Provider, key: string): void {
117
120
  process.env[ENV_VAR[provider]] = key;
118
121
  }
119
122
 
120
- export function getPrefs(): { budgetCapUSD: number; concurrency: number; budgetAlertPct: number } {
123
+ export interface Prefs {
124
+ budgetCapUSD: number;
125
+ concurrency: number;
126
+ budgetAlertPct: number;
127
+ taskTimeoutMin: number;
128
+ taskCostCapUSD: number;
129
+ }
130
+
131
+ export function getPrefs(): Prefs {
121
132
  const cfg = loadConfig();
122
133
  return {
123
134
  budgetCapUSD: cfg.budgetCapUSD ?? 25,
124
135
  concurrency: cfg.concurrency ?? 3,
125
136
  budgetAlertPct: cfg.budgetAlertPct ?? 80,
137
+ taskTimeoutMin: cfg.taskTimeoutMin ?? 10,
138
+ taskCostCapUSD: cfg.taskCostCapUSD ?? 3,
126
139
  };
127
140
  }
128
141
 
129
- export function setPrefs(prefs: { budgetCapUSD?: number; concurrency?: number; budgetAlertPct?: number }): void {
142
+ export function setPrefs(prefs: Partial<Prefs>): void {
130
143
  const cfg = loadConfig();
131
144
  if (prefs.budgetCapUSD !== undefined) cfg.budgetCapUSD = prefs.budgetCapUSD;
132
145
  if (prefs.concurrency !== undefined) cfg.concurrency = prefs.concurrency;
133
146
  if (prefs.budgetAlertPct !== undefined) cfg.budgetAlertPct = prefs.budgetAlertPct;
147
+ if (prefs.taskTimeoutMin !== undefined) cfg.taskTimeoutMin = prefs.taskTimeoutMin;
148
+ if (prefs.taskCostCapUSD !== undefined) cfg.taskCostCapUSD = prefs.taskCostCapUSD;
134
149
  saveConfig(cfg);
135
150
  }
package/src/tui/engine.ts CHANGED
@@ -6,7 +6,7 @@ import { spawn } from "node:child_process";
6
6
  import { homedir } from "node:os";
7
7
  import { fileURLToPath } from "node:url";
8
8
  import { basename, dirname, join } from "node:path";
9
- import type { Capability, Provider, RegistryEntry, Task, TaskOutcome, Tier } from "../types.js";
9
+ import type { Capability, Provider, RegistryEntry, Task, TaskLimits, TaskOutcome, Tier } from "../types.js";
10
10
  import { DEFAULT_POLICY, route } from "../router.js";
11
11
  import { REGISTRY } from "../registry.js";
12
12
  import { MODELS } from "../models.js";
@@ -21,7 +21,7 @@ import { narrateRetro } from "../narrate.js";
21
21
  import { decomposeIdea } from "../pm.js";
22
22
  import { assessIntake, type IntakeQuestion } from "../intake.js";
23
23
  import { councilEpics, type Epic, type CouncilResult } from "../council.js";
24
- import { newBuildState, loadState, saveState, type BuildState } from "../build-state.js";
24
+ import { newBuildState, loadState, saveState, completedIds, type BuildState } from "../build-state.js";
25
25
 
26
26
  const PROVIDER_KEYS: Record<Provider, string[]> = {
27
27
  anthropic: ["ANTHROPIC_API_KEY"],
@@ -81,6 +81,7 @@ export const ROLE_TIERS: { capability: Capability; tier: Tier; label: string }[]
81
81
  { capability: "plan", tier: "mid", label: "Project manager" },
82
82
  { capability: "design", tier: "high", label: "Designer" },
83
83
  { capability: "code", tier: "high", label: "Developer" },
84
+ { capability: "review", tier: "fast", label: "Reviewer" },
84
85
  { capability: "test", tier: "fast", label: "Tester" },
85
86
  { capability: "ops", tier: "high", label: "Runner / ops" },
86
87
  ];
@@ -332,7 +333,7 @@ export function saveProjectTasks(dir: string, tasks: Task[]): void {
332
333
  export function exportProject(dir: string): { md: string; csv: string } {
333
334
  const state = loadState(join(dir, "build-state.json"));
334
335
  if (!state) throw new Error("No project data to export.");
335
- const done = new Set(state.outcomes.map((o) => o.taskId));
336
+ const done = completedIds(state);
336
337
  const cost = new Map<string, number>();
337
338
  for (const o of state.outcomes) cost.set(o.taskId, (cost.get(o.taskId) ?? 0) + o.cost);
338
339
 
@@ -358,13 +359,14 @@ export function exportProject(dir: string): { md: string; csv: string } {
358
359
  const c = cost.has(t.id) ? ` — $${cost.get(t.id)!.toFixed(2)}` : "";
359
360
  const dep = (t.dependsOn ?? []).length ? ` _(after ${(t.dependsOn ?? []).join(", ")})_` : "";
360
361
  md.push(`- [${mark}] \`${t.id}\` **${t.capability}/${t.difficulty}** — ${t.title}${c}${dep}`);
362
+ if (t.notes) md.push(` - ✎ ${t.notes}`);
361
363
  }
362
364
  md.push("");
363
365
  }
364
366
 
365
367
  const esc = (s: string) => `"${String(s).replace(/"/g, '""')}"`;
366
368
  const csv = [
367
- "id,epic,capability,difficulty,status,cost,dependsOn,title",
369
+ "id,epic,capability,difficulty,status,cost,dependsOn,title,notes",
368
370
  ...state.tasks.map((t) =>
369
371
  [
370
372
  t.id,
@@ -375,6 +377,7 @@ export function exportProject(dir: string): { md: string; csv: string } {
375
377
  (cost.get(t.id) ?? 0).toFixed(2),
376
378
  esc((t.dependsOn ?? []).join(" ")),
377
379
  esc(t.title),
380
+ esc(t.notes ?? ""),
378
381
  ].join(","),
379
382
  ),
380
383
  ].join("\n");
@@ -390,7 +393,7 @@ export function exportProject(dir: string): { md: string; csv: string } {
390
393
  function projectRows(dir: string) {
391
394
  const state = loadState(join(dir, "build-state.json"));
392
395
  if (!state) throw new Error("No project data to export.");
393
- const done = new Set(state.outcomes.map((o) => o.taskId));
396
+ const done = completedIds(state);
394
397
  const cost = new Map<string, number>();
395
398
  for (const o of state.outcomes) cost.set(o.taskId, (cost.get(o.taskId) ?? 0) + o.cost);
396
399
  return { state, done, cost };
@@ -623,7 +626,7 @@ export async function breakdownEpic(
623
626
 
624
627
  export interface RunHandle {
625
628
  workspace: string;
626
- promise: Promise<{ totalCost: number; halted: boolean; files: string[] }>;
629
+ promise: Promise<{ totalCost: number; halted: boolean; haltReason?: string; files: string[] }>;
627
630
  }
628
631
 
629
632
  /** Run a planned build, streaming orchestrator events to the UI. Spends money.
@@ -635,6 +638,7 @@ export function startBuild(
635
638
  opts: {
636
639
  concurrency: number;
637
640
  budgetCapUSD: number;
641
+ taskLimits: TaskLimits;
638
642
  onEvent: (e: OrchestratorEvent) => void;
639
643
  workspace?: string;
640
644
  seedOutcomes?: TaskOutcome[];
@@ -658,7 +662,7 @@ export function startBuild(
658
662
  state.budgetCapUSD = opts.budgetCapUSD; // remember this project's cap
659
663
 
660
664
  const executor = makePiExecutor({ workspace, backend: "api" });
661
- const policy = { ...DEFAULT_POLICY, backendMode: "api" as const, budgetCapUSD: opts.budgetCapUSD };
665
+ const policy = { ...DEFAULT_POLICY, backendMode: "api" as const, budgetCapUSD: opts.budgetCapUSD, taskLimits: opts.taskLimits };
662
666
 
663
667
  // Version the workspace: init a repo, then commit after each finished task.
664
668
  initRepo(workspace);
@@ -691,6 +695,7 @@ export function startBuild(
691
695
  return {
692
696
  totalCost: result.totalCost,
693
697
  halted: result.halted,
698
+ haltReason: result.haltReason,
694
699
  files: (result.outcomes.at(-1)?.files ?? []).filter((f) => f !== "build-state.json"),
695
700
  };
696
701
  });
@@ -76,7 +76,7 @@ export function ListView({ tasks }: { tasks: BoardTask[] }): React.ReactElement
76
76
  <Box width={3}><Text>{ROLE_META[t.capability].emoji}</Text></Box>
77
77
  <Box flexGrow={1}><Text color={C.text} wrap="truncate-end">{t.title}</Text></Box>
78
78
  <Box width={10} justifyContent="flex-end">
79
- {t.verdict ? <Text color={t.verdict === "PASS" ? C.good : C.bad}>{t.verdict}</Text>
79
+ {t.verdict ? <Text color={t.verdict === "FAIL" ? C.bad : t.verdict === "PASS*" ? C.warn : C.good}>{t.verdict}</Text>
80
80
  : t.cost ? <Text color={C.dim}>${t.cost.toFixed(2)}</Text> : <Text> </Text>}
81
81
  </Box>
82
82
  </Box>
package/src/types.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  export type Backend = "api" | "web";
7
7
 
8
8
  /** What kind of work a task needs. Roles bind to capabilities, never to model names. */
9
- export type Capability = "plan" | "design" | "code" | "test" | "ops";
9
+ export type Capability = "plan" | "design" | "code" | "review" | "test" | "ops";
10
10
 
11
11
  /** How hard the task is. Drives which tier of model we spend on. */
12
12
  export type Difficulty = "trivial" | "low" | "medium" | "high";
@@ -90,6 +90,8 @@ export interface Task {
90
90
  epic?: string;
91
91
  story?: string;
92
92
  dependsOn?: string[];
93
+ /** Human-only annotation shown on the board. Never sent to any model. */
94
+ notes?: string;
93
95
  }
94
96
 
95
97
  // ---------------------------------------------------------------------------
@@ -105,6 +107,29 @@ export interface RoutingPolicy {
105
107
  difficultyToTier: Record<Difficulty, Tier>;
106
108
  /** Tester -> Developer feedback loop stop condition (used later, in the orchestrator). */
107
109
  maxFeedbackRounds: number;
110
+ /** Per-task limits. A task that runs longer or spends more is aborted, recorded as
111
+ * failed, and the build halts (resumable). 0 = unlimited. */
112
+ taskLimits: TaskLimits;
113
+ }
114
+
115
+ export interface TaskLimits {
116
+ timeoutMs: number;
117
+ costCapUSD: number;
118
+ }
119
+
120
+ /** Thrown by an executor when a task breaches its limits. The orchestrator turns it
121
+ * into a failed outcome + halt instead of letting it escape as a crash. */
122
+ export class TaskLimitError extends Error {
123
+ constructor(
124
+ public readonly kind: "timeout" | "cost",
125
+ public readonly taskId: string,
126
+ /** Money already spent on the aborted attempt (must still be billed). */
127
+ public readonly costSoFar: number,
128
+ message: string,
129
+ ) {
130
+ super(message);
131
+ this.name = "TaskLimitError";
132
+ }
108
133
  }
109
134
 
110
135
  // ---------------------------------------------------------------------------
@@ -141,6 +166,9 @@ export interface Bug {
141
166
  export interface Verdict {
142
167
  passed: boolean;
143
168
  bugs: Bug[];
169
+ /** True when the tester actually ran the app (headless Chromium). False = it could
170
+ * only read the code, so a PASS is weaker than it looks. */
171
+ runtimeChecked: boolean;
144
172
  }
145
173
 
146
174
  /** What a role produces when it runs a task. */
@@ -153,6 +181,9 @@ export interface RoleResult {
153
181
  cost: number;
154
182
  /** Only for test tasks — the pass/fail judgement. */
155
183
  verdict?: Verdict;
184
+ /** Set when the task was aborted (limit breach). A failed outcome is billed but
185
+ * never counts as "done": resume rebuilds it. */
186
+ error?: string;
156
187
  }
157
188
 
158
189
  /** Executor injected into the orchestrator. Real impl runs Pi; tests pass a fake. */
@@ -163,6 +194,8 @@ export type RoleExecutor = (input: {
163
194
  contextText: string;
164
195
  /** 0 on first attempt, 1+ during a Tester→Developer feedback round. */
165
196
  round: number;
197
+ /** Per-task limits the executor must enforce (throw TaskLimitError on breach). */
198
+ limits: TaskLimits;
166
199
  }) => Promise<RoleResult>;
167
200
 
168
201
  /** One recorded step of a build run. */