@payconductor/houndsman 0.7.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 (88) hide show
  1. package/README.md +20 -0
  2. package/package.json +1 -1
  3. package/src/cli/commands/command.ts +10 -1
  4. package/src/cli/commands/context-command.ts +40 -2
  5. package/src/cli/commands/hunt-command.ts +28 -2
  6. package/src/cli/commands/review-command.ts +30 -2
  7. package/src/cli/commands/worktree-command.ts +118 -0
  8. package/src/cli/help.ts +12 -2
  9. package/src/cli/index.ts +14 -4
  10. package/src/core/ai/ai-client.ts +21 -0
  11. package/src/core/ai/ai-sdk-model-provider.ts +43 -18
  12. package/src/core/ai/features/technical-summarizer.ts +43 -0
  13. package/src/core/ai/index.ts +4 -1
  14. package/src/core/ai/prompts/context.ts +3 -3
  15. package/src/core/ai/prompts/technical.ts +30 -0
  16. package/src/core/ai/provider-capabilities.ts +45 -0
  17. package/src/core/ai/schemas.ts +19 -3
  18. package/src/core/ai/types.ts +3 -1
  19. package/src/core/ast/service.ts +7 -2
  20. package/src/core/brief.ts +26 -4
  21. package/src/core/commands/context.ts +9 -1
  22. package/src/core/commands/findings.ts +11 -5
  23. package/src/core/commands/review.ts +13 -0
  24. package/src/core/commands/scaffold.ts +9 -2
  25. package/src/core/config.ts +30 -1
  26. package/src/core/defaults.ts +7 -0
  27. package/src/core/engine/houndsman-engine.ts +26 -3
  28. package/src/core/engine/review-scope.ts +17 -22
  29. package/src/core/engine/review-session.ts +25 -2
  30. package/src/core/engine/review-workspace.ts +204 -0
  31. package/src/core/git-runner.ts +8 -1
  32. package/src/core/jev/jev-detector.ts +10 -2
  33. package/src/core/orchestrator.ts +176 -15
  34. package/src/core/paths.ts +3 -0
  35. package/src/core/pr-context.ts +11 -6
  36. package/src/core/project-config.ts +21 -1
  37. package/src/core/renderers/resume-renderer.ts +17 -34
  38. package/src/core/report-builder.ts +6 -6
  39. package/src/core/services/git-repository.ts +46 -0
  40. package/src/core/services/pr-resolver.ts +147 -0
  41. package/src/core/services/worktree-manager.ts +293 -0
  42. package/src/core/storage/file/file-storage.ts +14 -1
  43. package/src/core/storage/paths.ts +30 -1
  44. package/src/core/storage/sqlite/mappers.ts +4 -0
  45. package/src/core/storage/sqlite/schema.ts +13 -3
  46. package/src/core/storage/sqlite/sqlite-storage.ts +28 -8
  47. package/src/core/storage/types.ts +13 -0
  48. package/src/core/storage/work-area.ts +20 -1
  49. package/src/core/technical-summary.ts +398 -0
  50. package/src/core/types.ts +2 -0
  51. package/src/harness/agent/agent-factory.ts +1 -0
  52. package/src/harness/agent/review-agent.ts +11 -1
  53. package/src/harness/config/harness-config.ts +6 -1
  54. package/src/harness/hunt/hunt-options.ts +17 -0
  55. package/src/harness/hunt/hunt-session.ts +7 -20
  56. package/src/index.ts +33 -0
  57. package/src/server/client/App.tsx +51 -12
  58. package/src/server/client/DeleteRunModal.tsx +163 -0
  59. package/src/server/client/PrsSection.tsx +296 -0
  60. package/src/server/client/ResumePanel.tsx +17 -20
  61. package/src/server/client/RunsSection.tsx +73 -0
  62. package/src/server/client/Sidebar.tsx +171 -358
  63. package/src/server/client/icons.tsx +10 -0
  64. package/src/server/client/sidebar-sections.tsx +73 -0
  65. package/src/server/client/utils/constants.ts +7 -2
  66. package/src/server/client/utils/sidebar-prefs.ts +64 -0
  67. package/src/server/client/utils/types.ts +27 -0
  68. package/src/server/index.ts +113 -406
  69. package/src/server/pr-inbox.ts +15 -4
  70. package/src/server/run-repository.ts +103 -2
  71. package/src/server/styles.css +454 -0
  72. package/src/skill/README.md +11 -3
  73. package/src/skill/SKILL.md +44 -10
  74. package/src/skill/hunt.md +33 -7
  75. package/src/tests/__snapshots__/baseline.test.ts.snap +99 -24
  76. package/src/tests/ai-core.test.ts +71 -0
  77. package/src/tests/ai-features.test.ts +36 -0
  78. package/src/tests/baseline.test.ts +4 -1
  79. package/src/tests/cli.test.ts +2 -1
  80. package/src/tests/harness-agent.test.ts +19 -0
  81. package/src/tests/review-resume.test.ts +36 -8
  82. package/src/tests/review-scope.test.ts +67 -0
  83. package/src/tests/review-scripts.test.ts +2 -2
  84. package/src/tests/server.test.ts +54 -1
  85. package/src/tests/sidebar-sections.test.ts +94 -0
  86. package/src/tests/technical-summary.test.ts +176 -0
  87. package/src/tests/worktree.test.ts +404 -0
  88. package/assets/logo-legacy.png +0 -0
@@ -0,0 +1,204 @@
1
+ /**
2
+ * Resolução do workspace de uma revisão (engine).
3
+ *
4
+ * Decide onde a revisão roda: clone principal, worktree explícito (`--worktree`)
5
+ * ou worktree criado/reusado para uma PR (`--pr`). Devolve a raiz operacional,
6
+ * a raiz do clone principal (identidade/config) e as refs do diff.
7
+ */
8
+
9
+ import { basename, dirname, resolve } from "node:path";
10
+ import { createGitRunner } from "../git-runner";
11
+ import { createGhRunner, type GhRunner } from "../services/gh-client";
12
+ import { GitRepository } from "../services/git-repository";
13
+ import { PrResolver } from "../services/pr-resolver";
14
+ import { WorktreeManager } from "../services/worktree-manager";
15
+ import { resolveWorktreesRoot } from "../storage/paths";
16
+ import { resolveRepoIdentity } from "../storage/repo-identity";
17
+ import type { RepoIdentity } from "../storage/types";
18
+
19
+ /** Pedido de resolução (flags do CLI). */
20
+ export type WorkspaceRequest = {
21
+ cwd: string;
22
+ base: string;
23
+ head: string;
24
+ branch?: string;
25
+ /** Número da PR (`--pr`). */
26
+ pr?: number;
27
+ /** Worktree já existente (`--worktree`). */
28
+ worktree?: string;
29
+ };
30
+
31
+ /** Dependências injetáveis (testes). */
32
+ export type WorkspaceDeps = {
33
+ ghRunner?: GhRunner;
34
+ /** `config.worktrees.location` (default: `workspace`). */
35
+ worktreesLocation?: string;
36
+ /** Override explícito da raiz de worktrees (testes). */
37
+ worktreesRoot?: string;
38
+ };
39
+
40
+ /** Workspace resolvido para a revisão. */
41
+ export type ResolvedWorkspace = {
42
+ /** Onde os arquivos e o git da revisão são lidos. */
43
+ root: string;
44
+ /** Clone principal (identidade estável + camada/config). */
45
+ mainRoot: string;
46
+ repo: RepoIdentity;
47
+ git: GitRepository;
48
+ branch: string;
49
+ base: string;
50
+ head: string;
51
+ prNumber?: number;
52
+ worktreePath?: string;
53
+ /** Avisos para o CLI (fetch parcial, reuso etc.). */
54
+ messages: string[];
55
+ };
56
+
57
+ /**
58
+ * Raiz do clone principal a partir de um worktree linkado.
59
+ * `--git-common-dir` aponta para `<main>/.git`; qualquer outro formato usa `fallback`.
60
+ */
61
+ export function mainRootOf(git: GitRepository, fallback: string): string {
62
+ const common = git.gitCommonDir();
63
+ if (!common) return fallback;
64
+ const normalized = common.replace(/[\\/]+$/, "");
65
+ return basename(normalized).toLowerCase() === ".git" ? dirname(normalized) : fallback;
66
+ }
67
+
68
+ /** Cria um repositório git enraizado em `root`. */
69
+ function repoAt(root: string): GitRepository {
70
+ return new GitRepository(createGitRunner(root));
71
+ }
72
+
73
+ /** Compara paths levando em conta o filesystem (Windows é case-insensitive). */
74
+ function samePath(a: string, b: string): boolean {
75
+ const norm = (path: string): string => {
76
+ const absolute = resolve(path).replace(/[\\/]+$/, "");
77
+ return process.platform === "win32" ? absolute.toLowerCase() : absolute;
78
+ };
79
+ return norm(a) === norm(b);
80
+ }
81
+
82
+ /** Resolve o workspace efetivo da revisão. */
83
+ export function resolveReviewWorkspace(
84
+ request: WorkspaceRequest,
85
+ deps: WorkspaceDeps = {},
86
+ ): ResolvedWorkspace {
87
+ const messages: string[] = [];
88
+ const cwdGit = repoAt(request.cwd);
89
+ const cwdRoot = resolve(cwdGit.rootPath() ?? request.cwd);
90
+ const mainRoot = resolve(mainRootOf(cwdGit, cwdRoot));
91
+ const mainGit = repoAt(mainRoot);
92
+
93
+ // Worktree explícito: a revisão roda onde o usuário mandou.
94
+ if (request.worktree) {
95
+ const path = resolve(request.cwd, request.worktree);
96
+ const git = repoAt(path);
97
+ const root = git.rootPath();
98
+ if (!root) {
99
+ throw new Error(`'${path}' não é um worktree/repositório git válido.`);
100
+ }
101
+ const repo = resolveRepoIdentity(git, root);
102
+ const branch = request.branch ?? git.currentBranch();
103
+ if (!branch) {
104
+ throw new Error(
105
+ `Worktree '${root}' está em detached HEAD. Informe --branch <nome>.`,
106
+ );
107
+ }
108
+ return {
109
+ root,
110
+ mainRoot: mainRootOf(git, repo.root),
111
+ repo,
112
+ git,
113
+ branch,
114
+ base: request.base,
115
+ head: request.head,
116
+ worktreePath: root,
117
+ messages,
118
+ };
119
+ }
120
+
121
+ // PR: cria/reusa o worktree detached no commit da PR.
122
+ if (request.pr !== undefined) {
123
+ const repo = resolveRepoIdentity(mainGit, mainRoot);
124
+ const resolver = new PrResolver(
125
+ deps.ghRunner ?? createGhRunner(mainRoot),
126
+ mainGit,
127
+ );
128
+ const resolved = resolver.resolve(request.pr);
129
+ if (!resolved.ok) {
130
+ throw new Error(
131
+ `Não foi possível resolver a PR #${request.pr}: ${resolved.detail}`,
132
+ );
133
+ }
134
+ const { pr } = resolved;
135
+ const refs = resolver.prepareRefs(pr);
136
+ if (refs.warning) messages.push(`GitHub: ${refs.warning}`);
137
+
138
+ const manager = new WorktreeManager(mainGit, {
139
+ repoKey: repo.key,
140
+ repoRoot: mainRoot,
141
+ baseDir:
142
+ deps.worktreesRoot ??
143
+ resolveWorktreesRoot(deps.worktreesLocation, mainRoot),
144
+ });
145
+ const worktree = manager.ensure({ branch: pr.headRefName, ref: refs.head });
146
+ const worktreePath = resolve(worktree.path);
147
+ messages.push(
148
+ `Worktree ${worktree.created ? "criado" : "reutilizado"}: ${worktreePath}`,
149
+ `PR #${pr.number} — ${pr.headRefName} → ${pr.baseRefName} @ ${refs.head.slice(0, 7)}.`,
150
+ );
151
+
152
+ return {
153
+ root: worktreePath,
154
+ mainRoot,
155
+ repo,
156
+ git: repoAt(worktreePath),
157
+ branch: pr.headRefName,
158
+ base: refs.base,
159
+ head: refs.head,
160
+ prNumber: pr.number,
161
+ worktreePath,
162
+ messages,
163
+ };
164
+ }
165
+
166
+ // Já estamos dentro de um worktree: ele é a raiz operacional.
167
+ const repo = resolveRepoIdentity(mainGit, mainRoot);
168
+ if (!samePath(cwdRoot, mainRoot)) {
169
+ const branch = request.branch ?? cwdGit.currentBranch();
170
+ if (!branch) {
171
+ throw new Error(
172
+ `Worktree '${cwdRoot}' está em detached HEAD. Informe --branch <nome>.`,
173
+ );
174
+ }
175
+ messages.push(`Usando o worktree atual: ${cwdRoot}`);
176
+ return {
177
+ root: cwdRoot,
178
+ mainRoot,
179
+ repo,
180
+ git: cwdGit,
181
+ branch,
182
+ base: request.base,
183
+ head: request.head,
184
+ worktreePath: cwdRoot,
185
+ messages,
186
+ };
187
+ }
188
+
189
+ // Clone principal.
190
+ const branch = request.branch ?? mainGit.currentBranch();
191
+ if (!branch) {
192
+ throw new Error("Não foi possível detectar a branch. Use --branch <nome>.");
193
+ }
194
+ return {
195
+ root: mainRoot,
196
+ mainRoot,
197
+ repo,
198
+ git: mainGit,
199
+ branch,
200
+ base: request.base,
201
+ head: request.head,
202
+ messages,
203
+ };
204
+ }
@@ -24,8 +24,15 @@ export type GitRunner = (args: string[]) => GitResult;
24
24
  * certo. Sem `cwd`, herda o diretório do processo (default).
25
25
  */
26
26
  export function createGitRunner(cwd?: string): GitRunner {
27
+ // `core.longpaths` evita "Filename too long" no checkout de worktrees
28
+ // dentro de caminhos fundos (MAX_PATH do Windows). O `-c` é propagado aos
29
+ // subprocessos do git via GIT_CONFIG_PARAMETERS.
30
+ const config =
31
+ process.platform === "win32"
32
+ ? ["-c", "core.quotepath=false", "-c", "core.longpaths=true"]
33
+ : ["-c", "core.quotepath=false"];
27
34
  return (args) => {
28
- const result = Bun.spawnSync(["git", "-c", "core.quotepath=false", ...args], {
35
+ const result = Bun.spawnSync(["git", ...config, ...args], {
29
36
  cwd,
30
37
  stdout: "pipe",
31
38
  stderr: "pipe",
@@ -9,6 +9,7 @@
9
9
  */
10
10
 
11
11
  import { readFileSync } from "node:fs";
12
+ import { join } from "node:path";
12
13
  import { collectDiff, type DiffSource } from "../diff-source";
13
14
  import type { ClassificationResult } from "../file-classifier";
14
15
  import type { WatchdogMeta } from "../types";
@@ -47,11 +48,18 @@ export class JevDetector {
47
48
 
48
49
  private readonly client: JevClient;
49
50
  private readonly questions: JevQuestionFactory;
51
+ /** Raiz dos arquivos (worktree ou clone principal). */
52
+ private readonly root: string;
50
53
 
51
54
  /** Construtor que recebe o cliente e o registry de watchdogs (injeção). */
52
- constructor(client: JevClient, registry: Record<string, WatchdogMeta>) {
55
+ constructor(
56
+ client: JevClient,
57
+ registry: Record<string, WatchdogMeta>,
58
+ root: string = process.cwd(),
59
+ ) {
53
60
  this.client = client;
54
61
  this.questions = new JevQuestionFactory(registry);
62
+ this.root = root;
55
63
  }
56
64
 
57
65
  /** Roda a pré-análise e devolve os candidatos (ordenados por confiança desc). */
@@ -157,7 +165,7 @@ export class JevDetector {
157
165
 
158
166
  private readHeader(filePath: string): string {
159
167
  try {
160
- return readFileSync(filePath, "utf8")
168
+ return readFileSync(join(this.root, filePath), "utf8")
161
169
  .split("\n")
162
170
  .slice(0, JevDetector.HEADER_LINES)
163
171
  .join("\n");
@@ -16,13 +16,16 @@
16
16
  * review.<watchdog>.md, context e context-summary.
17
17
  */
18
18
 
19
- import { readFileSync } from "node:fs";
19
+ import { existsSync, readFileSync } from "node:fs";
20
+ import { join } from "node:path";
20
21
  import type { AiClient } from "./ai/ai-client";
21
22
  import { AiBriefEnricher } from "./ai/features/brief-enricher";
22
23
  import { AiClassifier } from "./ai/features/classifier";
23
24
  import { AiContextSummarizer } from "./ai/features/context-summarizer";
24
25
  import { AiPreAnalyzer } from "./ai/features/pre-analyzer";
26
+ import { AiTechnicalSummarizer } from "./ai/features/technical-summarizer";
25
27
  import type { BriefPlan } from "./ai/schemas";
28
+ import { assetPath, OUTPUT_FORMAT_FILE } from "./assets";
26
29
  import type { AstService } from "./ast/service";
27
30
  import type { AstAnalysis, AstFileBrief, AstImpact } from "./ast/types";
28
31
  import {
@@ -39,7 +42,6 @@ import { ReviewScopeResolver, type RunClassification } from "./engine/review-sco
39
42
  import { ENV_NAMES } from "./env";
40
43
  import type { BudgetSelection, ClassificationResult } from "./file-classifier";
41
44
  import { buildStateFromFindings, fileReader } from "./findings-state";
42
- import { resolveSha } from "./git-revision";
43
45
  import { syncResolvedThreads } from "./github-review";
44
46
  import {
45
47
  type JevCandidate,
@@ -55,8 +57,8 @@ import {
55
57
  OUTPUT_FORMAT_PATH,
56
58
  REVIEW_FILE,
57
59
  REVIEW_RESUME_FILE,
60
+ TECHNICAL_SUMMARY_FILE,
58
61
  } from "./paths";
59
- import { currentBranch } from "./pr-context";
60
62
  import { ProjectConfig } from "./project-config";
61
63
  import { BriefRenderer } from "./renderers/brief-renderer";
62
64
  import {
@@ -76,8 +78,14 @@ import {
76
78
  } from "./report-builder";
77
79
  import { writeRunStatus } from "./run-status";
78
80
  import { systemClock } from "./services/clock";
81
+ import { GitRepository } from "./services/git-repository";
79
82
  import type { ReviewRecord, StorageProvider } from "./storage/types";
80
83
  import { ReviewWorkArea } from "./storage/work-area";
84
+ import {
85
+ buildTechnicalSummary,
86
+ parseTechnicalSummary,
87
+ type TechnicalSummary,
88
+ } from "./technical-summary";
81
89
  import type { WatchdogMeta } from "./types";
82
90
  import { findWatchdog } from "./watchdog-registry";
83
91
 
@@ -95,6 +103,12 @@ export type ReviewOptions = {
95
103
  full: boolean;
96
104
  /** Desativa as features de IA do fluxo normal (hunt não usa esta flag). */
97
105
  noAi: boolean;
106
+ /** PR de origem (resolvida via `--pr`). */
107
+ pr?: number;
108
+ /** Worktree informado via flag (`--worktree`). */
109
+ worktree?: string;
110
+ /** Worktree resolvido (absoluto; ausente = clone principal). */
111
+ worktreePath?: string;
98
112
  };
99
113
 
100
114
  /** Valores default das opções (o CLI sobrepõe com as flags). */
@@ -112,19 +126,49 @@ export function defaultOptions(): ReviewOptions {
112
126
  };
113
127
  }
114
128
 
115
- /** Contexto de uma run: storage + review + staging. */
129
+ /** Workspace onde os arquivos da review são lidos. */
130
+ export type ReviewWorkspaceContext = {
131
+ /** Raiz operacional (worktree ou clone principal). */
132
+ root?: string;
133
+ /** Raiz do clone principal (identidade/config). */
134
+ mainRoot?: string;
135
+ git?: GitRepository;
136
+ };
137
+
138
+ /** Contexto de uma run: storage + review + staging + raiz operacional. */
116
139
  export type ReviewContext = {
117
140
  storage: StorageProvider;
118
141
  review: ReviewRecord;
119
142
  work: ReviewWorkArea;
143
+ root: string;
144
+ mainRoot: string;
145
+ git: GitRepository;
120
146
  };
121
147
 
122
148
  /** Cria o contexto de uma review existente. */
123
149
  export function reviewContext(
124
150
  storage: StorageProvider,
125
151
  review: ReviewRecord,
152
+ workspace: ReviewWorkspaceContext = {},
126
153
  ): ReviewContext {
127
- return { storage, review, work: new ReviewWorkArea(review) };
154
+ const root = workspace.root ?? process.cwd();
155
+ return {
156
+ storage,
157
+ review,
158
+ work: new ReviewWorkArea(review),
159
+ root,
160
+ mainRoot: workspace.mainRoot ?? root,
161
+ git: workspace.git ?? new GitRepository(),
162
+ };
163
+ }
164
+
165
+ /**
166
+ * Schema do output: relativo à raiz da review quando a camada existe; no
167
+ * worktree sem camada, cai para o asset embarcado (caminho absoluto legível).
168
+ */
169
+ function outputFormatPathFor(ctx: ReviewContext): string {
170
+ if (existsSync(join(ctx.root, OUTPUT_FORMAT_PATH))) return OUTPUT_FORMAT_PATH;
171
+ return ctx.review.worktreePath ? assetPath(OUTPUT_FORMAT_FILE) : OUTPUT_FORMAT_PATH;
128
172
  }
129
173
 
130
174
  // ---------------------------------------------------------------------------
@@ -225,7 +269,8 @@ function writeBriefs(
225
269
  deleted: classification.deleted,
226
270
  branch,
227
271
  outputPath: work.findingFile(id),
228
- outputFormatPath: OUTPUT_FORMAT_PATH,
272
+ outputFormatPath: outputFormatPathFor(ctx),
273
+ worktree: ctx.review.worktreePath,
229
274
  docs,
230
275
  context,
231
276
  candidates: candidates.filter((c) => c.watchdogId === id),
@@ -277,7 +322,7 @@ async function resolveCandidates(
277
322
  const def = findWatchdog(config, id);
278
323
  if (!def) continue;
279
324
  const inputs = files.slice(0, 5).flatMap((file) => {
280
- const snippet = readSnippet(file);
325
+ const snippet = readSnippet(ctx.root, file);
281
326
  return snippet ? [{ file, snippet }] : [];
282
327
  });
283
328
  candidates.push(
@@ -314,6 +359,7 @@ async function resolveCandidates(
314
359
  const detector = new JevDetector(
315
360
  client,
316
361
  config.watchdogs as Record<string, WatchdogMeta>,
362
+ ctx.root,
317
363
  );
318
364
  const result = await detector.detect(classification, opts.base, opts.head, {
319
365
  diff,
@@ -377,9 +423,10 @@ function budgetMessage(
377
423
  }
378
424
 
379
425
  /** Primeiras 80 linhas de um arquivo (pré-análise por IA; fail-open). */
380
- function readSnippet(file: string): string | null {
426
+ function readSnippet(root: string, file: string): string | null {
381
427
  try {
382
- return readFileSync(file, "utf8").split("\n").slice(0, 80).join("\n");
428
+ const path = join(root, file);
429
+ return readFileSync(path, "utf8").split("\n").slice(0, 80).join("\n");
383
430
  } catch {
384
431
  return null;
385
432
  }
@@ -443,6 +490,50 @@ async function resolveContextSummary(
443
490
  return summary;
444
491
  }
445
492
 
493
+ /**
494
+ * Pontos técnicos estruturais: IA quando disponível e, sempre, um fallback
495
+ * determinístico a partir da classificação/AST. Persistido para auditoria —
496
+ * o `finalize` apenas o consome.
497
+ */
498
+ async function resolveTechnicalSummary(
499
+ ctx: ReviewContext,
500
+ ai: AiClient | undefined,
501
+ opts: ReviewOptions,
502
+ input: {
503
+ branch: string;
504
+ classification: RunClassification;
505
+ context: ContextSummary | null;
506
+ ast?: ReadonlyMap<string, AstAnalysis>;
507
+ impact?: AstImpact;
508
+ domains: readonly string[];
509
+ },
510
+ ): Promise<TechnicalSummary> {
511
+ const fallback = buildTechnicalSummary({
512
+ classification: input.classification,
513
+ ast: input.ast,
514
+ impact: input.impact,
515
+ headSha: input.classification.headSha,
516
+ });
517
+ const summary =
518
+ ai && !opts.noAi
519
+ ? ((await new AiTechnicalSummarizer(ai).summarize({
520
+ branch: input.branch,
521
+ context: input.context,
522
+ classification: input.classification,
523
+ ast: input.ast,
524
+ impact: input.impact,
525
+ headSha: input.classification.headSha,
526
+ domains: input.domains,
527
+ })) ?? fallback)
528
+ : fallback;
529
+ ctx.storage.writeArtifact(ctx.review.id, {
530
+ name: TECHNICAL_SUMMARY_FILE,
531
+ kind: "technical",
532
+ content: `${JSON.stringify(summary, null, 2)}\n`,
533
+ });
534
+ return summary;
535
+ }
536
+
446
537
  /**
447
538
  * Planos de revisão por watchdog (uma chamada por brief, concorrência
448
539
  * limitada). Sem IA/chave/feature, devolve mapa vazio e o brief fica igual.
@@ -507,12 +598,13 @@ export async function prepare(
507
598
  ctx.storage.updateReview(ctx.review.id, { base: opts.base, head: opts.head });
508
599
 
509
600
  // Marca o commit revisado e decide entre completo, incremental ou no-op.
510
- const headSha = resolveSha(opts.head);
601
+ const headSha = ctx.git.resolveSha(opts.head);
511
602
  const previous = ctx.storage.readFindings(ctx.review.id);
512
603
  const scope = await new ReviewScopeResolver(
513
604
  opts,
514
605
  config,
515
606
  opts.noAst ? undefined : ast,
607
+ ctx.git,
516
608
  ).resolve(previous, headSha);
517
609
 
518
610
  // Mesmo commit já revisado: preserva artefatos e triagem, sem re-revisar.
@@ -533,13 +625,30 @@ export async function prepare(
533
625
  return { messages };
534
626
  }
535
627
 
628
+ const { classification, diff } = scope;
629
+
630
+ // Run recém-criada e sem nenhum arquivo alterado: não persiste uma "run
631
+ // vazia" na lista. Acontece quando o comando roda no clone principal sem
632
+ // `--pr`/`--worktree` (ex.: `houndsman` na `main` logo após revisar uma PR).
633
+ // Run com artefatos anteriores (contexto, revisão) continua existindo.
634
+ if (Object.keys(classification.files).length === 0) {
635
+ const fresh = ctx.storage.listArtifacts(ctx.review.id).length === 0;
636
+ if (fresh) {
637
+ ctx.work.clean();
638
+ ctx.storage.deleteReview(ctx.review.id);
639
+ messages.push(
640
+ `Nenhuma alteração entre ${opts.base}...${opts.head} — nenhuma run criada.`,
641
+ );
642
+ return { messages };
643
+ }
644
+ }
645
+
536
646
  // Marca o run como "revisando" (reinicia o ciclo) — a UI mostra o status.
537
647
  writeRunStatus(ctx.storage, ctx.review.id, {
538
648
  status: "reviewing",
539
649
  resetStartedAt: true,
540
650
  });
541
651
 
542
- const { classification, diff } = scope;
543
652
  await applyAiClassification(classification, config, ai, opts);
544
653
  ctx.storage.writeArtifact(ctx.review.id, {
545
654
  name: "classification.json",
@@ -604,6 +713,14 @@ export async function prepare(
604
713
 
605
714
  persistContext(ctx);
606
715
  const context = await resolveContextSummary(ctx, ai, opts);
716
+ await resolveTechnicalSummary(ctx, ai, opts, {
717
+ branch,
718
+ classification,
719
+ context,
720
+ ast: astAnalyses,
721
+ impact: astImpact,
722
+ domains: activeIds.map((id) => findWatchdog(config, id)?.name ?? id),
723
+ });
607
724
 
608
725
  const manifestAst: ManifestAst | undefined = astAnalyses
609
726
  ? {
@@ -812,6 +929,40 @@ function readPersistedContextSummary(ctx: ReviewContext): ContextSummary | null
812
929
  }
813
930
  }
814
931
 
932
+ /** JSON persistido, ou `null` quando ausente/inválido. */
933
+ function readArtifactJson<T>(ctx: ReviewContext, name: string): T | null {
934
+ const artifact = ctx.storage.readArtifact(ctx.review.id, name);
935
+ if (!artifact) return null;
936
+ try {
937
+ return JSON.parse(artifact.content) as T;
938
+ } catch {
939
+ return null;
940
+ }
941
+ }
942
+
943
+ /** Pontos técnicos persistidos pelo `prepare`; `null` quando ausentes. */
944
+ function readPersistedTechnicalSummary(ctx: ReviewContext): TechnicalSummary | null {
945
+ const raw = readArtifactJson<unknown>(ctx, TECHNICAL_SUMMARY_FILE);
946
+ return raw ? parseTechnicalSummary(raw) : null;
947
+ }
948
+
949
+ /** Fallback para runs antigas: reconstrói os destaques dos artefatos de AST. */
950
+ function buildTechnicalFallback(
951
+ ctx: ReviewContext,
952
+ classification: RunClassification,
953
+ ): TechnicalSummary {
954
+ const ast = readArtifactJson<{ files?: Record<string, AstAnalysis> }>(
955
+ ctx,
956
+ "ast-summary.json",
957
+ );
958
+ return buildTechnicalSummary({
959
+ classification,
960
+ ast: ast?.files ? new Map(Object.entries(ast.files)) : undefined,
961
+ impact: readArtifactJson<AstImpact>(ctx, "ast-impact.json") ?? undefined,
962
+ headSha: classification.headSha,
963
+ });
964
+ }
965
+
815
966
  /**
816
967
  * Grava um `review.<watchdog>.md` por watchdog com achados e devolve o índice.
817
968
  * Mantém o `review.md` consolidado navegável em PRs grandes (centenas de achados).
@@ -937,7 +1088,7 @@ export function finalize(
937
1088
  // (`reviewed.headSha`) habilita o review incremental e o diff focado do verify.
938
1089
  const state = buildStateFromFindings(merged, {
939
1090
  branch,
940
- readFile: fileReader(process.cwd()),
1091
+ readFile: fileReader(ctx.root),
941
1092
  previous: ctx.storage.readFindings(ctx.review.id),
942
1093
  reviewedPaths: new Set([
943
1094
  ...reports.flatMap((report) => report.reviewedFiles),
@@ -987,6 +1138,8 @@ export function finalize(
987
1138
 
988
1139
  persistContext(ctx);
989
1140
  const context = ctx.work.readContextSummary() ?? readPersistedContextSummary(ctx);
1141
+ const technical =
1142
+ readPersistedTechnicalSummary(ctx) ?? buildTechnicalFallback(ctx, classification);
990
1143
  const nameOf = (id: string): string => findWatchdog(config, id)?.name ?? id;
991
1144
  const domains = writeDomainReports(
992
1145
  ctx,
@@ -1020,7 +1173,7 @@ export function finalize(
1020
1173
  context,
1021
1174
  classification,
1022
1175
  findings: merged,
1023
- nameOf,
1176
+ technical,
1024
1177
  }),
1025
1178
  });
1026
1179
 
@@ -1042,6 +1195,14 @@ export function finalize(
1042
1195
  }
1043
1196
 
1044
1197
  /** Resolve a branch (opção explícita ou a atual do git). */
1045
- export function resolveBranch(opts: ReviewOptions): string {
1046
- return opts.branch ?? currentBranch();
1198
+ export function resolveBranch(
1199
+ opts: ReviewOptions,
1200
+ git: GitRepository = new GitRepository(),
1201
+ ): string {
1202
+ if (opts.branch) return opts.branch;
1203
+ const branch = git.currentBranch();
1204
+ if (!branch) {
1205
+ throw new Error("Não foi possível detectar a branch. Use --branch <nome>.");
1206
+ }
1207
+ return branch;
1047
1208
  }
package/src/core/paths.ts CHANGED
@@ -55,3 +55,6 @@ export const REVIEW_FILE = "review.md";
55
55
 
56
56
  /** Resumo final da review (artefato `review-resume.md`) — leitura humana apenas. */
57
57
  export const REVIEW_RESUME_FILE = "review-resume.md";
58
+
59
+ /** Pontos técnicos estruturais usados no resumo final (`technical-summary.json`). */
60
+ export const TECHNICAL_SUMMARY_FILE = "technical-summary.json";
@@ -229,13 +229,17 @@ export function renderContextBrief(
229
229
 
230
230
  lines.push("## Regras", "");
231
231
  lines.push(
232
- "- **Seja curto** — o resumo abre o relatório e entra em **todos** os briefs.",
232
+ "- O resumo abre o relatório e o resumo final; nos briefs ele é recortado.",
233
233
  );
234
234
  lines.push("- Baseie-se **somente** no `context.json` — não invente intenção.");
235
- lines.push("- `objective`: 1 frase (≤ 200 caracteres) sobre o que a PR entrega.");
236
- lines.push("- `motivation`: 1 frase (≤ 240 caracteres) sobre o porquê.");
237
235
  lines.push(
238
- "- `changeSummary`: no máximo 4 bullets (≤ 160 caracteres cada), focados em comportamento — não liste arquivos.",
236
+ "- `objective`: até 2 frases (≤ 400 caracteres) sobre o que a PR entrega.",
237
+ );
238
+ lines.push(
239
+ "- `motivation`: até 3 frases (≤ 480 caracteres) sobre o porquê e o impacto.",
240
+ );
241
+ lines.push(
242
+ "- `changeSummary`: no máximo 6 bullets (≤ 240 caracteres cada), focados em comportamento — não liste arquivos.",
239
243
  );
240
244
  lines.push(
241
245
  "- NÃO copie a descrição da PR: sintetize. Copie `source` do contexto bruto.",
@@ -253,7 +257,8 @@ const CONTEXT_SUMMARY_TEMPLATE = {
253
257
  base: "main",
254
258
  head: "feature/x",
255
259
  },
256
- objective: "O que a PR entrega, em 1 frase.",
257
- motivation: "Por que a mudança é necessária, em 1-2 frases.",
260
+ objective: "O que a PR entrega, em 1-2 frases.",
261
+ motivation:
262
+ "Por que a mudança é necessária, com o problema e o impacto (até 3 frases).",
258
263
  changeSummary: ["Bullet de comportamento 1", "Bullet de comportamento 2"],
259
264
  };