@payconductor/houndsman 0.1.3 → 0.2.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.
@@ -6,7 +6,7 @@
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";
9
+ import { join, resolve } from "node:path";
10
10
  import { mediaPath } from "../core/assets";
11
11
  import { type HoundsmanConfig, resolveArtifactDir } from "../core/config";
12
12
  import {
@@ -15,9 +15,11 @@ import {
15
15
  emptyState,
16
16
  type FindingDecision,
17
17
  type FindingExecState,
18
+ type FindingState,
18
19
  type FindingsFilter,
19
20
  type FindingsStateFile,
20
21
  FindingsStateStore,
22
+ type FindingsSummary,
21
23
  type FindingUpdate,
22
24
  fileReader,
23
25
  filterFindings,
@@ -26,8 +28,16 @@ import {
26
28
  summarize,
27
29
  syncState,
28
30
  } from "../core/findings-state";
31
+ import {
32
+ buildSubmitPreview,
33
+ defaultGhRunner,
34
+ type GhRunner,
35
+ resolvePrTarget,
36
+ type SubmitSelection,
37
+ submitFindings,
38
+ } from "../core/github-review";
29
39
  import { STATE_FILE } from "../core/paths";
30
- import { pingFindingsStatus, readRunStatus } from "../core/run-status";
40
+ import { pingFindingsStatus, type RunStatus, readRunStatus } from "../core/run-status";
31
41
  import type { Severity } from "../core/types";
32
42
  import { listRunSlugs, readRunFile, readRunJson, runDirForSlug } from "./artifacts";
33
43
  import { buildClientBundle } from "./client-build";
@@ -67,6 +77,15 @@ function notFound(detail = "não encontrado"): Response {
67
77
  // Estado / dados
68
78
  // ---------------------------------------------------------------------------
69
79
 
80
+ /** Item da lista de execuções (sidebar) — já ordenado do mais recente ao antigo. */
81
+ type RunListItem = {
82
+ slug: string;
83
+ branch: string;
84
+ updatedAt?: string;
85
+ status: RunStatus | null;
86
+ summary: FindingsSummary;
87
+ };
88
+
70
89
  function buildForRun(dir: string, slug: string): FindingsStateFile | null {
71
90
  const manifest = readRunJson<{ branch?: string }>(dir, "manifest.json");
72
91
  return buildStateFromRunDir(dir, manifest?.branch ?? slug, fileReader(process.cwd()));
@@ -88,23 +107,36 @@ function loadState(dir: string, slug: string): FindingsStateFile {
88
107
  return emptyState(slug);
89
108
  }
90
109
 
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
- });
110
+ function listRuns(artifactDir: string): RunListItem[] {
111
+ return (
112
+ listRunSlugs(artifactDir)
113
+ .map((slug) => {
114
+ const dir = join(artifactDir, slug);
115
+ const state = loadState(dir, slug);
116
+ const manifest = readRunJson<{ branch?: string; generatedAt?: string }>(
117
+ dir,
118
+ "manifest.json",
119
+ );
120
+ const status = readRunStatus(dir);
121
+ return {
122
+ slug,
123
+ branch: state.branch || manifest?.branch || slug,
124
+ updatedAt: status?.updatedAt ?? state.updatedAt,
125
+ status: status?.status ?? null,
126
+ summary: summarize(state),
127
+ };
128
+ })
129
+ // Mais recentes primeiro (ISO 8601 compara lexicograficamente).
130
+ .sort((a, b) => (b.updatedAt ?? "").localeCompare(a.updatedAt ?? ""))
131
+ );
132
+ }
133
+
134
+ /**
135
+ * Finding + caminho absoluto. O cliente monta o link `vscode://file/...` e não
136
+ * conhece o cwd do servidor — o caminho relativo do relatório não basta.
137
+ */
138
+ function toClientFinding(finding: FindingState): FindingState & { absFile: string } {
139
+ return { ...finding, absFile: resolve(process.cwd(), finding.file) };
108
140
  }
109
141
 
110
142
  function runDetail(dir: string, slug: string): unknown {
@@ -138,7 +170,7 @@ function findingsFor(dir: string, slug: string, params: URLSearchParams): unknow
138
170
  };
139
171
  return {
140
172
  branch: state.branch,
141
- findings: filterFindings(state, filter),
173
+ findings: filterFindings(state, filter).map(toClientFinding),
142
174
  summary: summarize(state),
143
175
  };
144
176
  }
@@ -158,7 +190,7 @@ async function updateFinding(
158
190
  reorder(state);
159
191
  store.write(state);
160
192
  pingFindingsStatus(dir, state);
161
- return json({ finding });
193
+ return json({ finding: toClientFinding(finding) });
162
194
  }
163
195
 
164
196
  function syncRun(dir: string, slug: string): Response {
@@ -170,6 +202,46 @@ function syncRun(dir: string, slug: string): Response {
170
202
  return json({ result, summary: summarize(state) });
171
203
  }
172
204
 
205
+ /** Prévia do envio da revisão: candidatos + disponibilidade da PR (via gh). */
206
+ function submitPreview(dir: string, slug: string, ghRunner: GhRunner): Response {
207
+ const state = loadState(dir, slug);
208
+ const target = resolvePrTarget(ghRunner, state.branch || slug);
209
+ return json(buildSubmitPreview(state, target));
210
+ }
211
+
212
+ /**
213
+ * Envia os findings selecionados como comentários na PR. Fail-open: sem PR/gh
214
+ * devolve `{ available:false, reason }` (a UI só informa). Persiste o vínculo
215
+ * (`finding.github`) no `state.json` e atualiza o status da run.
216
+ */
217
+ async function submitRun(
218
+ dir: string,
219
+ slug: string,
220
+ ghRunner: GhRunner,
221
+ request: Request,
222
+ ): Promise<Response> {
223
+ const state = loadState(dir, slug);
224
+ const body = (await request.json().catch(() => ({}))) as SubmitSelection;
225
+ const selection: SubmitSelection = {
226
+ severities: body.severities,
227
+ execStates: body.execStates,
228
+ includeSent: body.includeSent,
229
+ };
230
+
231
+ const target = resolvePrTarget(ghRunner, state.branch || slug);
232
+ if (!target.ok) return json({ available: false, reason: target.reason });
233
+
234
+ const outcome = submitFindings({
235
+ state,
236
+ target: target.target,
237
+ runner: ghRunner,
238
+ selection,
239
+ });
240
+ new FindingsStateStore(join(dir, STATE_FILE), slug).write(state);
241
+ pingFindingsStatus(dir, state);
242
+ return json({ available: true, target: target.target, outcome });
243
+ }
244
+
173
245
  function serveArtifact(dir: string, name: string): Response {
174
246
  if (!/\.(json|md)$/i.test(name)) return notFound("tipo de artefato não servido");
175
247
  const content = readRunFile(dir, name);
@@ -196,7 +268,11 @@ async function serveLogo(): Promise<Response> {
196
268
  });
197
269
  }
198
270
 
199
- async function route(request: Request, artifactDir: string): Promise<Response> {
271
+ async function route(
272
+ request: Request,
273
+ artifactDir: string,
274
+ ghRunner: GhRunner,
275
+ ): Promise<Response> {
200
276
  const url = new URL(request.url);
201
277
  const path = url.pathname;
202
278
 
@@ -226,6 +302,12 @@ async function route(request: Request, artifactDir: string): Promise<Response> {
226
302
  return json(findingsFor(dir, slug, url.searchParams));
227
303
  }
228
304
  if (rest === "/sync" && request.method === "POST") return syncRun(dir, slug);
305
+ if (rest === "/submit-preview" && request.method === "GET") {
306
+ return submitPreview(dir, slug, ghRunner);
307
+ }
308
+ if (rest === "/submit" && request.method === "POST") {
309
+ return submitRun(dir, slug, ghRunner, request);
310
+ }
229
311
 
230
312
  const findingMatch = rest.match(/^\/findings\/([^/]+)$/);
231
313
  if (findingMatch) {
@@ -234,7 +316,7 @@ async function route(request: Request, artifactDir: string): Promise<Response> {
234
316
  const state = loadState(dir, slug);
235
317
  const finding = state.findings[id];
236
318
  return finding
237
- ? json({ finding })
319
+ ? json({ finding: toClientFinding(finding) })
238
320
  : notFound(`finding '${id}' não encontrado`);
239
321
  }
240
322
  if (request.method === "PATCH" || request.method === "POST") {
@@ -250,14 +332,19 @@ async function route(request: Request, artifactDir: string): Promise<Response> {
250
332
  return notFound();
251
333
  }
252
334
 
335
+ /** Dependências injetáveis do servidor (testes). */
336
+ export type ServerDeps = { ghRunner?: GhRunner };
337
+
253
338
  /** Cria o handler de requisições (testável sem abrir porta). */
254
339
  export function createRequestHandler(
255
340
  config: HoundsmanConfig,
341
+ deps: ServerDeps = {},
256
342
  ): (request: Request) => Promise<Response> {
257
343
  const artifactDir = resolveArtifactDir(config);
344
+ const ghRunner = deps.ghRunner ?? defaultGhRunner;
258
345
  return async (request) => {
259
346
  try {
260
- return await route(request, artifactDir);
347
+ return await route(request, artifactDir, ghRunner);
261
348
  } catch (error) {
262
349
  return json(
263
350
  { error: error instanceof Error ? error.message : String(error) },
@@ -271,6 +358,8 @@ export type ServerOptions = {
271
358
  config: HoundsmanConfig;
272
359
  port?: number;
273
360
  host?: string;
361
+ /** Runner do `gh` (injetável nos testes). */
362
+ ghRunner?: GhRunner;
274
363
  };
275
364
 
276
365
  /** Sobe o servidor e devolve a instância + URL. */
@@ -280,7 +369,7 @@ export function startServer(options: ServerOptions): { server: BunServer; url: s
280
369
  const server = Bun.serve({
281
370
  hostname: host,
282
371
  port,
283
- fetch: createRequestHandler(options.config),
372
+ fetch: createRequestHandler(options.config, { ghRunner: options.ghRunner }),
284
373
  });
285
374
  return { server, url: `http://${host}:${server.port}` };
286
375
  }
@@ -290,70 +379,240 @@ export function startServer(options: ServerOptions): { server: BunServer; url: s
290
379
  // ---------------------------------------------------------------------------
291
380
 
292
381
  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; }
382
+ @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');
383
+
384
+ :root {
385
+ color-scheme: dark;
386
+ --bg:#0a0a0c; --surface:#101014; --surface-2:#17171c; --card:#131318;
387
+ --border:rgba(255,255,255,.07); --border-strong:rgba(255,255,255,.14);
388
+ --fg:#ece9e4; --muted:#9d978e; --muted-2:#6d6862;
389
+ --accent:#f0a52c; --accent-strong:#ffc247; --accent-soft:rgba(240,165,44,.07); --accent-line:rgba(240,165,44,.25);
390
+ --blocking:#f2595e; --attention:#eda13a; --suggestion:#5aa4f0; --nit:#8d8a85; --success:#45c07a;
391
+ --radius:16px; --radius-lg:18px;
392
+ --font-sans:"Poppins","Space Grotesk","Geist Sans",ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif;
393
+ --font-mono:"JetBrains Mono",ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
394
+ }
395
+ * { box-sizing:border-box; }
396
+ html, body { height:100%; }
397
+ body { margin:0; font-size:14px; background:var(--bg); color:var(--fg); font-family:var(--font-sans); -webkit-font-smoothing:antialiased; }
398
+ body::before { content:""; position:fixed; inset:0; pointer-events:none; z-index:0;
399
+ background:radial-gradient(820px 420px at 8% -12%, rgba(240,165,44,.08), transparent 62%); }
400
+ code, pre, .mono { font-family:var(--font-mono); }
401
+ ::-webkit-scrollbar { width:10px; height:10px; }
402
+ ::-webkit-scrollbar-thumb { background:rgba(255,255,255,.1); border:2px solid transparent; border-radius:999px; background-clip:padding-box; }
403
+ ::-webkit-scrollbar-thumb:hover { background:rgba(255,255,255,.18); background-clip:padding-box; }
404
+ .app { position:relative; z-index:1; display:flex; height:100vh; overflow:hidden; }
405
+ .sidebar { width:292px; flex:0 0 292px; display:flex; flex-direction:column; gap:14px; padding:18px 14px;
406
+ background:var(--surface); border-right:1px solid var(--border); overflow-y:auto; }
407
+ .brand { display:flex; align-items:center; gap:12px; }
408
+ .brand .logo { width:42px; height:42px; object-fit:contain; border-radius:12px; background:var(--surface-2); border:1px solid var(--border); }
409
+ .sidebar h1 { font-size:15.5px; font-weight:700; letter-spacing:-.01em; margin:0; }
410
+ .sub { color:var(--muted); font-size:11.5px; margin:0; }
411
+ .section-label { margin:4px 0 0; font-size:10px; font-weight:700; letter-spacing:.16em; text-transform:uppercase; color:var(--muted-2); }
412
+ .runs { display:flex; flex-direction:column; gap:6px; }
413
+ .run { display:flex; flex-direction:column; gap:8px; text-align:left; cursor:pointer; font:inherit; color:inherit; padding:11px 13px;
414
+ background:transparent; border:1px solid transparent; border-radius:var(--radius); transition:background .15s ease, border-color .15s ease; }
415
+ .run:hover { background:var(--surface-2); border-color:var(--border); }
416
+ .run.active { background:var(--accent-soft); border-color:var(--accent-line); }
417
+ .run-top { display:flex; align-items:flex-start; gap:8px; }
418
+ .run-icon { flex:none; margin-top:2px; color:var(--muted-2); }
419
+ .run.active .run-icon { color:var(--accent); }
420
+ .run-branch { font-size:13.5px; font-weight:600; line-height:1.35; word-break:break-word; }
421
+ .run-meta { display:flex; align-items:center; gap:10px; font-family:var(--font-mono); font-size:11px; color:var(--muted); }
422
+ .run-when { display:inline-flex; align-items:center; gap:6px; }
423
+ .run-count { margin-left:auto; color:var(--muted-2); }
424
+ .run-badge { align-self:flex-start; display:inline-flex; align-items:center; gap:6px; padding:4px 9px; border-radius:999px;
425
+ font-size:10px; font-weight:700; letter-spacing:.07em; text-transform:uppercase;
426
+ color:var(--success); background:rgba(69,192,122,.12); border:1px solid rgba(69,192,122,.32); }
427
+ .run-status { font-size:10px; font-weight:600; letter-spacing:.07em; text-transform:uppercase; color:var(--muted-2); }
428
+ .status-reviewing, .status-fixing { color:var(--accent); }
429
+ .status-review_done { color:var(--suggestion); }
430
+ .status-fix_done { color:var(--success); }
431
+ .status-timeout { color:var(--blocking); }
432
+ .main { flex:1; min-width:0; display:flex; flex-direction:column; overflow-y:auto; }
433
+ .topbar { position:sticky; top:0; z-index:10; display:flex; align-items:center; justify-content:space-between; gap:16px;
434
+ padding:13px 24px; border-bottom:1px solid var(--border); background:rgba(16,16,20,.74);
435
+ backdrop-filter:blur(16px) saturate(140%); -webkit-backdrop-filter:blur(16px) saturate(140%); }
436
+ .segmented { display:inline-flex; gap:4px; padding:4px; background:var(--surface-2); border:1px solid var(--border); border-radius:999px; }
437
+ .seg-btn { display:inline-flex; align-items:center; gap:8px; font:inherit; font-size:12.5px; font-weight:600; cursor:pointer;
438
+ color:var(--muted); background:transparent; border:1px solid transparent; border-radius:999px; padding:7px 16px;
439
+ transition:color .15s ease, background .15s ease; }
440
+ .seg-btn:hover { color:var(--fg); }
441
+ .seg-btn.active { color:#1b1305; background:linear-gradient(180deg, var(--accent-strong), var(--accent)); box-shadow:0 4px 14px rgba(240,165,44,.24); }
442
+ .topbar-right { display:flex; align-items:center; gap:14px; }
443
+ .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); }
444
+ .dot { width:8px; height:8px; border-radius:50%; background:var(--success); box-shadow:0 0 0 3px rgba(69,192,122,.14); }
445
+ .dot.busy { background:var(--accent); box-shadow:0 0 0 3px rgba(240,165,44,.18); animation:pulse 1.1s ease-in-out infinite; }
446
+ .btn { display:inline-flex; align-items:center; gap:8px; font:inherit; font-size:12.5px; font-weight:500; cursor:pointer; color:var(--fg);
447
+ padding:8px 15px; background:var(--surface-2); border:1px solid var(--border-strong); border-radius:999px;
448
+ transition:background .15s ease, border-color .15s ease, color .15s ease; }
449
+ .btn:hover { background:#202027; border-color:var(--accent-line); }
450
+ .btn:disabled { opacity:.55; cursor:not-allowed; }
451
+ .icon.spin { animation:spin .8s linear infinite; }
452
+ .content { width:100%; max-width:1180px; margin:0 auto; padding:22px 24px 64px; }
453
+ .page-header { display:flex; align-items:flex-start; justify-content:space-between; gap:18px; }
454
+ .page-header-text { min-width:0; }
455
+ .page-header-actions { display:flex; align-items:center; gap:10px; flex:none; }
456
+ .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); }
457
+ .btn.submit-review:hover { background:linear-gradient(180deg, var(--accent-strong), var(--accent)); border-color:transparent; filter:brightness(1.07); }
458
+ .page-title { margin:0 0 8px; font-size:26px; font-weight:700; letter-spacing:-.02em; word-break:break-word; }
459
+ .page-sub { margin:0; max-width:78ch; color:var(--muted); font-size:12.5px; line-height:1.55; }
460
+ .page-sub code { color:var(--fg); background:rgba(255,255,255,.06); border-radius:6px; padding:1px 6px; font-size:11.5px; }
461
+ .error { color:#ff8b8f; font-size:12.5px; }
356
462
  .empty { color:var(--muted); font-size:13px; }
463
+ .kpis { display:grid; grid-template-columns:repeat(4, minmax(0,1fr)); gap:12px; margin:20px 0 18px; }
464
+ .kpi { display:flex; flex-direction:column; gap:8px; padding:15px 16px; background:var(--card);
465
+ border:1px solid var(--border); border-radius:var(--radius-lg); }
466
+ .kpi-label { font-size:10px; font-weight:700; letter-spacing:.14em; text-transform:uppercase; color:var(--muted-2); }
467
+ .kpi-value { font-size:30px; font-weight:700; line-height:1; letter-spacing:-.02em; font-variant-numeric:tabular-nums; }
468
+ .kpi-hint { font-family:var(--font-mono); font-size:11px; color:var(--muted-2); }
469
+ .kpi-execute .kpi-value { color:var(--accent); }
470
+ .kpi-ignore .kpi-value { color:var(--muted); }
471
+ .kpi-done .kpi-value { color:var(--success); }
472
+ .progress { margin:0 0 22px; }
473
+ .progress-head { display:flex; align-items:center; justify-content:space-between; margin-bottom:8px; font-size:11.5px; color:var(--muted); }
474
+ .progress-head .pct { color:var(--fg); font-family:var(--font-mono); }
475
+ .progress-track { height:8px; border-radius:999px; background:var(--surface-2); border:1px solid var(--border); overflow:hidden; }
476
+ .progress-fill { display:block; height:100%; border-radius:999px; background:linear-gradient(90deg, #2c8f57, var(--success)); transition:width .35s ease; }
477
+ .filters { display:flex; flex-wrap:wrap; align-items:center; gap:10px; margin-bottom:20px; }
478
+ .search { display:flex; align-items:center; gap:8px; flex:1 1 240px; max-width:330px; padding:8px 14px; color:var(--muted);
479
+ background:var(--surface-2); border:1px solid var(--border-strong); border-radius:999px; }
480
+ .search:focus-within { color:var(--accent); border-color:var(--accent-line); }
481
+ .search input { flex:1; min-width:0; font:inherit; font-size:12.5px; color:var(--fg); background:transparent; border:0; outline:0; }
482
+ .search input::placeholder { color:var(--muted-2); }
483
+ .search-clear { padding:0 2px; font-size:15px; line-height:1; color:var(--muted); background:transparent; border:0; cursor:pointer; }
484
+ .chips { display:inline-flex; gap:3px; padding:4px; background:var(--surface-2); border:1px solid var(--border); border-radius:999px; }
485
+ .chip { display:inline-flex; align-items:center; gap:6px; font:inherit; font-size:12px; font-weight:600; cursor:pointer; color:var(--muted);
486
+ padding:6px 13px; background:transparent; border:1px solid transparent; border-radius:999px; transition:color .15s ease, background .15s ease; }
487
+ .chip:hover { color:var(--fg); }
488
+ .chip.active { color:#14100a; background:#e9e5df!important; }
489
+ .sev-group { margin-bottom:24px; }
490
+ .sev-head { display:flex; align-items:center; gap:8px; margin:0 0 10px; font-size:11px; font-weight:700;
491
+ letter-spacing:.16em; text-transform:uppercase; color:var(--muted-2); }
492
+ .sev-head .n { font-family:var(--font-mono); letter-spacing:0; }
493
+ .sev-group.sev-blocking .sev-head { color:var(--blocking); }
494
+ .sev-group.sev-attention .sev-head { color:var(--attention); }
495
+ .sev-group.sev-suggestion .sev-head { color:var(--suggestion); }
496
+ .sev-group.sev-nit .sev-head { color:var(--nit); }
497
+ .card { position:relative; margin-bottom:20px; overflow:hidden; background:var(--card);
498
+ border:1px solid var(--border); border-left:4px solid var(--border-strong); border-radius:var(--radius-lg);
499
+ transition:background .15s ease, border-color .15s ease, opacity .2s ease; }
500
+ .card:hover { background:#16161b; }
501
+ .card.sev-blocking { border-left-color:var(--blocking); }
502
+ .card.sev-attention { border-left-color:var(--attention); }
503
+ .card.sev-suggestion { border-left-color:var(--suggestion); }
504
+ .card.sev-nit { border-left-color:var(--nit); }
505
+ .card.absent { opacity:.5; }
506
+ .card.done { opacity:.6; }
507
+ .card-head { display:flex; align-items:center; gap:10px; padding:12px 14px 0; }
508
+ .chev { display:grid; place-items:center; flex:none; width:22px; height:22px; cursor:pointer; color:var(--muted-2);
509
+ background:transparent; border:1px solid transparent; border-radius:7px; }
510
+ .chev:hover { color:var(--fg); background:rgba(255,255,255,.06); }
511
+ .chev:focus-visible { color:var(--fg); border-color:var(--accent-line); outline:none; }
512
+ .chev svg { transition:transform .18s ease; }
513
+ .chev.open svg { transform:rotate(90deg); }
514
+ .sev-badge { display:inline-flex; align-items:center; flex:none; padding:3px 9px; border-radius:999px;
515
+ font-size:10px; letter-spacing:.08em; text-transform:uppercase; }
516
+ .card.sev-blocking .sev-badge { color:var(--blocking); background:rgba(242,89,94,.12); }
517
+ .card.sev-attention .sev-badge { color:var(--attention); background:rgba(237,161,58,.12); }
518
+ .card.sev-suggestion .sev-badge { color:var(--suggestion); background:rgba(90,164,240,.12); }
519
+ .card.sev-nit .sev-badge { color:var(--nit); background:rgba(255,255,255,.06); }
520
+ .loc { display:inline-flex; align-items:center; gap:7px; max-width: 406ch; overflow:hidden; white-space:break-spaces; text-overflow:ellipsis;
521
+ padding:4px 9px; font-family:var(--font-mono); font-size:11.5px; color:#aba7a1; text-decoration:none; }
522
+ .loc:hover { color:#fff; }
523
+ .loc .ext { flex:none; opacity:0; transition:opacity .15s ease; }
524
+ .loc:hover .ext { opacity:1; }
525
+ .loc.static { color:var(--muted); }
526
+ .count { padding:2px 7px; font-family:var(--font-mono); font-size:11px; color:var(--muted);
527
+ background:var(--surface-2); border:1px solid var(--border); border-radius:999px; }
528
+ .spacer { flex:1; }
529
+ .done-btn { display:inline-flex; align-items:center; gap:7px; flex:none; font:inherit; font-size:12px; font-weight:600; cursor:pointer;
530
+ padding:7px 13px; color:var(--muted); background:transparent; border:1px solid var(--border-strong); border-radius:999px;
531
+ transition:color .15s ease, background .15s ease, border-color .15s ease; }
532
+ .done-btn:hover { color:var(--fg); border-color:rgba(69,192,122,.55); }
533
+ .done-btn:disabled { opacity:.55; cursor:not-allowed; }
534
+ .done-btn.is-done { color:var(--success); background:rgba(69,192,122,.14); border-color:rgba(69,192,122,.5); }
535
+ .card-title { margin:9px 0 0; padding:0 16px 20px; font-size:14px; font-weight:400; }
536
+ .card.done .card-title { color:#b7b7b7; text-decoration:line-through; text-decoration-color:rgba(255,255,255,.15); }
537
+ .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); }
538
+ .meta-row { display:flex; flex-wrap:wrap; gap:14px; font-size:11.5px; color:var(--muted); }
539
+ .meta { display:inline-flex; align-items:center; gap:7px; }
540
+ .meta-k { font-size:10px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--muted-2); }
541
+ .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; }
542
+ .action-block { display:flex; gap:11px; padding:16px; background:var(--accent-soft); border:1px solid var(--accent-line); border-radius:var(--radius); }
543
+ .action-icon { flex:none; display:inline-block; margin-right:5px; margin-bottom:-5px; color:var(--accent); }
544
+ .action-label { display:inline-block; font-size:10px; font-weight:600; letter-spacing:.12em; text-transform:uppercase; color:var(--accent); margin-right:5px; }
545
+ .action-block p { margin:0; font-size:13px; color:#f0ece6; }
546
+ .decision-tags-block { display:flex; align-items:center; gap:8px; }
547
+ .evidence { margin:0; padding: 16px; white-space:break-spaces; font-size:12px; line-height:1.6; color:#cdc7bf;
548
+ background:#0b0b0e; border:1px solid var(--border); border-radius:var(--radius); }
549
+ .tags { display:flex; flex-wrap:wrap; align-items:center; gap:7px; margin-left: auto; }
550
+ .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); }
551
+ .tag { display:inline-flex; align-items:center; gap:6px; padding:3px 9px; font-family:var(--font-mono); font-size:11px; color:var(--muted);
552
+ background:var(--surface-2); border:1px solid var(--border); border-radius:999px; }
553
+ .decisions { display:inline-flex; gap:3px; align-self:flex-start; padding:4px; background:var(--surface-2); border:1px solid var(--border); border-radius:999px; }
554
+ .dec-btn { font:inherit; font-size:12px; font-weight:600; cursor:pointer; color:var(--muted); padding:6px 15px;
555
+ background:transparent; border:1px solid transparent; border-radius:999px; transition:color .15s ease, background .15s ease; }
556
+ .dec-btn:hover { color:var(--fg); }
557
+ .dec-btn:disabled { opacity:.55; cursor:not-allowed; }
558
+ .dec-btn.dec-execute.active { color:#1b1305; background:linear-gradient(180deg, var(--accent-strong), var(--accent)); }
559
+ .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); }
560
+ .dec-btn.dec-pending.active { color:var(--fg); background:rgba(255,255,255,.08); box-shadow:inset 0 0 0 1px var(--border-strong); }
561
+ .comment { position:relative; }
562
+ .comment textarea { width:100%; min-height:58px; padding:11px 13px 26px; resize:vertical; font:inherit; font-size:12.5px; line-height:1.5;
563
+ color:var(--fg); background:var(--surface-2); border:1px solid var(--border-strong); border-radius:var(--radius); outline:none;
564
+ transition:border-color .15s ease, box-shadow .15s ease; }
565
+ .comment textarea::placeholder { color:var(--muted-2); }
566
+ .comment textarea:focus { border-color:var(--accent-line); box-shadow:0 0 0 3px rgba(240,165,44,.12); }
567
+ .comment-status { position:absolute; right:12px; bottom:9px; display:inline-flex; align-items:center; gap:6px;
568
+ font-family:var(--font-mono); font-size:11px; color:var(--muted); }
569
+ .comment-status.saved { color:var(--success); }
570
+ .artifacts { display:flex; flex-direction:column; }
571
+ .artifact-tabs { display:flex; flex-wrap:wrap; gap:6px; margin-bottom:12px; }
572
+ .artifact-tabs .chip { border:1px solid var(--border); background:var(--surface-2); }
573
+ .artifact-desc { margin:0 0 12px; color:var(--muted); font-size:12.5px; line-height:1.55; }
574
+ .artifact-view { margin:0; padding:14px; max-height:70vh; overflow:auto; font-size:12px; line-height:1.6; color:#cdc7bf;
575
+ background:#0b0b0e; border:1px solid var(--border); border-radius:var(--radius); }
576
+ .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;
577
+ color:var(--success); background:rgba(69,192,122,.12); border:1px solid rgba(69,192,122,.32); border-radius:999px; text-decoration:none; }
578
+ .sent-tag:hover { color:#fff; }
579
+ .modal-layer { position:fixed; inset:0; z-index:50; display:grid; place-items:center; padding:24px; }
580
+ .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); }
581
+ .modal { position:relative; width:min(640px, 100%); max-height:min(78vh, 780px); display:flex; flex-direction:column; overflow:hidden;
582
+ background:var(--surface); border:1px solid var(--border-strong); border-radius:var(--radius-lg); box-shadow:0 24px 70px rgba(0,0,0,.5); }
583
+ .modal-head { display:flex; align-items:flex-start; justify-content:space-between; gap:14px; padding:18px 20px 14px; border-bottom:1px solid var(--border); }
584
+ .modal-title { margin:0; font-size:17px; font-weight:700; letter-spacing:-.01em; }
585
+ .modal-sub { margin:5px 0 0; font-size:12px; color:var(--muted); line-height:1.5; }
586
+ .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; }
587
+ .modal-close:hover { color:var(--fg); border-color:var(--border-strong); }
588
+ .modal-body { display:flex; flex-direction:column; gap:16px; padding:16px 20px; overflow-y:auto; }
589
+ .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); }
590
+ .submit-field { display:flex; flex-direction:column; gap:8px; }
591
+ .submit-label { font-size:10px; font-weight:700; letter-spacing:.14em; text-transform:uppercase; color:var(--muted-2); }
592
+ .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); }
593
+ .submit-summary b { font-family:var(--font-mono); }
594
+ .submit-list { display:flex; flex-direction:column; gap:6px; max-height:240px; overflow-y:auto; padding-right:2px; }
595
+ .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; }
596
+ .submit-item.sent { opacity:.7; }
597
+ .submit-item .rule { flex:1; min-width:0; color:var(--muted); overflow:hidden; white-space:nowrap; text-overflow:ellipsis; }
598
+ .submit-empty { color:var(--muted); font-size:12.5px; }
599
+ .submit-field .chips { flex-wrap:wrap; }
600
+ .submit-check { display:inline-flex; align-items:center; gap:8px; font-size:12.5px; color:var(--muted); cursor:pointer; }
601
+ .submit-check input { accent-color:var(--accent); }
602
+ .submit-actions { display:flex; align-items:center; gap:8px; }
603
+ .sev-badge.sev-blocking { color:var(--blocking); background:rgba(242,89,94,.12); }
604
+ .sev-badge.sev-attention { color:var(--attention); background:rgba(237,161,58,.12); }
605
+ .sev-badge.sev-suggestion { color:var(--suggestion); background:rgba(90,164,240,.12); }
606
+ .sev-badge.sev-nit { color:var(--nit); background:rgba(255,255,255,.06); }
607
+ @keyframes pulse { 0%,100% { opacity:1; transform:scale(1); } 50% { opacity:.4; transform:scale(.8); } }
608
+ @keyframes spin { to { transform:rotate(360deg); } }
609
+ @media (max-width:980px) { .kpis { grid-template-columns:repeat(2, minmax(0,1fr)); } }
610
+ @media (max-width:820px) {
611
+ .sidebar { width:232px; flex-basis:232px; }
612
+ .topbar { padding:12px 16px; }
613
+ .content { padding:18px 16px 56px; }
614
+ .auto-refresh { display:none; }
615
+ }
357
616
  `;
358
617
 
359
618
  /** Id de build por processo — muda a URL do bundle a cada reinício do servidor. */
@@ -365,7 +624,12 @@ function renderShell(): string {
365
624
  <head>
366
625
  <meta charset="utf-8" />
367
626
  <meta name="viewport" content="width=device-width, initial-scale=1" />
627
+ <meta name="color-scheme" content="dark" />
368
628
  <title>Houndsman — Revisões</title>
629
+ <!-- Fontes de CDN: se estiver offline, o CSS cai para a pilha de sistema. -->
630
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
631
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
632
+ <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
633
  <style>${STYLES}</style>
370
634
  </head>
371
635
  <body>
@@ -11,6 +11,10 @@ aplicadas em nenhuma parte do código) devem ser **removidas**, não apenas reba
11
11
  1. `.houndsman/watchdogs/<id>.ts` — `WATCHDOG_DEFINITIONS[<id>]` é a fonte única de verdade:
12
12
  `description`, `scope`, `rules` (severidade + texto), `classify`, `contextFiles`,
13
13
  `reviewFocus`, `exclude`.
14
+
15
+ > **Onde cada coisa vive:** a **regra** vive só em `rules`; `reviewFocus` = paths a priorizar;
16
+ > `contextFiles` = docs a consultar; `exclude` = paths a ignorar; `scope`/`description` = prosa.
17
+ > Texto de regra em `reviewFocus`/`contextFiles`/`exclude` é **duplicação** a corrigir.
14
18
  2. `src/core/brief.ts` (no motor) — `PROCEDURE` (passos de revisão compartilhados).
15
19
  3. Ground truth (padrões reais do projeto):
16
20
  - Documentos de convenção do projeto (ex.: `AGENTS.md`, `CLAUDE.md`, `.github/copilot-instructions.md`).
@@ -41,6 +45,7 @@ classifique sem evidência (caminho de arquivo, linha, ou "0 ocorrências via gr
41
45
  | `OUTDATED` | Já foi aplicado, mas o código mudou | Remover ou reescrever |
42
46
  | `MISCALIBRATED` | Regra válida, mas severidade errada | Ajustar severidade |
43
47
  | `DEAD-PATH` | `scope`/`contextFiles`/`classify` apontam para caminho/arquivo inexistente | Corrigir ou remover |
48
+ | `REDUNDANT` | Regra duplicada no mesmo watchdog ou repetida em `reviewFocus`/`contextFiles`/`exclude` | Remover a cópia (a regra fica só em `rules`) |
44
49
 
45
50
  ## Formato do relatório
46
51
 
@@ -52,7 +52,7 @@ Um único skill (`/houndsman`), quatro capabilities (detalhadas em `hunt.md`, `t
52
52
  | `houndsman setup` | Cria a configuração inicial do projeto |
53
53
  | `houndsman update` | Sincroniza os arquivos auto-gerados (`.houndsman/core`) |
54
54
  | `houndsman doctor` | Valida a configuração, o ambiente e a credencial Jev (anti-drift) |
55
- | `houndsman test` | Roda a suíte de testes da lib |
55
+ | `houndsman test` | **Manutenção da lib** — roda a suíte de testes do pacote (não é uso do consumidor) |
56
56
 
57
57
  ### Flags
58
58
 
@@ -138,6 +138,8 @@ houndsman.config.ts # HoundsmanConfig (dados; descoberto em runtime)
138
138
  > prove que o padrão existe no código (regra negativa: prove que o anti-padrão **não** existe).
139
139
  > Regras devem ser **gerais/reutilizáveis** no domínio e declarar a **polaridade** no texto
140
140
  > (`Promover:` = deve acontecer; `Evitar:`/`Não`/`Nunca` = não deve acontecer).
141
+ > Cada regra vive **só** em `rules` — não a repita em `reviewFocus`/`contextFiles`/`scope`/
142
+ > `description`/`exclude` (isso só duplicaria o brief).
141
143
 
142
144
  1. Novas regras/watchdogs → editar `.houndsman/watchdogs/<id>.ts` (regras + escopo +
143
145
  `classify`) ou `doc-mappings.ts`/`exclusions.ts`. Um watchdog novo exige só o módulo em
@@ -60,7 +60,6 @@ bun run houndsman update # reescreve .houndsman/core/** e .houndsman/.gitig
60
60
 
61
61
  ```bash
62
62
  bun run houndsman doctor # valida config + ambiente + credencial Jev
63
- bun run houndsman test # suíte da lib (project-agnóstica)
64
63
  bun run houndsman --base main # prepara briefs do diff atual
65
64
  ```
66
65
 
package/src/skill/hunt.md CHANGED
@@ -271,7 +271,8 @@ completo) · `--no-jev` · `--fail-on-blocking` (com `--finalize`).
271
271
  > **`train`** (`/houndsman train`, doc `train.md`).
272
272
  > Regra de ouro: só é regra o que o projeto **aplica hoje** — prove com evidência no código
273
273
  > (regra negativa com 0 ocorrências = seguida → **manter**).
274
- > Após qualquer alteração, rodar `houndsman doctor` + `houndsman test` + `ci:fix` + `ci`.
274
+ > Após qualquer alteração, rodar `houndsman doctor`. (`houndsman test` e `ci:fix`/`ci` são
275
+ > comandos da **lib**, não do projeto consumidor.)
275
276
 
276
277
  ---
277
278