@payconductor/houndsman 0.8.0 → 0.10.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 (51) hide show
  1. package/package.json +1 -1
  2. package/src/core/ai/ai-client.ts +21 -0
  3. package/src/core/ai/ai-sdk-model-provider.ts +43 -18
  4. package/src/core/ai/features/technical-summarizer.ts +43 -0
  5. package/src/core/ai/index.ts +4 -1
  6. package/src/core/ai/prompts/context.ts +3 -3
  7. package/src/core/ai/prompts/technical.ts +30 -0
  8. package/src/core/ai/provider-capabilities.ts +45 -0
  9. package/src/core/ai/schemas.ts +19 -3
  10. package/src/core/ai/types.ts +3 -1
  11. package/src/core/brief.ts +25 -4
  12. package/src/core/commands/scaffold.ts +5 -0
  13. package/src/core/config.ts +17 -1
  14. package/src/core/git-runner.ts +8 -1
  15. package/src/core/orchestrator.ts +114 -2
  16. package/src/core/paths.ts +3 -0
  17. package/src/core/pr-context.ts +11 -6
  18. package/src/core/project-config.ts +9 -1
  19. package/src/core/renderers/resume-renderer.ts +17 -34
  20. package/src/core/report-builder.ts +6 -6
  21. package/src/core/services/worktree-manager.ts +29 -5
  22. package/src/core/storage/types.ts +1 -0
  23. package/src/core/technical-summary.ts +398 -0
  24. package/src/harness/agent/agent-factory.ts +1 -0
  25. package/src/harness/agent/review-agent.ts +11 -1
  26. package/src/harness/config/harness-config.ts +6 -1
  27. package/src/index.ts +2 -0
  28. package/src/server/client/App.tsx +2 -1
  29. package/src/server/client/PrsSection.tsx +296 -0
  30. package/src/server/client/ResumePanel.tsx +10 -13
  31. package/src/server/client/RunsSection.tsx +73 -0
  32. package/src/server/client/Sidebar.tsx +171 -413
  33. package/src/server/client/sidebar-sections.tsx +73 -0
  34. package/src/server/client/utils/constants.ts +7 -2
  35. package/src/server/client/utils/sidebar-prefs.ts +64 -0
  36. package/src/server/client/utils/types.ts +1 -0
  37. package/src/server/index.ts +23 -409
  38. package/src/server/styles.css +454 -0
  39. package/src/tests/__snapshots__/baseline.test.ts.snap +5 -0
  40. package/src/tests/ai-core.test.ts +71 -0
  41. package/src/tests/ai-features.test.ts +36 -0
  42. package/src/tests/baseline.test.ts +4 -1
  43. package/src/tests/harness-agent.test.ts +19 -0
  44. package/src/tests/review-resume.test.ts +36 -8
  45. package/src/tests/review-scope.test.ts +67 -0
  46. package/src/tests/review-scripts.test.ts +2 -2
  47. package/src/tests/server.test.ts +16 -1
  48. package/src/tests/sidebar-sections.test.ts +94 -0
  49. package/src/tests/technical-summary.test.ts +176 -0
  50. package/src/tests/worktree.test.ts +39 -4
  51. package/assets/logo-legacy.png +0 -0
@@ -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>
@@ -0,0 +1,296 @@
1
+ /**
2
+ * Seção `PRs` da sidebar: lista as PRs abertas via `gh`, abre a run local da
3
+ * branch, cria worktree dedicado e faz checkout quando a árvore está limpa.
4
+ *
5
+ * A seção fica montada mesmo quando outra está ativa — o fetch só ocorre na
6
+ * primeira ativação (`active`), preservando filtros e estados ao alternar.
7
+ */
8
+
9
+ import { useCallback, useEffect, useRef, useState } from "preact/hooks";
10
+ import { BranchIcon, Clock, GitHub, GitPullRequest, Refresh, Spinner } from "./icons";
11
+ import type { SidebarSectionContext } from "./sidebar-sections";
12
+ import { api } from "./utils/api";
13
+ import { formatRunTime } from "./utils/format";
14
+ import type {
15
+ CheckoutResponse,
16
+ PrListItem,
17
+ PrListResponse,
18
+ WorktreeState,
19
+ } from "./utils/types";
20
+
21
+ const REVIEW_LABELS: Record<string, string> = {
22
+ APPROVED: "aprovada",
23
+ CHANGES_REQUESTED: "mudanças",
24
+ REVIEW_REQUIRED: "revisão pendente",
25
+ };
26
+
27
+ function PrItem({
28
+ pr,
29
+ currentBranch,
30
+ worktree,
31
+ busy,
32
+ confirming,
33
+ onConfirm,
34
+ onCancel,
35
+ onCheckout,
36
+ onSelectRun,
37
+ }: {
38
+ pr: PrListItem;
39
+ currentBranch: string | null;
40
+ worktree: WorktreeState;
41
+ busy: boolean;
42
+ confirming: boolean;
43
+ onConfirm: (number: number) => void;
44
+ onCancel: () => void;
45
+ onCheckout: (pr: PrListItem) => void;
46
+ onSelectRun: (slug: string) => void;
47
+ }) {
48
+ const isCurrent = currentBranch === pr.headRefName;
49
+ const runSlug = pr.runSlug;
50
+ const review = pr.reviewDecision ? REVIEW_LABELS[pr.reviewDecision] : undefined;
51
+ // O título da PR às vezes repete o nome da branch; nesse caso só o
52
+ // indicador `head → base` fica visível.
53
+ const showTitle = pr.title.trim() !== pr.headRefName.trim();
54
+ return (
55
+ <article class={`pr${isCurrent ? " current" : ""}`}>
56
+ <div class="pr-head">
57
+ <GitPullRequest size={14} class="pr-icon" />
58
+ <span class="pr-number">#{pr.number}</span>
59
+ {pr.isDraft && <span class="tag">draft</span>}
60
+ {isCurrent && <span class="pr-current">atual</span>}
61
+ {pr.worktreePath && (
62
+ <span
63
+ class="pr-worktree-badge"
64
+ title={`Worktree: ${pr.worktreePath}`}
65
+ >
66
+ <BranchIcon size={10} />
67
+ worktree
68
+ </span>
69
+ )}
70
+ <span class="spacer" />
71
+ {pr.updatedAt && (
72
+ <span class="run-when">
73
+ <Clock size={12} />
74
+ {formatRunTime(pr.updatedAt)}
75
+ </span>
76
+ )}
77
+ </div>
78
+ {showTitle && <p class="pr-title">{pr.title}</p>}
79
+ <p class="pr-meta">
80
+ <span class="pr-branch">{pr.headRefName}</span>
81
+ <span class="pr-arrow">→</span>
82
+ <span>{pr.baseRefName || "—"}</span>
83
+ <span>· {pr.author}</span>
84
+ </p>
85
+ {review && <p class={`pr-review pr-review-${pr.reviewDecision}`}>{review}</p>}
86
+ <div class="pr-actions">
87
+ {runSlug && (
88
+ <button
89
+ type="button"
90
+ class="pr-btn"
91
+ title="Abrir a run local desta branch"
92
+ onClick={() => onSelectRun(runSlug)}
93
+ >
94
+ Abrir run
95
+ </button>
96
+ )}
97
+ {pr.url && (
98
+ <a
99
+ class="pr-btn"
100
+ href={pr.url}
101
+ target="_blank"
102
+ rel="noreferrer"
103
+ title="Abrir a PR no GitHub"
104
+ >
105
+ <GitHub size={12} /> GitHub
106
+ </a>
107
+ )}
108
+ {confirming ? (
109
+ <>
110
+ <button
111
+ type="button"
112
+ class="pr-btn danger"
113
+ disabled={busy}
114
+ title="Confirmar checkout da PR"
115
+ onClick={() => onCheckout(pr)}
116
+ >
117
+ {busy ? <Spinner size={12} class="icon spin" /> : "Confirmar"}
118
+ </button>
119
+ <button
120
+ type="button"
121
+ class="pr-btn"
122
+ disabled={busy}
123
+ onClick={onCancel}
124
+ >
125
+ Cancelar
126
+ </button>
127
+ </>
128
+ ) : (
129
+ <button
130
+ type="button"
131
+ class="pr-btn"
132
+ disabled={!worktree.clean || busy}
133
+ title={
134
+ worktree.clean
135
+ ? `Fazer checkout da branch ${pr.headRefName}`
136
+ : "Há alterações não commitadas — faça commit ou stash antes"
137
+ }
138
+ onClick={() => onConfirm(pr.number)}
139
+ >
140
+ Checkout
141
+ </button>
142
+ )}
143
+ </div>
144
+ </article>
145
+ );
146
+ }
147
+
148
+ export function PrsSection({ active, onSelect, onRefreshRuns }: SidebarSectionContext) {
149
+ const [data, setData] = useState<PrListResponse | null>(null);
150
+ const [loading, setLoading] = useState(false);
151
+ const [error, setError] = useState("");
152
+ const [notice, setNotice] = useState("");
153
+ const [busy, setBusy] = useState<number | null>(null);
154
+ const [confirming, setConfirming] = useState<number | null>(null);
155
+ const requested = useRef(false);
156
+
157
+ const loadPrs = useCallback(async () => {
158
+ setLoading(true);
159
+ setError("");
160
+ try {
161
+ setData(await api<PrListResponse>("/api/prs"));
162
+ } catch (err) {
163
+ setError(String(err));
164
+ } finally {
165
+ setLoading(false);
166
+ }
167
+ }, []);
168
+
169
+ useEffect(() => {
170
+ if (!active || requested.current) return;
171
+ requested.current = true;
172
+ void loadPrs();
173
+ }, [active, loadPrs]);
174
+
175
+ const checkout = useCallback(
176
+ async (pr: PrListItem) => {
177
+ setBusy(pr.number);
178
+ setError("");
179
+ setNotice("");
180
+ try {
181
+ const result = await api<CheckoutResponse>("/api/prs/checkout", {
182
+ method: "POST",
183
+ headers: { "content-type": "application/json" },
184
+ body: JSON.stringify({ number: pr.number }),
185
+ });
186
+ if (!result.ok) {
187
+ if (result.reason === "dirty") {
188
+ setData((current) =>
189
+ current
190
+ ? {
191
+ ...current,
192
+ worktree: {
193
+ ...current.worktree,
194
+ clean: false,
195
+ changes:
196
+ result.changes?.length ??
197
+ current.worktree.changes,
198
+ sample:
199
+ result.changes ?? current.worktree.sample,
200
+ },
201
+ }
202
+ : current,
203
+ );
204
+ }
205
+ setError(
206
+ result.reason === "dirty"
207
+ ? `Checkout bloqueado: ${result.detail}`
208
+ : result.detail,
209
+ );
210
+ return;
211
+ }
212
+ setConfirming(null);
213
+ setNotice(
214
+ `Checkout de #${pr.number} concluído${
215
+ result.branch ? ` — ${result.branch}` : ""
216
+ }.`,
217
+ );
218
+ await loadPrs();
219
+ onRefreshRuns();
220
+ if (pr.runSlug) onSelect(pr.runSlug);
221
+ } catch (err) {
222
+ setError(String(err));
223
+ } finally {
224
+ setBusy(null);
225
+ }
226
+ },
227
+ [loadPrs, onRefreshRuns, onSelect],
228
+ );
229
+
230
+ const openCount = data?.available ? data.prs.length : null;
231
+
232
+ return (
233
+ <>
234
+ <div class="pr-toolbar">
235
+ <p class="section-label">
236
+ {openCount === null
237
+ ? "PRs abertas"
238
+ : `${openCount} ${openCount === 1 ? "aberta" : "abertas"}`}
239
+ </p>
240
+ <button
241
+ type="button"
242
+ class="icon-btn"
243
+ title="Atualizar lista de PRs"
244
+ disabled={loading}
245
+ onClick={() => void loadPrs()}
246
+ >
247
+ <Refresh size={13} class={loading ? "icon spin" : undefined} />
248
+ </button>
249
+ </div>
250
+
251
+ {error && <p class="error">{error}</p>}
252
+ {notice && <p class="pr-notice">{notice}</p>}
253
+ {data && !data.available && (
254
+ <p class="empty">
255
+ GitHub indisponível
256
+ {data.reason ? `: ${data.reason}` : "."}
257
+ </p>
258
+ )}
259
+ {data && !data.worktree.available && (
260
+ <p class="empty">Não foi possível ler o estado do git.</p>
261
+ )}
262
+ {data?.worktree.available && !data.worktree.clean && (
263
+ <p class="pr-warning">
264
+ {data.worktree.changes} alteração(ões) não commitada(s) — checkout
265
+ bloqueado até limpar a árvore.
266
+ </p>
267
+ )}
268
+ {data?.currentBranch && (
269
+ <p class="pr-current-branch">
270
+ Branch atual: <code>{data.currentBranch}</code>
271
+ </p>
272
+ )}
273
+
274
+ <nav class="prs" aria-label="Pull requests abertas">
275
+ {loading && !data && <p class="empty">Carregando PRs…</p>}
276
+ {data?.available && data.prs.length === 0 && (
277
+ <p class="empty">Nenhuma PR aberta.</p>
278
+ )}
279
+ {data?.prs.map((pr) => (
280
+ <PrItem
281
+ key={pr.number}
282
+ pr={pr}
283
+ currentBranch={data.currentBranch}
284
+ worktree={data.worktree}
285
+ busy={busy !== null}
286
+ confirming={confirming === pr.number}
287
+ onConfirm={setConfirming}
288
+ onCancel={() => setConfirming(null)}
289
+ onCheckout={checkout}
290
+ onSelectRun={onSelect}
291
+ />
292
+ ))}
293
+ </nav>
294
+ </>
295
+ );
296
+ }
@@ -1,21 +1,19 @@
1
1
  /**
2
2
  * Aba "Resumo" (inicial): visão principal da revisão.
3
3
  *
4
- * O texto narrativo vem do artefato `review-resume.md` (Resumo de negócio); os
5
- * números viram cards a partir do `classification.json`/`manifest.json` e o
6
- * card de domínios resume os achados por severidade em cada watchdog — assim a
7
- * UI mostra os mesmos dados do resumo de forma mais organizada, sem duplicar o
8
- * texto.
4
+ * O texto narrativo vem do artefato `review-resume.md` e é exibido por inteiro
5
+ * (resumo de negócio + pontos técnicos); os números viram cards a partir do
6
+ * `classification.json`/`manifest.json` e o card de domínios resume os achados
7
+ * por severidade em cada watchdog.
9
8
  */
10
9
 
11
10
  import type { ComponentChildren } from "preact";
12
11
  import { useEffect, useMemo, useState } from "preact/hooks";
13
- import { ExternalLink, Layers, Trash } from "./icons";
14
- import { MarkdownBlocks } from "./MarkdownText";
12
+ import { ExternalLink, Trash } from "./icons";
13
+ import { MarkdownText } from "./MarkdownText";
15
14
  import { api } from "./utils/api";
16
15
  import { SEVERITY_LABELS, SEVERITY_ORDER } from "./utils/constants";
17
16
  import { formatRunTime } from "./utils/format";
18
- import { sectionBlocks } from "./utils/markdown";
19
17
  import type {
20
18
  ClassificationArtifact,
21
19
  Finding,
@@ -126,7 +124,6 @@ export function ResumePanel({
126
124
  }, [slug]);
127
125
 
128
126
  const manifest: RunManifest | null = detail?.manifest ?? null;
129
- const business = useMemo(() => sectionBlocks(resume, "Resumo de negócio"), [resume]);
130
127
  const status =
131
128
  summary.bySeverity.blocking > 0
132
129
  ? STATUS_VIEW.blocking
@@ -295,16 +292,16 @@ export function ResumePanel({
295
292
 
296
293
  <div class="resume-grid">
297
294
  <section class="resume-card">
298
- <h3 class="resume-card-title">Resumo de negócio</h3>
295
+ <h3 class="resume-card-title">Resumo da revisão</h3>
299
296
  {loading ? (
300
297
  <p class="empty">Carregando resumo…</p>
301
- ) : business.length > 0 ? (
298
+ ) : resume.trim().length > 0 ? (
302
299
  <div class="resume-narrative">
303
- <MarkdownBlocks blocks={business} />
300
+ <MarkdownText text={resume} />
304
301
  </div>
305
302
  ) : (
306
303
  <p class="empty">
307
- O resumo narrativo ainda não foi gerado para esta run. Rode o{" "}
304
+ O resumo ainda não foi gerado para esta run. Rode o{" "}
308
305
  <code>--finalize</code> para criar{" "}
309
306
  <code>review-resume.md</code>.
310
307
  </p>
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Seção `Execuções` da sidebar: lista das runs revisadas, com status, data e
3
+ * contagem de findings. Selecionar uma run troca apenas o conteúdo principal.
4
+ */
5
+
6
+ import { BranchIcon, Clock, Sparkles } from "./icons";
7
+ import type { SidebarSectionContext } from "./sidebar-sections";
8
+ import { describeRunStatus } from "./utils/constants";
9
+ import { formatRunTime } from "./utils/format";
10
+ import type { RunSummary } from "./utils/types";
11
+
12
+ function RunItem({
13
+ run,
14
+ active,
15
+ onSelect,
16
+ }: {
17
+ run: RunSummary;
18
+ active: boolean;
19
+ onSelect: (slug: string) => void;
20
+ }) {
21
+ const status = describeRunStatus(run);
22
+ const fixReady = status?.kind === "fix_done";
23
+ return (
24
+ <button
25
+ type="button"
26
+ title={`Abrir a run da branch ${run.branch}`}
27
+ class={`run${active ? " active" : ""}`}
28
+ onClick={() => onSelect(run.slug)}
29
+ >
30
+ <span class="run-top">
31
+ <BranchIcon size={14} class="run-icon" />
32
+ <span class="run-branch">{run.branch}</span>
33
+ </span>
34
+ <span class="run-meta">
35
+ <span class="run-when">
36
+ <Clock size={12} />
37
+ {formatRunTime(run.updatedAt) || "sem data"}
38
+ </span>
39
+ <span
40
+ class="run-count"
41
+ title={`${run.summary.present} de ${run.summary.total} findings presentes`}
42
+ >
43
+ {run.summary.present}/{run.summary.total}
44
+ </span>
45
+ </span>
46
+ {fixReady ? (
47
+ <span class="run-badge">
48
+ <Sparkles size={12} /> Correção pronta
49
+ </span>
50
+ ) : (
51
+ status && (
52
+ <span class={`run-status status-${status.kind}`}>{status.label}</span>
53
+ )
54
+ )}
55
+ </button>
56
+ );
57
+ }
58
+
59
+ export function RunsSection({ runs, slug, onSelect }: SidebarSectionContext) {
60
+ return (
61
+ <nav class="runs" aria-label="Execuções">
62
+ {runs.length === 0 && <p class="empty">Nenhuma run encontrada.</p>}
63
+ {runs.map((run) => (
64
+ <RunItem
65
+ key={run.slug}
66
+ run={run}
67
+ active={run.slug === slug}
68
+ onSelect={onSelect}
69
+ />
70
+ ))}
71
+ </nav>
72
+ );
73
+ }