@payconductor/houndsman 0.3.0 → 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 (91) hide show
  1. package/README.md +25 -39
  2. package/package.json +1 -1
  3. package/src/cli/commands/context-command.ts +11 -9
  4. package/src/cli/commands/doctor-command.ts +5 -0
  5. package/src/cli/commands/findings-command.ts +13 -3
  6. package/src/cli/commands/review-command.ts +1 -2
  7. package/src/cli/commands/status-command.ts +46 -0
  8. package/src/cli/help.ts +8 -3
  9. package/src/cli/index.ts +5 -2
  10. package/src/core/commands/context.ts +29 -13
  11. package/src/core/commands/findings.ts +216 -125
  12. package/src/core/commands/review.ts +7 -8
  13. package/src/core/commands/scaffold.ts +15 -16
  14. package/src/core/commands/status.ts +63 -0
  15. package/src/core/config.ts +16 -7
  16. package/src/core/engine/houndsman-engine.ts +15 -1
  17. package/src/core/engine/review-scope.ts +26 -8
  18. package/src/core/engine/review-session.ts +37 -9
  19. package/src/core/findings-state.ts +53 -75
  20. package/src/core/github-review.ts +620 -57
  21. package/src/core/jev/jev-candidates.ts +49 -35
  22. package/src/core/orchestrator.ts +271 -134
  23. package/src/core/paths.ts +26 -17
  24. package/src/core/pr-context.ts +19 -5
  25. package/src/core/project-config.ts +4 -5
  26. package/src/core/renderers/manifest-writer.ts +29 -18
  27. package/src/core/renderers/resume-renderer.ts +151 -0
  28. package/src/core/report-builder.ts +7 -4
  29. package/src/core/run-layout.ts +4 -104
  30. package/src/core/run-status.ts +59 -50
  31. package/src/core/services/git-repository.ts +16 -0
  32. package/src/core/services/project-files.ts +13 -1
  33. package/src/core/storage/factory.ts +24 -0
  34. package/src/core/storage/file/file-storage.ts +271 -0
  35. package/src/core/storage/paths.ts +64 -0
  36. package/src/core/storage/repo-identity.ts +70 -0
  37. package/src/core/storage/sqlite/mappers.ts +162 -0
  38. package/src/core/storage/sqlite/schema.ts +116 -0
  39. package/src/core/storage/sqlite/sqlite-storage.ts +431 -0
  40. package/src/core/storage/types.ts +152 -0
  41. package/src/core/storage/work-area.ts +191 -0
  42. package/src/index.ts +27 -6
  43. package/src/server/artifacts.ts +8 -61
  44. package/src/server/client/App.tsx +26 -9
  45. package/src/server/client/ArtifactsPanel.tsx +144 -23
  46. package/src/server/client/AutoRefresh.tsx +1 -1
  47. package/src/server/client/Filters.tsx +7 -2
  48. package/src/server/client/FindingCard.tsx +50 -15
  49. package/src/server/client/FindingsList.tsx +2 -2
  50. package/src/server/client/MarkdownText.tsx +181 -0
  51. package/src/server/client/ResumePanel.tsx +368 -0
  52. package/src/server/client/RunList.tsx +3 -3
  53. package/src/server/client/SubmitModal.tsx +58 -10
  54. package/src/server/client/SummaryBar.tsx +1 -1
  55. package/src/server/client/icons.tsx +17 -0
  56. package/src/server/client/utils/code-highlight.ts +422 -0
  57. package/src/server/client/utils/constants.ts +226 -0
  58. package/src/server/client/utils/json-highlight.ts +55 -0
  59. package/src/server/client/utils/markdown.ts +319 -0
  60. package/src/server/client/{types.ts → utils/types.ts} +81 -0
  61. package/src/server/index.ts +154 -16
  62. package/src/server/run-repository.ts +153 -136
  63. package/src/skill/OUTPUT-FORMAT.md +6 -4
  64. package/src/skill/README.md +22 -14
  65. package/src/skill/SETUP.md +4 -4
  66. package/src/skill/example-review.md +22 -14
  67. package/src/skill/fix.md +5 -5
  68. package/src/skill/hunt.md +55 -32
  69. package/src/skill/train.md +1 -1
  70. package/src/skill/verify.md +7 -6
  71. package/src/tests/__snapshots__/baseline.test.ts.snap +74 -61
  72. package/src/tests/baseline.test.ts +79 -34
  73. package/src/tests/cli.test.ts +40 -19
  74. package/src/tests/code-highlight.test.ts +123 -0
  75. package/src/tests/domain.test.ts +1 -1
  76. package/src/tests/findings-commands.test.ts +153 -131
  77. package/src/tests/findings-state.test.ts +56 -5
  78. package/src/tests/fixtures/fake-project.ts +0 -1
  79. package/src/tests/github-review.test.ts +409 -60
  80. package/src/tests/infrastructure.test.ts +33 -38
  81. package/src/tests/markdown.test.ts +149 -0
  82. package/src/tests/review-resume.test.ts +101 -0
  83. package/src/tests/review-scope.test.ts +302 -0
  84. package/src/tests/review-scripts.test.ts +81 -5
  85. package/src/tests/run-status-view.test.ts +94 -0
  86. package/src/tests/run-status.test.ts +86 -23
  87. package/src/tests/server.test.ts +184 -96
  88. package/src/tests/storage.test.ts +449 -0
  89. package/src/server/client/constants.ts +0 -91
  90. /package/src/server/client/{api.ts → utils/api.ts} +0 -0
  91. /package/src/server/client/{format.ts → utils/format.ts} +0 -0
@@ -0,0 +1,422 @@
1
+ /**
2
+ * Destaque de sintaxe de código (cliente) — implementação própria e leve.
3
+ *
4
+ * A linguagem é inferida pela extensão do arquivo do finding. Para extensões
5
+ * conhecidas destacamos strings, comentários, números, keywords, tipos,
6
+ * funções e propriedades; para as demais, o evidence continua texto puro.
7
+ */
8
+
9
+ import { highlightJson } from "./json-highlight";
10
+
11
+ export type CodeTokenType =
12
+ | "plain"
13
+ | "keyword"
14
+ | "string"
15
+ | "comment"
16
+ | "number"
17
+ | "type"
18
+ | "function"
19
+ | "property"
20
+ | "punct";
21
+
22
+ export type CodeToken = { text: string; type: CodeTokenType };
23
+
24
+ export type HighlightedCode = {
25
+ language: string;
26
+ label: string;
27
+ tokens: CodeToken[];
28
+ };
29
+
30
+ type LanguageConfig = {
31
+ label: string;
32
+ keywords: ReadonlySet<string>;
33
+ types?: ReadonlySet<string>;
34
+ lineComments: readonly string[];
35
+ blockComments: readonly (readonly [string, string])[];
36
+ /** Templates strings com backtick (JS/TS/Go). */
37
+ backtick?: boolean;
38
+ /** Strings de três aspas (Python). */
39
+ tripleQuotes?: boolean;
40
+ /** `chave:` no início da linha vira propriedade (YAML). */
41
+ keyColon?: boolean;
42
+ /** Identificador seguido de `:` vira propriedade (CSS). */
43
+ cssProperty?: boolean;
44
+ /** Rastreia tags/atributos (HTML/XML). */
45
+ html?: boolean;
46
+ };
47
+
48
+ function words(value: string): ReadonlySet<string> {
49
+ return new Set(value.split(/\s+/).filter(Boolean));
50
+ }
51
+
52
+ const JS_KEYWORDS = words(
53
+ "as async await break case catch class const continue debugger declare default delete do else enum export extends false finally for from function get if implements import in infer instanceof interface is keyof let module namespace new null of private protected public readonly return satisfies set static super switch this throw true try type typeof undefined var void while with yield",
54
+ );
55
+
56
+ const CONFIG: Record<string, LanguageConfig> = {
57
+ typescript: {
58
+ label: "TypeScript",
59
+ keywords: JS_KEYWORDS,
60
+ types: words("any bigint boolean never number object string symbol unknown"),
61
+ lineComments: ["//"],
62
+ blockComments: [["/*", "*/"]],
63
+ backtick: true,
64
+ },
65
+ javascript: {
66
+ label: "JavaScript",
67
+ keywords: JS_KEYWORDS,
68
+ lineComments: ["//"],
69
+ blockComments: [["/*", "*/"]],
70
+ backtick: true,
71
+ },
72
+ python: {
73
+ label: "Python",
74
+ keywords: words(
75
+ "and as assert async await break class continue def del elif else except False finally for from global if import in is lambda None nonlocal not or pass raise return True try while with yield",
76
+ ),
77
+ lineComments: ["#"],
78
+ blockComments: [],
79
+ tripleQuotes: true,
80
+ },
81
+ go: {
82
+ label: "Go",
83
+ keywords: words(
84
+ "break case chan const continue default defer else fallthrough for func go goto if import interface map package range return select struct switch type var nil true false",
85
+ ),
86
+ types: words(
87
+ "bool byte complex64 complex128 error float32 float64 int int8 int16 int32 int64 rune string uint uint8 uint16 uint32 uint64 uintptr",
88
+ ),
89
+ lineComments: ["//"],
90
+ blockComments: [["/*", "*/"]],
91
+ backtick: true,
92
+ },
93
+ rust: {
94
+ label: "Rust",
95
+ keywords: words(
96
+ "as async await break const continue crate dyn else enum extern false fn for if impl in let loop match mod move mut pub ref return self Self static struct super trait true type unsafe use where while",
97
+ ),
98
+ types: words(
99
+ "bool char f32 f64 i8 i16 i32 i64 i128 isize str String u8 u16 u32 u64 u128 usize",
100
+ ),
101
+ lineComments: ["//"],
102
+ blockComments: [["/*", "*/"]],
103
+ },
104
+ java: {
105
+ label: "Java",
106
+ keywords: words(
107
+ "abstract assert boolean break byte case catch char class const continue default do double else enum extends final finally float for goto if implements import instanceof int interface long native new null package private protected public record return sealed short static strictfp super switch synchronized this throw throws transient true false try var void volatile while",
108
+ ),
109
+ types: words("boolean byte char double float int long short String void"),
110
+ lineComments: ["//"],
111
+ blockComments: [["/*", "*/"]],
112
+ },
113
+ csharp: {
114
+ label: "C#",
115
+ keywords: words(
116
+ "abstract as async await base bool break byte case catch char checked class const continue decimal default delegate do double else enum event explicit extern false finally fixed float for foreach goto if implicit in int interface internal is lock long namespace new null object operator out override params private protected public readonly record ref return sbyte sealed short sizeof stackalloc static string struct switch this throw true try typeof uint ulong unchecked unsafe ushort using var virtual void volatile while",
117
+ ),
118
+ types: words(
119
+ "bool byte char decimal double float int long object sbyte short string uint ulong ushort void",
120
+ ),
121
+ lineComments: ["//"],
122
+ blockComments: [["/*", "*/"]],
123
+ },
124
+ php: {
125
+ label: "PHP",
126
+ keywords: words(
127
+ "abstract and array as break callable case catch class clone const continue declare default do echo else elseif empty enddeclare endfor endforeach endif endswitch endwhile eval exit extends final finally fn for foreach function global goto if implements include include_once instanceof insteadof interface isset list match namespace new null or print private protected public readonly require require_once return static switch throw trait true false try unset use var while xor yield",
128
+ ),
129
+ lineComments: ["//", "#"],
130
+ blockComments: [["/*", "*/"]],
131
+ },
132
+ ruby: {
133
+ label: "Ruby",
134
+ keywords: words(
135
+ "alias and begin break case class def defined? do else elsif end ensure false for if in module next nil not or redo rescue retry return self super then true undef unless until when while yield",
136
+ ),
137
+ lineComments: ["#"],
138
+ blockComments: [],
139
+ },
140
+ bash: {
141
+ label: "Shell",
142
+ keywords: words(
143
+ "if then else elif fi for in do done while until case esac function select time local export readonly return break continue true false",
144
+ ),
145
+ lineComments: ["#"],
146
+ blockComments: [],
147
+ },
148
+ sql: {
149
+ label: "SQL",
150
+ keywords: words(
151
+ "select from where join inner left right full outer on group by order having limit offset insert into values update set delete create table alter drop index view as and or not null is in like between exists case when then else end distinct union all count sum avg min max returning with asc desc",
152
+ ),
153
+ lineComments: ["--"],
154
+ blockComments: [["/*", "*/"]],
155
+ },
156
+ yaml: {
157
+ label: "YAML",
158
+ keywords: words("true false null yes no on off"),
159
+ lineComments: ["#"],
160
+ blockComments: [],
161
+ keyColon: true,
162
+ },
163
+ css: {
164
+ label: "CSS",
165
+ keywords: words(
166
+ "important media import supports keyframes font-face from to and or not only",
167
+ ),
168
+ lineComments: [],
169
+ blockComments: [["/*", "*/"]],
170
+ cssProperty: true,
171
+ },
172
+ html: {
173
+ label: "HTML",
174
+ keywords: new Set(),
175
+ lineComments: [],
176
+ blockComments: [["<!--", "-->"]],
177
+ html: true,
178
+ },
179
+ };
180
+
181
+ const EXTENSION_TO_LANGUAGE: Record<string, string> = {
182
+ ts: "typescript",
183
+ tsx: "typescript",
184
+ mts: "typescript",
185
+ cts: "typescript",
186
+ js: "javascript",
187
+ jsx: "javascript",
188
+ mjs: "javascript",
189
+ cjs: "javascript",
190
+ json: "json",
191
+ py: "python",
192
+ go: "go",
193
+ rs: "rust",
194
+ java: "java",
195
+ cs: "csharp",
196
+ php: "php",
197
+ rb: "ruby",
198
+ sh: "bash",
199
+ bash: "bash",
200
+ zsh: "bash",
201
+ sql: "sql",
202
+ yml: "yaml",
203
+ yaml: "yaml",
204
+ css: "css",
205
+ scss: "css",
206
+ html: "html",
207
+ htm: "html",
208
+ xml: "html",
209
+ };
210
+
211
+ /** Linguagem a partir da extensão do arquivo (null = sem highlight). */
212
+ export function languageFromFile(file: string): string | null {
213
+ const base = file.split(/[\\/]/).pop() ?? file;
214
+ const dot = base.lastIndexOf(".");
215
+ if (dot <= 0 || dot === base.length - 1) return null;
216
+ return EXTENSION_TO_LANGUAGE[base.slice(dot + 1).toLowerCase()] ?? null;
217
+ }
218
+
219
+ /** Linguagem a partir do nome/alias usado em fences markdown (`ts`, `py`…). */
220
+ export function languageFromName(name: string | null | undefined): string | null {
221
+ if (!name) return null;
222
+ const key = name.trim().toLowerCase();
223
+ const alias: Record<string, string> = {
224
+ ts: "typescript",
225
+ tsx: "typescript",
226
+ js: "javascript",
227
+ jsx: "javascript",
228
+ py: "python",
229
+ rs: "rust",
230
+ cs: "csharp",
231
+ "c#": "csharp",
232
+ rb: "ruby",
233
+ sh: "bash",
234
+ shell: "bash",
235
+ yml: "yaml",
236
+ scss: "css",
237
+ xml: "html",
238
+ };
239
+ const language = alias[key] ?? key;
240
+ return language === "json" || CONFIG[language] ? language : null;
241
+ }
242
+
243
+ /** Destaca o código; null quando a linguagem é desconhecida/ausente. */
244
+ export function highlightCode(
245
+ code: string,
246
+ language: string | null,
247
+ ): HighlightedCode | null {
248
+ if (!language) return null;
249
+ if (language === "json") {
250
+ return {
251
+ language,
252
+ label: "JSON",
253
+ tokens: highlightJson(code).map((token) => ({
254
+ text: token.text,
255
+ type:
256
+ token.type === "key"
257
+ ? "property"
258
+ : token.type === "literal"
259
+ ? "keyword"
260
+ : token.type,
261
+ })),
262
+ };
263
+ }
264
+ const config = CONFIG[language];
265
+ if (!config) return null;
266
+ return {
267
+ language,
268
+ label: config.label,
269
+ tokens: tokenize(code, config),
270
+ };
271
+ }
272
+
273
+ // ---------------------------------------------------------------------------
274
+ // Tokenização
275
+ // ---------------------------------------------------------------------------
276
+
277
+ function matchComment(rest: string, config: LanguageConfig): string | null {
278
+ for (const [start, end] of config.blockComments) {
279
+ if (!rest.startsWith(start)) continue;
280
+ const close = rest.indexOf(end, start.length);
281
+ return close < 0 ? rest : rest.slice(0, close + end.length);
282
+ }
283
+ for (const marker of config.lineComments) {
284
+ if (!rest.startsWith(marker)) continue;
285
+ const newline = rest.indexOf("\n");
286
+ return newline < 0 ? rest : rest.slice(0, newline);
287
+ }
288
+ return null;
289
+ }
290
+
291
+ function previousNonSpace(code: string, index: number): string {
292
+ for (let i = index - 1; i >= 0; i--) {
293
+ if (!/\s/.test(code[i])) return code[i];
294
+ }
295
+ return "";
296
+ }
297
+
298
+ function nextNonSpace(code: string, index: number): string {
299
+ for (let i = index; i < code.length; i++) {
300
+ if (!/\s/.test(code[i])) return code[i];
301
+ }
302
+ return "";
303
+ }
304
+
305
+ function atLineStart(code: string, index: number): boolean {
306
+ const lineStart = code.lastIndexOf("\n", index - 1) + 1;
307
+ return /^\s*$/.test(code.slice(lineStart, index));
308
+ }
309
+
310
+ function tokenize(code: string, config: LanguageConfig): CodeToken[] {
311
+ const tokens: CodeToken[] = [];
312
+ let index = 0;
313
+ let inTag = false;
314
+ const push = (text: string, type: CodeTokenType): void => {
315
+ if (text.length > 0) tokens.push({ text, type });
316
+ };
317
+
318
+ while (index < code.length) {
319
+ const rest = code.slice(index);
320
+
321
+ const comment = matchComment(rest, config);
322
+ if (comment !== null) {
323
+ push(comment, "comment");
324
+ index += comment.length;
325
+ continue;
326
+ }
327
+
328
+ if (config.tripleQuotes && (rest.startsWith('"""') || rest.startsWith("'''"))) {
329
+ const quote = rest.slice(0, 3);
330
+ const close = code.indexOf(quote, index + 3);
331
+ const end = close < 0 ? code.length : close + 3;
332
+ push(code.slice(index, end), "string");
333
+ index = end;
334
+ continue;
335
+ }
336
+
337
+ const char = code[index];
338
+ if (char === '"' || char === "'" || (char === "`" && config.backtick)) {
339
+ let end = index + 1;
340
+ while (end < code.length) {
341
+ if (code[end] === "\\" && char !== "`") {
342
+ end += 2;
343
+ continue;
344
+ }
345
+ if (code[end] === char) {
346
+ end++;
347
+ break;
348
+ }
349
+ end++;
350
+ }
351
+ push(code.slice(index, end), "string");
352
+ index = end;
353
+ continue;
354
+ }
355
+
356
+ const number = /^(?:0[xX][0-9a-fA-F]+|\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)/.exec(rest);
357
+ if (number) {
358
+ push(number[0], "number");
359
+ index += number[0].length;
360
+ continue;
361
+ }
362
+
363
+ const identifier = /^[A-Za-z_$][\w$]*/.exec(rest);
364
+ if (identifier) {
365
+ const word = identifier[0];
366
+ const previous = previousNonSpace(code, index);
367
+ const next = nextNonSpace(code, index + word.length);
368
+ let type: CodeTokenType = "plain";
369
+ if (config.keywords.has(word)) {
370
+ type = "keyword";
371
+ } else if (config.types?.has(word)) {
372
+ type = "type";
373
+ } else if (next === "(") {
374
+ type = "function";
375
+ } else if (config.html && inTag) {
376
+ type =
377
+ previous === "<" || (previous === "/" && code[index - 2] === "<")
378
+ ? "keyword"
379
+ : "property";
380
+ } else if (config.keyColon && next === ":" && atLineStart(code, index)) {
381
+ type = "property";
382
+ } else if (config.cssProperty && next === ":") {
383
+ type = "property";
384
+ } else if (previous === ".") {
385
+ type = "property";
386
+ } else if (/^[A-Z]/.test(word)) {
387
+ type = "type";
388
+ }
389
+ push(word, type);
390
+ index += word.length;
391
+ continue;
392
+ }
393
+
394
+ if (char === "<") {
395
+ push(char, "punct");
396
+ inTag = config.html ? true : inTag;
397
+ index++;
398
+ continue;
399
+ }
400
+ if (char === ">" && inTag) {
401
+ push(char, "punct");
402
+ inTag = false;
403
+ index++;
404
+ continue;
405
+ }
406
+ if (/[{}()[\];,.:=+\-*/%<>!&|?~^@]/.test(char)) {
407
+ push(char, "punct");
408
+ index++;
409
+ continue;
410
+ }
411
+ if (/\s/.test(char)) {
412
+ const whitespace = /^\s+/.exec(rest);
413
+ const text = whitespace?.[0] ?? char;
414
+ push(text, "plain");
415
+ index += text.length;
416
+ continue;
417
+ }
418
+ push(char, "plain");
419
+ index++;
420
+ }
421
+ return tokens;
422
+ }
@@ -0,0 +1,226 @@
1
+ /**
2
+ * Constantes de apresentação do cliente (labels, ordem, emojis).
3
+ */
4
+
5
+ import type {
6
+ ArtifactKind,
7
+ ArtifactMeta,
8
+ Decision,
9
+ ExecState,
10
+ RunStatus,
11
+ RunSummary,
12
+ Severity,
13
+ } from "./types";
14
+
15
+ /** Rótulo de exibição de cada severidade (o CSS dá a cor via `sev-<nivel>`). */
16
+ export const SEVERITY_LABELS: Record<Severity, string> = {
17
+ blocking: "Blocking",
18
+ attention: "Attention",
19
+ suggestion: "Suggestion",
20
+ nit: "Nit",
21
+ };
22
+
23
+ export const SEVERITY_ORDER: Severity[] = ["blocking", "attention", "suggestion", "nit"];
24
+
25
+ export const DECISIONS: Decision[] = ["pending", "execute", "ignore"];
26
+
27
+ export const DECISION_LABELS: Record<Decision, string> = {
28
+ pending: "Pendente",
29
+ execute: "Executar",
30
+ ignore: "Ignorar",
31
+ };
32
+
33
+ export const EXEC_STATES: ExecState[] = ["pending", "in_progress", "done", "stale"];
34
+
35
+ export const RUN_STATUS_LABELS: Record<RunStatus, string> = {
36
+ reviewing: "Revisando",
37
+ review_done: "Revisão pronta",
38
+ fixing: "Corrigindo",
39
+ fix_done: "Correção pronta",
40
+ };
41
+
42
+ /** Limiar de inatividade da correção para exibir timeout (display-only). */
43
+ export const STATUS_TIMEOUT_MS = 10 * 60 * 1000;
44
+
45
+ /** Revisão despacha subagentes em ondas longas — janela maior que a correção. */
46
+ export const REVIEW_TIMEOUT_MS = 30 * 60 * 1000;
47
+
48
+ /** Intervalo do polling da UI (o badge conta o tempo desde a última sync). */
49
+ export const AUTO_REFRESH_MS = 10_000;
50
+
51
+ export type RunStatusView = { kind: string; label: string; timeout: boolean };
52
+
53
+ /**
54
+ * View do status de uma run — timeout só existe quando há um heartbeat de
55
+ * execução (`reviewing`/`fixing`). `updatedAt` muda com triagem/sync e não
56
+ * serve para provar que a skill está rodando.
57
+ */
58
+ export function describeRunStatus(
59
+ run: RunSummary,
60
+ now: number = Date.now(),
61
+ ): RunStatusView | null {
62
+ const status = run.status;
63
+ if (!status) return null;
64
+ const active = status === "reviewing" || status === "fixing";
65
+ if (!active) {
66
+ return { kind: status, label: RUN_STATUS_LABELS[status], timeout: false };
67
+ }
68
+
69
+ // Sem heartbeat não há execução conhecida: evita o falso "timeout".
70
+ const heartbeat = run.heartbeatAt ? Date.parse(run.heartbeatAt) : Number.NaN;
71
+ if (!Number.isFinite(heartbeat)) return null;
72
+
73
+ const timeoutMs = status === "reviewing" ? REVIEW_TIMEOUT_MS : STATUS_TIMEOUT_MS;
74
+ if (now - heartbeat > timeoutMs) {
75
+ return {
76
+ kind: "timeout",
77
+ label: status === "reviewing" ? "Timeout na revisão" : "Timeout na correção",
78
+ timeout: true,
79
+ };
80
+ }
81
+ return { kind: status, label: RUN_STATUS_LABELS[status], timeout: false };
82
+ }
83
+
84
+ /** Ordem canônica dos tipos de artefato na lista da UI. */
85
+ const KIND_ORDER: ArtifactKind[] = [
86
+ "review",
87
+ "resume",
88
+ "domain-review",
89
+ "verify",
90
+ "brief",
91
+ "manifest",
92
+ "classification",
93
+ "candidates",
94
+ "context",
95
+ "context-brief",
96
+ "context-summary",
97
+ "finding-report",
98
+ "other",
99
+ ];
100
+
101
+ /** Grupo visual de cada tipo (relatórios, briefs, núcleo, contexto…). */
102
+ const GROUP_BY_KIND: Record<ArtifactKind, { key: string; label: string; order: number }> =
103
+ {
104
+ review: { key: "report", label: "Relatórios", order: 0 },
105
+ resume: { key: "report", label: "Relatórios", order: 0 },
106
+ "domain-review": { key: "report", label: "Relatórios", order: 0 },
107
+ verify: { key: "verify", label: "Verificação", order: 1 },
108
+ brief: { key: "brief", label: "Briefs (auditoria)", order: 2 },
109
+ manifest: { key: "core", label: "Núcleo da revisão", order: 3 },
110
+ classification: { key: "core", label: "Núcleo da revisão", order: 3 },
111
+ candidates: { key: "core", label: "Núcleo da revisão", order: 3 },
112
+ context: { key: "context", label: "Contexto da PR", order: 4 },
113
+ "context-brief": { key: "context", label: "Contexto da PR", order: 4 },
114
+ "context-summary": { key: "context", label: "Contexto da PR", order: 4 },
115
+ "finding-report": { key: "other", label: "Outros", order: 5 },
116
+ other: { key: "other", label: "Outros", order: 5 },
117
+ };
118
+
119
+ /** Rótulo curto do tipo (badge do item). */
120
+ export const ARTIFACT_KIND_LABELS: Record<ArtifactKind, string> = {
121
+ review: "relatório",
122
+ resume: "resumo",
123
+ "domain-review": "domínio",
124
+ verify: "verify",
125
+ brief: "brief",
126
+ manifest: "manifesto",
127
+ classification: "classificação",
128
+ candidates: "jev",
129
+ context: "contexto",
130
+ "context-brief": "brief ctx",
131
+ "context-summary": "resumo ctx",
132
+ "finding-report": "findings",
133
+ other: "outro",
134
+ };
135
+
136
+ /** Nome exibido (briefs perdem o prefixo `briefs/`). */
137
+ export function displayArtifactName(meta: ArtifactMeta): string {
138
+ return meta.name.startsWith("briefs/")
139
+ ? meta.name.slice("briefs/".length)
140
+ : meta.name;
141
+ }
142
+
143
+ /** Descrição curta do artefato (cabeçalho do painel). */
144
+ export function describeArtifact(meta: ArtifactMeta): string {
145
+ switch (meta.kind) {
146
+ case "review":
147
+ return "Relatório consolidado da revisão — o entregável.";
148
+ case "resume":
149
+ return "Resumo final da revisão: visão de negócio e pontos técnicos diretos (leitura humana).";
150
+ case "domain-review":
151
+ return "Relatório isolado do domínio: achados daquele watchdog.";
152
+ case "verify":
153
+ return "Resultado do `findings verify` (resolvidos, persistentes e incertos).";
154
+ case "brief":
155
+ return "Instruções enviadas ao subagente (auditoria do prompt).";
156
+ case "manifest":
157
+ return "Resumo da execução: watchdogs, arquivos, orçamento, churn, Jev e contexto.";
158
+ case "classification":
159
+ return "Classificação do diff: watchdogs ativados por arquivo, status (A/M/D/R) e churn.";
160
+ case "candidates":
161
+ return "Indícios da pré-análise Jev (regra + arquivo + confiança) — não são achados.";
162
+ case "context":
163
+ return "Payload bruto da PR coletado via `gh` (ou marcador de indisponível).";
164
+ case "context-brief":
165
+ return "Brief do subagente que resume o contexto da PR.";
166
+ case "context-summary":
167
+ return "Resumo da PR (objetivo, motivação e alterações) gerado no Passo 0.";
168
+ default:
169
+ return meta.name;
170
+ }
171
+ }
172
+
173
+ /** `true` se o conteúdo deve ser renderizado com destaque de JSON. */
174
+ export function isJsonArtifact(meta: ArtifactMeta | undefined): boolean {
175
+ if (!meta) return false;
176
+ return meta.contentType.includes("json") || meta.name.toLowerCase().endsWith(".json");
177
+ }
178
+
179
+ /** `true` se o conteúdo deve ser renderizado como markdown. */
180
+ export function isMarkdownArtifact(meta: ArtifactMeta | undefined): boolean {
181
+ if (!meta) return false;
182
+ return (
183
+ meta.contentType.includes("markdown") || meta.name.toLowerCase().endsWith(".md")
184
+ );
185
+ }
186
+
187
+ /** Agrupa os artefatos para a navegação lateral (ordem estável). */
188
+ export function groupArtifacts(
189
+ items: ArtifactMeta[],
190
+ ): { key: string; label: string; items: ArtifactMeta[] }[] {
191
+ const groups = new Map<
192
+ string,
193
+ { key: string; label: string; order: number; items: ArtifactMeta[] }
194
+ >();
195
+ for (const item of items) {
196
+ const meta = GROUP_BY_KIND[item.kind] ?? GROUP_BY_KIND.other;
197
+ const group = groups.get(meta.key) ?? {
198
+ key: meta.key,
199
+ label: meta.label,
200
+ order: meta.order,
201
+ items: [],
202
+ };
203
+ group.items.push(item);
204
+ groups.set(meta.key, group);
205
+ }
206
+ return [...groups.values()]
207
+ .sort((a, b) => a.order - b.order)
208
+ .map((group) => ({
209
+ key: group.key,
210
+ label: group.label,
211
+ items: group.items.sort(
212
+ (a, b) =>
213
+ KIND_ORDER.indexOf(a.kind) - KIND_ORDER.indexOf(b.kind) ||
214
+ a.name.localeCompare(b.name),
215
+ ),
216
+ }));
217
+ }
218
+
219
+ /** Legenda única de cada aba (explica o que a aba é; mostrada quando selecionada). */
220
+ export const TAB_CAPTIONS = {
221
+ resume: "Resumo principal da revisão: o que muda em termos de negócio, números diretos (arquivos, churn, domínios) e os pontos técnicos que exigem ação.",
222
+ findings:
223
+ "Triagem dos achados da revisão: decida Executar, Ignorar ou deixe Pendente — o comentário orienta o auto-fix. Agente: houndsman findings list --decision pending --json · findings done <id>.",
224
+ artifacts:
225
+ "Artefatos persistidos da run: relatórios, briefs de auditoria, manifesto, classificação, Jev, contexto da PR e verificação. JSON com destaque de sintaxe; markdown renderizado com highlight de código.",
226
+ } as const;
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Destaque de sintaxe JSON (cliente) — implementação própria e leve.
3
+ *
4
+ * Não puxa lib de highlight: tokeniza o texto já formatado e devolve spans.
5
+ * Funciona para qualquer JSON gerado pelo houndsman (manifest, classification,
6
+ * context, candidates, …).
7
+ */
8
+
9
+ export type JsonTokenType = "plain" | "key" | "string" | "number" | "literal" | "punct";
10
+
11
+ export type JsonToken = { text: string; type: JsonTokenType };
12
+
13
+ const TOKEN =
14
+ /("(?:\\.|[^"\\])*")(\s*:)?|(-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)|\b(true|false|null)\b|([{}[\],:])/g;
15
+
16
+ /** Pretty-print best-effort; devolve o original se não for JSON válido. */
17
+ export function formatJson(text: string): string {
18
+ try {
19
+ return JSON.stringify(JSON.parse(text), null, 2);
20
+ } catch {
21
+ return text;
22
+ }
23
+ }
24
+
25
+ /** Tokeniza o JSON preservando o texto original (inclusive espaços/quebras). */
26
+ export function highlightJson(text: string): JsonToken[] {
27
+ const tokens: JsonToken[] = [];
28
+ let last = 0;
29
+ for (const match of text.matchAll(TOKEN)) {
30
+ const index = match.index ?? 0;
31
+ if (index > last) {
32
+ tokens.push({ text: text.slice(last, index), type: "plain" });
33
+ }
34
+ if (match[1] !== undefined) {
35
+ tokens.push({
36
+ text: match[1],
37
+ type: match[2] !== undefined ? "key" : "string",
38
+ });
39
+ if (match[2] !== undefined) {
40
+ tokens.push({ text: match[2], type: "punct" });
41
+ }
42
+ } else if (match[3] !== undefined) {
43
+ tokens.push({ text: match[3], type: "number" });
44
+ } else if (match[4] !== undefined) {
45
+ tokens.push({ text: match[4], type: "literal" });
46
+ } else if (match[5] !== undefined) {
47
+ tokens.push({ text: match[5], type: "punct" });
48
+ }
49
+ last = index + match[0].length;
50
+ }
51
+ if (last < text.length) {
52
+ tokens.push({ text: text.slice(last), type: "plain" });
53
+ }
54
+ return tokens;
55
+ }