JacobLinCool Codex commited on
Commit
5a83369
·
verified ·
1 Parent(s): 37d98f1

feat: add advisor cockpit and artifact export

Browse files

Co-authored-by: Codex <noreply@openai.com>

hackathon_advisor/agent.py CHANGED
@@ -70,6 +70,29 @@ class AdvisorEngine:
70
  tool_events.append(ToolEvent("compare_ideas", "Compared the current idea board."))
71
  return self._result(normalized, corrections, response, state, tool_events, [], [], None, [], {})
72
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
73
  title, pitch = idea_from_text(normalized)
74
  idea, event = self.tools.save_idea(state, title, pitch)
75
  tool_events.append(event)
@@ -155,6 +178,7 @@ class AdvisorEngine:
155
  plan: list[str],
156
  artifact: dict[str, Any],
157
  ) -> TurnResult:
 
158
  return TurnResult(
159
  normalized_text=normalized_text,
160
  corrections=corrections,
@@ -173,6 +197,41 @@ class AdvisorEngine:
173
  idea.to_dict() if item.get("id") == idea.id else item for item in state.get("ideas", [])
174
  ]
175
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
176
  def _align_score_with_whitespace(self, score: ScoreCard, item: WhitespaceItem) -> ScoreCard:
177
  if item.score < 0.70:
178
  return score
@@ -246,6 +305,7 @@ class AdvisorEngine:
246
  return {
247
  "title": idea.title,
248
  "verdict": score.verdict,
 
249
  "caption": f"Mothback inked my Build Small fate page: {idea.title} - {score.verdict}.",
250
  "seal": score.to_dict(),
251
  }
 
70
  tool_events.append(ToolEvent("compare_ideas", "Compared the current idea board."))
71
  return self._result(normalized, corrections, response, state, tool_events, [], [], None, [], {})
72
 
73
+ if PLAN_RE.search(normalized) and state.get("ideas"):
74
+ idea = self._current_idea(state)
75
+ if idea is not None:
76
+ score, event = self.tools.score_idea(idea)
77
+ self._store_idea(state, idea)
78
+ tool_events.append(event)
79
+ plan, event = self.tools.make_plan(idea)
80
+ tool_events.append(event)
81
+ response = self._plan_response(idea, score, plan)
82
+ artifact = self._artifact(idea, score)
83
+ return self._result(
84
+ normalized,
85
+ corrections,
86
+ response,
87
+ state,
88
+ tool_events,
89
+ [],
90
+ [],
91
+ score,
92
+ plan,
93
+ artifact,
94
+ )
95
+
96
  title, pitch = idea_from_text(normalized)
97
  idea, event = self.tools.save_idea(state, title, pitch)
98
  tool_events.append(event)
 
178
  plan: list[str],
179
  artifact: dict[str, Any],
180
  ) -> TurnResult:
181
+ self._record_trace(state, normalized_text, response, tool_events, score, plan, artifact)
182
  return TurnResult(
183
  normalized_text=normalized_text,
184
  corrections=corrections,
 
197
  idea.to_dict() if item.get("id") == idea.id else item for item in state.get("ideas", [])
198
  ]
199
 
200
+ def _current_idea(self, state: dict[str, Any]) -> Idea | None:
201
+ current_id = state.get("current_idea_id")
202
+ for item in state.get("ideas", []):
203
+ if item.get("id") == current_id:
204
+ return Idea(**item)
205
+ if state.get("ideas"):
206
+ return Idea(**state["ideas"][-1])
207
+ return None
208
+
209
+ def _record_trace(
210
+ self,
211
+ state: dict[str, Any],
212
+ normalized_text: str,
213
+ response: str,
214
+ tool_events: list[ToolEvent],
215
+ score: ScoreCard | None,
216
+ plan: list[str],
217
+ artifact: dict[str, Any],
218
+ ) -> None:
219
+ trace = list(state.get("trace", []))
220
+ trace.append(
221
+ {
222
+ "input": normalized_text[:240],
223
+ "tools": [event.to_dict() for event in tool_events],
224
+ "verdict": score.verdict if score else "",
225
+ "overall": score.overall if score else None,
226
+ "plan_steps": len(plan),
227
+ "artifact_title": artifact.get("title", ""),
228
+ "response": response[:360],
229
+ }
230
+ )
231
+ state["trace"] = trace[-12:]
232
+ if artifact:
233
+ state["last_artifact"] = artifact
234
+
235
  def _align_score_with_whitespace(self, score: ScoreCard, item: WhitespaceItem) -> ScoreCard:
236
  if item.score < 0.70:
237
  return score
 
305
  return {
306
  "title": idea.title,
307
  "verdict": score.verdict,
308
+ "overall": score.overall,
309
  "caption": f"Mothback inked my Build Small fate page: {idea.title} - {score.verdict}.",
310
  "seal": score.to_dict(),
311
  }
static/app.js CHANGED
@@ -7,11 +7,17 @@ const ink = document.querySelector("#ink");
7
  const corrections = document.querySelector("#corrections");
8
  const projectsEl = document.querySelector("#projects");
9
  const whitespaceEl = document.querySelector("#whitespace");
 
 
 
 
10
  const verdictEl = document.querySelector("#verdict");
11
  const overallEl = document.querySelector("#overall");
 
12
 
13
  let session = {};
14
  let clientPromise = Client.connect(window.location.origin);
 
15
 
16
  bootstrap();
17
 
@@ -19,11 +25,28 @@ form.addEventListener("submit", async (event) => {
19
  event.preventDefault();
20
  const message = input.value.trim();
21
  if (!message) return;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
  input.value = "";
23
  submit.disabled = true;
 
24
  ink.textContent = "";
25
  ink.classList.remove("bleed", "gold");
26
  corrections.textContent = "";
 
27
 
28
  try {
29
  const client = await clientPromise;
@@ -44,15 +67,20 @@ form.addEventListener("submit", async (event) => {
44
  ink.classList.add("bleed");
45
  } finally {
46
  submit.disabled = false;
 
47
  input.focus();
48
  }
49
- });
50
 
51
  async function bootstrap() {
52
  const response = await fetch("/api/bootstrap");
53
  const data = await response.json();
54
  renderProjects(data.top_projects || []);
55
  renderWhitespace(data.whitespace || []);
 
 
 
 
56
  }
57
 
58
  function handleEvent(event) {
@@ -74,15 +102,63 @@ function handleEvent(event) {
74
  session = event.state || {};
75
  if (event.projects?.length) renderProjects(event.projects);
76
  if (event.whitespace?.length) renderWhitespace(event.whitespace);
 
 
 
77
  if (event.score) {
78
  verdictEl.textContent = event.score.verdict;
79
  overallEl.textContent = Number(event.score.overall).toFixed(1);
 
80
  ink.classList.toggle("bleed", event.score.verdict.startsWith("ECHO"));
81
  ink.classList.toggle("gold", event.score.verdict.startsWith("UNWRITTEN"));
82
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
83
  }
84
  }
85
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
86
  function renderProjects(projects) {
87
  projectsEl.innerHTML = "";
88
  if (!projects.length) {
@@ -120,6 +196,143 @@ function renderWhitespace(items) {
120
  }
121
  }
122
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
123
  function escapeHtml(value) {
124
  return String(value)
125
  .replaceAll("&", "&amp;")
 
7
  const corrections = document.querySelector("#corrections");
8
  const projectsEl = document.querySelector("#projects");
9
  const whitespaceEl = document.querySelector("#whitespace");
10
+ const ideasEl = document.querySelector("#ideas");
11
+ const scoreEl = document.querySelector("#score");
12
+ const planEl = document.querySelector("#plan");
13
+ const traceEl = document.querySelector("#trace");
14
  const verdictEl = document.querySelector("#verdict");
15
  const overallEl = document.querySelector("#overall");
16
+ const exportButton = document.querySelector("#export-artifact");
17
 
18
  let session = {};
19
  let clientPromise = Client.connect(window.location.origin);
20
+ let currentArtifact = null;
21
 
22
  bootstrap();
23
 
 
25
  event.preventDefault();
26
  const message = input.value.trim();
27
  if (!message) return;
28
+ await runTurn(message);
29
+ });
30
+
31
+ document.querySelectorAll("[data-command]").forEach((button) => {
32
+ button.addEventListener("click", async () => {
33
+ await runTurn(button.dataset.command);
34
+ });
35
+ });
36
+
37
+ exportButton.addEventListener("click", () => {
38
+ if (!currentArtifact) return;
39
+ exportArtifact(currentArtifact);
40
+ });
41
+
42
+ async function runTurn(message) {
43
  input.value = "";
44
  submit.disabled = true;
45
+ setCommandDisabled(true);
46
  ink.textContent = "";
47
  ink.classList.remove("bleed", "gold");
48
  corrections.textContent = "";
49
+ planEl.innerHTML = "";
50
 
51
  try {
52
  const client = await clientPromise;
 
67
  ink.classList.add("bleed");
68
  } finally {
69
  submit.disabled = false;
70
+ setCommandDisabled(false);
71
  input.focus();
72
  }
73
+ }
74
 
75
  async function bootstrap() {
76
  const response = await fetch("/api/bootstrap");
77
  const data = await response.json();
78
  renderProjects(data.top_projects || []);
79
  renderWhitespace(data.whitespace || []);
80
+ renderIdeas([]);
81
+ renderScore(null);
82
+ renderPlan([]);
83
+ renderTrace([]);
84
  }
85
 
86
  function handleEvent(event) {
 
102
  session = event.state || {};
103
  if (event.projects?.length) renderProjects(event.projects);
104
  if (event.whitespace?.length) renderWhitespace(event.whitespace);
105
+ renderIdeas(session.ideas || []);
106
+ renderTrace(session.trace || []);
107
+ renderPlan(event.plan || []);
108
  if (event.score) {
109
  verdictEl.textContent = event.score.verdict;
110
  overallEl.textContent = Number(event.score.overall).toFixed(1);
111
+ renderScore(event.score);
112
  ink.classList.toggle("bleed", event.score.verdict.startsWith("ECHO"));
113
  ink.classList.toggle("gold", event.score.verdict.startsWith("UNWRITTEN"));
114
  }
115
+ if (event.artifact?.title) {
116
+ currentArtifact = event.artifact;
117
+ exportButton.disabled = false;
118
+ }
119
+ }
120
+ }
121
+
122
+ function renderIdeas(ideas) {
123
+ ideasEl.innerHTML = "";
124
+ if (!ideas.length) {
125
+ ideasEl.innerHTML = `<div class="empty">No pages written.</div>`;
126
+ return;
127
+ }
128
+ for (const idea of ideas.slice(-4).reverse()) {
129
+ const score = idea.score?.overall ? Number(idea.score.overall).toFixed(1) : "0.0";
130
+ const item = document.createElement("div");
131
+ item.className = "idea";
132
+ item.innerHTML = `
133
+ <strong>${escapeHtml(idea.title)}</strong>
134
+ <p>${escapeHtml((idea.pitch || "").slice(0, 120))}</p>
135
+ <span>${escapeHtml(idea.score?.verdict || "DRAFT")} · ${score}</span>
136
+ `;
137
+ ideasEl.append(item);
138
  }
139
  }
140
 
141
+ function renderScore(score) {
142
+ const rows = [
143
+ ["Originality", score?.originality || 0],
144
+ ["Delight", score?.delight || 0],
145
+ ["AI Need", score?.ai_necessity || 0],
146
+ ["Feasible", score?.feasibility || 0],
147
+ ["Prize Fit", score?.prize_fit || 0],
148
+ ];
149
+ scoreEl.innerHTML = rows
150
+ .map(
151
+ ([label, value]) => `
152
+ <div class="score-row">
153
+ <span>${label}</span>
154
+ <meter min="0" max="10" value="${value}"></meter>
155
+ <strong>${value}</strong>
156
+ </div>
157
+ `,
158
+ )
159
+ .join("");
160
+ }
161
+
162
  function renderProjects(projects) {
163
  projectsEl.innerHTML = "";
164
  if (!projects.length) {
 
196
  }
197
  }
198
 
199
+ function renderPlan(steps) {
200
+ planEl.innerHTML = "";
201
+ if (!steps.length) {
202
+ planEl.innerHTML = `<li class="empty">No wax path pressed.</li>`;
203
+ return;
204
+ }
205
+ for (const step of steps) {
206
+ const item = document.createElement("li");
207
+ item.textContent = step;
208
+ planEl.append(item);
209
+ }
210
+ }
211
+
212
+ function renderTrace(trace) {
213
+ traceEl.innerHTML = "";
214
+ if (!trace.length) {
215
+ traceEl.innerHTML = `<div class="empty">No tool marks yet.</div>`;
216
+ return;
217
+ }
218
+ for (const event of trace.slice(-4).reverse()) {
219
+ const item = document.createElement("div");
220
+ item.className = "trace";
221
+ const tools = (event.tools || []).map((tool) => tool.name).join(" -> ") || "reply";
222
+ item.innerHTML = `
223
+ <strong>${escapeHtml(event.verdict || "TURN")} ${event.overall ? Number(event.overall).toFixed(1) : ""}</strong>
224
+ <p>${escapeHtml(tools)}</p>
225
+ `;
226
+ traceEl.append(item);
227
+ }
228
+ }
229
+
230
+ function setCommandDisabled(disabled) {
231
+ document.querySelectorAll(".command-row button").forEach((button) => {
232
+ button.disabled = disabled || (button.id === "export-artifact" && !currentArtifact);
233
+ });
234
+ }
235
+
236
+ function exportArtifact(artifact) {
237
+ const canvas = document.createElement("canvas");
238
+ canvas.width = 1200;
239
+ canvas.height = 675;
240
+ const ctx = canvas.getContext("2d");
241
+ drawParchment(ctx, canvas.width, canvas.height);
242
+ const seal = artifact.seal || {};
243
+ ctx.fillStyle = "#25160e";
244
+ ctx.font = "700 58px Georgia, serif";
245
+ wrapText(ctx, artifact.title, 78, 112, 760, 66);
246
+ ctx.font = "28px Georgia, serif";
247
+ ctx.fillStyle = "#6b4e35";
248
+ wrapText(ctx, artifact.caption || "", 82, 252, 720, 36);
249
+
250
+ ctx.save();
251
+ ctx.translate(930, 226);
252
+ ctx.rotate(-0.08);
253
+ ctx.fillStyle = artifact.verdict?.startsWith("UNWRITTEN") ? "#b68a12" : "#8d2d26";
254
+ ctx.beginPath();
255
+ ctx.arc(0, 0, 120, 0, Math.PI * 2);
256
+ ctx.fill();
257
+ ctx.fillStyle = "#fff0b5";
258
+ ctx.textAlign = "center";
259
+ ctx.font = "800 27px Inter, sans-serif";
260
+ wrapText(ctx, artifact.verdict || "UNWRITTEN", -92, -28, 184, 32, "center");
261
+ ctx.font = "700 58px Georgia, serif";
262
+ ctx.fillText(Number(artifact.overall || seal.overall || 0).toFixed(1), 0, 48);
263
+ ctx.restore();
264
+
265
+ const rows = [
266
+ ["Originality", seal.originality || 0],
267
+ ["Delight", seal.delight || 0],
268
+ ["AI Need", seal.ai_necessity || 0],
269
+ ["Feasible", seal.feasibility || 0],
270
+ ["Prize Fit", seal.prize_fit || 0],
271
+ ];
272
+ rows.forEach(([label, value], index) => {
273
+ const y = 418 + index * 34;
274
+ ctx.fillStyle = "#6b4e35";
275
+ ctx.font = "700 20px Inter, sans-serif";
276
+ ctx.fillText(label, 82, y);
277
+ ctx.fillStyle = "rgba(80, 47, 22, 0.22)";
278
+ ctx.fillRect(240, y - 17, 320, 16);
279
+ ctx.fillStyle = artifact.verdict?.startsWith("UNWRITTEN") ? "#2f7a49" : "#8d2d26";
280
+ ctx.fillRect(240, y - 17, 32 * Number(value), 16);
281
+ ctx.fillStyle = "#25160e";
282
+ ctx.fillText(String(value), 582, y);
283
+ });
284
+
285
+ const link = document.createElement("a");
286
+ link.download = `${slugify(artifact.title || "unwritten-page")}.png`;
287
+ link.href = canvas.toDataURL("image/png");
288
+ link.click();
289
+ }
290
+
291
+ function drawParchment(ctx, width, height) {
292
+ const gradient = ctx.createLinearGradient(0, 0, width, height);
293
+ gradient.addColorStop(0, "#ead7a7");
294
+ gradient.addColorStop(0.55, "#d4b476");
295
+ gradient.addColorStop(1, "#b98a4c");
296
+ ctx.fillStyle = gradient;
297
+ ctx.fillRect(0, 0, width, height);
298
+ ctx.fillStyle = "rgba(59, 33, 15, 0.16)";
299
+ for (let i = 0; i < 360; i += 1) {
300
+ const x = (i * 73) % width;
301
+ const y = (i * 37) % height;
302
+ ctx.fillRect(x, y, 2 + (i % 7), 1);
303
+ }
304
+ ctx.strokeStyle = "rgba(72, 39, 18, 0.42)";
305
+ ctx.lineWidth = 16;
306
+ ctx.strokeRect(28, 28, width - 56, height - 56);
307
+ }
308
+
309
+ function wrapText(ctx, text, x, y, maxWidth, lineHeight, align = "left") {
310
+ const words = String(text).split(/\s+/);
311
+ let line = "";
312
+ const originalAlign = ctx.textAlign;
313
+ ctx.textAlign = align;
314
+ for (const word of words) {
315
+ const next = line ? `${line} ${word}` : word;
316
+ if (ctx.measureText(next).width > maxWidth && line) {
317
+ ctx.fillText(line, align === "center" ? x + maxWidth / 2 : x, y);
318
+ line = word;
319
+ y += lineHeight;
320
+ } else {
321
+ line = next;
322
+ }
323
+ }
324
+ if (line) ctx.fillText(line, align === "center" ? x + maxWidth / 2 : x, y);
325
+ ctx.textAlign = originalAlign;
326
+ }
327
+
328
+ function slugify(value) {
329
+ return String(value)
330
+ .toLowerCase()
331
+ .replace(/[^a-z0-9]+/g, "-")
332
+ .replace(/^-|-$/g, "")
333
+ .slice(0, 60);
334
+ }
335
+
336
  function escapeHtml(value) {
337
  return String(value)
338
  .replaceAll("&", "&amp;")
static/index.html CHANGED
@@ -26,6 +26,14 @@
26
  />
27
  <button id="submit" type="submit" title="Ink the page">Ink</button>
28
  </form>
 
 
 
 
 
 
 
 
29
  <div id="corrections" class="corrections" aria-live="polite"></div>
30
  </section>
31
 
@@ -34,7 +42,12 @@
34
  <span id="verdict">UNWRITTEN</span>
35
  <strong id="overall">0.0</strong>
36
  </div>
 
37
  <div class="panels">
 
 
 
 
38
  <article>
39
  <h2>Echoes</h2>
40
  <div id="projects" class="project-list"></div>
@@ -43,6 +56,14 @@
43
  <h2>Gold Margins</h2>
44
  <div id="whitespace" class="whitespace-list"></div>
45
  </article>
 
 
 
 
 
 
 
 
46
  </div>
47
  </section>
48
  </div>
 
26
  />
27
  <button id="submit" type="submit" title="Ink the page">Ink</button>
28
  </form>
29
+ <div class="command-row" aria-label="Advisor commands">
30
+ <button type="button" data-command="write bolder and find whitespace" title="Find a gold margin">
31
+ Gap
32
+ </button>
33
+ <button type="button" data-command="make a build plan" title="Draft a build plan">Plan</button>
34
+ <button type="button" data-command="compare ideas" title="Compare the idea board">Rank</button>
35
+ <button type="button" id="export-artifact" title="Export the current fate page" disabled>PNG</button>
36
+ </div>
37
  <div id="corrections" class="corrections" aria-live="polite"></div>
38
  </section>
39
 
 
42
  <span id="verdict">UNWRITTEN</span>
43
  <strong id="overall">0.0</strong>
44
  </div>
45
+ <div id="score" class="score-grid" aria-label="Seal quadrants"></div>
46
  <div class="panels">
47
+ <article>
48
+ <h2>Idea Board</h2>
49
+ <div id="ideas" class="idea-list"></div>
50
+ </article>
51
  <article>
52
  <h2>Echoes</h2>
53
  <div id="projects" class="project-list"></div>
 
56
  <h2>Gold Margins</h2>
57
  <div id="whitespace" class="whitespace-list"></div>
58
  </article>
59
+ <article>
60
+ <h2>Build Plan</h2>
61
+ <ol id="plan" class="plan-list"></ol>
62
+ </article>
63
+ <article>
64
+ <h2>Trace</h2>
65
+ <div id="trace" class="trace-list"></div>
66
+ </article>
67
  </div>
68
  </section>
69
  </div>
static/styles.css CHANGED
@@ -160,11 +160,34 @@ h2 {
160
  cursor: pointer;
161
  }
162
 
163
- .prompt-row button:disabled {
164
  opacity: 0.58;
165
  cursor: wait;
166
  }
167
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
168
  .corrections {
169
  min-height: 30px;
170
  padding-top: 10px;
@@ -205,19 +228,56 @@ h2 {
205
  line-height: 1;
206
  }
207
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
208
  .panels {
209
  display: grid;
210
- gap: 18px;
 
211
  }
212
 
213
  .project-list,
214
- .whitespace-list {
 
 
215
  display: grid;
216
- gap: 10px;
217
  }
218
 
219
  .project,
220
- .gap {
 
 
221
  border-left: 3px solid rgba(80, 47, 22, 0.48);
222
  padding: 8px 10px;
223
  background: rgba(255, 241, 196, 0.34);
@@ -225,7 +285,9 @@ h2 {
225
  }
226
 
227
  .project strong,
228
- .gap strong {
 
 
229
  display: block;
230
  color: #2a170d;
231
  font-size: 0.98rem;
@@ -233,13 +295,38 @@ h2 {
233
  }
234
 
235
  .project p,
236
- .gap p {
 
 
237
  margin: 4px 0 0;
238
  color: var(--muted-ink);
239
  font-size: 0.86rem;
240
  line-height: 1.35;
241
  }
242
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
243
  .empty {
244
  color: var(--muted-ink);
245
  font-size: 0.95rem;
@@ -259,6 +346,7 @@ h2 {
259
  min-height: 100vh;
260
  grid-template-columns: 1fr;
261
  border-radius: 0;
 
262
  }
263
 
264
  .page {
@@ -268,4 +356,16 @@ h2 {
268
  .ink {
269
  min-height: 168px;
270
  }
 
 
 
 
 
 
 
 
 
 
 
 
271
  }
 
160
  cursor: pointer;
161
  }
162
 
163
+ button:disabled {
164
  opacity: 0.58;
165
  cursor: wait;
166
  }
167
 
168
+ .command-row {
169
+ display: grid;
170
+ grid-template-columns: repeat(4, minmax(0, 1fr));
171
+ gap: 8px;
172
+ margin-top: 10px;
173
+ }
174
+
175
+ .command-row button {
176
+ min-width: 0;
177
+ height: 38px;
178
+ border: 1px solid rgba(80, 47, 22, 0.34);
179
+ border-radius: 8px;
180
+ background: rgba(255, 243, 203, 0.46);
181
+ color: #2a170d;
182
+ font-size: 0.86rem;
183
+ font-weight: 850;
184
+ cursor: pointer;
185
+ }
186
+
187
+ .command-row button:hover:not(:disabled) {
188
+ background: rgba(255, 243, 203, 0.68);
189
+ }
190
+
191
  .corrections {
192
  min-height: 30px;
193
  padding-top: 10px;
 
228
  line-height: 1;
229
  }
230
 
231
+ .score-grid {
232
+ display: grid;
233
+ gap: 7px;
234
+ margin: 0 0 22px;
235
+ }
236
+
237
+ .score-row {
238
+ display: grid;
239
+ grid-template-columns: 88px minmax(0, 1fr) 28px;
240
+ align-items: center;
241
+ gap: 9px;
242
+ color: var(--muted-ink);
243
+ font-size: 0.8rem;
244
+ font-weight: 800;
245
+ }
246
+
247
+ .score-row meter {
248
+ width: 100%;
249
+ height: 9px;
250
+ }
251
+
252
+ .score-row meter::-webkit-meter-bar {
253
+ border: 0;
254
+ border-radius: 999px;
255
+ background: rgba(80, 47, 22, 0.2);
256
+ }
257
+
258
+ .score-row meter::-webkit-meter-optimum-value {
259
+ border-radius: 999px;
260
+ background: linear-gradient(90deg, var(--red), var(--gold), var(--leaf));
261
+ }
262
+
263
  .panels {
264
  display: grid;
265
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
266
+ gap: 16px;
267
  }
268
 
269
  .project-list,
270
+ .whitespace-list,
271
+ .idea-list,
272
+ .trace-list {
273
  display: grid;
274
+ gap: 9px;
275
  }
276
 
277
  .project,
278
+ .gap,
279
+ .idea,
280
+ .trace {
281
  border-left: 3px solid rgba(80, 47, 22, 0.48);
282
  padding: 8px 10px;
283
  background: rgba(255, 241, 196, 0.34);
 
285
  }
286
 
287
  .project strong,
288
+ .gap strong,
289
+ .idea strong,
290
+ .trace strong {
291
  display: block;
292
  color: #2a170d;
293
  font-size: 0.98rem;
 
295
  }
296
 
297
  .project p,
298
+ .gap p,
299
+ .idea p,
300
+ .trace p {
301
  margin: 4px 0 0;
302
  color: var(--muted-ink);
303
  font-size: 0.86rem;
304
  line-height: 1.35;
305
  }
306
 
307
+ .idea span {
308
+ display: inline-block;
309
+ margin-top: 6px;
310
+ color: #70401e;
311
+ font-size: 0.76rem;
312
+ font-weight: 900;
313
+ letter-spacing: 0.04em;
314
+ }
315
+
316
+ .plan-list {
317
+ display: grid;
318
+ gap: 7px;
319
+ min-height: 31px;
320
+ margin: 0;
321
+ padding-left: 20px;
322
+ }
323
+
324
+ .plan-list li {
325
+ color: #2a170d;
326
+ font-size: 0.86rem;
327
+ line-height: 1.32;
328
+ }
329
+
330
  .empty {
331
  color: var(--muted-ink);
332
  font-size: 0.95rem;
 
346
  min-height: 100vh;
347
  grid-template-columns: 1fr;
348
  border-radius: 0;
349
+ overflow: visible;
350
  }
351
 
352
  .page {
 
356
  .ink {
357
  min-height: 168px;
358
  }
359
+
360
+ .panels {
361
+ grid-template-columns: 1fr;
362
+ }
363
+
364
+ .command-row {
365
+ grid-template-columns: repeat(2, minmax(0, 1fr));
366
+ }
367
+
368
+ .score-row {
369
+ grid-template-columns: 82px minmax(0, 1fr) 26px;
370
+ }
371
  }
tests/test_agent.py CHANGED
@@ -13,6 +13,8 @@ def test_agent_scores_and_persists_idea() -> None:
13
  assert result.score is not None
14
  assert result.state["ideas"]
15
  assert result.state["ideas"][0]["score"] is not None
 
 
16
  assert result.response
17
 
18
 
@@ -35,3 +37,15 @@ def test_agent_preserves_canonical_jargon_case() -> None:
35
 
36
  assert "MiniCPM5" in result.artifact["title"]
37
  assert "ZeroGPU" in result.artifact["title"]
 
 
 
 
 
 
 
 
 
 
 
 
 
13
  assert result.score is not None
14
  assert result.state["ideas"]
15
  assert result.state["ideas"][0]["score"] is not None
16
+ assert result.state["trace"]
17
+ assert result.state["last_artifact"]["title"] == result.artifact["title"]
18
  assert result.response
19
 
20
 
 
37
 
38
  assert "MiniCPM5" in result.artifact["title"]
39
  assert "ZeroGPU" in result.artifact["title"]
40
+
41
+
42
+ def test_plan_command_uses_current_idea() -> None:
43
+ index = ProjectIndex.from_file(Path("data/projects.json"))
44
+ engine = AdvisorEngine(index)
45
+
46
+ first = engine.turn("A local-first archive cartographer for family photos", {})
47
+ planned = engine.turn("make a build plan", first.state)
48
+
49
+ assert planned.plan
50
+ assert planned.artifact["title"] == first.artifact["title"]
51
+ assert planned.state["ideas"][0]["title"] == first.artifact["title"]