hex-audit 0.4.4 → 0.5.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.
Files changed (102) hide show
  1. package/DEMO.md +14 -12
  2. package/README.md +66 -61
  3. package/dashboard/.next/BUILD_ID +1 -1
  4. package/dashboard/.next/app-build-manifest.json +18 -11
  5. package/dashboard/.next/app-path-routes-manifest.json +1 -1
  6. package/dashboard/.next/build-manifest.json +4 -4
  7. package/dashboard/.next/next-server.js.nft.json +1 -1
  8. package/dashboard/.next/prerender-manifest.js +1 -1
  9. package/dashboard/.next/prerender-manifest.json +1 -1
  10. package/dashboard/.next/required-server-files.json +1 -1
  11. package/dashboard/.next/routes-manifest.json +1 -1
  12. package/dashboard/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  13. package/dashboard/.next/server/app/_not-found.html +1 -1
  14. package/dashboard/.next/server/app/_not-found.rsc +4 -4
  15. package/dashboard/.next/server/app/access/page_client-reference-manifest.js +1 -1
  16. package/dashboard/.next/server/app/api/flows/route.js +1 -1
  17. package/dashboard/.next/server/app/api/issues/[id]/move/route.js +1 -1
  18. package/dashboard/.next/server/app/api/issues/[id]/route.js +1 -1
  19. package/dashboard/.next/server/app/calls/page_client-reference-manifest.js +1 -1
  20. package/dashboard/.next/server/app/conformance/page_client-reference-manifest.js +1 -1
  21. package/dashboard/.next/server/app/diagram/page_client-reference-manifest.js +1 -1
  22. package/dashboard/.next/server/app/diagram.html +1 -1
  23. package/dashboard/.next/server/app/diagram.rsc +4 -4
  24. package/dashboard/.next/server/app/flows/page_client-reference-manifest.js +1 -1
  25. package/dashboard/.next/server/app/flows.html +1 -1
  26. package/dashboard/.next/server/app/flows.rsc +4 -4
  27. package/dashboard/.next/server/app/functions/page_client-reference-manifest.js +1 -1
  28. package/dashboard/.next/server/app/issues/page.js +12 -1
  29. package/dashboard/.next/server/app/issues/page_client-reference-manifest.js +1 -1
  30. package/dashboard/.next/server/app/overleaf/page.js +1 -0
  31. package/dashboard/.next/server/app/overleaf/page.js.nft.json +1 -0
  32. package/dashboard/.next/server/app/overleaf/page_client-reference-manifest.js +1 -0
  33. package/dashboard/.next/server/app/page_client-reference-manifest.js +1 -1
  34. package/dashboard/.next/server/app/progress/page_client-reference-manifest.js +1 -1
  35. package/dashboard/.next/server/app/stats/page_client-reference-manifest.js +1 -1
  36. package/dashboard/.next/server/app-paths-manifest.json +9 -8
  37. package/dashboard/.next/server/chunks/431.js +1 -1
  38. package/dashboard/.next/server/functions-config-manifest.json +1 -1
  39. package/dashboard/.next/server/middleware-build-manifest.js +1 -1
  40. package/dashboard/.next/server/pages/404.html +1 -1
  41. package/dashboard/.next/server/pages/500.html +1 -1
  42. package/dashboard/.next/server/server-reference-manifest.js +1 -1
  43. package/dashboard/.next/server/server-reference-manifest.json +1 -1
  44. package/dashboard/.next/static/chunks/app/issues/page-2bb52a36f20497fe.js +1 -0
  45. package/dashboard/.next/static/chunks/app/{layout-527d8aa86e483fbd.js → layout-9c3c84993dd895f6.js} +1 -1
  46. package/dashboard/.next/static/chunks/app/overleaf/page-bcea12a80c97b92f.js +1 -0
  47. package/dashboard/.next/static/chunks/{main-70c6d841ad572657.js → main-8f1e837090aef5cf.js} +1 -1
  48. package/dashboard/.next/static/css/9c84313d0484b74f.css +3 -0
  49. package/dashboard/app/api/flows/route.ts +4 -0
  50. package/dashboard/app/api/issues/[id]/move/route.ts +29 -58
  51. package/dashboard/app/api/issues/[id]/route.ts +11 -102
  52. package/dashboard/app/issues/IssuesBoardClient.tsx +155 -50
  53. package/dashboard/app/issues/page.tsx +40 -3
  54. package/dashboard/app/overleaf/OverleafClient.tsx +97 -0
  55. package/dashboard/app/overleaf/page.tsx +32 -0
  56. package/dashboard/components/DashboardLayout.tsx +8 -2
  57. package/dashboard/lib/finding-markdown.ts +110 -0
  58. package/dashboard/next.config.mjs +4 -0
  59. package/dist/analysis/external-calls.d.ts +8 -1
  60. package/dist/analysis/external-calls.d.ts.map +1 -1
  61. package/dist/analysis/external-calls.js +57 -13
  62. package/dist/analysis/external-calls.js.map +1 -1
  63. package/dist/commands/calls.d.ts.map +1 -1
  64. package/dist/commands/calls.js +15 -6
  65. package/dist/commands/calls.js.map +1 -1
  66. package/dist/commands/dashboard.d.ts.map +1 -1
  67. package/dist/commands/dashboard.js +12 -3
  68. package/dist/commands/dashboard.js.map +1 -1
  69. package/dist/commands/init.js +19 -19
  70. package/dist/commands/init.js.map +1 -1
  71. package/dist/commands/issue.d.ts +3 -0
  72. package/dist/commands/issue.d.ts.map +1 -0
  73. package/dist/commands/issue.js +195 -0
  74. package/dist/commands/issue.js.map +1 -0
  75. package/dist/core/config.d.ts +1 -0
  76. package/dist/core/config.d.ts.map +1 -1
  77. package/dist/core/config.js +10 -0
  78. package/dist/core/config.js.map +1 -1
  79. package/dist/core/issues.d.ts +116 -0
  80. package/dist/core/issues.d.ts.map +1 -0
  81. package/dist/core/issues.js +403 -0
  82. package/dist/core/issues.js.map +1 -0
  83. package/dist/core/schema.d.ts +24 -8
  84. package/dist/core/schema.d.ts.map +1 -1
  85. package/dist/core/schema.js +12 -1
  86. package/dist/core/schema.js.map +1 -1
  87. package/dist/index.js +3 -1
  88. package/dist/index.js.map +1 -1
  89. package/dist/skills/generate-overleaf.md +18 -10
  90. package/dist/skills/ingest-aa-report.md +15 -19
  91. package/dist/skills/init-audit.md +13 -18
  92. package/dist/skills/sync-issues.md +86 -195
  93. package/dist/skills/validate-all-findings.md +50 -0
  94. package/dist/skills/validate-issue.md +20 -10
  95. package/dist/skills/write-finding.md +23 -28
  96. package/package.json +1 -1
  97. package/dashboard/.next/server/app/api/flows.body +0 -1
  98. package/dashboard/.next/server/app/api/flows.meta +0 -1
  99. package/dashboard/.next/static/chunks/app/issues/page-dc110d4cc0a5c398.js +0 -1
  100. package/dashboard/.next/static/css/7a38e5062ba6d403.css +0 -3
  101. /package/dashboard/.next/static/{ePObaFrrwft0BlvlllUMo → NrFPHIFHWQ5jNF62Px_X8}/_buildManifest.js +0 -0
  102. /package/dashboard/.next/static/{ePObaFrrwft0BlvlllUMo → NrFPHIFHWQ5jNF62Px_X8}/_ssgManifest.js +0 -0
@@ -11,12 +11,15 @@ import {
11
11
  useDroppable,
12
12
  } from '@dnd-kit/core';
13
13
  import { SeverityBadge } from '@/components/SeverityBadge';
14
+ import { findingToHackmd, type FormatContext } from '@/lib/finding-markdown';
14
15
 
15
- export type BoardColumn = 'potential' | 'verified' | 'invalid' | 'duplicate';
16
+ export type BoardColumn = 'potential' | 'verified' | 'synced' | 'invalid' | 'duplicate';
17
+ export type SyncState = 'none' | 'unsynced' | 'synced_open' | 'synced_closed' | 'conflict';
16
18
  export type IssueStatus =
17
19
  | 'pending_validation'
18
20
  | 'unverified'
19
21
  | 'verified'
22
+ | 'synced'
20
23
  | 'rejected'
21
24
  | 'duplicate';
22
25
  export type IssueSource = 'manual' | 'auditagent' | 'conformance' | 'github';
@@ -36,7 +39,10 @@ export interface BoardIssue {
36
39
  code_locations: Array<{ file: string; snippet?: string }>;
37
40
  github_synced: boolean;
38
41
  github_issue_url?: string;
42
+ github_issue_number?: number;
39
43
  github_state?: string;
44
+ /** Derived in page.tsx: how this verified finding stands vs GitHub. */
45
+ sync_state?: SyncState;
40
46
  duplicate_of: string | null;
41
47
  match_signals?: {
42
48
  contract: boolean;
@@ -54,7 +60,8 @@ export interface BoardIssue {
54
60
 
55
61
  const COLUMN_DEFS: Array<{ id: BoardColumn; label: string; description: string }> = [
56
62
  { id: 'potential', label: 'Potential', description: 'Awaiting validation' },
57
- { id: 'verified', label: 'Verified', description: 'Confirmed issues ready to report' },
63
+ { id: 'verified', label: 'Verified', description: 'Confirmed, ready to push' },
64
+ { id: 'synced', label: 'Synced to GitHub', description: 'On GitHub — edit there, not here' },
58
65
  { id: 'invalid', label: 'Invalid', description: 'Rejected after review' },
59
66
  { id: 'duplicate', label: 'Duplicate', description: 'Covered by another entry' },
60
67
  ];
@@ -70,7 +77,13 @@ const SEVERITY_ORDER: Record<BoardIssue['severity'], number> = {
70
77
  const SEVERITIES: BoardIssue['severity'][] = ['Critical', 'High', 'Medium', 'Low', 'Info'];
71
78
  const RESOLUTIONS = ['Fixed', 'Mitigated', 'Acknowledged', 'Not Fixed', 'Unresolved'];
72
79
 
73
- export function IssuesBoardClient({ issues: initialIssues }: { issues: BoardIssue[] }) {
80
+ export function IssuesBoardClient({
81
+ issues: initialIssues,
82
+ formatCtx = {},
83
+ }: {
84
+ issues: BoardIssue[];
85
+ formatCtx?: FormatContext;
86
+ }) {
74
87
  const [issues, setIssues] = useState<BoardIssue[]>(initialIssues);
75
88
  const [openId, setOpenId] = useState<string | null>(null);
76
89
  const [sourceFilter, setSourceFilter] = useState<IssueSource | 'all'>('all');
@@ -92,6 +105,7 @@ export function IssuesBoardClient({ issues: initialIssues }: { issues: BoardIssu
92
105
  const map: Record<BoardColumn, BoardIssue[]> = {
93
106
  potential: [],
94
107
  verified: [],
108
+ synced: [],
95
109
  invalid: [],
96
110
  duplicate: [],
97
111
  };
@@ -112,6 +126,7 @@ export function IssuesBoardClient({ issues: initialIssues }: { issues: BoardIssu
112
126
  const total: Record<BoardColumn, number> = {
113
127
  potential: 0,
114
128
  verified: 0,
129
+ synced: 0,
115
130
  invalid: 0,
116
131
  duplicate: 0,
117
132
  };
@@ -181,6 +196,8 @@ export function IssuesBoardClient({ issues: initialIssues }: { issues: BoardIssu
181
196
  const id = active.id as string;
182
197
  const current = issues.find((i) => i.id === id);
183
198
  if (!current || current.column === dest) return;
199
+ // Synced is reached only via /sync-issues, and synced cards are locked.
200
+ if (dest === 'synced' || current.column === 'synced') return;
184
201
  void moveIssue(id, dest);
185
202
  };
186
203
 
@@ -193,15 +210,15 @@ export function IssuesBoardClient({ issues: initialIssues }: { issues: BoardIssu
193
210
  <h1 className="text-title font-semibold text-text-primary">Issues</h1>
194
211
  <p className="mt-1 text-body text-text-secondary">
195
212
  {issues.length} total — {counts.potential} potential, {counts.verified} verified,{' '}
196
- {counts.invalid} invalid, {counts.duplicate} duplicate. Drag a card to change column;
197
- click to edit.
213
+ {counts.synced} synced, {counts.invalid} invalid, {counts.duplicate} duplicate. Drag a
214
+ card between columns; click to edit. Synced cards are read-only (edit on GitHub).
198
215
  </p>
199
216
  </div>
200
217
  <SourceFilter value={sourceFilter} onChange={setSourceFilter} />
201
218
  </header>
202
219
 
203
220
  <DndContext sensors={sensors} onDragEnd={handleDragEnd}>
204
- <div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-4">
221
+ <div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-5">
205
222
  {COLUMN_DEFS.map((col) => (
206
223
  <Column key={col.id} column={col} issues={grouped[col.id]} onOpen={setOpenId} />
207
224
  ))}
@@ -211,6 +228,7 @@ export function IssuesBoardClient({ issues: initialIssues }: { issues: BoardIssu
211
228
  {openIssue && (
212
229
  <IssueModal
213
230
  issue={openIssue}
231
+ formatCtx={formatCtx}
214
232
  onClose={() => setOpenId(null)}
215
233
  onSave={(updates) => updateIssue(openIssue.id, updates)}
216
234
  />
@@ -225,6 +243,8 @@ function columnToStatus(column: BoardColumn): IssueStatus {
225
243
  return 'pending_validation';
226
244
  case 'verified':
227
245
  return 'verified';
246
+ case 'synced':
247
+ return 'synced';
228
248
  case 'invalid':
229
249
  return 'rejected';
230
250
  case 'duplicate':
@@ -278,7 +298,8 @@ function Column({
278
298
  issues: BoardIssue[];
279
299
  onOpen: (id: string) => void;
280
300
  }) {
281
- const { isOver, setNodeRef } = useDroppable({ id: column.id });
301
+ // The Synced column is reached only via /sync-issues — never a drop target.
302
+ const { isOver, setNodeRef } = useDroppable({ id: column.id, disabled: column.id === 'synced' });
282
303
  return (
283
304
  <section
284
305
  ref={setNodeRef}
@@ -307,8 +328,11 @@ function Column({
307
328
  }
308
329
 
309
330
  function Card({ issue, onOpen }: { issue: BoardIssue; onOpen: (id: string) => void }) {
331
+ // Synced cards are locked: not draggable (edits happen on GitHub).
332
+ const locked = issue.column === 'synced';
310
333
  const { attributes, listeners, setNodeRef, isDragging, transform } = useDraggable({
311
334
  id: issue.id,
335
+ disabled: locked,
312
336
  });
313
337
  const style: React.CSSProperties = transform
314
338
  ? {
@@ -321,8 +345,8 @@ function Card({ issue, onOpen }: { issue: BoardIssue; onOpen: (id: string) => vo
321
345
  <article
322
346
  ref={setNodeRef}
323
347
  style={style}
324
- {...attributes}
325
- {...listeners}
348
+ {...(locked ? {} : attributes)}
349
+ {...(locked ? {} : listeners)}
326
350
  onClick={(e) => {
327
351
  // Drag listeners may swallow the click during a drag; treat anything
328
352
  // with a transform as a drag, not a click.
@@ -330,7 +354,9 @@ function Card({ issue, onOpen }: { issue: BoardIssue; onOpen: (id: string) => vo
330
354
  e.stopPropagation();
331
355
  onOpen(issue.id);
332
356
  }}
333
- className={`group cursor-grab rounded-md border bg-surface-0 p-3 text-left active:cursor-grabbing ${
357
+ className={`group rounded-md border bg-surface-0 p-3 text-left ${
358
+ locked ? 'cursor-pointer' : 'cursor-grab active:cursor-grabbing'
359
+ } ${
334
360
  isDragging ? 'border-accent shadow-lg' : 'border-border-subtle hover:border-border-default'
335
361
  }`}
336
362
  >
@@ -341,17 +367,7 @@ function Card({ issue, onOpen }: { issue: BoardIssue; onOpen: (id: string) => vo
341
367
  <h3 className="mt-1 text-body font-medium text-text-primary line-clamp-2">{issue.title}</h3>
342
368
  <div className="mt-2 flex flex-wrap items-center gap-1.5 text-caption">
343
369
  <SourceChip source={issue.source} />
344
- {issue.github_synced && (
345
- <a
346
- href={issue.github_issue_url}
347
- target="_blank"
348
- rel="noopener noreferrer"
349
- onClick={(e) => e.stopPropagation()}
350
- className="rounded-md bg-surface-2 px-1.5 py-0.5 text-text-secondary hover:bg-surface-3 hover:text-text-primary"
351
- >
352
- GH {issue.github_state === 'closed' ? '✕' : '✓'}
353
- </a>
354
- )}
370
+ <SyncChip issue={issue} />
355
371
  {issue.duplicate_of && (
356
372
  <span className="rounded-md bg-surface-2 px-1.5 py-0.5 text-text-secondary">
357
373
  dup of {issue.duplicate_of}
@@ -409,12 +425,60 @@ function SourceChip({ source }: { source: IssueSource }) {
409
425
  );
410
426
  }
411
427
 
428
+ /**
429
+ * GitHub-sync state chip. Only verified findings are push candidates, so other
430
+ * statuses render nothing (sync_state === 'none').
431
+ */
432
+ function SyncChip({ issue }: { issue: BoardIssue }) {
433
+ const state = issue.sync_state ?? 'none';
434
+ if (state === 'none') return null;
435
+
436
+ if (state === 'unsynced') {
437
+ return (
438
+ <span
439
+ title="Run /sync-issues to push verified findings to GitHub."
440
+ className="rounded-md border border-dashed border-border-default px-1.5 py-0.5 text-text-tertiary"
441
+ >
442
+ GH unsynced
443
+ </span>
444
+ );
445
+ }
446
+ if (state === 'conflict') {
447
+ return (
448
+ <span
449
+ title="Local and remote diverged. Re-run /sync-issues to reconcile."
450
+ className="rounded-md bg-[var(--critical)]/15 px-1.5 py-0.5 text-[var(--critical)]"
451
+ >
452
+ GH conflict
453
+ </span>
454
+ );
455
+ }
456
+ const closed = state === 'synced_closed';
457
+ const label = `GH #${issue.github_issue_number ?? '?'} ${closed ? 'closed' : 'open'}`;
458
+ const cls = closed ? 'bg-surface-2 text-text-tertiary' : 'bg-[var(--low)]/15 text-[var(--low)]';
459
+ return issue.github_issue_url ? (
460
+ <a
461
+ href={issue.github_issue_url}
462
+ target="_blank"
463
+ rel="noopener noreferrer"
464
+ onClick={(e) => e.stopPropagation()}
465
+ className={`rounded-md px-1.5 py-0.5 hover:underline ${cls}`}
466
+ >
467
+ {label}
468
+ </a>
469
+ ) : (
470
+ <span className={`rounded-md px-1.5 py-0.5 ${cls}`}>{label}</span>
471
+ );
472
+ }
473
+
412
474
  function IssueModal({
413
475
  issue,
476
+ formatCtx,
414
477
  onClose,
415
478
  onSave,
416
479
  }: {
417
480
  issue: BoardIssue;
481
+ formatCtx: FormatContext;
418
482
  onClose: () => void;
419
483
  onSave: (updates: Partial<BoardIssue>) => Promise<void> | void;
420
484
  }) {
@@ -425,12 +489,39 @@ function IssueModal({
425
489
  const [resolution, setResolution] = useState(issue.resolution ?? '');
426
490
  const [updateFromClient, setUpdateFromClient] = useState(issue.update_from_client ?? '');
427
491
  const [saving, setSaving] = useState(false);
492
+ const [copied, setCopied] = useState(false);
428
493
  const overlayRef = useRef<HTMLDivElement>(null);
429
494
 
495
+ // Synced issues are read-only in Hex; edits happen on GitHub.
496
+ const locked = issue.status === 'synced';
497
+
430
498
  useEffect(() => {
431
499
  overlayRef.current?.focus();
432
500
  }, []);
433
501
 
502
+ // Copy the current (possibly edited) field values, not just the saved ones.
503
+ const copyAsHackmd = async () => {
504
+ const md = findingToHackmd(
505
+ {
506
+ ...issue,
507
+ title,
508
+ severity,
509
+ description,
510
+ recommendation,
511
+ resolution: resolution || undefined,
512
+ update_from_client: updateFromClient || undefined,
513
+ },
514
+ formatCtx,
515
+ );
516
+ try {
517
+ await navigator.clipboard.writeText(md);
518
+ setCopied(true);
519
+ setTimeout(() => setCopied(false), 1500);
520
+ } catch {
521
+ /* clipboard blocked (insecure context) — no-op */
522
+ }
523
+ };
524
+
434
525
  const submit = async () => {
435
526
  setSaving(true);
436
527
  const updates: Partial<BoardIssue> = {
@@ -466,19 +557,10 @@ function IssueModal({
466
557
  onClick={(e) => e.stopPropagation()}
467
558
  >
468
559
  <div className="flex items-center justify-between border-b border-border-default bg-surface-2 px-4 py-3">
469
- <div className="flex items-center gap-2">
560
+ <div className="flex items-center gap-2 text-caption">
470
561
  <span className="font-mono text-body text-text-tertiary">{issue.id}</span>
471
562
  <SourceChip source={issue.source} />
472
- {issue.github_synced && issue.github_issue_url && (
473
- <a
474
- href={issue.github_issue_url}
475
- target="_blank"
476
- rel="noopener noreferrer"
477
- className="rounded-md bg-surface-3 px-1.5 py-0.5 text-caption text-text-secondary hover:text-text-primary"
478
- >
479
- GitHub issue
480
- </a>
481
- )}
563
+ <SyncChip issue={issue} />
482
564
  </div>
483
565
  <button
484
566
  type="button"
@@ -500,12 +582,19 @@ function IssueModal({
500
582
  </div>
501
583
 
502
584
  <div className="flex-1 overflow-y-auto p-4">
585
+ {locked && (
586
+ <div className="mb-4 rounded-md border border-border-default bg-surface-2 px-3 py-2 text-caption text-text-secondary">
587
+ This issue is synced to GitHub and is read-only here. Edit it on GitHub, then run{' '}
588
+ <span className="font-mono">/sync-issues</span> to pull the changes back.
589
+ </div>
590
+ )}
503
591
  <Field label="Title">
504
592
  <input
505
593
  type="text"
506
594
  value={title}
507
595
  onChange={(e) => setTitle(e.target.value)}
508
- className="w-full rounded-md border border-border-default bg-surface-0 px-3 py-2 text-body text-text-primary focus:border-accent focus:outline-none"
596
+ disabled={locked}
597
+ className="w-full rounded-md border border-border-default bg-surface-0 px-3 py-2 text-body text-text-primary focus:border-accent focus:outline-none disabled:opacity-60"
509
598
  />
510
599
  </Field>
511
600
 
@@ -514,7 +603,8 @@ function IssueModal({
514
603
  <select
515
604
  value={severity}
516
605
  onChange={(e) => setSeverity(e.target.value as BoardIssue['severity'])}
517
- className="w-full rounded-md border border-border-default bg-surface-0 px-3 py-2 text-body text-text-primary focus:border-accent focus:outline-none"
606
+ disabled={locked}
607
+ className="w-full rounded-md border border-border-default bg-surface-0 px-3 py-2 text-body text-text-primary focus:border-accent focus:outline-none disabled:opacity-60"
518
608
  >
519
609
  {SEVERITIES.map((s) => (
520
610
  <option key={s} value={s}>
@@ -527,7 +617,8 @@ function IssueModal({
527
617
  <select
528
618
  value={resolution}
529
619
  onChange={(e) => setResolution(e.target.value)}
530
- className="w-full rounded-md border border-border-default bg-surface-0 px-3 py-2 text-body text-text-primary focus:border-accent focus:outline-none"
620
+ disabled={locked}
621
+ className="w-full rounded-md border border-border-default bg-surface-0 px-3 py-2 text-body text-text-primary focus:border-accent focus:outline-none disabled:opacity-60"
531
622
  >
532
623
  <option value="">— none —</option>
533
624
  {RESOLUTIONS.map((r) => (
@@ -544,7 +635,8 @@ function IssueModal({
544
635
  value={description}
545
636
  onChange={(e) => setDescription(e.target.value)}
546
637
  rows={8}
547
- className="w-full rounded-md border border-border-default bg-surface-0 px-3 py-2 font-mono text-caption text-text-primary focus:border-accent focus:outline-none"
638
+ disabled={locked}
639
+ className="w-full rounded-md border border-border-default bg-surface-0 px-3 py-2 font-mono text-caption text-text-primary focus:border-accent focus:outline-none disabled:opacity-60"
548
640
  />
549
641
  </Field>
550
642
 
@@ -553,7 +645,8 @@ function IssueModal({
553
645
  value={recommendation}
554
646
  onChange={(e) => setRecommendation(e.target.value)}
555
647
  rows={4}
556
- className="w-full rounded-md border border-border-default bg-surface-0 px-3 py-2 font-mono text-caption text-text-primary focus:border-accent focus:outline-none"
648
+ disabled={locked}
649
+ className="w-full rounded-md border border-border-default bg-surface-0 px-3 py-2 font-mono text-caption text-text-primary focus:border-accent focus:outline-none disabled:opacity-60"
557
650
  />
558
651
  </Field>
559
652
 
@@ -562,8 +655,9 @@ function IssueModal({
562
655
  value={updateFromClient}
563
656
  onChange={(e) => setUpdateFromClient(e.target.value)}
564
657
  rows={4}
658
+ disabled={locked}
565
659
  placeholder="How the client responded — used by /generate-overleaf."
566
- className="w-full rounded-md border border-border-default bg-surface-0 px-3 py-2 font-mono text-caption text-text-primary focus:border-accent focus:outline-none"
660
+ className="w-full rounded-md border border-border-default bg-surface-0 px-3 py-2 font-mono text-caption text-text-primary focus:border-accent focus:outline-none disabled:opacity-60"
567
661
  />
568
662
  </Field>
569
663
 
@@ -596,22 +690,33 @@ function IssueModal({
596
690
  )}
597
691
  </div>
598
692
 
599
- <div className="flex items-center justify-end gap-2 border-t border-border-default bg-surface-2 px-4 py-3">
600
- <button
601
- type="button"
602
- onClick={onClose}
603
- className="rounded-md px-3 py-1.5 text-body text-text-secondary hover:text-text-primary"
604
- >
605
- Cancel
606
- </button>
693
+ <div className="flex items-center justify-between gap-2 border-t border-border-default bg-surface-2 px-4 py-3">
607
694
  <button
608
695
  type="button"
609
- onClick={submit}
610
- disabled={saving}
611
- className="rounded-md bg-accent px-3 py-1.5 text-body font-medium text-surface-0 hover:bg-accent-secondary disabled:opacity-50"
696
+ onClick={copyAsHackmd}
697
+ className="rounded-md border border-border-default px-3 py-1.5 text-body text-text-secondary hover:bg-surface-3 hover:text-text-primary"
612
698
  >
613
- {saving ? 'Saving…' : 'Save'}
699
+ {copied ? 'Copied!' : 'Copy as HackMD'}
614
700
  </button>
701
+ <div className="flex items-center gap-2">
702
+ <button
703
+ type="button"
704
+ onClick={onClose}
705
+ className="rounded-md px-3 py-1.5 text-body text-text-secondary hover:text-text-primary"
706
+ >
707
+ {locked ? 'Close' : 'Cancel'}
708
+ </button>
709
+ {!locked && (
710
+ <button
711
+ type="button"
712
+ onClick={submit}
713
+ disabled={saving}
714
+ className="rounded-md bg-accent px-3 py-1.5 text-body font-medium text-surface-0 hover:bg-accent-secondary disabled:opacity-50"
715
+ >
716
+ {saving ? 'Saving…' : 'Save'}
717
+ </button>
718
+ )}
719
+ </div>
615
720
  </div>
616
721
  </div>
617
722
  </div>
@@ -8,7 +8,6 @@ import { IssuesBoardClient, type BoardIssue } from './IssuesBoardClient';
8
8
  // stale (or empty) HTML for every audit project, ignoring the runtime cwd.
9
9
  export const dynamic = 'force-dynamic';
10
10
 
11
-
12
11
  interface FindingsFile {
13
12
  findings: Array<{
14
13
  id: string;
@@ -20,11 +19,17 @@ interface FindingsFile {
20
19
  update_from_client?: string;
21
20
  root_cause?: { locations?: Array<{ file: string; snippet?: string }> };
22
21
  poc?: { status?: string; file?: string | null; validation_memo?: string | null };
23
- github?: { issue_number?: number; issue_url?: string; state?: string };
22
+ github?: { issue_number?: number; issue_url?: string; state?: string; sync_status?: string };
24
23
  category?: string;
25
24
  }>;
26
25
  }
27
26
 
27
+ interface ConfigFile {
28
+ settings?: {
29
+ report?: { repository_url?: string; initial_commit?: string };
30
+ };
31
+ }
32
+
28
33
  interface TrackingFile {
29
34
  findings: Array<{
30
35
  id: string;
@@ -74,10 +79,33 @@ interface SpecConformanceFile {
74
79
  }>;
75
80
  }
76
81
 
82
+ /**
83
+ * Derive the GitHub sync chip state. Verified findings are push candidates
84
+ * (unsynced until pushed); synced findings always carry an issue number.
85
+ * Everything else gets no chip.
86
+ */
87
+ function computeSyncState(
88
+ status: string | undefined,
89
+ github: { issue_number?: number; state?: string; sync_status?: string } | undefined,
90
+ ): BoardIssue['sync_state'] {
91
+ if (status !== 'verified' && status !== 'synced') return 'none';
92
+ if (!github?.issue_number) return 'unsynced';
93
+ if (
94
+ github.sync_status === 'conflict' ||
95
+ github.sync_status === 'local_ahead' ||
96
+ github.sync_status === 'remote_ahead'
97
+ ) {
98
+ return 'conflict';
99
+ }
100
+ return github.state === 'closed' ? 'synced_closed' : 'synced_open';
101
+ }
102
+
77
103
  function statusToColumn(status: string | undefined): BoardIssue['column'] {
78
104
  switch (status) {
79
105
  case 'verified':
80
106
  return 'verified';
107
+ case 'synced':
108
+ return 'synced';
81
109
  case 'rejected':
82
110
  return 'invalid';
83
111
  case 'duplicate':
@@ -179,7 +207,9 @@ function buildBoardIssues(): BoardIssue[] {
179
207
  code_locations: locations,
180
208
  github_synced: Boolean(finding?.github?.issue_number),
181
209
  github_issue_url: finding?.github?.issue_url,
210
+ github_issue_number: finding?.github?.issue_number,
182
211
  github_state: finding?.github?.state,
212
+ sync_state: computeSyncState(t.status, finding?.github),
183
213
  duplicate_of: t.duplicate_of ?? dup?.matches ?? null,
184
214
  match_signals: matchSignals,
185
215
  reasoning: dup?.reasoning,
@@ -210,7 +240,9 @@ function buildBoardIssues(): BoardIssue[] {
210
240
  code_locations: f.root_cause?.locations ?? [],
211
241
  github_synced: Boolean(f.github?.issue_number),
212
242
  github_issue_url: f.github?.issue_url,
243
+ github_issue_number: f.github?.issue_number,
213
244
  github_state: f.github?.state,
245
+ sync_state: computeSyncState('verified', f.github),
214
246
  duplicate_of: null,
215
247
  match_signals: undefined,
216
248
  reasoning: undefined,
@@ -227,6 +259,11 @@ function buildBoardIssues(): BoardIssue[] {
227
259
 
228
260
  export default function IssuesPage() {
229
261
  const issues = buildBoardIssues();
262
+ const config = readJsonFile<ConfigFile>('config.json');
263
+ const formatCtx = {
264
+ repositoryUrl: config?.settings?.report?.repository_url,
265
+ commit: config?.settings?.report?.initial_commit,
266
+ };
230
267
 
231
268
  if (issues.length === 0) {
232
269
  return (
@@ -242,5 +279,5 @@ export default function IssuesPage() {
242
279
  );
243
280
  }
244
281
 
245
- return <IssuesBoardClient issues={issues} />;
282
+ return <IssuesBoardClient issues={issues} formatCtx={formatCtx} />;
246
283
  }
@@ -0,0 +1,97 @@
1
+ 'use client';
2
+
3
+ import { useState } from 'react';
4
+
5
+ export interface OverleafSections {
6
+ executive_summary: string | null;
7
+ audited_files: string | null;
8
+ summary_of_findings: string | null;
9
+ findings: string | null;
10
+ }
11
+
12
+ type SectionKey = keyof OverleafSections;
13
+
14
+ const TABS: Array<{ key: SectionKey; label: string; file: string }> = [
15
+ { key: 'executive_summary', label: 'Executive Summary', file: 'executive_summary.txt' },
16
+ { key: 'audited_files', label: 'Audited Files', file: 'audited_files.txt' },
17
+ { key: 'summary_of_findings', label: 'Summary of Issues', file: 'summary_of_findings.txt' },
18
+ { key: 'findings', label: 'Issues', file: 'findings.txt' },
19
+ ];
20
+
21
+ export function OverleafClient({ sections }: { sections: OverleafSections }) {
22
+ const [active, setActive] = useState<SectionKey>('executive_summary');
23
+ const [copied, setCopied] = useState(false);
24
+
25
+ const content = sections[active];
26
+
27
+ const copy = async () => {
28
+ if (!content) return;
29
+ try {
30
+ await navigator.clipboard.writeText(content);
31
+ setCopied(true);
32
+ setTimeout(() => setCopied(false), 1500);
33
+ } catch {
34
+ /* clipboard blocked (insecure context) — no-op */
35
+ }
36
+ };
37
+
38
+ return (
39
+ <div>
40
+ <header className="mb-6">
41
+ <h1 className="text-title font-semibold text-text-primary">Overleaf</h1>
42
+ <p className="mt-1 text-body text-text-secondary">
43
+ LaTeX report sections from <span className="font-mono text-caption">.hex/overleaf/</span>.
44
+ Copy each into the matching slot of the Nethermind Overleaf template.
45
+ </p>
46
+ </header>
47
+
48
+ {/* Segmented control */}
49
+ <div className="mb-4 flex flex-wrap gap-1.5">
50
+ {TABS.map((tab) => {
51
+ const isActive = tab.key === active;
52
+ const present = Boolean(sections[tab.key]);
53
+ return (
54
+ <button
55
+ key={tab.key}
56
+ type="button"
57
+ onClick={() => setActive(tab.key)}
58
+ className={`rounded-md px-3 py-1.5 text-caption font-medium ${
59
+ isActive
60
+ ? 'bg-accent text-surface-0'
61
+ : 'bg-surface-2 text-text-secondary hover:text-text-primary'
62
+ } ${present ? '' : 'opacity-50'}`}
63
+ >
64
+ {tab.label}
65
+ </button>
66
+ );
67
+ })}
68
+ </div>
69
+
70
+ <div className="rounded-lg border border-border-subtle bg-surface-1">
71
+ <div className="flex items-center justify-between border-b border-border-subtle px-4 py-2.5">
72
+ <span className="font-mono text-caption text-text-tertiary">
73
+ {TABS.find((t) => t.key === active)?.file}
74
+ </span>
75
+ <button
76
+ type="button"
77
+ onClick={copy}
78
+ disabled={!content}
79
+ className="rounded-md border border-border-default px-2.5 py-1 text-caption text-text-secondary hover:bg-surface-2 hover:text-text-primary disabled:opacity-50"
80
+ >
81
+ {copied ? 'Copied!' : 'Copy'}
82
+ </button>
83
+ </div>
84
+ {content ? (
85
+ <pre className="max-h-[70vh] overflow-auto p-4 font-mono text-caption leading-relaxed text-text-secondary whitespace-pre-wrap break-words">
86
+ {content}
87
+ </pre>
88
+ ) : (
89
+ <p className="p-4 text-body text-text-tertiary">
90
+ This section has not been generated yet. Run{' '}
91
+ <span className="font-mono text-caption">/generate-overleaf</span>.
92
+ </p>
93
+ )}
94
+ </div>
95
+ </div>
96
+ );
97
+ }
@@ -0,0 +1,32 @@
1
+ import { readMarkdownFile } from '@/lib/data';
2
+ import { NotYetGenerated } from '@/components/NotYetGenerated';
3
+ import { OverleafClient, type OverleafSections } from './OverleafClient';
4
+
5
+ // Read .hex/overleaf/ at request time, not at build time.
6
+ export const dynamic = 'force-dynamic';
7
+
8
+ export default function OverleafPage() {
9
+ const sections: OverleafSections = {
10
+ executive_summary: readMarkdownFile('overleaf/executive_summary.txt'),
11
+ audited_files: readMarkdownFile('overleaf/audited_files.txt'),
12
+ summary_of_findings: readMarkdownFile('overleaf/summary_of_findings.txt'),
13
+ findings: readMarkdownFile('overleaf/findings.txt'),
14
+ };
15
+
16
+ const anyPresent = Object.values(sections).some(Boolean);
17
+ if (!anyPresent) {
18
+ return (
19
+ <div>
20
+ <header className="mb-6">
21
+ <h1 className="text-title font-semibold text-text-primary">Overleaf</h1>
22
+ <p className="mt-1 text-body text-text-secondary">
23
+ LaTeX report sections, ready to paste into the Nethermind Overleaf template.
24
+ </p>
25
+ </header>
26
+ <NotYetGenerated command="/generate-overleaf" />
27
+ </div>
28
+ );
29
+ }
30
+
31
+ return <OverleafClient sections={sections} />;
32
+ }
@@ -326,7 +326,13 @@ const NAV_GROUPS = [
326
326
  { href: '/flows', label: 'Flows', icon: IconFlows },
327
327
  ],
328
328
  },
329
- { label: 'Issues', items: [{ href: '/issues', label: 'Board', icon: IconFindings }] },
329
+ {
330
+ label: 'Issues',
331
+ items: [
332
+ { href: '/issues', label: 'Board', icon: IconFindings },
333
+ { href: '/overleaf', label: 'Overleaf', icon: IconReport },
334
+ ],
335
+ },
330
336
  ];
331
337
 
332
338
  export { NAV_GROUPS };
@@ -395,7 +401,7 @@ export function DashboardLayout({ children }: { children: React.ReactNode }) {
395
401
  <div className="px-5 pb-4 pt-6">
396
402
  <div className="flex items-baseline gap-2">
397
403
  <span className="text-title font-semibold text-text-primary">Hex</span>
398
- <span className="text-caption text-text-tertiary">v0.4.4</span>
404
+ <span className="text-caption text-text-tertiary">v0.5.0</span>
399
405
  </div>
400
406
  </div>
401
407