hex-audit 0.5.4 → 0.5.5

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.
Files changed (93) hide show
  1. package/README.md +22 -1
  2. package/dashboard/.next/BUILD_ID +1 -1
  3. package/dashboard/.next/app-build-manifest.json +20 -20
  4. package/dashboard/.next/app-path-routes-manifest.json +1 -1
  5. package/dashboard/.next/build-manifest.json +4 -4
  6. package/dashboard/.next/next-server.js.nft.json +1 -1
  7. package/dashboard/.next/prerender-manifest.js +1 -1
  8. package/dashboard/.next/prerender-manifest.json +1 -1
  9. package/dashboard/.next/required-server-files.json +1 -1
  10. package/dashboard/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  11. package/dashboard/.next/server/app/_not-found.html +1 -1
  12. package/dashboard/.next/server/app/_not-found.rsc +1 -1
  13. package/dashboard/.next/server/app/access/page_client-reference-manifest.js +1 -1
  14. package/dashboard/.next/server/app/api/notes/done/route.js +1 -0
  15. package/dashboard/.next/server/app/api/notes/done/route.js.nft.json +1 -0
  16. package/dashboard/.next/server/app/api/notes/lead/route.js +1 -0
  17. package/dashboard/.next/server/app/api/notes/lead/route.js.nft.json +1 -0
  18. package/dashboard/.next/server/app/api/notes/question/route.js +1 -0
  19. package/dashboard/.next/server/app/api/notes/question/route.js.nft.json +1 -0
  20. package/dashboard/.next/server/app/api/notes/record/route.js +3 -3
  21. package/dashboard/.next/server/app/api/notes/route.js +2 -2
  22. package/dashboard/.next/server/app/calls/page_client-reference-manifest.js +1 -1
  23. package/dashboard/.next/server/app/conformance/page_client-reference-manifest.js +1 -1
  24. package/dashboard/.next/server/app/diagram/page_client-reference-manifest.js +1 -1
  25. package/dashboard/.next/server/app/diagram.html +1 -1
  26. package/dashboard/.next/server/app/diagram.rsc +1 -1
  27. package/dashboard/.next/server/app/flows/page_client-reference-manifest.js +1 -1
  28. package/dashboard/.next/server/app/flows.html +1 -1
  29. package/dashboard/.next/server/app/flows.rsc +1 -1
  30. package/dashboard/.next/server/app/functions/page_client-reference-manifest.js +1 -1
  31. package/dashboard/.next/server/app/issues/page.js +4 -4
  32. package/dashboard/.next/server/app/issues/page_client-reference-manifest.js +1 -1
  33. package/dashboard/.next/server/app/notes/page.js +3 -3
  34. package/dashboard/.next/server/app/notes/page_client-reference-manifest.js +1 -1
  35. package/dashboard/.next/server/app/overleaf/page_client-reference-manifest.js +1 -1
  36. package/dashboard/.next/server/app/page.js +2 -2
  37. package/dashboard/.next/server/app/page_client-reference-manifest.js +1 -1
  38. package/dashboard/.next/server/app/progress/page.js +1 -1
  39. package/dashboard/.next/server/app/progress/page_client-reference-manifest.js +1 -1
  40. package/dashboard/.next/server/app/stats/page_client-reference-manifest.js +1 -1
  41. package/dashboard/.next/server/app-paths-manifest.json +8 -5
  42. package/dashboard/.next/server/chunks/530.js +1 -1
  43. package/dashboard/.next/server/chunks/622.js +3 -0
  44. package/dashboard/.next/server/functions-config-manifest.json +1 -1
  45. package/dashboard/.next/server/middleware-build-manifest.js +1 -1
  46. package/dashboard/.next/server/pages/404.html +1 -1
  47. package/dashboard/.next/server/pages/500.html +1 -1
  48. package/dashboard/.next/server/server-reference-manifest.js +1 -1
  49. package/dashboard/.next/server/server-reference-manifest.json +1 -1
  50. package/dashboard/.next/static/chunks/app/issues/{page-abb062e4cf118340.js → page-72089090c04e96ee.js} +1 -1
  51. package/dashboard/.next/static/chunks/app/notes/page-17af94c67ba79c00.js +1 -0
  52. package/dashboard/.next/static/chunks/app/page-24bac51dfe007c66.js +1 -0
  53. package/dashboard/.next/static/chunks/app/progress/page-427fb18da3afb061.js +1 -0
  54. package/dashboard/.next/static/chunks/{main-a6cc6e599cefa893.js → main-9a811c95265dbd11.js} +1 -1
  55. package/dashboard/.next/static/css/4a38d8f6f6b7a68f.css +3 -0
  56. package/dashboard/app/api/notes/done/route.ts +26 -0
  57. package/dashboard/app/api/notes/lead/route.ts +40 -0
  58. package/dashboard/app/api/notes/question/route.ts +30 -0
  59. package/dashboard/app/api/notes/record/route.ts +10 -2
  60. package/dashboard/app/notes/NotesClient.tsx +494 -77
  61. package/dashboard/app/notes/page.tsx +5 -2
  62. package/dashboard/app/progress/ProgressClient.tsx +29 -8
  63. package/dashboard/app/progress/page.tsx +2 -0
  64. package/dashboard/components/MarkdownRenderer.tsx +31 -0
  65. package/dashboard/next.config.mjs +11 -0
  66. package/dashboard/whisper-server.py +85 -0
  67. package/dist/commands/dashboard.d.ts.map +1 -1
  68. package/dist/commands/dashboard.js +23 -1
  69. package/dist/commands/dashboard.js.map +1 -1
  70. package/dist/commands/note.d.ts.map +1 -1
  71. package/dist/commands/note.js +55 -2
  72. package/dist/commands/note.js.map +1 -1
  73. package/dist/core/notes.d.ts +68 -0
  74. package/dist/core/notes.d.ts.map +1 -1
  75. package/dist/core/notes.js +167 -0
  76. package/dist/core/notes.js.map +1 -1
  77. package/dist/core/review.d.ts +14 -0
  78. package/dist/core/review.d.ts.map +1 -0
  79. package/dist/core/review.js +80 -0
  80. package/dist/core/review.js.map +1 -0
  81. package/dist/core/whisper.d.ts +8 -0
  82. package/dist/core/whisper.d.ts.map +1 -1
  83. package/dist/core/whisper.js +55 -0
  84. package/dist/core/whisper.js.map +1 -1
  85. package/dist/skills/diane.md +48 -66
  86. package/dist/skills/ingest-aa-report.md +4 -4
  87. package/package.json +2 -1
  88. package/dashboard/.next/static/chunks/app/notes/page-bc458d3e1639c526.js +0 -1
  89. package/dashboard/.next/static/chunks/app/page-1226422e170d8895.js +0 -1
  90. package/dashboard/.next/static/chunks/app/progress/page-f07ac39d1d27b480.js +0 -1
  91. package/dashboard/.next/static/css/23b3f575e87bea75.css +0 -3
  92. /package/dashboard/.next/static/{JIjye0HUTWALF3gbtaZF8 → ZvCvC4cMbL3GR8kloB9OJ}/_buildManifest.js +0 -0
  93. /package/dashboard/.next/static/{JIjye0HUTWALF3gbtaZF8 → ZvCvC4cMbL3GR8kloB9OJ}/_ssgManifest.js +0 -0
@@ -2,12 +2,8 @@
2
2
 
3
3
  import { useCallback, useMemo, useRef, useState } from 'react';
4
4
  import { useRouter } from 'next/navigation';
5
- import { MarkdownRenderer } from '@/components/MarkdownRenderer';
6
-
7
- interface ContractNote {
8
- name: string;
9
- body: string;
10
- }
5
+ import { MarkdownRenderer, InlineMarkdown } from '@/components/MarkdownRenderer';
6
+ import type { ContractNote, FnEntry, Question, Lead } from '../../../src/core/notes';
11
7
 
12
8
  interface NotesClientProps {
13
9
  general: string;
@@ -19,6 +15,7 @@ interface NotesClientProps {
19
15
 
20
16
  type RecState = 'idle' | 'recording' | 'transcribing';
21
17
  const OTHER = '__other__';
18
+ const GENERAL = 'general';
22
19
 
23
20
  function fmt(sec: number): string {
24
21
  const m = Math.floor(sec / 60);
@@ -35,42 +32,18 @@ export function NotesClient({
35
32
  }: NotesClientProps) {
36
33
  const router = useRouter();
37
34
 
38
- // ── Docs (left rail) ──
39
- const docs = useMemo(
40
- () => [{ key: 'general', label: 'General', body: general }, ...contractNotes.map((c) => ({ key: c.name, label: c.name, body: c.body }))],
41
- [general, contractNotes],
35
+ // ── Left rail: General + each contract note ──
36
+ const railItems = useMemo(
37
+ () => [
38
+ { key: GENERAL, label: 'General' },
39
+ ...contractNotes.map((n) => ({ key: n.contract, label: n.contract })),
40
+ ],
41
+ [contractNotes],
42
42
  );
43
43
  const initialKey =
44
- activeContract && docs.some((d) => d.key === activeContract) ? activeContract : 'general';
44
+ activeContract && railItems.some((d) => d.key === activeContract) ? activeContract : GENERAL;
45
45
  const [selectedKey, setSelectedKey] = useState<string>(initialKey);
46
- const current = docs.find((d) => d.key === selectedKey) ?? docs[0];
47
-
48
- // ── Editing ──
49
- const [editing, setEditing] = useState(false);
50
- const [draft, setDraft] = useState('');
51
- const [saving, setSaving] = useState(false);
52
-
53
- const startEdit = () => {
54
- setDraft(current.body);
55
- setEditing(true);
56
- };
57
- const save = async () => {
58
- setSaving(true);
59
- try {
60
- const res = await fetch('/api/notes', {
61
- method: 'PUT',
62
- headers: { 'Content-Type': 'application/json' },
63
- body: JSON.stringify({ target: current.key, body: draft }),
64
- });
65
- if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error ?? 'save failed');
66
- setEditing(false);
67
- router.refresh();
68
- } catch (err) {
69
- setStatusMsg(err instanceof Error ? err.message : 'Save failed');
70
- } finally {
71
- setSaving(false);
72
- }
73
- };
46
+ const selectedNote = contractNotes.find((n) => n.contract === selectedKey) ?? null;
74
47
 
75
48
  // ── Recording ──
76
49
  const [recState, setRecState] = useState<RecState>('idle');
@@ -209,18 +182,15 @@ export function NotesClient({
209
182
  )}
210
183
  </div>
211
184
 
212
- {/* ── Notes (rail + doc) ── */}
185
+ {/* ── Notes (rail + structured view) ── */}
213
186
  <div className="flex gap-sp-5">
214
187
  <nav className="w-44 shrink-0 space-y-0.5">
215
- {docs.map((d) => (
188
+ {railItems.map((d) => (
216
189
  <button
217
190
  key={d.key}
218
191
  type="button"
219
- onClick={() => {
220
- setSelectedKey(d.key);
221
- setEditing(false);
222
- }}
223
- className={`block w-full truncate rounded-md px-3 py-1.5 text-left text-body ${
192
+ onClick={() => setSelectedKey(d.key)}
193
+ className={`block w-full truncate rounded-md px-3 py-1.5 text-left text-body transition-colors duration-200 ${
224
194
  d.key === selectedKey
225
195
  ? 'bg-accent-subtle font-medium text-accent'
226
196
  : 'text-text-secondary hover:bg-surface-2 hover:text-text-primary'
@@ -231,54 +201,501 @@ export function NotesClient({
231
201
  ))}
232
202
  </nav>
233
203
 
234
- <div className="min-w-0 flex-1 rounded-lg border border-border-subtle bg-surface-1 p-5">
235
- <div className="mb-3 flex items-center justify-between">
236
- <span className="text-heading font-medium text-text-primary">{current.label}</span>
237
- {editing ? (
204
+ <div className="min-w-0 flex-1">
205
+ {selectedKey === GENERAL ? (
206
+ <GeneralDoc body={general} />
207
+ ) : (
208
+ <ContractView
209
+ note={selectedNote}
210
+ contract={selectedKey}
211
+ onMutate={() => router.refresh()}
212
+ />
213
+ )}
214
+ </div>
215
+ </div>
216
+ </div>
217
+ );
218
+ }
219
+
220
+ // ─── General (freeform markdown) ────────────────────────────────────
221
+
222
+ function GeneralDoc({ body }: { body: string }) {
223
+ return (
224
+ <div className="rounded-lg border border-border-subtle bg-surface-1 p-5">
225
+ <div className="mb-3 text-heading font-medium text-text-primary">General</div>
226
+ {body.trim() ? (
227
+ <MarkdownRenderer content={body} />
228
+ ) : (
229
+ <p className="text-body text-text-tertiary">
230
+ No protocol-wide notes yet. Record a narration above against{' '}
231
+ <span className="font-medium">Other…</span> as <code className="font-mono">general</code> and
232
+ run <code className="font-mono">/diane</code>.
233
+ </p>
234
+ )}
235
+ </div>
236
+ );
237
+ }
238
+
239
+ // ─── Contract (structured view) ─────────────────────────────────────
240
+
241
+ const SECTIONS = [
242
+ { id: 'leads', label: 'Leads' },
243
+ { id: 'description', label: 'Description' },
244
+ { id: 'questions', label: 'Questions' },
245
+ ] as const;
246
+
247
+ function scrollToSection(id: string) {
248
+ document.getElementById(`note-${id}`)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
249
+ }
250
+
251
+ function ContractView({
252
+ note,
253
+ contract,
254
+ onMutate,
255
+ }: {
256
+ note: ContractNote | null;
257
+ contract: string;
258
+ onMutate: () => void;
259
+ }) {
260
+ const [busy, setBusy] = useState(false);
261
+
262
+ const post = useCallback(
263
+ async (url: string, body: unknown) => {
264
+ setBusy(true);
265
+ try {
266
+ const res = await fetch(url, {
267
+ method: 'POST',
268
+ headers: { 'Content-Type': 'application/json' },
269
+ body: JSON.stringify(body),
270
+ });
271
+ if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error ?? 'request failed');
272
+ onMutate();
273
+ } catch {
274
+ /* surface via refresh; keep UI resilient */
275
+ } finally {
276
+ setBusy(false);
277
+ }
278
+ },
279
+ [onMutate],
280
+ );
281
+
282
+ const isEmpty =
283
+ !note ||
284
+ (note.leads.length === 0 &&
285
+ note.questions.length === 0 &&
286
+ note.description.purpose.length === 0 &&
287
+ note.description.inheritance.length === 0 &&
288
+ note.description.storage.length === 0 &&
289
+ note.description.roles.length === 0 &&
290
+ note.description.functions.length === 0);
291
+
292
+ return (
293
+ <div className="rounded-lg border border-border-subtle bg-surface-1 p-5">
294
+ {/* Title + mark-done + section nav */}
295
+ <div className="mb-4 flex flex-wrap items-center justify-between gap-3">
296
+ <div className="flex items-center gap-2">
297
+ <span className="text-heading font-medium text-text-primary">{contract}</span>
298
+ {note?.file && (
299
+ <span className="font-mono text-caption text-text-tertiary">{note.file}</span>
300
+ )}
301
+ </div>
302
+ <label className="flex cursor-pointer items-center gap-2 text-caption text-text-secondary">
303
+ <input
304
+ type="checkbox"
305
+ checked={Boolean(note?.marked_done)}
306
+ disabled={busy || !note}
307
+ onChange={(e) => post('/api/notes/done', { contract, done: e.target.checked })}
308
+ className="h-4 w-4 rounded border-border-emphasis bg-surface-1 accent-[var(--accent)]"
309
+ />
310
+ Mark done reading
311
+ </label>
312
+ </div>
313
+
314
+ {isEmpty ? (
315
+ <p className="text-body text-text-tertiary">
316
+ No notes yet — record a narration above and run <code className="font-mono">/diane</code>.
317
+ </p>
318
+ ) : (
319
+ <>
320
+ <nav className="mb-5 flex flex-wrap items-center gap-1 border-b border-border-subtle pb-3 text-caption">
321
+ {SECTIONS.map((s) => (
322
+ <button
323
+ key={s.id}
324
+ type="button"
325
+ onClick={() => scrollToSection(s.id)}
326
+ className="rounded-md px-2 py-1 text-text-secondary transition-colors duration-200 hover:bg-surface-2 hover:text-text-primary"
327
+ >
328
+ {s.label}
329
+ </button>
330
+ ))}
331
+ </nav>
332
+
333
+ <div className="space-y-6">
334
+ <LeadsSection
335
+ leads={note!.leads}
336
+ contract={contract}
337
+ busy={busy}
338
+ post={post}
339
+ />
340
+ <DescriptionSection note={note!} />
341
+ <QuestionsSection
342
+ questions={note!.questions}
343
+ contract={contract}
344
+ busy={busy}
345
+ post={post}
346
+ />
347
+ </div>
348
+ </>
349
+ )}
350
+ </div>
351
+ );
352
+ }
353
+
354
+ // ─── Leads ──────────────────────────────────────────────────────────
355
+
356
+ function LeadsSection({
357
+ leads,
358
+ contract,
359
+ busy,
360
+ post,
361
+ }: {
362
+ leads: Lead[];
363
+ contract: string;
364
+ busy: boolean;
365
+ post: (url: string, body: unknown) => Promise<void>;
366
+ }) {
367
+ const open = leads.filter((l) => l.status === 'open');
368
+ const closed = leads.filter((l) => l.status !== 'open');
369
+ const [showClosed, setShowClosed] = useState(false);
370
+
371
+ return (
372
+ <section id="note-leads">
373
+ <h3 className="mb-2 text-heading font-medium text-text-primary">Leads</h3>
374
+ {open.length === 0 ? (
375
+ <p className="text-body text-text-tertiary">No open leads.</p>
376
+ ) : (
377
+ <ul className="space-y-2">
378
+ {open.map((lead) => (
379
+ <li
380
+ key={lead.id}
381
+ className="flex items-start justify-between gap-3 rounded-md border border-border-subtle bg-surface-2 px-3 py-2"
382
+ >
383
+ <span className="min-w-0 flex-1 text-body text-text-primary">
384
+ <InlineMarkdown>{lead.text}</InlineMarkdown>
385
+ </span>
386
+ <button
387
+ type="button"
388
+ disabled={busy}
389
+ onClick={() =>
390
+ post('/api/notes/lead', { contract, id: lead.id, action: 'close', status: 'dismissed' })
391
+ }
392
+ className="shrink-0 rounded-md border border-border-default px-2.5 py-1 text-caption text-text-secondary transition-colors duration-200 hover:bg-surface-3 hover:text-text-primary disabled:opacity-50"
393
+ >
394
+ Close
395
+ </button>
396
+ </li>
397
+ ))}
398
+ </ul>
399
+ )}
400
+
401
+ {closed.length > 0 && (
402
+ <div className="mt-3">
403
+ <button
404
+ type="button"
405
+ onClick={() => setShowClosed((v) => !v)}
406
+ className="flex items-center gap-1.5 text-caption text-text-tertiary transition-colors duration-200 hover:text-text-secondary"
407
+ >
408
+ <svg
409
+ className={`h-3.5 w-3.5 transition-transform duration-200 ${showClosed ? 'rotate-90' : ''}`}
410
+ fill="none"
411
+ viewBox="0 0 24 24"
412
+ stroke="currentColor"
413
+ strokeWidth={2}
414
+ >
415
+ <path strokeLinecap="round" strokeLinejoin="round" d="m9 5 7 7-7 7" />
416
+ </svg>
417
+ Closed ({closed.length})
418
+ </button>
419
+ {showClosed && (
420
+ <ul className="mt-2 space-y-1.5">
421
+ {closed.map((lead) => (
422
+ <li
423
+ key={lead.id}
424
+ className="flex items-start justify-between gap-3 rounded-md px-3 py-1.5"
425
+ >
426
+ <span className="min-w-0 flex-1 text-caption text-text-tertiary">
427
+ <span className="line-through"><InlineMarkdown>{lead.text}</InlineMarkdown></span>
428
+ {lead.status === 'logged' && lead.ref && (
429
+ <span className="ml-2 font-mono text-text-secondary">→ {lead.ref}</span>
430
+ )}
431
+ {lead.status === 'dismissed' && (
432
+ <span className="ml-2 text-text-tertiary">dismissed</span>
433
+ )}
434
+ </span>
435
+ <button
436
+ type="button"
437
+ disabled={busy}
438
+ onClick={() => post('/api/notes/lead', { contract, id: lead.id, action: 'reopen' })}
439
+ className="shrink-0 rounded-md px-2 py-0.5 text-caption text-text-secondary transition-colors duration-200 hover:text-accent disabled:opacity-50"
440
+ >
441
+ Reopen
442
+ </button>
443
+ </li>
444
+ ))}
445
+ </ul>
446
+ )}
447
+ </div>
448
+ )}
449
+ </section>
450
+ );
451
+ }
452
+
453
+ // ─── Description ────────────────────────────────────────────────────
454
+
455
+ function BulletList({ label, items }: { label: string; items: string[] }) {
456
+ if (items.length === 0) return null;
457
+ return (
458
+ <div>
459
+ <h4 className="mb-1 text-body font-medium text-text-secondary">{label}</h4>
460
+ <ul className="list-disc space-y-0.5 pl-5 text-body text-text-primary">
461
+ {items.map((it, i) => (
462
+ <li key={i}>
463
+ <InlineMarkdown>{it}</InlineMarkdown>
464
+ </li>
465
+ ))}
466
+ </ul>
467
+ </div>
468
+ );
469
+ }
470
+
471
+ function DescriptionSection({ note }: { note: ContractNote }) {
472
+ const { purpose, inheritance, storage, roles, functions } = note.description;
473
+ const hasAny =
474
+ purpose.length > 0 ||
475
+ inheritance.length > 0 ||
476
+ storage.length > 0 ||
477
+ roles.length > 0 ||
478
+ functions.length > 0;
479
+
480
+ return (
481
+ <section id="note-description">
482
+ <h3 className="mb-3 text-heading font-medium text-text-primary">Description</h3>
483
+ {!hasAny ? (
484
+ <p className="text-body text-text-tertiary">No description recorded yet.</p>
485
+ ) : (
486
+ <div className="space-y-4">
487
+ <BulletList label="Purpose" items={purpose} />
488
+ <BulletList label="Inheritance" items={inheritance} />
489
+ <BulletList label="Storage" items={storage} />
490
+ <BulletList label="Roles & modifiers" items={roles} />
491
+ {functions.length > 0 && (
492
+ <div>
493
+ <h4 className="mb-1.5 text-body font-medium text-text-secondary">Functions</h4>
494
+ <div className="space-y-1.5">
495
+ {functions.map((fn) => (
496
+ <FunctionAccordion key={fn.id} fn={fn} />
497
+ ))}
498
+ </div>
499
+ </div>
500
+ )}
501
+ </div>
502
+ )}
503
+ </section>
504
+ );
505
+ }
506
+
507
+ function FunctionAccordion({ fn }: { fn: FnEntry }) {
508
+ const [open, setOpen] = useState(false);
509
+ const meta: [string, string | undefined][] = [
510
+ ['Purpose', fn.purpose],
511
+ ['Access', fn.access],
512
+ ['Effects', fn.effects],
513
+ ];
514
+ return (
515
+ <div className="rounded-md border border-border-subtle">
516
+ <button
517
+ type="button"
518
+ onClick={() => setOpen((v) => !v)}
519
+ className="flex w-full items-center gap-2 rounded-md bg-surface-2 px-3 py-2 text-left transition-colors duration-200 hover:bg-surface-3"
520
+ >
521
+ <svg
522
+ className={`h-4 w-4 shrink-0 text-text-tertiary transition-transform duration-200 ${open ? 'rotate-90' : ''}`}
523
+ fill="none"
524
+ viewBox="0 0 24 24"
525
+ stroke="currentColor"
526
+ strokeWidth={2}
527
+ >
528
+ <path strokeLinecap="round" strokeLinejoin="round" d="m9 5 7 7-7 7" />
529
+ </svg>
530
+ <span className="min-w-0 truncate font-mono text-body text-text-primary">{fn.sig}</span>
531
+ {fn.warnings.length > 0 && (
532
+ <span className="ml-auto shrink-0 text-caption text-[var(--high)]">
533
+ ⚠ {fn.warnings.length}
534
+ </span>
535
+ )}
536
+ </button>
537
+ {open && (
538
+ <div className="space-y-2 px-3 py-2.5">
539
+ {meta
540
+ .filter(([, v]) => v && v.trim())
541
+ .map(([label, v]) => (
542
+ <p key={label} className="text-body text-text-primary">
543
+ <span className="text-text-secondary">{label}: </span>
544
+ <InlineMarkdown>{v as string}</InlineMarkdown>
545
+ </p>
546
+ ))}
547
+ {fn.notes.length > 0 && (
548
+ <ul className="list-disc space-y-0.5 pl-5 text-body text-text-primary">
549
+ {fn.notes.map((n, i) => (
550
+ <li key={i}>
551
+ <InlineMarkdown>{n}</InlineMarkdown>
552
+ </li>
553
+ ))}
554
+ </ul>
555
+ )}
556
+ {fn.warnings.length > 0 && (
557
+ <ul className="space-y-1">
558
+ {fn.warnings.map((w, i) => (
559
+ <li
560
+ key={i}
561
+ className="flex items-start gap-1.5 rounded-md bg-[var(--high)]/10 px-2 py-1 text-body text-[var(--high)]"
562
+ >
563
+ <span aria-hidden>⚠</span>
564
+ <span className="min-w-0 flex-1">
565
+ <InlineMarkdown>{w}</InlineMarkdown>
566
+ </span>
567
+ </li>
568
+ ))}
569
+ </ul>
570
+ )}
571
+ </div>
572
+ )}
573
+ </div>
574
+ );
575
+ }
576
+
577
+ // ─── Questions ──────────────────────────────────────────────────────
578
+
579
+ function QuestionsSection({
580
+ questions,
581
+ contract,
582
+ busy,
583
+ post,
584
+ }: {
585
+ questions: Question[];
586
+ contract: string;
587
+ busy: boolean;
588
+ post: (url: string, body: unknown) => Promise<void>;
589
+ }) {
590
+ return (
591
+ <section id="note-questions">
592
+ <h3 className="mb-2 text-heading font-medium text-text-primary">Questions</h3>
593
+ {questions.length === 0 ? (
594
+ <p className="text-body text-text-tertiary">No questions recorded yet.</p>
595
+ ) : (
596
+ <div className="space-y-1.5">
597
+ {questions.map((q) => (
598
+ <QuestionAccordion
599
+ key={q.id}
600
+ question={q}
601
+ contract={contract}
602
+ busy={busy}
603
+ post={post}
604
+ />
605
+ ))}
606
+ </div>
607
+ )}
608
+ </section>
609
+ );
610
+ }
611
+
612
+ function QuestionAccordion({
613
+ question,
614
+ contract,
615
+ busy,
616
+ post,
617
+ }: {
618
+ question: Question;
619
+ contract: string;
620
+ busy: boolean;
621
+ post: (url: string, body: unknown) => Promise<void>;
622
+ }) {
623
+ const answered = Boolean(question.answer && question.answer.trim());
624
+ const [open, setOpen] = useState(false);
625
+ const [editing, setEditing] = useState(false);
626
+ const [draft, setDraft] = useState(question.answer ?? '');
627
+
628
+ return (
629
+ <div className="rounded-md border border-border-subtle">
630
+ <button
631
+ type="button"
632
+ onClick={() => setOpen((v) => !v)}
633
+ className="flex w-full items-center gap-2 rounded-md bg-surface-2 px-3 py-2 text-left transition-colors duration-200 hover:bg-surface-3"
634
+ >
635
+ <svg
636
+ className={`h-4 w-4 shrink-0 text-text-tertiary transition-transform duration-200 ${open ? 'rotate-90' : ''}`}
637
+ fill="none"
638
+ viewBox="0 0 24 24"
639
+ stroke="currentColor"
640
+ strokeWidth={2}
641
+ >
642
+ <path strokeLinecap="round" strokeLinejoin="round" d="m9 5 7 7-7 7" />
643
+ </svg>
644
+ <span className={`min-w-0 flex-1 text-body ${answered ? 'text-text-primary' : 'text-text-tertiary'}`}>
645
+ <InlineMarkdown>{question.q}</InlineMarkdown>
646
+ </span>
647
+ {!answered && <span className="shrink-0 text-caption text-text-tertiary">open</span>}
648
+ </button>
649
+ {open && (
650
+ <div className="space-y-2 px-3 py-2.5">
651
+ {editing ? (
652
+ <div className="space-y-2">
653
+ <textarea
654
+ value={draft}
655
+ onChange={(e) => setDraft(e.target.value)}
656
+ rows={3}
657
+ className="w-full resize-y rounded-md border border-border-default bg-surface-0 p-2 text-body text-text-primary focus:outline-none focus:ring-2 focus:ring-accent"
658
+ />
238
659
  <div className="flex gap-2">
239
660
  <button
240
661
  type="button"
241
- onClick={() => setEditing(false)}
242
- className="rounded-md px-3 py-1 text-caption text-text-secondary hover:text-text-primary"
662
+ onClick={() => {
663
+ setEditing(false);
664
+ setDraft(question.answer ?? '');
665
+ }}
666
+ className="rounded-md px-2.5 py-1 text-caption text-text-secondary transition-colors duration-200 hover:text-text-primary"
243
667
  >
244
668
  Cancel
245
669
  </button>
246
670
  <button
247
671
  type="button"
248
- onClick={save}
249
- disabled={saving}
250
- className="rounded-md bg-accent px-3 py-1 text-caption font-medium text-surface-0 disabled:opacity-50"
672
+ disabled={busy || !draft.trim()}
673
+ onClick={async () => {
674
+ await post('/api/notes/question', { contract, id: question.id, answer: draft.trim() });
675
+ setEditing(false);
676
+ }}
677
+ className="rounded-md bg-accent px-2.5 py-1 text-caption font-medium text-surface-0 transition-opacity duration-200 hover:opacity-90 disabled:opacity-50"
251
678
  >
252
- {saving ? 'Saving…' : 'Save'}
679
+ Save answer
253
680
  </button>
254
681
  </div>
255
- ) : (
682
+ </div>
683
+ ) : (
684
+ <div className="flex items-start justify-between gap-3">
685
+ <p className={`min-w-0 flex-1 text-body ${answered ? 'text-text-primary' : 'text-text-tertiary'}`}>
686
+ {answered ? <InlineMarkdown>{question.answer as string}</InlineMarkdown> : '— open'}
687
+ </p>
256
688
  <button
257
689
  type="button"
258
- onClick={startEdit}
259
- className="rounded-md border border-border-default px-3 py-1 text-caption text-text-secondary hover:bg-surface-2 hover:text-text-primary"
690
+ onClick={() => setEditing(true)}
691
+ className="shrink-0 rounded-md border border-border-default px-2.5 py-1 text-caption text-text-secondary transition-colors duration-200 hover:bg-surface-3 hover:text-text-primary"
260
692
  >
261
- Edit
693
+ {answered ? 'Edit' : 'Answer'}
262
694
  </button>
263
- )}
264
- </div>
265
-
266
- {editing ? (
267
- <textarea
268
- value={draft}
269
- onChange={(e) => setDraft(e.target.value)}
270
- className="h-[60vh] w-full resize-y rounded-md border border-border-default bg-surface-0 p-3 font-mono text-body text-text-primary focus:outline-none focus:ring-2 focus:ring-accent"
271
- />
272
- ) : current.body.trim() ? (
273
- <MarkdownRenderer content={current.body} />
274
- ) : (
275
- <p className="text-body text-text-tertiary">
276
- No notes yet for {current.label}. Record a narration above and run{' '}
277
- <code className="font-mono">/diane</code>, or click Edit to write notes by hand.
278
- </p>
695
+ </div>
279
696
  )}
280
697
  </div>
281
- </div>
698
+ )}
282
699
  </div>
283
700
  );
284
701
  }
@@ -1,5 +1,5 @@
1
1
  import { getOutputDirPath, readJsonFile } from '@/lib/data';
2
- import { readNotesIndex, readNote } from '../../../src/core/notes';
2
+ import { readNotesIndex, readNote, readContractNote, type ContractNote } from '../../../src/core/notes';
3
3
  import { NotesClient } from './NotesClient';
4
4
 
5
5
  // Read .hex/notes at request time (see app/stats/page.tsx for the rationale).
@@ -18,7 +18,6 @@ export default function NotesPage() {
18
18
  const dir = getOutputDirPath();
19
19
  const index = readNotesIndex(dir);
20
20
  const general = readNote(dir, 'general');
21
- const contractNotes = index.contracts.map((name) => ({ name, body: readNote(dir, name) }));
22
21
 
23
22
  // Dropdown options: contracts in scope (from stats) ∪ contracts that already
24
23
  // have notes, so the auditor can record against any contract immediately.
@@ -27,6 +26,10 @@ export default function NotesPage() {
27
26
  .filter((c) => c.type === 'contract')
28
27
  .map((c) => c.contract);
29
28
  const options = Array.from(new Set([...scope, ...index.contracts])).sort();
29
+
30
+ // Load the structured note for each contract that has one (the index tracks
31
+ // every contract a note doc has been written for).
32
+ const contractNotes: ContractNote[] = index.contracts.map((name) => readContractNote(dir, name));
30
33
  const unprocessed = index.sessions.filter((s) => !s.processed).length;
31
34
 
32
35
  return (