@payconductor/houndsman 0.8.0 → 0.11.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 (55) hide show
  1. package/package.json +1 -1
  2. package/src/cli/index.ts +9 -1
  3. package/src/core/ai/ai-client.ts +21 -0
  4. package/src/core/ai/ai-sdk-model-provider.ts +43 -18
  5. package/src/core/ai/features/technical-summarizer.ts +43 -0
  6. package/src/core/ai/index.ts +4 -1
  7. package/src/core/ai/prompts/base.ts +7 -0
  8. package/src/core/ai/prompts/context.ts +5 -4
  9. package/src/core/ai/prompts/technical.ts +31 -0
  10. package/src/core/ai/provider-capabilities.ts +45 -0
  11. package/src/core/ai/schemas.ts +19 -3
  12. package/src/core/ai/types.ts +3 -1
  13. package/src/core/brief.ts +25 -4
  14. package/src/core/commands/scaffold.ts +5 -0
  15. package/src/core/config-loader.ts +18 -4
  16. package/src/core/config.ts +17 -1
  17. package/src/core/engine/review-workspace.ts +6 -2
  18. package/src/core/git-runner.ts +8 -1
  19. package/src/core/orchestrator.ts +114 -2
  20. package/src/core/paths.ts +3 -0
  21. package/src/core/pr-context.ts +11 -6
  22. package/src/core/project-config.ts +9 -1
  23. package/src/core/renderers/resume-renderer.ts +17 -34
  24. package/src/core/report-builder.ts +6 -6
  25. package/src/core/services/worktree-manager.ts +90 -7
  26. package/src/core/storage/types.ts +1 -0
  27. package/src/core/technical-summary.ts +398 -0
  28. package/src/harness/agent/agent-factory.ts +1 -0
  29. package/src/harness/agent/review-agent.ts +11 -1
  30. package/src/harness/config/harness-config.ts +6 -1
  31. package/src/index.ts +2 -0
  32. package/src/server/client/App.tsx +2 -1
  33. package/src/server/client/PrsSection.tsx +296 -0
  34. package/src/server/client/ResumePanel.tsx +10 -13
  35. package/src/server/client/RunsSection.tsx +73 -0
  36. package/src/server/client/Sidebar.tsx +171 -413
  37. package/src/server/client/sidebar-sections.tsx +73 -0
  38. package/src/server/client/utils/constants.ts +7 -2
  39. package/src/server/client/utils/sidebar-prefs.ts +64 -0
  40. package/src/server/client/utils/types.ts +1 -0
  41. package/src/server/index.ts +23 -409
  42. package/src/server/styles.css +454 -0
  43. package/src/tests/__snapshots__/baseline.test.ts.snap +5 -0
  44. package/src/tests/ai-core.test.ts +71 -0
  45. package/src/tests/ai-features.test.ts +47 -0
  46. package/src/tests/baseline.test.ts +4 -1
  47. package/src/tests/harness-agent.test.ts +19 -0
  48. package/src/tests/review-resume.test.ts +36 -8
  49. package/src/tests/review-scope.test.ts +67 -0
  50. package/src/tests/review-scripts.test.ts +2 -2
  51. package/src/tests/server.test.ts +16 -1
  52. package/src/tests/sidebar-sections.test.ts +94 -0
  53. package/src/tests/technical-summary.test.ts +176 -0
  54. package/src/tests/worktree.test.ts +110 -5
  55. package/assets/logo-legacy.png +0 -0
@@ -0,0 +1,398 @@
1
+ /**
2
+ * Pontos técnicos estruturais do resumo final (`technical-summary.json`).
3
+ *
4
+ * Descrevem a arquitetura da mudança (módulos, contratos, implementações,
5
+ * dependências e impacto) — nunca os achados da revisão. A IA apenas refina a
6
+ * redação; sem ela, a heurística abaixo é o resultado final.
7
+ */
8
+
9
+ import type { AstAnalysis, AstImpact, AstSymbol, AstSymbolKind } from "./ast/types";
10
+ import type { ClassificationResult } from "./file-classifier";
11
+
12
+ /** Destaque técnico exibido como um bullet do resumo. */
13
+ export type TechnicalHighlight = {
14
+ /** Rótulo curto (ex.: "Contratos", "Implementações"). */
15
+ area: string;
16
+ /** Detalhe em uma linha, com paths/símbolos. */
17
+ detail: string;
18
+ };
19
+
20
+ /** Pontos técnicos de uma revisão. */
21
+ export type TechnicalSummary = {
22
+ /** `ai` quando o texto veio do modelo; `deterministic` no fallback. */
23
+ source: "ai" | "deterministic";
24
+ /** Commit coberto — permite saber a que revisão o resumo se refere. */
25
+ headSha?: string;
26
+ /** Parágrafo curto de abertura (opcional). */
27
+ overview?: string;
28
+ highlights: TechnicalHighlight[];
29
+ };
30
+
31
+ /** Entrada mínima para montar os destaques. */
32
+ export type TechnicalInput = {
33
+ classification: Pick<ClassificationResult, "files" | "churn">;
34
+ ast?: ReadonlyMap<string, AstAnalysis>;
35
+ impact?: AstImpact;
36
+ headSha?: string;
37
+ };
38
+
39
+ /** Entrada do payload enviado à IA (inclui contexto de negócio e domínios). */
40
+ export type TechnicalPromptInput = TechnicalInput & {
41
+ branch: string;
42
+ context?: {
43
+ objective?: string;
44
+ motivation?: string;
45
+ changeSummary?: string[];
46
+ } | null;
47
+ domains?: readonly string[];
48
+ };
49
+
50
+ const MAX_HIGHLIGHTS = 6;
51
+ const MAX_DETAIL = 240;
52
+
53
+ /** Corta o texto em `max` caracteres, sinalizando com reticências. */
54
+ function truncate(value: string, max: number): string {
55
+ const text = value.trim().replace(/\s+/g, " ");
56
+ return text.length <= max ? text : `${text.slice(0, max - 1).trimEnd()}…`;
57
+ }
58
+
59
+ /** Diretório do arquivo (`(raiz)` para paths sem pasta). */
60
+ function moduleOf(path: string): string {
61
+ const slash = path.lastIndexOf("/");
62
+ return slash > 0 ? path.slice(0, slash) : "(raiz)";
63
+ }
64
+
65
+ /** `true` para pacotes externos (fora do projeto e de aliases locais). */
66
+ function isExternal(source: string): boolean {
67
+ return (
68
+ !source.startsWith(".") &&
69
+ !source.startsWith("/") &&
70
+ !source.startsWith("#") &&
71
+ !source.startsWith("@/")
72
+ );
73
+ }
74
+
75
+ /** `true` para símbolos que representam contratos públicos. */
76
+ function isContract(kind: AstSymbolKind): boolean {
77
+ return kind === "interface" || kind === "type" || kind === "enum";
78
+ }
79
+
80
+ type SymbolRef = { symbol: AstSymbol; file: string };
81
+
82
+ /** Lista curta de símbolos com o arquivo de origem. */
83
+ function symbolDetail(refs: SymbolRef[], max = 4): string {
84
+ const seen = new Set<string>();
85
+ const parts: string[] = [];
86
+ for (const ref of refs) {
87
+ const key = `${ref.file}|${ref.symbol.name}`;
88
+ if (seen.has(key)) continue;
89
+ seen.add(key);
90
+ parts.push(`\`${ref.symbol.name}\` (${ref.file})`);
91
+ if (parts.length === max) break;
92
+ }
93
+ const total = new Set(refs.map((ref) => `${ref.file}|${ref.symbol.name}`)).size;
94
+ return `${parts.join(", ")}${total > parts.length ? ` +${total - parts.length}` : ""}`;
95
+ }
96
+
97
+ type SignatureRef = {
98
+ name: string;
99
+ before: string;
100
+ after: string;
101
+ file: string;
102
+ };
103
+
104
+ /** Assinaturas alteradas, com o `antes → depois`. */
105
+ function signatureDetail(changes: SignatureRef[], max = 3): string {
106
+ return changes
107
+ .slice(0, max)
108
+ .map(
109
+ (change) =>
110
+ `\`${change.name}\` (${truncate(change.before, 60)} → ${truncate(change.after, 60)})`,
111
+ )
112
+ .join(", ");
113
+ }
114
+
115
+ type ExportRef = {
116
+ name: string;
117
+ change: "added" | "removed";
118
+ file: string;
119
+ };
120
+
121
+ type PromptSymbolRef = { name: string; kind: string; file: string };
122
+
123
+ /** Exports adicionados/removidos, com o arquivo. */
124
+ function exportDetail(changes: ExportRef[], max = 4): string {
125
+ return changes
126
+ .slice(0, max)
127
+ .map((change) => `\`${change.name}\` (${change.change}, ${change.file})`)
128
+ .join(", ");
129
+ }
130
+
131
+ /** Agrupa paths por diretório, do maior para o menor grupo. */
132
+ function groupByModule(paths: string[]): [string, number][] {
133
+ const counts = new Map<string, number>();
134
+ for (const path of paths) {
135
+ const dir = moduleOf(path);
136
+ counts.set(dir, (counts.get(dir) ?? 0) + 1);
137
+ }
138
+ return [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
139
+ }
140
+
141
+ /**
142
+ * Monta os destaques determinísticos. Prioriza símbolos do AST; sem fatos
143
+ * estruturais, agrupa os arquivos novos por diretório. Nunca lança.
144
+ */
145
+ export function buildTechnicalSummary(input: TechnicalInput): TechnicalSummary {
146
+ const highlights: TechnicalHighlight[] = [];
147
+ const files = input.classification.files;
148
+ const added = Object.entries(files)
149
+ .filter(([, file]) => file.status === "added")
150
+ .map(([path]) => path);
151
+ const deleted = Object.entries(files)
152
+ .filter(([, file]) => file.status === "deleted")
153
+ .map(([path]) => path);
154
+
155
+ const contracts: SymbolRef[] = [];
156
+ const implementations: SymbolRef[] = [];
157
+ const functions: SymbolRef[] = [];
158
+ const signatures: SignatureRef[] = [];
159
+ const exportsChanged: ExportRef[] = [];
160
+ const imports = new Set<string>();
161
+
162
+ if (input.ast) {
163
+ for (const [file, analysis] of input.ast) {
164
+ const delta = analysis.delta;
165
+ if (!delta) continue;
166
+ for (const symbol of delta.addedSymbols) {
167
+ if (isContract(symbol.kind)) contracts.push({ symbol, file });
168
+ else if (symbol.kind === "class") implementations.push({ symbol, file });
169
+ else if (symbol.kind === "function") functions.push({ symbol, file });
170
+ }
171
+ for (const change of delta.changedSignatures) {
172
+ if (!change.exported) continue;
173
+ signatures.push({
174
+ name: change.name,
175
+ before: change.before,
176
+ after: change.after,
177
+ file,
178
+ });
179
+ }
180
+ for (const change of delta.changedExports) {
181
+ exportsChanged.push({
182
+ name: change.name,
183
+ change: change.change,
184
+ file,
185
+ });
186
+ }
187
+ for (const source of delta.addedImports) {
188
+ if (isExternal(source)) imports.add(source);
189
+ }
190
+ }
191
+ }
192
+
193
+ if (contracts.length > 0) {
194
+ highlights.push({ area: "Contratos", detail: symbolDetail(contracts) });
195
+ }
196
+ if (implementations.length > 0) {
197
+ highlights.push({
198
+ area: "Implementações",
199
+ detail: symbolDetail(implementations),
200
+ });
201
+ }
202
+ if (functions.length > 0 && contracts.length === 0 && implementations.length === 0) {
203
+ highlights.push({ area: "Funções", detail: symbolDetail(functions) });
204
+ }
205
+ if (signatures.length > 0) {
206
+ highlights.push({
207
+ area: "Assinaturas alteradas",
208
+ detail: signatureDetail(signatures),
209
+ });
210
+ }
211
+ if (exportsChanged.length > 0) {
212
+ highlights.push({
213
+ area: "Exports alterados",
214
+ detail: exportDetail(exportsChanged),
215
+ });
216
+ }
217
+ if (imports.size > 0) {
218
+ highlights.push({
219
+ area: "Novas dependências",
220
+ detail: [...imports]
221
+ .slice(0, 6)
222
+ .map((source) => `\`${source}\``)
223
+ .join(", "),
224
+ });
225
+ }
226
+
227
+ // Sem fatos do AST, a estrutura visível é o conjunto de diretórios novos.
228
+ if (highlights.length === 0) {
229
+ for (const [dir, count] of groupByModule(added).slice(0, 3)) {
230
+ highlights.push({
231
+ area: "Estrutura",
232
+ detail: `Novo diretório \`${dir}\` com ${count} arquivo(s).`,
233
+ });
234
+ }
235
+ }
236
+
237
+ if (deleted.length > 0 && highlights.length < MAX_HIGHLIGHTS) {
238
+ highlights.push({
239
+ area: "Remoções",
240
+ detail: `${deleted
241
+ .slice(0, 4)
242
+ .map((path) => `\`${path}\``)
243
+ .join(", ")}${deleted.length > 4 ? ` +${deleted.length - 4}` : ""}`,
244
+ });
245
+ }
246
+
247
+ if (
248
+ input.impact &&
249
+ input.impact.affectedFiles.length > 0 &&
250
+ highlights.length < MAX_HIGHLIGHTS
251
+ ) {
252
+ const exported = input.impact.exportedChanges.length;
253
+ highlights.push({
254
+ area: "Impacto",
255
+ detail:
256
+ `${input.impact.affectedFiles.length} arquivo(s) importam os alterados` +
257
+ (exported > 0 ? `; ${exported} export(s) público(s) alterado(s)` : "") +
258
+ ".",
259
+ });
260
+ }
261
+
262
+ return {
263
+ source: "deterministic",
264
+ headSha: input.headSha,
265
+ highlights: highlights.slice(0, MAX_HIGHLIGHTS).map((highlight) => ({
266
+ area: truncate(highlight.area, 80),
267
+ detail: truncate(highlight.detail, MAX_DETAIL),
268
+ })),
269
+ };
270
+ }
271
+
272
+ /** Payload compacto enviado ao modelo (nunca arquivos inteiros). */
273
+ export function buildTechnicalPromptPayload(
274
+ input: TechnicalPromptInput,
275
+ ): Record<string, unknown> {
276
+ const files = Object.entries(input.classification.files)
277
+ .map(([path, file]) => ({
278
+ path,
279
+ status: file.status,
280
+ additions: input.classification.churn[path]?.additions ?? 0,
281
+ deletions: input.classification.churn[path]?.deletions ?? 0,
282
+ }))
283
+ .sort(
284
+ (a, b) =>
285
+ b.additions + b.deletions - (a.additions + a.deletions) ||
286
+ a.path.localeCompare(b.path),
287
+ )
288
+ .slice(0, 80);
289
+
290
+ const contracts: PromptSymbolRef[] = [];
291
+ const implementations: PromptSymbolRef[] = [];
292
+ const signatures: SignatureRef[] = [];
293
+ const exportsChanged: ExportRef[] = [];
294
+ const imports = new Set<string>();
295
+ const deletedFiles: string[] = [];
296
+
297
+ for (const [path, file] of Object.entries(input.classification.files)) {
298
+ if (file.status === "deleted") deletedFiles.push(path);
299
+ }
300
+
301
+ if (input.ast) {
302
+ for (const [file, analysis] of input.ast) {
303
+ const delta = analysis.delta;
304
+ if (!delta) continue;
305
+ for (const symbol of delta.addedSymbols) {
306
+ if (isContract(symbol.kind)) {
307
+ contracts.push({ name: symbol.name, kind: symbol.kind, file });
308
+ } else if (symbol.kind === "class") {
309
+ implementations.push({ name: symbol.name, kind: symbol.kind, file });
310
+ }
311
+ }
312
+ for (const change of delta.changedSignatures) {
313
+ if (change.exported) {
314
+ signatures.push({
315
+ name: change.name,
316
+ before: change.before,
317
+ after: change.after,
318
+ file,
319
+ });
320
+ }
321
+ }
322
+ for (const change of delta.changedExports) {
323
+ exportsChanged.push({
324
+ name: change.name,
325
+ change: change.change,
326
+ file,
327
+ });
328
+ }
329
+ for (const source of delta.addedImports) {
330
+ if (isExternal(source)) imports.add(source);
331
+ }
332
+ }
333
+ }
334
+
335
+ return {
336
+ branch: input.branch,
337
+ headSha: input.headSha,
338
+ business: input.context
339
+ ? {
340
+ objective: input.context.objective,
341
+ motivation: input.context.motivation,
342
+ changeSummary: input.context.changeSummary,
343
+ }
344
+ : undefined,
345
+ domains: input.domains ?? [],
346
+ files,
347
+ structure: {
348
+ contracts: contracts.slice(0, 40),
349
+ implementations: implementations.slice(0, 40),
350
+ changedSignatures: signatures.slice(0, 20),
351
+ changedExports: exportsChanged.slice(0, 20),
352
+ addedImports: [...imports].slice(0, 20),
353
+ deletedFiles: deletedFiles.slice(0, 20),
354
+ },
355
+ impact: input.impact
356
+ ? {
357
+ affectedFiles: input.impact.affectedFiles.length,
358
+ importers: Object.keys(input.impact.importers).length,
359
+ exportedChanges: input.impact.exportedChanges.length,
360
+ }
361
+ : undefined,
362
+ };
363
+ }
364
+
365
+ /** Valida/normaliza o `technical-summary.json`; null quando vazio/inválido. */
366
+ export function parseTechnicalSummary(raw: unknown): TechnicalSummary | null {
367
+ if (typeof raw !== "object" || raw === null) return null;
368
+ const obj = raw as Record<string, unknown>;
369
+
370
+ const highlights: TechnicalHighlight[] = [];
371
+ if (Array.isArray(obj.highlights)) {
372
+ for (const item of obj.highlights) {
373
+ if (typeof item !== "object" || item === null) continue;
374
+ const entry = item as Record<string, unknown>;
375
+ const area = typeof entry.area === "string" ? entry.area.trim() : "";
376
+ const detail = typeof entry.detail === "string" ? entry.detail.trim() : "";
377
+ if (!area || !detail) continue;
378
+ highlights.push({
379
+ area: truncate(area, 80),
380
+ detail: truncate(detail, MAX_DETAIL),
381
+ });
382
+ if (highlights.length === MAX_HIGHLIGHTS) break;
383
+ }
384
+ }
385
+
386
+ const overview =
387
+ typeof obj.overview === "string" && obj.overview.trim().length > 0
388
+ ? truncate(obj.overview, 600)
389
+ : undefined;
390
+ if (highlights.length === 0 && !overview) return null;
391
+
392
+ return {
393
+ source: obj.source === "deterministic" ? "deterministic" : "ai",
394
+ headSha: typeof obj.headSha === "string" ? obj.headSha : undefined,
395
+ overview,
396
+ highlights,
397
+ };
398
+ }
@@ -37,6 +37,7 @@ export class AgentFactory {
37
37
  reporter: this.deps.reporter,
38
38
  maxOutputTokens: config.maxOutputTokensFor(context.watchdog.id),
39
39
  temperature: config.temperature,
40
+ providerOptions: config.providerOptions,
40
41
  timeoutMs: config.timeoutMs,
41
42
  ast: this.deps.ast,
42
43
  readFile: this.deps.readFile,
@@ -8,7 +8,13 @@
8
8
 
9
9
  import { readFileSync } from "node:fs";
10
10
  import { join } from "node:path";
11
- import { type AiUsage, addUsage, emptyUsage, type ModelProvider } from "../../core/ai";
11
+ import {
12
+ type AiProviderOptions,
13
+ type AiUsage,
14
+ addUsage,
15
+ emptyUsage,
16
+ type ModelProvider,
17
+ } from "../../core/ai";
12
18
  import type { AstService } from "../../core/ast/service";
13
19
  import type { WatchdogReport } from "../../core/report-builder";
14
20
  import type { Severity } from "../../core/types";
@@ -49,6 +55,7 @@ export type ReviewAgentDeps = {
49
55
  reporter: AgentReporter;
50
56
  maxOutputTokens: number;
51
57
  temperature: number;
58
+ providerOptions?: AiProviderOptions;
52
59
  timeoutMs: number;
53
60
  ast: AstService;
54
61
  readFile?: (path: string) => string | null;
@@ -338,6 +345,9 @@ export class ReviewAgent {
338
345
  maxSteps,
339
346
  maxOutputTokens: this.deps.maxOutputTokens,
340
347
  temperature: this.deps.temperature,
348
+ ...(this.deps.providerOptions
349
+ ? { providerOptions: this.deps.providerOptions }
350
+ : {}),
341
351
  signal: AbortSignal.timeout(this.deps.timeoutMs),
342
352
  });
343
353
  this.usage = addUsage(this.usage, response.usage);
@@ -5,7 +5,7 @@
5
5
  * aplica apenas overrides de execução (`hunt --model`, `--max-calls`, etc.).
6
6
  */
7
7
 
8
- import type { AiProviderKind } from "../../core/config";
8
+ import type { AiProviderKind, AiProviderOptions } from "../../core/config";
9
9
  import { ProjectConfig } from "../../core/project-config";
10
10
 
11
11
  /** Overrides de execução do harness. */
@@ -76,6 +76,11 @@ export class HarnessConfig {
76
76
  get temperature(): number {
77
77
  return this.project.aiTemperature;
78
78
  }
79
+
80
+ /** Opções do AI SDK por provider (config do projeto, sem override de flag). */
81
+ get providerOptions(): AiProviderOptions | undefined {
82
+ return this.project.aiProviderOptions;
83
+ }
79
84
  }
80
85
 
81
86
  /** Inteiro positivo ou o default. */
package/src/index.ts CHANGED
@@ -49,6 +49,7 @@ export { runFindingsCommand, runVerifyCommand } from "./core/commands/findings";
49
49
  export { ProjectScaffolder } from "./core/commands/scaffold";
50
50
  export type {
51
51
  AiProviderKind,
52
+ AiProviderOptions,
52
53
  HoundsmanAi,
53
54
  HoundsmanAiFeatures,
54
55
  HoundsmanAst,
@@ -195,6 +196,7 @@ export type {
195
196
  WorktreeManagerOptions,
196
197
  } from "./core/services/worktree-manager";
197
198
  export {
199
+ branchSlug,
198
200
  parseWorktreeList,
199
201
  repoSlug,
200
202
  WorktreeManager,
@@ -259,11 +259,12 @@ export function App() {
259
259
  type="button"
260
260
  key={item.id}
261
261
  title={item.hint}
262
+ aria-label={item.label}
262
263
  class={`seg-btn${tab === item.id ? " active" : ""}`}
263
264
  onClick={() => setTab(item.id)}
264
265
  >
265
266
  <TabIcon id={item.id} />
266
- {item.label}
267
+ <span class="seg-label">{item.label}</span>
267
268
  </button>
268
269
  ))}
269
270
  </div>