projectinator 0.1.5 → 0.3.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 +19 -12
- package/package.json +4 -4
- package/src/bakeoff.ts +7 -13
- package/src/build-state.ts +3 -2
- package/src/cost.ts +4 -6
- package/src/council.ts +7 -12
- package/src/estimate.ts +7 -0
- package/src/executor.ts +13 -12
- package/src/intake.ts +4 -8
- package/src/models.ts +58 -10
- package/src/narrate.ts +4 -8
- package/src/openrouter.ts +13 -7
- package/src/orchestrator.ts +71 -32
- package/src/pm.ts +14 -15
- package/src/preview.ts +15 -1
- package/src/registry.ts +35 -21
- package/src/research.ts +4 -8
- package/src/retro.ts +2 -2
- package/src/roles.ts +84 -42
- package/src/router.ts +2 -5
- package/src/run-build.ts +20 -8
- package/src/run-dev.ts +2 -5
- package/src/run-pm.ts +3 -5
- package/src/tui/App.tsx +31 -11
- package/src/tui/BoardEditor.tsx +14 -2
- package/src/tui/EditableBoard.tsx +25 -15
- package/src/tui/Kanban.tsx +5 -2
- package/src/tui/Settings.tsx +34 -8
- package/src/tui/components.tsx +5 -3
- package/src/tui/config.ts +17 -2
- package/src/tui/engine.ts +12 -7
- package/src/tui/panels.tsx +1 -1
- package/src/tui.tsx +4 -0
- package/src/types.ts +34 -1
package/src/roles.ts
CHANGED
|
@@ -5,25 +5,25 @@
|
|
|
5
5
|
// the orchestrator's feedback loop depends on.
|
|
6
6
|
|
|
7
7
|
import {
|
|
8
|
-
AuthStorage,
|
|
9
|
-
ModelRegistry,
|
|
10
8
|
createAgentSession,
|
|
11
9
|
defineTool,
|
|
12
10
|
type AgentSession,
|
|
13
11
|
} from "@earendil-works/pi-coding-agent";
|
|
14
12
|
import { Type, type Static } from "typebox";
|
|
15
|
-
import
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
13
|
+
import {
|
|
14
|
+
TaskLimitError,
|
|
15
|
+
type Backend,
|
|
16
|
+
type Capability,
|
|
17
|
+
type Provider,
|
|
18
|
+
type RegistryEntry,
|
|
19
|
+
type RoleExecutor,
|
|
20
|
+
type RoleResult,
|
|
21
|
+
type Task,
|
|
22
|
+
type TaskLimits,
|
|
23
|
+
type Verdict,
|
|
24
24
|
} from "./types.js";
|
|
25
|
-
import { resolvePiModel } from "./executor.js"
|
|
26
|
-
import { renderCheck } from "./preview.js";
|
|
25
|
+
import { piRuntime, resolvePiModel } from "./executor.js"
|
|
26
|
+
import { renderCheck, chromiumAvailable, CHROMIUM_INSTALL_HINT } from "./preview.js";
|
|
27
27
|
import { estimateCost } from "./cost.js";
|
|
28
28
|
import { getModel } from "./models.js";
|
|
29
29
|
import { addSessionCost } from "./session-cost.js";
|
|
@@ -45,6 +45,11 @@ const ROLE_INTRO: Record<Capability, string> = {
|
|
|
45
45
|
"reuse and extend existing files, follow the file structure the design spec defines, and make sure files reference each other with correct paths " +
|
|
46
46
|
"(imports/requires, <script src> and <link href>, relative paths). Create only the files this task needs; never delete or clobber files unrelated to your task. " +
|
|
47
47
|
"MUST-RUN-ON-DOUBLE-CLICK: for a plain static site with no bundler/build step, the app has to work when the user just opens index.html as a file (file://). Do NOT use `<script type=\"module\">` with relative `import`s, and do not `fetch()` local files — browsers block both on file://, leaving a blank page. Split code with several plain `<script src>` tags in dependency order (globals), not ES modules. If the app genuinely needs a server (a real backend, bundler, or framework), write a short README.md with the exact run command.",
|
|
48
|
+
review:
|
|
49
|
+
"You are the REVIEWER. Do NOT edit files and do NOT run the app. Read the task, the design context, and the files in the working directory. " +
|
|
50
|
+
"Check: every file the design named exists; every <script src> / <link href> / import resolves to a real file; nothing is referenced but never defined " +
|
|
51
|
+
"(functions, element ids, CSS classes the JS relies on); a plain static site uses no ES modules or fetch() of local files (both break on double-click / file://); " +
|
|
52
|
+
"and the task's stated deliverable is actually present. Report only real defects a developer must fix — not style. Then call submit_verdict exactly once.",
|
|
48
53
|
test: "You are the TESTER. For a web app, FIRST call check_app to actually run it in a headless browser — it reports how the app renders BOTH served over http AND opened directly as a file (double-click / file://). Confirm it renders, shows the expected content, and has no JavaScript/console errors. The app MUST also work on double-click (file://) UNLESS a README documents how to run it — if check_app says double-click is BROKEN and there is no README with a run command, that is a HIGH-severity bug (report it, describe the file:// failure). Then inspect the files against the task and check multi-file wiring (referenced files exist, paths/imports resolve). Then call submit_verdict with pass/fail and any bugs. A blank render or a JS error is a high-severity bug. Do not fix anything yourself.",
|
|
49
54
|
ops: "You are OPS. Perform the operational task (build, config, deploy prep) using your tools. Report what you did as text.",
|
|
50
55
|
};
|
|
@@ -56,7 +61,7 @@ export function buildRolePrompt(task: Task, contextText: string): string {
|
|
|
56
61
|
`Task ${task.id}: ${task.title}`,
|
|
57
62
|
contextText ? `\n${contextText}` : "",
|
|
58
63
|
"",
|
|
59
|
-
task.capability === "test"
|
|
64
|
+
task.capability === "test" || task.capability === "review"
|
|
60
65
|
? "When finished, call submit_verdict exactly once."
|
|
61
66
|
: "Complete the task, then stop. Do not explain at length.",
|
|
62
67
|
];
|
|
@@ -80,8 +85,10 @@ type VerdictRaw = Static<typeof VerdictSchema>;
|
|
|
80
85
|
|
|
81
86
|
// ---- tester "run the app" tool: headless render + error capture ----
|
|
82
87
|
|
|
83
|
-
|
|
84
|
-
|
|
88
|
+
/** check_app + a flag telling whether a render actually happened this task. */
|
|
89
|
+
function buildCheckTool(workspace: string, chromium: boolean) {
|
|
90
|
+
let rendered = false;
|
|
91
|
+
const tool = defineTool({
|
|
85
92
|
name: "check_app",
|
|
86
93
|
label: "Run the app",
|
|
87
94
|
description:
|
|
@@ -93,8 +100,15 @@ function buildCheckTool(workspace: string) {
|
|
|
93
100
|
{ additionalProperties: true },
|
|
94
101
|
),
|
|
95
102
|
execute: async (_id, params: { file?: string }) => {
|
|
103
|
+
if (!chromium) {
|
|
104
|
+
return {
|
|
105
|
+
content: [{ type: "text", text: `check_app is UNAVAILABLE: headless Chromium is not installed (${CHROMIUM_INSTALL_HINT}). You cannot run the app. Review the files by reading them, say so in your verdict, and do not claim the app was executed.` }],
|
|
106
|
+
details: {},
|
|
107
|
+
};
|
|
108
|
+
}
|
|
96
109
|
try {
|
|
97
110
|
const r = await renderCheck(workspace, params.file || "index.html");
|
|
111
|
+
rendered = true;
|
|
98
112
|
const doubleClick = r.doubleClickBroken
|
|
99
113
|
? "BROKEN — renders behind a server but is blank/erroring when opened directly as a file (double-click). "
|
|
100
114
|
+ "Most likely ES modules + relative imports (or fetch of local files), which browsers block on file://. "
|
|
@@ -119,9 +133,10 @@ function buildCheckTool(workspace: string) {
|
|
|
119
133
|
}
|
|
120
134
|
},
|
|
121
135
|
});
|
|
136
|
+
return { tool, rendered: () => rendered };
|
|
122
137
|
}
|
|
123
138
|
|
|
124
|
-
function buildVerdictTool() {
|
|
139
|
+
function buildVerdictTool(runtimeChecked: () => boolean) {
|
|
125
140
|
let captured: Verdict | undefined;
|
|
126
141
|
const tool = defineTool({
|
|
127
142
|
name: "submit_verdict",
|
|
@@ -129,7 +144,7 @@ function buildVerdictTool() {
|
|
|
129
144
|
description: "Submit your pass/fail judgement and any bugs found.",
|
|
130
145
|
parameters: VerdictSchema,
|
|
131
146
|
execute: async (_id, params: VerdictRaw) => {
|
|
132
|
-
captured = { passed: params.passed, bugs: params.bugs };
|
|
147
|
+
captured = { passed: params.passed, bugs: params.bugs, runtimeChecked: runtimeChecked() };
|
|
133
148
|
return {
|
|
134
149
|
content: [{ type: "text", text: `Verdict: ${params.passed ? "PASS" : "FAIL"} (${params.bugs.length} bugs)` }],
|
|
135
150
|
details: {},
|
|
@@ -144,16 +159,17 @@ function buildVerdictTool() {
|
|
|
144
159
|
// that provider's sensible model, so route() resolves everything to it.
|
|
145
160
|
|
|
146
161
|
const PROVIDER_MODELS: Record<Provider, { strong: string; mid: string; cheap: string }> = {
|
|
147
|
-
anthropic: { strong: "claude-opus-
|
|
162
|
+
anthropic: { strong: "claude-opus-5", mid: "claude-sonnet-5", cheap: "claude-haiku-4-5" },
|
|
148
163
|
openai: { strong: "gpt-5.6-sol", mid: "gpt-5.6-terra", cheap: "gpt-5.6-luna" },
|
|
149
|
-
google: { strong: "gemini-3.1-pro-preview", mid: "gemini-3.1-pro-preview", cheap: "gemini-3-flash
|
|
150
|
-
openrouter: { strong: "anthropic/claude-opus-
|
|
164
|
+
google: { strong: "gemini-3.1-pro-preview", mid: "gemini-3.1-pro-preview", cheap: "gemini-3.8-flash" },
|
|
165
|
+
openrouter: { strong: "anthropic/claude-opus-5", mid: "anthropic/claude-sonnet-5", cheap: "google/gemini-3.8-flash" },
|
|
151
166
|
};
|
|
152
167
|
|
|
153
168
|
const CAP_STRENGTH: Record<Capability, "strong" | "mid" | "cheap"> = {
|
|
154
169
|
plan: "mid",
|
|
155
170
|
design: "strong",
|
|
156
171
|
code: "strong",
|
|
172
|
+
review: "cheap",
|
|
157
173
|
test: "cheap",
|
|
158
174
|
ops: "strong",
|
|
159
175
|
};
|
|
@@ -161,7 +177,7 @@ const CAP_STRENGTH: Record<Capability, "strong" | "mid" | "cheap"> = {
|
|
|
161
177
|
/** A registry where every capability routes to one provider's models. */
|
|
162
178
|
export function lockRegistryToProvider(provider: Provider): RegistryEntry[] {
|
|
163
179
|
const m = PROVIDER_MODELS[provider];
|
|
164
|
-
const caps: Capability[] = ["plan", "design", "code", "test", "ops"];
|
|
180
|
+
const caps: Capability[] = ["plan", "design", "code", "review", "test", "ops"];
|
|
165
181
|
const tiers = ["fast", "mid", "high"] as const;
|
|
166
182
|
const out: RegistryEntry[] = [];
|
|
167
183
|
for (const capability of caps) {
|
|
@@ -183,7 +199,6 @@ export function lockRegistryToProvider(provider: Provider): RegistryEntry[] {
|
|
|
183
199
|
export interface PiExecutorOptions {
|
|
184
200
|
workspace: string;
|
|
185
201
|
backend: Backend;
|
|
186
|
-
authStorage?: AuthStorage;
|
|
187
202
|
thinkingLevel?: "off" | "low" | "medium" | "high";
|
|
188
203
|
onEvent?: Parameters<AgentSession["subscribe"]>[0];
|
|
189
204
|
/** Called when a task falls back from its routed provider to another one. */
|
|
@@ -262,8 +277,6 @@ function listFiles(dir: string): string[] {
|
|
|
262
277
|
/** Build a real RoleExecutor backed by Pi. Each call spends money. Falls back to
|
|
263
278
|
* another key-holding provider when the routed one errors or returns 0 tokens. */
|
|
264
279
|
export function makePiExecutor(opts: PiExecutorOptions): RoleExecutor {
|
|
265
|
-
const authStorage = opts.authStorage ?? AuthStorage.create();
|
|
266
|
-
|
|
267
280
|
// One attempt on a specific provider/model. Returns the result + total tokens
|
|
268
281
|
// (0 tokens = the provider call didn't really happen → treat as a failure).
|
|
269
282
|
const runOnce = async (
|
|
@@ -271,31 +284,54 @@ export function makePiExecutor(opts: PiExecutorOptions): RoleExecutor {
|
|
|
271
284
|
contextText: string,
|
|
272
285
|
provider: Provider,
|
|
273
286
|
modelId: string,
|
|
287
|
+
limits: TaskLimits,
|
|
274
288
|
): Promise<{ result: RoleResult; tokensTotal: number }> => {
|
|
275
|
-
const
|
|
276
|
-
const model = resolvePiModel(
|
|
289
|
+
const runtime = await piRuntime();
|
|
290
|
+
const model = resolvePiModel(runtime, provider, modelId);
|
|
277
291
|
|
|
278
292
|
const isTest = task.capability === "test";
|
|
279
|
-
const
|
|
280
|
-
const checkTool = isTest ? buildCheckTool(opts.workspace) : undefined;
|
|
293
|
+
const isReview = task.capability === "review";
|
|
294
|
+
const checkTool = isTest ? buildCheckTool(opts.workspace, await chromiumAvailable()) : undefined;
|
|
295
|
+
// A review never runs the app, so its verdict is never "runtime checked".
|
|
296
|
+
const verdictTool = isTest || isReview ? buildVerdictTool(checkTool ? checkTool.rendered : () => false) : undefined;
|
|
281
297
|
|
|
282
298
|
const { session } = await createAgentSession({
|
|
283
299
|
model,
|
|
284
300
|
cwd: opts.workspace,
|
|
285
|
-
|
|
286
|
-
modelRegistry: registry,
|
|
301
|
+
modelRuntime: runtime,
|
|
287
302
|
thinkingLevel: opts.thinkingLevel ?? "medium",
|
|
288
303
|
...(isTest
|
|
289
|
-
? { customTools: [verdictTool!.tool, checkTool
|
|
290
|
-
:
|
|
304
|
+
? { customTools: [verdictTool!.tool, checkTool!.tool], tools: ["read", "bash", "ls", "grep", "find", "check_app", "submit_verdict"] }
|
|
305
|
+
: isReview
|
|
306
|
+
? { customTools: [verdictTool!.tool], tools: ["read", "ls", "grep", "find", "submit_verdict"] }
|
|
307
|
+
: { tools: ["read", "write", "edit", "bash", "ls", "grep", "find"] }),
|
|
291
308
|
});
|
|
292
309
|
|
|
293
310
|
const unsub = opts.onEvent ? session.subscribe(opts.onEvent) : undefined;
|
|
311
|
+
|
|
312
|
+
// Per-task limits. Cost is checked on every session event (Pi updates its stats as
|
|
313
|
+
// each assistant turn lands); time by a timer. On breach the session is aborted and
|
|
314
|
+
// the awaited prompt settles; we then throw so the orchestrator halts the build.
|
|
315
|
+
let breach: TaskLimitError | undefined;
|
|
316
|
+
const trip = (e: TaskLimitError) => {
|
|
317
|
+
if (breach) return;
|
|
318
|
+
breach = e;
|
|
319
|
+
void session.abort();
|
|
320
|
+
};
|
|
321
|
+
const unsubCost = limits.costCapUSD > 0
|
|
322
|
+
? session.subscribe(() => {
|
|
323
|
+
const spent = session.getSessionStats().cost;
|
|
324
|
+
if (spent > limits.costCapUSD) trip(new TaskLimitError("cost", task.id, round2(spent), `spent $${spent.toFixed(2)} > per-task cap $${limits.costCapUSD}`));
|
|
325
|
+
})
|
|
326
|
+
: undefined;
|
|
327
|
+
const timer = limits.timeoutMs > 0
|
|
328
|
+
? setTimeout(() => trip(new TaskLimitError("timeout", task.id, round2(session.getSessionStats().cost), `ran longer than ${Math.round(limits.timeoutMs / 60_000)} min`)), limits.timeoutMs)
|
|
329
|
+
: undefined;
|
|
294
330
|
try {
|
|
295
|
-
// Give code/test the WHOLE current file tree (not just direct-dep files), so a
|
|
331
|
+
// Give code/review/test the WHOLE current file tree (not just direct-dep files), so a
|
|
296
332
|
// dev building one file knows every other file that already exists to wire into.
|
|
297
333
|
let fullContext = contextText;
|
|
298
|
-
if (task.capability === "code" || task.capability === "test") {
|
|
334
|
+
if (task.capability === "code" || task.capability === "review" || task.capability === "test") {
|
|
299
335
|
const existing = listFiles(opts.workspace);
|
|
300
336
|
if (existing.length) {
|
|
301
337
|
fullContext = [contextText, `Files already in the working directory:\n${existing.map((f) => ` ${f}`).join("\n")}`]
|
|
@@ -303,16 +339,18 @@ export function makePiExecutor(opts: PiExecutorOptions): RoleExecutor {
|
|
|
303
339
|
.join("\n\n");
|
|
304
340
|
}
|
|
305
341
|
}
|
|
306
|
-
|
|
342
|
+
// An aborted prompt may reject with Pi's own error; the breach is the real cause.
|
|
343
|
+
await session.prompt(buildRolePrompt(task, fullContext)).catch((e: unknown) => { if (!breach) throw e; });
|
|
344
|
+
if (breach) throw breach;
|
|
307
345
|
|
|
308
346
|
let verdict = verdictTool?.get();
|
|
309
|
-
if (
|
|
310
|
-
await session.followUp("Call submit_verdict now with your judgement.");
|
|
347
|
+
if (verdictTool && !verdict) {
|
|
348
|
+
await session.followUp("Call submit_verdict now with your judgement.").catch((e: unknown) => { if (!breach) throw e; });
|
|
311
349
|
verdict = verdictTool?.get();
|
|
312
350
|
}
|
|
351
|
+
if (breach) throw breach;
|
|
313
352
|
|
|
314
353
|
const stats = session.getSessionStats();
|
|
315
|
-
addSessionCost(stats.cost);
|
|
316
354
|
// Feed real usage back to sharpen estimates — but only for a real run.
|
|
317
355
|
if (stats.tokens.total > 0) {
|
|
318
356
|
const inputTotal = stats.tokens.input + stats.tokens.cacheRead;
|
|
@@ -326,24 +364,28 @@ export function makePiExecutor(opts: PiExecutorOptions): RoleExecutor {
|
|
|
326
364
|
};
|
|
327
365
|
return { result, tokensTotal: stats.tokens.total };
|
|
328
366
|
} finally {
|
|
367
|
+
clearTimeout(timer);
|
|
368
|
+
unsubCost?.();
|
|
369
|
+
addSessionCost(session.getSessionStats().cost); // bill every attempt, aborted or not
|
|
329
370
|
unsub?.();
|
|
330
371
|
session.dispose();
|
|
331
372
|
}
|
|
332
373
|
};
|
|
333
374
|
|
|
334
|
-
return async ({ task, decision, contextText }) => {
|
|
375
|
+
return async ({ task, decision, contextText, limits }) => {
|
|
335
376
|
const chain = fallbackChain(decision.provider, decision.model.id, task.capability);
|
|
336
377
|
let lastErr: unknown;
|
|
337
378
|
for (let i = 0; i < chain.length; i++) {
|
|
338
379
|
const cand = chain[i]!;
|
|
339
380
|
try {
|
|
340
|
-
const att = await runOnce(task, contextText, cand.provider, cand.model);
|
|
381
|
+
const att = await runOnce(task, contextText, cand.provider, cand.model, limits);
|
|
341
382
|
if (att.tokensTotal > 0) {
|
|
342
383
|
if (i > 0) opts.onFallback?.({ taskId: task.id, from: decision.provider, to: cand.provider, model: cand.model });
|
|
343
384
|
return att.result;
|
|
344
385
|
}
|
|
345
386
|
lastErr = new Error(`${cand.provider}/${cand.model} returned 0 tokens (invalid key, no account credit/balance, or no access to this model)`);
|
|
346
387
|
} catch (e) {
|
|
388
|
+
if (e instanceof TaskLimitError) throw e; // a limit breach is final — never retry elsewhere
|
|
347
389
|
lastErr = e;
|
|
348
390
|
}
|
|
349
391
|
}
|
|
@@ -356,4 +398,4 @@ function round2(n: number): number {
|
|
|
356
398
|
}
|
|
357
399
|
|
|
358
400
|
// exported for tests
|
|
359
|
-
export { buildVerdictTool, VerdictSchema, estimateCost, getModel };
|
|
401
|
+
export { buildCheckTool, buildVerdictTool, VerdictSchema, estimateCost, getModel };
|
package/src/router.ts
CHANGED
|
@@ -81,7 +81,7 @@ export function route(task: Task, ctx: RouteContext): RouteDecision {
|
|
|
81
81
|
|
|
82
82
|
// 5. Cost.
|
|
83
83
|
const cost = estimateCost(task.estTokens, model);
|
|
84
|
-
const runningTotal =
|
|
84
|
+
const runningTotal = Math.round(((ctx.runningTotalBefore ?? 0) + cost) * 10_000) / 10_000;
|
|
85
85
|
const overCap = runningTotal > policy.budgetCapUSD;
|
|
86
86
|
if (overCap) reasons.push(`OVER CAP: running $${runningTotal} > cap $${policy.budgetCapUSD}`);
|
|
87
87
|
|
|
@@ -110,14 +110,11 @@ export function routeBacklog(tasks: Task[], ctx: RouteContext): RouteDecision[]
|
|
|
110
110
|
return out;
|
|
111
111
|
}
|
|
112
112
|
|
|
113
|
-
function round2(n: number): number {
|
|
114
|
-
return Math.round(n * 100) / 100;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
113
|
/** A sensible default policy. */
|
|
118
114
|
export const DEFAULT_POLICY: RoutingPolicy = {
|
|
119
115
|
backendMode: "cost-first",
|
|
120
116
|
budgetCapUSD: 15,
|
|
121
117
|
difficultyToTier: { trivial: "fast", low: "mid", medium: "mid", high: "high" },
|
|
122
118
|
maxFeedbackRounds: 3,
|
|
119
|
+
taskLimits: { timeoutMs: 10 * 60_000, costCapUSD: 3 },
|
|
123
120
|
};
|
package/src/run-build.ts
CHANGED
|
@@ -11,7 +11,6 @@
|
|
|
11
11
|
import { mkdirSync } from "node:fs";
|
|
12
12
|
import { fileURLToPath } from "node:url";
|
|
13
13
|
import { dirname, join } from "node:path";
|
|
14
|
-
import { AuthStorage } from "@earendil-works/pi-coding-agent";
|
|
15
14
|
import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent";
|
|
16
15
|
import type { Provider, Task } from "./types.js";
|
|
17
16
|
import { DEFAULT_POLICY } from "./router.js";
|
|
@@ -20,7 +19,7 @@ import { lockRegistryToProvider, makePiExecutor } from "./roles.js";
|
|
|
20
19
|
import { estimateTokens } from "./estimate.js";
|
|
21
20
|
import { route } from "./router.js";
|
|
22
21
|
import { decomposeIdea } from "./pm.js";
|
|
23
|
-
import { newBuildState, saveState, loadState, type BuildState } from "./build-state.js";
|
|
22
|
+
import { newBuildState, saveState, loadState, completedIds, type BuildState } from "./build-state.js";
|
|
24
23
|
import { initRepo, commitTask } from "./git.js";
|
|
25
24
|
|
|
26
25
|
const args = process.argv.slice(2);
|
|
@@ -32,16 +31,25 @@ const lockIdx = args.indexOf("--lock");
|
|
|
32
31
|
const lockProvider = (lockIdx >= 0 ? args[lockIdx + 1] : "anthropic") as Provider;
|
|
33
32
|
const concIdx = args.indexOf("--concurrency");
|
|
34
33
|
const concurrency = Math.max(1, parseInt((concIdx >= 0 ? args[concIdx + 1] : "1") ?? "1", 10) || 1);
|
|
34
|
+
const capIdx = args.indexOf("--task-cap");
|
|
35
|
+
const tmoIdx = args.indexOf("--task-timeout");
|
|
35
36
|
const consumed = new Set(
|
|
36
37
|
["--live", "--mini", "--fan", "--resume", "--lock", lockIdx >= 0 ? args[lockIdx + 1] : "",
|
|
37
|
-
"--concurrency", concIdx >= 0 ? args[concIdx + 1] : ""
|
|
38
|
+
"--concurrency", concIdx >= 0 ? args[concIdx + 1] : "",
|
|
39
|
+
"--task-cap", capIdx >= 0 ? args[capIdx + 1] : "",
|
|
40
|
+
"--task-timeout", tmoIdx >= 0 ? args[tmoIdx + 1] : ""].filter(Boolean),
|
|
38
41
|
);
|
|
39
42
|
const idea = args.filter((a) => !consumed.has(a)).join(" ").trim() ||
|
|
40
43
|
"A one-page site with a headline and a contact form.";
|
|
41
44
|
|
|
42
45
|
const money = (n: number) => `$${n.toFixed(2)}`;
|
|
43
46
|
const registry = lockRegistryToProvider(lockProvider);
|
|
44
|
-
|
|
47
|
+
// --task-cap USD / --task-timeout MIN override the per-task limits (0 = unlimited).
|
|
48
|
+
const taskLimits = {
|
|
49
|
+
costCapUSD: capIdx >= 0 ? parseFloat(args[capIdx + 1] ?? "") : DEFAULT_POLICY.taskLimits.costCapUSD,
|
|
50
|
+
timeoutMs: tmoIdx >= 0 ? parseFloat(args[tmoIdx + 1] ?? "") * 60_000 : DEFAULT_POLICY.taskLimits.timeoutMs,
|
|
51
|
+
};
|
|
52
|
+
const policy = { ...DEFAULT_POLICY, backendMode: "api" as const, budgetCapUSD: mini ? 10 : 25, taskLimits };
|
|
45
53
|
|
|
46
54
|
// --- a tiny fixed backlog for cheap end-to-end proof ---
|
|
47
55
|
function miniBacklog(): Task[] {
|
|
@@ -51,7 +59,8 @@ function miniBacklog(): Task[] {
|
|
|
51
59
|
return [
|
|
52
60
|
mk("D-1", "design", "low", "Write a short design spec for a centered card that says 'Hello from Projectinator' on a soft gradient background."),
|
|
53
61
|
mk("C-1", "code", "low", "Create index.html implementing the design spec exactly. Single self-contained file with embedded CSS.", ["D-1"]),
|
|
54
|
-
mk("
|
|
62
|
+
mk("R-1", "review", "trivial", "Review index.html against the design spec: file present, no unresolved references, runs on double-click.", ["C-1"]),
|
|
63
|
+
mk("T-1", "test", "trivial", "Open/read index.html and verify it is valid HTML and matches the design spec (centered card, the headline text, a gradient).", ["R-1"]),
|
|
55
64
|
];
|
|
56
65
|
}
|
|
57
66
|
|
|
@@ -66,7 +75,9 @@ function fanBacklog(): Task[] {
|
|
|
66
75
|
mk("DB", "design", "low", "Design spec for an 'FAQ' accordion (3 questions)."),
|
|
67
76
|
mk("CA", "code", "low", "Create newsletter.html from the newsletter design spec.", ["DA"]),
|
|
68
77
|
mk("CB", "code", "low", "Create faq.html from the FAQ design spec.", ["DB"]),
|
|
69
|
-
mk("
|
|
78
|
+
mk("RA", "review", "trivial", "Review newsletter.html against its spec.", ["CA"]),
|
|
79
|
+
mk("RB", "review", "trivial", "Review faq.html against its spec.", ["CB"]),
|
|
80
|
+
mk("TJ", "test", "trivial", "Verify newsletter.html and faq.html are valid and match their specs.", ["RA", "RB"]),
|
|
70
81
|
];
|
|
71
82
|
}
|
|
72
83
|
|
|
@@ -135,7 +146,7 @@ if (prior) {
|
|
|
135
146
|
state.status = "running";
|
|
136
147
|
tasks = prior.tasks; // authoritative backlog from the interrupted run
|
|
137
148
|
seedOutcomes = prior.outcomes;
|
|
138
|
-
const doneCount =
|
|
149
|
+
const doneCount = completedIds(prior).size;
|
|
139
150
|
console.log(` Resuming: ${doneCount} task(s) already done, restored ${money(prior.totalCost)}.`);
|
|
140
151
|
} else {
|
|
141
152
|
if (resume) console.log(" (No prior state found — starting fresh.)");
|
|
@@ -159,8 +170,9 @@ const onProgress = (e: OrchestratorEvent) => {
|
|
|
159
170
|
if (e.type === "task_start") console.log(` ▶ ${e.task.id} [${e.task.capability}] -> ${e.provider}/${e.modelId} (round ${e.round})`);
|
|
160
171
|
else if (e.type === "task_done") {
|
|
161
172
|
const hash = commitTask(workspace, e.outcome.taskId, titleById.get(e.outcome.taskId) ?? e.outcome.taskId);
|
|
162
|
-
console.log(` ✓ ${e.outcome.taskId} ${money(e.outcome.cost)} running ${money(e.runningTotal)}${e.outcome.verdict ? ` verdict=${e.outcome.verdict.passed ? "PASS" : "FAIL"}` : ""}${hash ? ` [${hash}]` : ""}`);
|
|
173
|
+
console.log(` ✓ ${e.outcome.taskId} ${money(e.outcome.cost)} running ${money(e.runningTotal)}${e.outcome.verdict ? ` verdict=${e.outcome.verdict.passed ? (e.outcome.verdict.runtimeChecked ? "PASS" : "PASS* (app not executed — no Chromium)") : "FAIL"}` : ""}${hash ? ` [${hash}]` : ""}`);
|
|
163
174
|
}
|
|
175
|
+
else if (e.type === "task_failed") console.log(` ⛔ ${e.outcome.taskId} ABORTED (${e.outcome.error}) — billed ${money(e.outcome.cost)}, halting`);
|
|
164
176
|
else if (e.type === "task_skipped") console.log(` · ${e.taskId} skipped (already done)`);
|
|
165
177
|
else if (e.type === "test_failed") console.log(` ✗ ${e.taskId} FAILED (${e.bugs} bugs) — round ${e.round}`);
|
|
166
178
|
else if (e.type === "retry_dev") console.log(` ↻ re-running ${e.taskId} to fix ${e.forTest}`);
|
package/src/run-dev.ts
CHANGED
|
@@ -8,11 +8,10 @@
|
|
|
8
8
|
import { mkdirSync } from "node:fs";
|
|
9
9
|
import { fileURLToPath } from "node:url";
|
|
10
10
|
import { dirname, join } from "node:path";
|
|
11
|
-
import { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent";
|
|
12
11
|
import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent";
|
|
13
12
|
import type { Task } from "./types.js";
|
|
14
13
|
import { DEFAULT_POLICY, route } from "./router.js";
|
|
15
|
-
import { buildDeveloperPrompt, executeTask, resolvePiModel } from "./executor.js";
|
|
14
|
+
import { buildDeveloperPrompt, executeTask, piRuntime, resolvePiModel } from "./executor.js";
|
|
16
15
|
|
|
17
16
|
const TASK: Task = {
|
|
18
17
|
id: "T-DEV1",
|
|
@@ -29,9 +28,7 @@ const live = process.argv.includes("--live");
|
|
|
29
28
|
const policy = { ...DEFAULT_POLICY, backendMode: "api" as const };
|
|
30
29
|
const decision = route(TASK, { policy });
|
|
31
30
|
|
|
32
|
-
const
|
|
33
|
-
const registry = ModelRegistry.create(auth);
|
|
34
|
-
const piModel = resolvePiModel(registry, decision.provider, decision.model.id); // offline, free
|
|
31
|
+
const piModel = resolvePiModel(await piRuntime(), decision.provider, decision.model.id); // offline, free
|
|
35
32
|
|
|
36
33
|
const money = (n: number) => `$${n.toFixed(2)}`;
|
|
37
34
|
|
package/src/run-pm.ts
CHANGED
|
@@ -5,11 +5,10 @@
|
|
|
5
5
|
//
|
|
6
6
|
// Live decomposition needs an API key (PM routes to an OpenAI model by default).
|
|
7
7
|
|
|
8
|
-
import { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent";
|
|
9
8
|
import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent";
|
|
10
9
|
import { DEFAULT_POLICY, routeBacklog } from "./router.js";
|
|
11
10
|
import { findEntry } from "./registry.js";
|
|
12
|
-
import { resolvePiModel } from "./executor.js"
|
|
11
|
+
import { piRuntime, resolvePiModel } from "./executor.js"
|
|
13
12
|
import { decomposeIdea, pmSystemPrompt } from "./pm.js";
|
|
14
13
|
|
|
15
14
|
const args = process.argv.slice(2);
|
|
@@ -26,13 +25,12 @@ const idea = args.filter((a) => !consumed.has(a)).join(" ").trim() ||
|
|
|
26
25
|
const backend = "api" as const; // web-login backend not built yet
|
|
27
26
|
const money = (n: number) => `$${n.toFixed(2)}`;
|
|
28
27
|
|
|
29
|
-
const
|
|
30
|
-
const registry = ModelRegistry.create(auth);
|
|
28
|
+
const runtime = await piRuntime();
|
|
31
29
|
const { entry } = findEntry("plan", "mid");
|
|
32
30
|
const pick = pmOverride
|
|
33
31
|
? { provider: pmOverride.split("/")[0] as typeof entry.byBackend[typeof backend]["provider"], model: pmOverride.split("/").slice(1).join("/") }
|
|
34
32
|
: entry.byBackend[backend];
|
|
35
|
-
const pm = resolvePiModel(
|
|
33
|
+
const pm = resolvePiModel(runtime, pick.provider, pick.model); // offline
|
|
36
34
|
|
|
37
35
|
console.log(`\n Projectinator — Phase 3 PM decomposer [${live ? "LIVE" : "DRY"}]\n`);
|
|
38
36
|
console.log(` Idea: ${idea}`);
|
package/src/tui/App.tsx
CHANGED
|
@@ -57,8 +57,15 @@ import {
|
|
|
57
57
|
type ProjectInfo,
|
|
58
58
|
} from "./engine.js";
|
|
59
59
|
import { deploy, DEPLOY_META, type DeployTarget } from "./deploy.js";
|
|
60
|
-
import { startStaticServer, type StaticServer } from "../preview.js";
|
|
61
|
-
import type { Task, TaskOutcome } from "../types.js";
|
|
60
|
+
import { startStaticServer, CHROMIUM_INSTALL_HINT, type StaticServer } from "../preview.js";
|
|
61
|
+
import type { Capability, Task, TaskOutcome } from "../types.js";
|
|
62
|
+
import { completedIds } from "../build-state.js";
|
|
63
|
+
import type { Verdict } from "../types.js";
|
|
64
|
+
|
|
65
|
+
/** Board label: PASS* = a TEST that passed without ever running the app (Chromium missing).
|
|
66
|
+
* Reviews never run anything, so they are plain PASS/FAIL. */
|
|
67
|
+
const verdictLabel = (v: Verdict, capability: Capability): "PASS" | "PASS*" | "FAIL" =>
|
|
68
|
+
!v.passed ? "FAIL" : v.runtimeChecked || capability !== "test" ? "PASS" : "PASS*";
|
|
62
69
|
|
|
63
70
|
type Phase =
|
|
64
71
|
| "setup" | "home" | "settings" | "projects" | "projectActions" | "addAsset" | "rename" | "confirmDelete" | "filterEpic" | "editBoard" | "kanban" | "templates" | "exportMenu" | "deployMenu" | "deploying" | "preview" | "bakeoff" | "history" | "retro" | "burndown" | "saveTemplate" | "importTemplate" | "myTemplates" | "tplActions"
|
|
@@ -77,7 +84,7 @@ export default function App(): React.ReactElement {
|
|
|
77
84
|
const [tasks, setTasks] = useState<TaskView[]>([]);
|
|
78
85
|
const [spent, setSpent] = useState(0);
|
|
79
86
|
const [gate, setGate] = useState<{ resolve: (d: "continue" | "stop") => void } | null>(null);
|
|
80
|
-
const [buildResult, setBuildResult] = useState<{ halted: boolean; files: string[]; workspace: string } | null>(null);
|
|
87
|
+
const [buildResult, setBuildResult] = useState<{ halted: boolean; haltReason?: string; files: string[]; workspace: string } | null>(null);
|
|
81
88
|
|
|
82
89
|
// Existing-project context (open/resume/make-changes).
|
|
83
90
|
const [projects, setProjects] = useState<ProjectInfo[]>([]);
|
|
@@ -295,11 +302,14 @@ export default function App(): React.ReactElement {
|
|
|
295
302
|
...t,
|
|
296
303
|
status: "done",
|
|
297
304
|
cost: (t.cost ?? 0) + e.outcome.cost,
|
|
298
|
-
verdict: e.outcome.verdict ? (e.outcome.verdict.
|
|
305
|
+
verdict: e.outcome.verdict ? verdictLabel(e.outcome.verdict, e.outcome.capability) : t.verdict,
|
|
299
306
|
}
|
|
300
307
|
: t,
|
|
301
308
|
),
|
|
302
309
|
);
|
|
310
|
+
} else if (e.type === "task_failed") {
|
|
311
|
+
setSpent(e.runningTotal);
|
|
312
|
+
setTasks((ts) => ts.map((t) => (t.id === e.outcome.taskId ? { ...t, status: "failed", cost: (t.cost ?? 0) + e.outcome.cost } : t)));
|
|
303
313
|
} else if (e.type === "task_skipped") {
|
|
304
314
|
setTasks((ts) => ts.map((t) => (t.id === e.taskId ? { ...t, status: "skipped" } : t)));
|
|
305
315
|
} else if (e.type === "test_failed") {
|
|
@@ -318,6 +328,7 @@ export default function App(): React.ReactElement {
|
|
|
318
328
|
const handle = startBuild(idea, plan, {
|
|
319
329
|
concurrency: prefs.concurrency,
|
|
320
330
|
budgetCapUSD: projectCap ?? prefs.budgetCapUSD,
|
|
331
|
+
taskLimits: { timeoutMs: prefs.taskTimeoutMin * 60_000, costCapUSD: prefs.taskCostCapUSD },
|
|
321
332
|
onEvent,
|
|
322
333
|
workspace: targetWorkspace,
|
|
323
334
|
seedOutcomes: seed,
|
|
@@ -329,7 +340,7 @@ export default function App(): React.ReactElement {
|
|
|
329
340
|
.then((r) => {
|
|
330
341
|
if (!alive) return;
|
|
331
342
|
setSpent(r.totalCost);
|
|
332
|
-
setBuildResult({ halted: r.halted, files: r.files, workspace: handle.workspace });
|
|
343
|
+
setBuildResult({ halted: r.halted, haltReason: r.haltReason, files: r.files, workspace: handle.workspace });
|
|
333
344
|
setPhase("done");
|
|
334
345
|
if (getNotify()) {
|
|
335
346
|
notifyBuildDone(
|
|
@@ -496,7 +507,7 @@ export default function App(): React.ReactElement {
|
|
|
496
507
|
}
|
|
497
508
|
|
|
498
509
|
if (phase === "projectActions" && selected) {
|
|
499
|
-
const doneIds =
|
|
510
|
+
const doneIds = completedIds(selected.state);
|
|
500
511
|
// Buildable if it was halted OR the backlog has tasks that were never built.
|
|
501
512
|
const hasUnbuilt = selected.state.tasks.some((t) => !doneIds.has(t.id));
|
|
502
513
|
const canResume = selected.status === "halted" || hasUnbuilt;
|
|
@@ -512,6 +523,7 @@ export default function App(): React.ReactElement {
|
|
|
512
523
|
title: t.title,
|
|
513
524
|
epic: t.epic,
|
|
514
525
|
dependsOn: t.dependsOn,
|
|
526
|
+
notes: t.notes,
|
|
515
527
|
status: doneIds.has(t.id) ? "done" : "pending",
|
|
516
528
|
cost: costById.get(t.id),
|
|
517
529
|
assignee: modelById.has(t.id) ? modelLabel(modelById.get(t.id)!) : undefined,
|
|
@@ -612,7 +624,7 @@ export default function App(): React.ReactElement {
|
|
|
612
624
|
setPhase("change");
|
|
613
625
|
} else if (i.value === "resume") {
|
|
614
626
|
const { registry, lock } = chooseRegistry(providers);
|
|
615
|
-
const done =
|
|
627
|
+
const done = completedIds(selected.state);
|
|
616
628
|
setPlan({
|
|
617
629
|
tasks: selected.state.tasks,
|
|
618
630
|
provider: lock ?? providers[0]!,
|
|
@@ -641,7 +653,7 @@ export default function App(): React.ReactElement {
|
|
|
641
653
|
}
|
|
642
654
|
|
|
643
655
|
if (phase === "editBoard" && selected) {
|
|
644
|
-
const doneIds =
|
|
656
|
+
const doneIds = completedIds(selected.state);
|
|
645
657
|
return (
|
|
646
658
|
<Box flexDirection="column">
|
|
647
659
|
<EditableBoard
|
|
@@ -817,7 +829,7 @@ export default function App(): React.ReactElement {
|
|
|
817
829
|
}
|
|
818
830
|
|
|
819
831
|
if (phase === "kanban" && selected) {
|
|
820
|
-
const done =
|
|
832
|
+
const done = completedIds(selected.state);
|
|
821
833
|
const costBy = new Map<string, number>();
|
|
822
834
|
const modelBy = new Map<string, string>();
|
|
823
835
|
for (const o of selected.state.outcomes) {
|
|
@@ -830,6 +842,7 @@ export default function App(): React.ReactElement {
|
|
|
830
842
|
title: t.title,
|
|
831
843
|
epic: t.epic,
|
|
832
844
|
dependsOn: t.dependsOn,
|
|
845
|
+
notes: t.notes,
|
|
833
846
|
status: done.has(t.id) ? "done" : "pending",
|
|
834
847
|
cost: costBy.get(t.id),
|
|
835
848
|
assignee: modelBy.has(t.id) ? modelLabel(modelBy.get(t.id)!) : undefined,
|
|
@@ -1572,13 +1585,14 @@ export default function App(): React.ReactElement {
|
|
|
1572
1585
|
|
|
1573
1586
|
if (phase === "building") {
|
|
1574
1587
|
const running = tasks.filter((t) => t.status === "running").length;
|
|
1575
|
-
const metaById = new Map((plan?.tasks ?? []).map((t) => [t.id, { deps: t.dependsOn ?? [], epic: t.epic }]));
|
|
1588
|
+
const metaById = new Map((plan?.tasks ?? []).map((t) => [t.id, { deps: t.dependsOn ?? [], epic: t.epic, notes: t.notes }]));
|
|
1576
1589
|
const board: BoardTask[] = tasks.map((t) => ({
|
|
1577
1590
|
id: t.id,
|
|
1578
1591
|
capability: t.capability,
|
|
1579
1592
|
title: t.title,
|
|
1580
1593
|
epic: metaById.get(t.id)?.epic,
|
|
1581
1594
|
dependsOn: metaById.get(t.id)?.deps,
|
|
1595
|
+
notes: metaById.get(t.id)?.notes,
|
|
1582
1596
|
status: t.status,
|
|
1583
1597
|
cost: t.cost,
|
|
1584
1598
|
verdict: t.verdict,
|
|
@@ -1630,6 +1644,9 @@ export default function App(): React.ReactElement {
|
|
|
1630
1644
|
{alerting ? (
|
|
1631
1645
|
<Text color={C.warn}>⚠ {Math.round((spent / cap) * 100)}% of the ${cap} cap spent — nearing the limit.</Text>
|
|
1632
1646
|
) : null}
|
|
1647
|
+
{board.some((t) => t.verdict === "PASS*") ? (
|
|
1648
|
+
<Text color={C.warn}>⚠ Tester could not run the app (no headless Chromium) — PASS* verdicts are code-reading only. {CHROMIUM_INSTALL_HINT}.</Text>
|
|
1649
|
+
) : null}
|
|
1633
1650
|
</Box>
|
|
1634
1651
|
);
|
|
1635
1652
|
})()}
|
|
@@ -1641,8 +1658,11 @@ export default function App(): React.ReactElement {
|
|
|
1641
1658
|
return (
|
|
1642
1659
|
<Box flexDirection="column">
|
|
1643
1660
|
<Text bold color={buildResult.halted ? C.warn : C.good}>
|
|
1644
|
-
{buildResult.halted ?
|
|
1661
|
+
{buildResult.halted ? `⚠ Build halted (${buildResult.haltReason ?? "budget cap"})` : "✓ Build complete"}
|
|
1645
1662
|
</Text>
|
|
1663
|
+
{tasks.some((t) => t.verdict === "PASS*") ? (
|
|
1664
|
+
<Text color={C.warn}>⚠ Tests marked PASS* were never executed in a browser — {CHROMIUM_INSTALL_HINT}.</Text>
|
|
1665
|
+
) : null}
|
|
1646
1666
|
<Box marginTop={1}>
|
|
1647
1667
|
<Standup
|
|
1648
1668
|
tasks={tasks.map((t) => ({ id: t.id, capability: t.capability, title: t.title, status: t.status, cost: t.cost, verdict: t.verdict }))}
|
package/src/tui/BoardEditor.tsx
CHANGED
|
@@ -11,7 +11,7 @@ import { cleanDeps } from "./engine.js";
|
|
|
11
11
|
import { estimateTokens } from "../estimate.js";
|
|
12
12
|
import { groupByEpic } from "./Kanban.js";
|
|
13
13
|
|
|
14
|
-
const CAPS: Capability[] = ["plan", "design", "code", "test", "ops"]
|
|
14
|
+
const CAPS: Capability[] = ["plan", "design", "code", "review", "test", "ops"]
|
|
15
15
|
const DIFFS: Difficulty[] = ["trivial", "low", "medium", "high"];
|
|
16
16
|
|
|
17
17
|
interface Card extends Task {
|
|
@@ -34,7 +34,7 @@ export function BoardEditor({
|
|
|
34
34
|
const [items, setItems] = useState<Card[]>(() => tasks.map((t) => ({ ...t, parked: true })));
|
|
35
35
|
const [cursor, setCursor] = useState(0);
|
|
36
36
|
const [editing, setEditing] = useState(false);
|
|
37
|
-
const [editField, setEditField] = useState<"title" | "epic" | "deps">("title");
|
|
37
|
+
const [editField, setEditField] = useState<"title" | "epic" | "deps" | "notes">("title");
|
|
38
38
|
const [draft, setDraft] = useState("");
|
|
39
39
|
const [warn, setWarn] = useState("");
|
|
40
40
|
const [busy, setBusy] = useState<string>(""); // epic being broken down
|
|
@@ -134,6 +134,10 @@ export function BoardEditor({
|
|
|
134
134
|
setEditField("deps");
|
|
135
135
|
setDraft((selected.dependsOn ?? []).join(" "));
|
|
136
136
|
setEditing(true);
|
|
137
|
+
} else if (input === "n") {
|
|
138
|
+
setEditField("notes");
|
|
139
|
+
setDraft(selected.notes ?? "");
|
|
140
|
+
setEditing(true);
|
|
137
141
|
} else if (input === "c") {
|
|
138
142
|
const cap = CAPS[(CAPS.indexOf(selected.capability) + 1) % CAPS.length]!;
|
|
139
143
|
update(selected.id, { capability: cap, estTokens: estimateTokens(cap, selected.difficulty) });
|
|
@@ -170,6 +174,7 @@ export function BoardEditor({
|
|
|
170
174
|
)}
|
|
171
175
|
</Box>
|
|
172
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}
|
|
173
178
|
</Box>
|
|
174
179
|
);
|
|
175
180
|
})}
|
|
@@ -202,6 +207,12 @@ export function BoardEditor({
|
|
|
202
207
|
/>
|
|
203
208
|
</Box>
|
|
204
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}
|
|
205
216
|
|
|
206
217
|
{/* column headers */}
|
|
207
218
|
<Box marginTop={1}>
|
|
@@ -233,6 +244,7 @@ export function BoardEditor({
|
|
|
233
244
|
{ keys: "e", label: "edit" },
|
|
234
245
|
{ keys: "g", label: "epic" },
|
|
235
246
|
{ keys: "D", label: "deps" },
|
|
247
|
+
{ keys: "n", label: "note" },
|
|
236
248
|
{ keys: "c", label: "cap" },
|
|
237
249
|
{ keys: "f", label: "diff" },
|
|
238
250
|
{ keys: "b", label: "break" },
|