@payconductor/houndsman 0.1.4 → 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.
Files changed (84) hide show
  1. package/package.json +1 -1
  2. package/src/cli/arg-parser.ts +8 -0
  3. package/src/cli/commands/command.ts +23 -0
  4. package/src/cli/commands/context-command.ts +38 -0
  5. package/src/cli/commands/doctor-command.ts +55 -0
  6. package/src/cli/commands/findings-command.ts +28 -0
  7. package/src/cli/commands/help-command.ts +13 -0
  8. package/src/cli/commands/registry.ts +34 -0
  9. package/src/cli/commands/review-command.ts +22 -0
  10. package/src/cli/commands/server-command.ts +37 -0
  11. package/src/cli/commands/setup-command.ts +34 -0
  12. package/src/cli/commands/watchdog-command.ts +34 -0
  13. package/src/cli/help.ts +43 -0
  14. package/src/cli/index.ts +62 -260
  15. package/src/core/arg-parser.ts +48 -0
  16. package/src/core/commands/findings.ts +163 -147
  17. package/src/core/commands/review.ts +18 -35
  18. package/src/core/commands/scaffold.ts +174 -64
  19. package/src/core/commands/setup.ts +16 -11
  20. package/src/core/commands/update.ts +7 -7
  21. package/src/core/commands/watchdog.ts +8 -80
  22. package/src/core/config.ts +9 -19
  23. package/src/core/defaults.ts +20 -0
  24. package/src/core/diff-source.ts +1 -13
  25. package/src/core/doc-discoverer.ts +25 -15
  26. package/src/core/domain/budget.ts +42 -0
  27. package/src/core/domain/classification.ts +94 -0
  28. package/src/core/domain/diff.ts +53 -0
  29. package/src/core/domain/watchdog.ts +96 -0
  30. package/src/core/engine/carryover.ts +67 -0
  31. package/src/core/engine/houndsman-engine.ts +51 -0
  32. package/src/core/engine/review-scope.ts +95 -0
  33. package/src/core/engine/review-session.ts +51 -0
  34. package/src/core/file-classifier.ts +11 -67
  35. package/src/core/findings-state.ts +189 -38
  36. package/src/core/git-revision.ts +14 -40
  37. package/src/core/git-runner.ts +46 -0
  38. package/src/core/github-review.ts +423 -0
  39. package/src/core/orchestrator.ts +116 -337
  40. package/src/core/paths.ts +9 -0
  41. package/src/core/pr-context.ts +28 -54
  42. package/src/core/project-config.ts +57 -0
  43. package/src/core/renderers/batch-presenter.ts +40 -0
  44. package/src/core/renderers/brief-renderer.ts +23 -0
  45. package/src/core/renderers/findings-presenter.ts +74 -0
  46. package/src/core/renderers/manifest-writer.ts +112 -0
  47. package/src/core/renderers/report-renderer.ts +66 -0
  48. package/src/core/renderers/verify-presenter.ts +50 -0
  49. package/src/core/renderers/watchdog-presenter.ts +100 -0
  50. package/src/core/report-builder.ts +12 -7
  51. package/src/core/run-layout.ts +108 -0
  52. package/src/core/run-status.ts +6 -11
  53. package/src/core/services/clock.ts +12 -0
  54. package/src/core/services/file-classifier.ts +34 -0
  55. package/src/core/services/fix-batch-builder.ts +13 -0
  56. package/src/core/services/gh-client.ts +54 -0
  57. package/src/core/services/git-repository.ts +123 -0
  58. package/src/core/services/project-files.ts +79 -0
  59. package/src/core/services/verifier.ts +58 -0
  60. package/src/core/verify.ts +2 -1
  61. package/src/core/watchdog-registry.ts +58 -16
  62. package/src/index.ts +31 -1
  63. package/src/server/client/App.tsx +174 -49
  64. package/src/server/client/ArtifactsPanel.tsx +2 -2
  65. package/src/server/client/AutoRefresh.tsx +38 -0
  66. package/src/server/client/Filters.tsx +72 -53
  67. package/src/server/client/FindingCard.tsx +212 -71
  68. package/src/server/client/FindingsList.tsx +8 -6
  69. package/src/server/client/RunList.tsx +30 -7
  70. package/src/server/client/SubmitModal.tsx +418 -0
  71. package/src/server/client/SummaryBar.tsx +65 -12
  72. package/src/server/client/constants.ts +9 -12
  73. package/src/server/client/format.ts +40 -0
  74. package/src/server/client/icons.tsx +143 -0
  75. package/src/server/client/types.ts +69 -0
  76. package/src/server/index.ts +316 -222
  77. package/src/server/run-repository.ts +291 -0
  78. package/src/tests/__snapshots__/baseline.test.ts.snap +814 -0
  79. package/src/tests/baseline.test.ts +513 -0
  80. package/src/tests/cli.test.ts +167 -0
  81. package/src/tests/domain.test.ts +328 -0
  82. package/src/tests/github-review.test.ts +320 -0
  83. package/src/tests/infrastructure.test.ts +210 -0
  84. package/src/tests/server.test.ts +117 -0
@@ -6,31 +6,16 @@
6
6
  * Bind padrão em `127.0.0.1` — ferramenta local, sem autenticação.
7
7
  */
8
8
 
9
- import { join } from "node:path";
10
9
  import { mediaPath } from "../core/assets";
11
- import { type HoundsmanConfig, resolveArtifactDir } from "../core/config";
10
+ import type { HoundsmanConfig } from "../core/config";
11
+ import type { FindingUpdate } from "../core/findings-state";
12
12
  import {
13
- applyFindingUpdate,
14
- buildStateFromRunDir,
15
- emptyState,
16
- type FindingDecision,
17
- type FindingExecState,
18
- type FindingsFilter,
19
- type FindingsStateFile,
20
- FindingsStateStore,
21
- type FindingUpdate,
22
- fileReader,
23
- filterFindings,
24
- reorder,
25
- type SyncResult,
26
- summarize,
27
- syncState,
28
- } from "../core/findings-state";
29
- import { STATE_FILE } from "../core/paths";
30
- import { pingFindingsStatus, readRunStatus } from "../core/run-status";
31
- import type { Severity } from "../core/types";
32
- import { listRunSlugs, readRunFile, readRunJson, runDirForSlug } from "./artifacts";
13
+ defaultGhRunner,
14
+ type GhRunner,
15
+ type SubmitSelection,
16
+ } from "../core/github-review";
33
17
  import { buildClientBundle } from "./client-build";
18
+ import { RunRepository } from "./run-repository";
34
19
 
35
20
  type BunServer = ReturnType<typeof Bun.serve>;
36
21
 
@@ -67,118 +52,8 @@ function notFound(detail = "não encontrado"): Response {
67
52
  // Estado / dados
68
53
  // ---------------------------------------------------------------------------
69
54
 
70
- function buildForRun(dir: string, slug: string): FindingsStateFile | null {
71
- const manifest = readRunJson<{ branch?: string }>(dir, "manifest.json");
72
- return buildStateFromRunDir(dir, manifest?.branch ?? slug, fileReader(process.cwd()));
73
- }
74
-
75
- /**
76
- * Lê o estado da run. Se ausente, reconstrói dos artefatos e PERSISTE — os ids
77
- * são aleatórios, então precisam ser estáveis entre requisições.
78
- */
79
- function loadState(dir: string, slug: string): FindingsStateFile {
80
- const store = new FindingsStateStore(join(dir, STATE_FILE), slug);
81
- const existing = store.read();
82
- if (existing) return existing;
83
- const built = buildForRun(dir, slug);
84
- if (built) {
85
- store.write(built);
86
- return built;
87
- }
88
- return emptyState(slug);
89
- }
90
-
91
- function listRuns(artifactDir: string): unknown[] {
92
- return listRunSlugs(artifactDir).map((slug) => {
93
- const dir = join(artifactDir, slug);
94
- const state = loadState(dir, slug);
95
- const manifest = readRunJson<{ branch?: string; generatedAt?: string }>(
96
- dir,
97
- "manifest.json",
98
- );
99
- const status = readRunStatus(dir);
100
- return {
101
- slug,
102
- branch: state.branch || manifest?.branch || slug,
103
- updatedAt: status?.updatedAt ?? state.updatedAt,
104
- status: status?.status ?? null,
105
- summary: summarize(state),
106
- };
107
- });
108
- }
109
-
110
- function runDetail(dir: string, slug: string): unknown {
111
- const state = loadState(dir, slug);
112
- const classification = readRunJson<{
113
- watchdogs?: Record<string, string[]>;
114
- unclassified?: string[];
115
- deleted?: string[];
116
- }>(dir, "classification.json");
117
- return {
118
- slug,
119
- branch: state.branch || slug,
120
- status: readRunStatus(dir),
121
- manifest: readRunJson(dir, "manifest.json"),
122
- summary: summarize(state),
123
- watchdogs: Object.keys(classification?.watchdogs ?? {}),
124
- unclassified: classification?.unclassified ?? [],
125
- deleted: classification?.deleted ?? [],
126
- };
127
- }
128
-
129
- function findingsFor(dir: string, slug: string, params: URLSearchParams): unknown {
130
- const state = loadState(dir, slug);
131
- const filter: FindingsFilter = {
132
- decision: (params.get("decision") as FindingDecision) || undefined,
133
- execState: (params.get("exec") as FindingExecState) || undefined,
134
- severity: (params.get("severity") as Severity) || undefined,
135
- watchdog: params.get("watchdog") || undefined,
136
- file: params.get("file") || undefined,
137
- includeAbsent: params.get("all") === "1",
138
- };
139
- return {
140
- branch: state.branch,
141
- findings: filterFindings(state, filter),
142
- summary: summarize(state),
143
- };
144
- }
145
-
146
- async function updateFinding(
147
- dir: string,
148
- slug: string,
149
- id: string,
150
- request: Request,
151
- ): Promise<Response> {
152
- const body = (await request.json().catch(() => ({}))) as FindingUpdate;
153
- const store = new FindingsStateStore(join(dir, STATE_FILE), slug);
154
- const state = store.read() ?? buildForRun(dir, slug) ?? emptyState(slug);
155
- const finding = state.findings[id];
156
- if (!finding) return notFound(`finding '${id}' não encontrado`);
157
- applyFindingUpdate(finding, body);
158
- reorder(state);
159
- store.write(state);
160
- pingFindingsStatus(dir, state);
161
- return json({ finding });
162
- }
163
-
164
- function syncRun(dir: string, slug: string): Response {
165
- const store = new FindingsStateStore(join(dir, STATE_FILE), slug);
166
- const state = store.read() ?? buildForRun(dir, slug) ?? emptyState(slug);
167
- const result: SyncResult = syncState(state, fileReader(process.cwd()));
168
- store.write(state);
169
- pingFindingsStatus(dir, state);
170
- return json({ result, summary: summarize(state) });
171
- }
172
-
173
- function serveArtifact(dir: string, name: string): Response {
174
- if (!/\.(json|md)$/i.test(name)) return notFound("tipo de artefato não servido");
175
- const content = readRunFile(dir, name);
176
- if (content === null) return notFound(`artefato '${name}' não encontrado`);
177
- const type = name.toLowerCase().endsWith(".json")
178
- ? "application/json; charset=utf-8"
179
- : "text/markdown; charset=utf-8";
180
- return text(content, type);
181
- }
55
+ /** Repositório das runs (dados) — ver `run-repository.ts`. */
56
+ type ServerDeps = { ghRunner?: GhRunner };
182
57
 
183
58
  // ---------------------------------------------------------------------------
184
59
  // Rotas
@@ -196,7 +71,11 @@ async function serveLogo(): Promise<Response> {
196
71
  });
197
72
  }
198
73
 
199
- async function route(request: Request, artifactDir: string): Promise<Response> {
74
+ async function route(
75
+ request: Request,
76
+ runs: RunRepository,
77
+ ghRunner: GhRunner,
78
+ ): Promise<Response> {
200
79
  const url = new URL(request.url);
201
80
  const path = url.pathname;
202
81
 
@@ -210,54 +89,77 @@ async function route(request: Request, artifactDir: string): Promise<Response> {
210
89
  if (path === "/api/health") return json({ ok: true });
211
90
 
212
91
  if (path === "/api/runs" && request.method === "GET") {
213
- return json({ runs: listRuns(artifactDir) });
92
+ return json({ runs: runs.list() });
214
93
  }
215
94
 
216
95
  const match = path.match(API_RUN);
217
96
  if (!match) return notFound();
218
97
  const slug = decodeURIComponent(match[1]);
219
98
  const rest = match[2] ?? "";
220
- const dir = runDirForSlug(artifactDir, slug);
221
- if (!dir) return notFound(`run '${slug}' não encontrada`);
99
+ if (!runs.exists(slug)) return notFound(`run '${slug}' não encontrada`);
222
100
 
223
- if (rest === "" || rest === "/") return json(runDetail(dir, slug));
101
+ if (rest === "" || rest === "/") return json(runs.detail(slug));
224
102
 
225
103
  if (rest === "/findings" && request.method === "GET") {
226
- return json(findingsFor(dir, slug, url.searchParams));
104
+ return json(runs.findings(slug, url.searchParams));
105
+ }
106
+ if (rest === "/sync" && request.method === "POST") return json(runs.sync(slug));
107
+ if (rest === "/submit-preview" && request.method === "GET") {
108
+ return json(runs.submitPreview(slug, ghRunner));
109
+ }
110
+ if (rest === "/submit" && request.method === "POST") {
111
+ const body = (await request.json().catch(() => ({}))) as SubmitSelection;
112
+ const selection: SubmitSelection = {
113
+ severities: body.severities,
114
+ execStates: body.execStates,
115
+ includeSent: body.includeSent,
116
+ };
117
+ return json(runs.submit(slug, selection, ghRunner));
227
118
  }
228
- if (rest === "/sync" && request.method === "POST") return syncRun(dir, slug);
229
119
 
230
120
  const findingMatch = rest.match(/^\/findings\/([^/]+)$/);
231
121
  if (findingMatch) {
232
122
  const id = decodeURIComponent(findingMatch[1]);
233
123
  if (request.method === "GET") {
234
- const state = loadState(dir, slug);
235
- const finding = state.findings[id];
124
+ const finding = runs.finding(slug, id);
236
125
  return finding
237
126
  ? json({ finding })
238
127
  : notFound(`finding '${id}' não encontrado`);
239
128
  }
240
129
  if (request.method === "PATCH" || request.method === "POST") {
241
- return updateFinding(dir, slug, id, request);
130
+ const body = (await request.json().catch(() => ({}))) as FindingUpdate;
131
+ const finding = runs.update(slug, id, body);
132
+ return finding
133
+ ? json({ finding })
134
+ : notFound(`finding '${id}' não encontrado`);
242
135
  }
243
136
  }
244
137
 
245
138
  const artifactMatch = rest.match(/^\/artifacts\/(.+)$/);
246
139
  if (artifactMatch && request.method === "GET") {
247
- return serveArtifact(dir, decodeURIComponent(artifactMatch[1]));
140
+ const name = decodeURIComponent(artifactMatch[1]);
141
+ const artifact = runs.artifact(slug, name);
142
+ if (!artifact) return notFound(`artefato '${name}' não encontrado`);
143
+ return text(artifact.content, artifact.type);
248
144
  }
249
145
 
250
146
  return notFound();
251
147
  }
252
148
 
253
- /** Cria o handler de requisições (testável sem abrir porta). */
254
- export function createRequestHandler(
255
- config: HoundsmanConfig,
256
- ): (request: Request) => Promise<Response> {
257
- const artifactDir = resolveArtifactDir(config);
258
- return async (request) => {
149
+ /** Servidor local do houndsman (rotas + shell). */
150
+ export class HoundsmanServer {
151
+ private readonly runs: RunRepository;
152
+ private readonly ghRunner: GhRunner;
153
+
154
+ constructor(config: HoundsmanConfig, deps: ServerDeps = {}) {
155
+ this.runs = RunRepository.fromConfig(config);
156
+ this.ghRunner = deps.ghRunner ?? defaultGhRunner;
157
+ }
158
+
159
+ /** Handler de requisições (testável sem abrir porta). */
160
+ readonly handler = async (request: Request): Promise<Response> => {
259
161
  try {
260
- return await route(request, artifactDir);
162
+ return await route(request, this.runs, this.ghRunner);
261
163
  } catch (error) {
262
164
  return json(
263
165
  { error: error instanceof Error ? error.message : String(error) },
@@ -265,24 +167,41 @@ export function createRequestHandler(
265
167
  );
266
168
  }
267
169
  };
170
+
171
+ /** Sobe o servidor e devolve a instância + URL. */
172
+ start(options: { port?: number; host?: string } = {}): {
173
+ server: BunServer;
174
+ url: string;
175
+ } {
176
+ const host = options.host ?? "127.0.0.1";
177
+ const port = options.port ?? 7331;
178
+ const server = Bun.serve({ hostname: host, port, fetch: this.handler });
179
+ return { server, url: `http://${host}:${server.port}` };
180
+ }
181
+ }
182
+
183
+ /** Cria o handler de requisições (testável sem abrir porta). */
184
+ export function createRequestHandler(
185
+ config: HoundsmanConfig,
186
+ deps: ServerDeps = {},
187
+ ): (request: Request) => Promise<Response> {
188
+ return new HoundsmanServer(config, deps).handler;
268
189
  }
269
190
 
270
191
  export type ServerOptions = {
271
192
  config: HoundsmanConfig;
272
193
  port?: number;
273
194
  host?: string;
195
+ /** Runner do `gh` (injetável nos testes). */
196
+ ghRunner?: GhRunner;
274
197
  };
275
198
 
276
199
  /** Sobe o servidor e devolve a instância + URL. */
277
200
  export function startServer(options: ServerOptions): { server: BunServer; url: string } {
278
- const host = options.host ?? "127.0.0.1";
279
- const port = options.port ?? 7331;
280
- const server = Bun.serve({
281
- hostname: host,
282
- port,
283
- fetch: createRequestHandler(options.config),
201
+ return new HoundsmanServer(options.config, { ghRunner: options.ghRunner }).start({
202
+ port: options.port,
203
+ host: options.host,
284
204
  });
285
- return { server, url: `http://${host}:${server.port}` };
286
205
  }
287
206
 
288
207
  // ---------------------------------------------------------------------------
@@ -290,70 +209,240 @@ export function startServer(options: ServerOptions): { server: BunServer; url: s
290
209
  // ---------------------------------------------------------------------------
291
210
 
292
211
  const STYLES = `
293
- :root { color-scheme: dark; --bg:#161616; --panel:#1e1e1e; --border:#2f2f2f; --fg:#e6e6e6; --muted:#9aa0a6; --accent:#4c8dff; }
294
- * { box-sizing: border-box; }
295
- body { margin:0; font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; background:var(--bg); color:var(--fg); }
296
- code, pre { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
297
- .app { display:flex; min-height:100vh; }
298
- .sidebar { width:240px; border-right:1px solid var(--border); background:var(--panel); padding:16px; }
299
- .brand { display:flex; align-items:center; gap:10px; margin-bottom:10px; }
300
- .brand .logo { width:40px; height:40px; object-fit:contain; border-radius:8px; background:#111; border:1px solid var(--border); }
301
- .sidebar h1 { font-size:16px; margin:0; }
302
- .sub { color:var(--muted); font-size:12px; margin:2px 0 12px; }
303
- .brand .sub { margin:0; }
304
- .runs { display:flex; flex-direction:column; gap:4px; }
305
- .run { text-align:left; background:transparent; border:1px solid transparent; border-radius:6px; color:var(--fg); padding:8px; cursor:pointer; display:flex; flex-direction:column; gap:2px; }
306
- .run:hover { background:#262626; }
307
- .run.active { border-color:var(--accent); background:#22304d; }
308
- .run-branch { font-weight:600; font-size:13px; }
309
- .run-meta { color:var(--muted); font-size:11px; }
310
- .run-status { font-size:10px; text-transform:uppercase; letter-spacing:.04em; color:var(--muted); }
311
- .status-reviewing, .status-fixing { color:#e3a008; }
312
- .status-review_done { color:#4c8dff; }
313
- .status-fix_done { color:#3fb950; }
314
- .status-timeout { color:#e5484d; }
315
- .main { flex:1; padding:16px 20px; max-width:980px; }
316
- .topbar { display:flex; justify-content:space-between; align-items:center; margin-bottom:12px; }
317
- .tabs { display:flex; gap:6px; flex-wrap:wrap; }
318
- .btn { background:#262626; color:var(--fg); border:1px solid var(--border); border-radius:6px; padding:5px 10px; font-size:12px; cursor:pointer; }
319
- .btn:hover { border-color:var(--accent); }
320
- .btn.active { background:#22304d; border-color:var(--accent); }
321
- .btn:disabled { opacity:.5; cursor:not-allowed; }
322
- .summary-bar { display:flex; gap:14px; flex-wrap:wrap; color:var(--muted); font-size:12px; border:1px solid var(--border); border-radius:6px; padding:8px 10px; margin-bottom:10px; }
323
- .filters { display:flex; gap:8px; flex-wrap:wrap; margin-bottom:10px; }
324
- .filters input[type=text], .filters input:not([type]), .filters select { background:#1b1b1b; color:var(--fg); border:1px solid var(--border); border-radius:6px; padding:6px 8px; font-size:12px; }
325
- .filters input:not([type]) { min-width:240px; }
326
- .toggle { color:var(--muted); font-size:12px; display:flex; align-items:center; gap:6px; }
327
- .caption { color:var(--muted); font-size:12px; line-height:1.5; margin:0 0 12px; }
328
- .caption code { background:#242424; padding:1px 5px; border-radius:4px; }
329
- .sev-group h2 { font-size:13px; text-transform:uppercase; letter-spacing:.04em; color:var(--muted); }
330
- .sev-group h2 .n { color:#666; }
331
- .card { background:var(--panel); border:1px solid var(--border); border-left:3px solid var(--border); border-radius:8px; padding:12px 14px; margin-bottom:10px; }
332
- .card.sev-blocking { border-left-color:#e5484d; }
333
- .card.sev-attention { border-left-color:#e3a008; }
334
- .card.sev-suggestion { border-left-color:#4c8dff; }
335
- .card.sev-nit { border-left-color:#666; }
336
- .card.absent { opacity:.55; }
337
- .card-head { display:flex; align-items:center; gap:10px; font-size:12px; color:var(--muted); }
338
- .card-head .loc { color:var(--fg); }
339
- .card-head .spacer { flex:1; }
340
- .card-head .id { color:#7c7c7c; }
341
- .rule { font-size:14px; margin:8px 0 4px; }
342
- .msg { margin:2px 0; font-size:13px; }
343
- .action { margin:2px 0; font-size:13px; color:#cfcfcf; }
344
- .evidence { background:#111; border:1px solid var(--border); border-radius:6px; padding:8px; font-size:12px; overflow:auto; }
345
- .card-foot { display:flex; gap:12px; color:var(--muted); font-size:11px; margin:6px 0; }
346
- .decisions { display:flex; gap:6px; flex-wrap:wrap; margin:6px 0; }
347
- .dec-execute.active { background:#153b21; border-color:#2ea043; }
348
- .dec-ignore.active { background:#3a2323; border-color:#e5484d; }
349
- .comment-row textarea { width:100%; min-height:44px; background:#1b1b1b; color:var(--fg); border:1px solid var(--border); border-radius:6px; padding:6px 8px; font-size:12px; }
350
- .comment-actions { display:flex; gap:6px; margin-top:6px; }
351
- .btn.done.active { background:#153b21; border-color:#2ea043; }
352
- .artifacts .tabs { margin-bottom:10px; }
353
- .artifact-desc { color:var(--muted); font-size:12px; line-height:1.5; margin:0 0 10px; }
354
- .artifact-view { background:#111; border:1px solid var(--border); border-radius:6px; padding:12px; font-size:12px; overflow:auto; max-height:70vh; }
355
- .error { color:#ff7b72; font-size:13px; }
212
+ @import url('https://fonts.googleapis.com/css2?family=Poppins:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap');
213
+
214
+ :root {
215
+ color-scheme: dark;
216
+ --bg:#0a0a0c; --surface:#101014; --surface-2:#17171c; --card:#131318;
217
+ --border:rgba(255,255,255,.07); --border-strong:rgba(255,255,255,.14);
218
+ --fg:#ece9e4; --muted:#9d978e; --muted-2:#6d6862;
219
+ --accent:#f0a52c; --accent-strong:#ffc247; --accent-soft:rgba(240,165,44,.07); --accent-line:rgba(240,165,44,.25);
220
+ --blocking:#f2595e; --attention:#eda13a; --suggestion:#5aa4f0; --nit:#8d8a85; --success:#45c07a;
221
+ --radius:16px; --radius-lg:18px;
222
+ --font-sans:"Poppins","Space Grotesk","Geist Sans",ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif;
223
+ --font-mono:"JetBrains Mono",ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
224
+ }
225
+ * { box-sizing:border-box; }
226
+ html, body { height:100%; }
227
+ body { margin:0; font-size:14px; background:var(--bg); color:var(--fg); font-family:var(--font-sans); -webkit-font-smoothing:antialiased; }
228
+ body::before { content:""; position:fixed; inset:0; pointer-events:none; z-index:0;
229
+ background:radial-gradient(820px 420px at 8% -12%, rgba(240,165,44,.08), transparent 62%); }
230
+ code, pre, .mono { font-family:var(--font-mono); }
231
+ ::-webkit-scrollbar { width:10px; height:10px; }
232
+ ::-webkit-scrollbar-thumb { background:rgba(255,255,255,.1); border:2px solid transparent; border-radius:999px; background-clip:padding-box; }
233
+ ::-webkit-scrollbar-thumb:hover { background:rgba(255,255,255,.18); background-clip:padding-box; }
234
+ .app { position:relative; z-index:1; display:flex; height:100vh; overflow:hidden; }
235
+ .sidebar { width:292px; flex:0 0 292px; display:flex; flex-direction:column; gap:14px; padding:18px 14px;
236
+ background:var(--surface); border-right:1px solid var(--border); overflow-y:auto; }
237
+ .brand { display:flex; align-items:center; gap:12px; }
238
+ .brand .logo { width:42px; height:42px; object-fit:contain; border-radius:12px; background:var(--surface-2); border:1px solid var(--border); }
239
+ .sidebar h1 { font-size:15.5px; font-weight:700; letter-spacing:-.01em; margin:0; }
240
+ .sub { color:var(--muted); font-size:11.5px; margin:0; }
241
+ .section-label { margin:4px 0 0; font-size:10px; font-weight:700; letter-spacing:.16em; text-transform:uppercase; color:var(--muted-2); }
242
+ .runs { display:flex; flex-direction:column; gap:6px; }
243
+ .run { display:flex; flex-direction:column; gap:8px; text-align:left; cursor:pointer; font:inherit; color:inherit; padding:11px 13px;
244
+ background:transparent; border:1px solid transparent; border-radius:var(--radius); transition:background .15s ease, border-color .15s ease; }
245
+ .run:hover { background:var(--surface-2); border-color:var(--border); }
246
+ .run.active { background:var(--accent-soft); border-color:var(--accent-line); }
247
+ .run-top { display:flex; align-items:flex-start; gap:8px; }
248
+ .run-icon { flex:none; margin-top:2px; color:var(--muted-2); }
249
+ .run.active .run-icon { color:var(--accent); }
250
+ .run-branch { font-size:13.5px; font-weight:600; line-height:1.35; word-break:break-word; }
251
+ .run-meta { display:flex; align-items:center; gap:10px; font-family:var(--font-mono); font-size:11px; color:var(--muted); }
252
+ .run-when { display:inline-flex; align-items:center; gap:6px; }
253
+ .run-count { margin-left:auto; color:var(--muted-2); }
254
+ .run-badge { align-self:flex-start; display:inline-flex; align-items:center; gap:6px; padding:4px 9px; border-radius:999px;
255
+ font-size:10px; font-weight:700; letter-spacing:.07em; text-transform:uppercase;
256
+ color:var(--success); background:rgba(69,192,122,.12); border:1px solid rgba(69,192,122,.32); }
257
+ .run-status { font-size:10px; font-weight:600; letter-spacing:.07em; text-transform:uppercase; color:var(--muted-2); }
258
+ .status-reviewing, .status-fixing { color:var(--accent); }
259
+ .status-review_done { color:var(--suggestion); }
260
+ .status-fix_done { color:var(--success); }
261
+ .status-timeout { color:var(--blocking); }
262
+ .main { flex:1; min-width:0; display:flex; flex-direction:column; overflow-y:auto; }
263
+ .topbar { position:sticky; top:0; z-index:10; display:flex; align-items:center; justify-content:space-between; gap:16px;
264
+ padding:13px 24px; border-bottom:1px solid var(--border); background:rgba(16,16,20,.74);
265
+ backdrop-filter:blur(16px) saturate(140%); -webkit-backdrop-filter:blur(16px) saturate(140%); }
266
+ .segmented { display:inline-flex; gap:4px; padding:4px; background:var(--surface-2); border:1px solid var(--border); border-radius:999px; }
267
+ .seg-btn { display:inline-flex; align-items:center; gap:8px; font:inherit; font-size:12.5px; font-weight:600; cursor:pointer;
268
+ color:var(--muted); background:transparent; border:1px solid transparent; border-radius:999px; padding:7px 16px;
269
+ transition:color .15s ease, background .15s ease; }
270
+ .seg-btn:hover { color:var(--fg); }
271
+ .seg-btn.active { color:#1b1305; background:linear-gradient(180deg, var(--accent-strong), var(--accent)); box-shadow:0 4px 14px rgba(240,165,44,.24); }
272
+ .topbar-right { display:flex; align-items:center; gap:14px; }
273
+ .auto-refresh { display:inline-flex; align-items:center; gap:8px; white-space:nowrap; font-family:var(--font-mono); font-size:11.5px; color:var(--muted); }
274
+ .dot { width:8px; height:8px; border-radius:50%; background:var(--success); box-shadow:0 0 0 3px rgba(69,192,122,.14); }
275
+ .dot.busy { background:var(--accent); box-shadow:0 0 0 3px rgba(240,165,44,.18); animation:pulse 1.1s ease-in-out infinite; }
276
+ .btn { display:inline-flex; align-items:center; gap:8px; font:inherit; font-size:12.5px; font-weight:500; cursor:pointer; color:var(--fg);
277
+ padding:8px 15px; background:var(--surface-2); border:1px solid var(--border-strong); border-radius:999px;
278
+ transition:background .15s ease, border-color .15s ease, color .15s ease; }
279
+ .btn:hover { background:#202027; border-color:var(--accent-line); }
280
+ .btn:disabled { opacity:.55; cursor:not-allowed; }
281
+ .icon.spin { animation:spin .8s linear infinite; }
282
+ .content { width:100%; max-width:1180px; margin:0 auto; padding:22px 24px 64px; }
283
+ .page-header { display:flex; align-items:flex-start; justify-content:space-between; gap:18px; }
284
+ .page-header-text { min-width:0; }
285
+ .page-header-actions { display:flex; align-items:center; gap:10px; flex:none; }
286
+ .btn.submit-review { color:#1b1305; background:linear-gradient(180deg, var(--accent-strong), var(--accent)); border-color:transparent; box-shadow:0 4px 14px rgba(240,165,44,.22); }
287
+ .btn.submit-review:hover { background:linear-gradient(180deg, var(--accent-strong), var(--accent)); border-color:transparent; filter:brightness(1.07); }
288
+ .page-title { margin:0 0 8px; font-size:26px; font-weight:700; letter-spacing:-.02em; word-break:break-word; }
289
+ .page-sub { margin:0; max-width:78ch; color:var(--muted); font-size:12.5px; line-height:1.55; }
290
+ .page-sub code { color:var(--fg); background:rgba(255,255,255,.06); border-radius:6px; padding:1px 6px; font-size:11.5px; }
291
+ .error { color:#ff8b8f; font-size:12.5px; }
356
292
  .empty { color:var(--muted); font-size:13px; }
293
+ .kpis { display:grid; grid-template-columns:repeat(4, minmax(0,1fr)); gap:12px; margin:20px 0 18px; }
294
+ .kpi { display:flex; flex-direction:column; gap:8px; padding:15px 16px; background:var(--card);
295
+ border:1px solid var(--border); border-radius:var(--radius-lg); }
296
+ .kpi-label { font-size:10px; font-weight:700; letter-spacing:.14em; text-transform:uppercase; color:var(--muted-2); }
297
+ .kpi-value { font-size:30px; font-weight:700; line-height:1; letter-spacing:-.02em; font-variant-numeric:tabular-nums; }
298
+ .kpi-hint { font-family:var(--font-mono); font-size:11px; color:var(--muted-2); }
299
+ .kpi-execute .kpi-value { color:var(--accent); }
300
+ .kpi-ignore .kpi-value { color:var(--muted); }
301
+ .kpi-done .kpi-value { color:var(--success); }
302
+ .progress { margin:0 0 22px; }
303
+ .progress-head { display:flex; align-items:center; justify-content:space-between; margin-bottom:8px; font-size:11.5px; color:var(--muted); }
304
+ .progress-head .pct { color:var(--fg); font-family:var(--font-mono); }
305
+ .progress-track { height:8px; border-radius:999px; background:var(--surface-2); border:1px solid var(--border); overflow:hidden; }
306
+ .progress-fill { display:block; height:100%; border-radius:999px; background:linear-gradient(90deg, #2c8f57, var(--success)); transition:width .35s ease; }
307
+ .filters { display:flex; flex-wrap:wrap; align-items:center; gap:10px; margin-bottom:20px; }
308
+ .search { display:flex; align-items:center; gap:8px; flex:1 1 240px; max-width:330px; padding:8px 14px; color:var(--muted);
309
+ background:var(--surface-2); border:1px solid var(--border-strong); border-radius:999px; }
310
+ .search:focus-within { color:var(--accent); border-color:var(--accent-line); }
311
+ .search input { flex:1; min-width:0; font:inherit; font-size:12.5px; color:var(--fg); background:transparent; border:0; outline:0; }
312
+ .search input::placeholder { color:var(--muted-2); }
313
+ .search-clear { padding:0 2px; font-size:15px; line-height:1; color:var(--muted); background:transparent; border:0; cursor:pointer; }
314
+ .chips { display:inline-flex; gap:3px; padding:4px; background:var(--surface-2); border:1px solid var(--border); border-radius:999px; }
315
+ .chip { display:inline-flex; align-items:center; gap:6px; font:inherit; font-size:12px; font-weight:600; cursor:pointer; color:var(--muted);
316
+ padding:6px 13px; background:transparent; border:1px solid transparent; border-radius:999px; transition:color .15s ease, background .15s ease; }
317
+ .chip:hover { color:var(--fg); }
318
+ .chip.active { color:#14100a; background:#e9e5df!important; }
319
+ .sev-group { margin-bottom:24px; }
320
+ .sev-head { display:flex; align-items:center; gap:8px; margin:0 0 10px; font-size:11px; font-weight:700;
321
+ letter-spacing:.16em; text-transform:uppercase; color:var(--muted-2); }
322
+ .sev-head .n { font-family:var(--font-mono); letter-spacing:0; }
323
+ .sev-group.sev-blocking .sev-head { color:var(--blocking); }
324
+ .sev-group.sev-attention .sev-head { color:var(--attention); }
325
+ .sev-group.sev-suggestion .sev-head { color:var(--suggestion); }
326
+ .sev-group.sev-nit .sev-head { color:var(--nit); }
327
+ .card { position:relative; margin-bottom:20px; overflow:hidden; background:var(--card);
328
+ border:1px solid var(--border); border-left:4px solid var(--border-strong); border-radius:var(--radius-lg);
329
+ transition:background .15s ease, border-color .15s ease, opacity .2s ease; }
330
+ .card:hover { background:#16161b; }
331
+ .card.sev-blocking { border-left-color:var(--blocking); }
332
+ .card.sev-attention { border-left-color:var(--attention); }
333
+ .card.sev-suggestion { border-left-color:var(--suggestion); }
334
+ .card.sev-nit { border-left-color:var(--nit); }
335
+ .card.absent { opacity:.5; }
336
+ .card.done { opacity:.6; }
337
+ .card-head { display:flex; align-items:center; gap:10px; padding:12px 14px 0; }
338
+ .chev { display:grid; place-items:center; flex:none; width:22px; height:22px; cursor:pointer; color:var(--muted-2);
339
+ background:transparent; border:1px solid transparent; border-radius:7px; }
340
+ .chev:hover { color:var(--fg); background:rgba(255,255,255,.06); }
341
+ .chev:focus-visible { color:var(--fg); border-color:var(--accent-line); outline:none; }
342
+ .chev svg { transition:transform .18s ease; }
343
+ .chev.open svg { transform:rotate(90deg); }
344
+ .sev-badge { display:inline-flex; align-items:center; flex:none; padding:3px 9px; border-radius:999px;
345
+ font-size:10px; letter-spacing:.08em; text-transform:uppercase; }
346
+ .card.sev-blocking .sev-badge { color:var(--blocking); background:rgba(242,89,94,.12); }
347
+ .card.sev-attention .sev-badge { color:var(--attention); background:rgba(237,161,58,.12); }
348
+ .card.sev-suggestion .sev-badge { color:var(--suggestion); background:rgba(90,164,240,.12); }
349
+ .card.sev-nit .sev-badge { color:var(--nit); background:rgba(255,255,255,.06); }
350
+ .loc { display:inline-flex; align-items:center; gap:7px; max-width: 406ch; overflow:hidden; white-space:break-spaces; text-overflow:ellipsis;
351
+ padding:4px 9px; font-family:var(--font-mono); font-size:11.5px; color:#aba7a1; text-decoration:none; }
352
+ .loc:hover { color:#fff; }
353
+ .loc .ext { flex:none; opacity:0; transition:opacity .15s ease; }
354
+ .loc:hover .ext { opacity:1; }
355
+ .loc.static { color:var(--muted); }
356
+ .count { padding:2px 7px; font-family:var(--font-mono); font-size:11px; color:var(--muted);
357
+ background:var(--surface-2); border:1px solid var(--border); border-radius:999px; }
358
+ .spacer { flex:1; }
359
+ .done-btn { display:inline-flex; align-items:center; gap:7px; flex:none; font:inherit; font-size:12px; font-weight:600; cursor:pointer;
360
+ padding:7px 13px; color:var(--muted); background:transparent; border:1px solid var(--border-strong); border-radius:999px;
361
+ transition:color .15s ease, background .15s ease, border-color .15s ease; }
362
+ .done-btn:hover { color:var(--fg); border-color:rgba(69,192,122,.55); }
363
+ .done-btn:disabled { opacity:.55; cursor:not-allowed; }
364
+ .done-btn.is-done { color:var(--success); background:rgba(69,192,122,.14); border-color:rgba(69,192,122,.5); }
365
+ .card-title { margin:9px 0 0; padding:0 16px 20px; font-size:14px; font-weight:400; }
366
+ .card.done .card-title { color:#b7b7b7; text-decoration:line-through; text-decoration-color:rgba(255,255,255,.15); }
367
+ .card-body { display:flex; flex-direction:column; gap:12px; padding:20px 16px 15px; background:rgba(255,255,255,.012); border-top:1px solid var(--border); }
368
+ .meta-row { display:flex; flex-wrap:wrap; gap:14px; font-size:11.5px; color:var(--muted); }
369
+ .meta { display:inline-flex; align-items:center; gap:7px; }
370
+ .meta-k { font-size:10px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--muted-2); }
371
+ .meta code { padding:2px 6px; font-size:11.5px; color:var(--fg); background:rgba(255,255,255,.04); border:1px solid var(--border); border-radius:6px; }
372
+ .action-block { display:flex; gap:11px; padding:16px; background:var(--accent-soft); border:1px solid var(--accent-line); border-radius:var(--radius); }
373
+ .action-icon { flex:none; display:inline-block; margin-right:5px; margin-bottom:-5px; color:var(--accent); }
374
+ .action-label { display:inline-block; font-size:10px; font-weight:600; letter-spacing:.12em; text-transform:uppercase; color:var(--accent); margin-right:5px; }
375
+ .action-block p { margin:0; font-size:13px; color:#f0ece6; }
376
+ .decision-tags-block { display:flex; align-items:center; gap:8px; }
377
+ .evidence { margin:0; padding: 16px; white-space:break-spaces; font-size:12px; line-height:1.6; color:#cdc7bf;
378
+ background:#0b0b0e; border:1px solid var(--border); border-radius:var(--radius); }
379
+ .tags { display:flex; flex-wrap:wrap; align-items:center; gap:7px; margin-left: auto; }
380
+ .tags-label { display:inline-flex; align-items:center; gap:6px; font-size:10px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--muted-2); }
381
+ .tag { display:inline-flex; align-items:center; gap:6px; padding:3px 9px; font-family:var(--font-mono); font-size:11px; color:var(--muted);
382
+ background:var(--surface-2); border:1px solid var(--border); border-radius:999px; }
383
+ .decisions { display:inline-flex; gap:3px; align-self:flex-start; padding:4px; background:var(--surface-2); border:1px solid var(--border); border-radius:999px; }
384
+ .dec-btn { font:inherit; font-size:12px; font-weight:600; cursor:pointer; color:var(--muted); padding:6px 15px;
385
+ background:transparent; border:1px solid transparent; border-radius:999px; transition:color .15s ease, background .15s ease; }
386
+ .dec-btn:hover { color:var(--fg); }
387
+ .dec-btn:disabled { opacity:.55; cursor:not-allowed; }
388
+ .dec-btn.dec-execute.active { color:#1b1305; background:linear-gradient(180deg, var(--accent-strong), var(--accent)); }
389
+ .dec-btn.dec-ignore.active { color:#ffd2d3; background:rgba(242,89,94,.18); box-shadow:inset 0 0 0 1px rgba(242,89,94,.45); }
390
+ .dec-btn.dec-pending.active { color:var(--fg); background:rgba(255,255,255,.08); box-shadow:inset 0 0 0 1px var(--border-strong); }
391
+ .comment { position:relative; }
392
+ .comment textarea { width:100%; min-height:58px; padding:11px 13px 26px; resize:vertical; font:inherit; font-size:12.5px; line-height:1.5;
393
+ color:var(--fg); background:var(--surface-2); border:1px solid var(--border-strong); border-radius:var(--radius); outline:none;
394
+ transition:border-color .15s ease, box-shadow .15s ease; }
395
+ .comment textarea::placeholder { color:var(--muted-2); }
396
+ .comment textarea:focus { border-color:var(--accent-line); box-shadow:0 0 0 3px rgba(240,165,44,.12); }
397
+ .comment-status { position:absolute; right:12px; bottom:9px; display:inline-flex; align-items:center; gap:6px;
398
+ font-family:var(--font-mono); font-size:11px; color:var(--muted); }
399
+ .comment-status.saved { color:var(--success); }
400
+ .artifacts { display:flex; flex-direction:column; }
401
+ .artifact-tabs { display:flex; flex-wrap:wrap; gap:6px; margin-bottom:12px; }
402
+ .artifact-tabs .chip { border:1px solid var(--border); background:var(--surface-2); }
403
+ .artifact-desc { margin:0 0 12px; color:var(--muted); font-size:12.5px; line-height:1.55; }
404
+ .artifact-view { margin:0; padding:14px; max-height:70vh; overflow:auto; font-size:12px; line-height:1.6; color:#cdc7bf;
405
+ background:#0b0b0e; border:1px solid var(--border); border-radius:var(--radius); }
406
+ .sent-tag { display:inline-flex; align-items:center; gap:5px; padding:2px 8px; font-size:10px; font-weight:700; letter-spacing:.06em; text-transform:uppercase;
407
+ color:var(--success); background:rgba(69,192,122,.12); border:1px solid rgba(69,192,122,.32); border-radius:999px; text-decoration:none; }
408
+ .sent-tag:hover { color:#fff; }
409
+ .modal-layer { position:fixed; inset:0; z-index:50; display:grid; place-items:center; padding:24px; }
410
+ .modal-backdrop { position:absolute; inset:0; width:100%; padding:0; background:rgba(5,5,7,.66); border:0; cursor:default; backdrop-filter:blur(5px); -webkit-backdrop-filter:blur(5px); }
411
+ .modal { position:relative; width:min(640px, 100%); max-height:min(78vh, 780px); display:flex; flex-direction:column; overflow:hidden;
412
+ background:var(--surface); border:1px solid var(--border-strong); border-radius:var(--radius-lg); box-shadow:0 24px 70px rgba(0,0,0,.5); }
413
+ .modal-head { display:flex; align-items:flex-start; justify-content:space-between; gap:14px; padding:18px 20px 14px; border-bottom:1px solid var(--border); }
414
+ .modal-title { margin:0; font-size:17px; font-weight:700; letter-spacing:-.01em; }
415
+ .modal-sub { margin:5px 0 0; font-size:12px; color:var(--muted); line-height:1.5; }
416
+ .modal-close { flex:none; display:grid; place-items:center; width:30px; height:30px; font:inherit; font-size:19px; line-height:1; color:var(--muted); background:transparent; border:1px solid var(--border); border-radius:9px; cursor:pointer; }
417
+ .modal-close:hover { color:var(--fg); border-color:var(--border-strong); }
418
+ .modal-body { display:flex; flex-direction:column; gap:16px; padding:16px 20px; overflow-y:auto; }
419
+ .modal-foot { display:flex; align-items:center; justify-content:space-between; gap:12px; padding:14px 20px; background:var(--surface-2); border-top:1px solid var(--border); }
420
+ .submit-field { display:flex; flex-direction:column; gap:8px; }
421
+ .submit-label { font-size:10px; font-weight:700; letter-spacing:.14em; text-transform:uppercase; color:var(--muted-2); }
422
+ .submit-summary { padding:13px 15px; font-size:12.5px; color:var(--fg); background:var(--accent-soft); border:1px solid var(--accent-line); border-radius:var(--radius); }
423
+ .submit-summary b { font-family:var(--font-mono); }
424
+ .submit-list { display:flex; flex-direction:column; gap:6px; max-height:240px; overflow-y:auto; padding-right:2px; }
425
+ .submit-item { display:flex; align-items:center; gap:10px; padding:8px 11px; font-size:12px; background:var(--surface-2); border:1px solid var(--border); border-radius:10px; }
426
+ .submit-item.sent { opacity:.7; }
427
+ .submit-item .rule { flex:1; min-width:0; color:var(--muted); overflow:hidden; white-space:nowrap; text-overflow:ellipsis; }
428
+ .submit-empty { color:var(--muted); font-size:12.5px; }
429
+ .submit-field .chips { flex-wrap:wrap; }
430
+ .submit-check { display:inline-flex; align-items:center; gap:8px; font-size:12.5px; color:var(--muted); cursor:pointer; }
431
+ .submit-check input { accent-color:var(--accent); }
432
+ .submit-actions { display:flex; align-items:center; gap:8px; }
433
+ .sev-badge.sev-blocking { color:var(--blocking); background:rgba(242,89,94,.12); }
434
+ .sev-badge.sev-attention { color:var(--attention); background:rgba(237,161,58,.12); }
435
+ .sev-badge.sev-suggestion { color:var(--suggestion); background:rgba(90,164,240,.12); }
436
+ .sev-badge.sev-nit { color:var(--nit); background:rgba(255,255,255,.06); }
437
+ @keyframes pulse { 0%,100% { opacity:1; transform:scale(1); } 50% { opacity:.4; transform:scale(.8); } }
438
+ @keyframes spin { to { transform:rotate(360deg); } }
439
+ @media (max-width:980px) { .kpis { grid-template-columns:repeat(2, minmax(0,1fr)); } }
440
+ @media (max-width:820px) {
441
+ .sidebar { width:232px; flex-basis:232px; }
442
+ .topbar { padding:12px 16px; }
443
+ .content { padding:18px 16px 56px; }
444
+ .auto-refresh { display:none; }
445
+ }
357
446
  `;
358
447
 
359
448
  /** Id de build por processo — muda a URL do bundle a cada reinício do servidor. */
@@ -365,7 +454,12 @@ function renderShell(): string {
365
454
  <head>
366
455
  <meta charset="utf-8" />
367
456
  <meta name="viewport" content="width=device-width, initial-scale=1" />
457
+ <meta name="color-scheme" content="dark" />
368
458
  <title>Houndsman — Revisões</title>
459
+ <!-- Fontes de CDN: se estiver offline, o CSS cai para a pilha de sistema. -->
460
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
461
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
462
+ <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" />
369
463
  <style>${STYLES}</style>
370
464
  </head>
371
465
  <body>