@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
package/README.md CHANGED
@@ -98,6 +98,26 @@ bun run houndsman hunt --fail-on-blocking
98
98
 
99
99
  Detalhes (catálogo de tools, máquina de estados, custos e segurança): [`docs/HARNESS.md`](docs/HARNESS.md).
100
100
 
101
+ ### 8. Várias PRs ao mesmo tempo (worktrees)
102
+
103
+ Cada revisão pode rodar em um **worktree dedicado** (detached no commit da PR), sem
104
+ checkout no clone principal e sem bloquear a revisão das outras PRs:
105
+
106
+ ```bash
107
+ houndsman --pr 123 # cria/reusa o worktree e prepara os briefs
108
+ houndsman hunt --pr 123 # revisão nativa no mesmo worktree
109
+ houndsman --pr 123 --finalize # consolida o relatório
110
+ houndsman worktree list # worktrees ativos
111
+ houndsman worktree remove feature/x
112
+ ```
113
+
114
+ Os worktrees ficam em `<repo>/.houndsman/worktrees/<repo>/<branch>` (ignorados pelo git) e
115
+ são removidos manualmente (ou com `houndsman worktree prune`). Ficam **dentro do
116
+ workspace** de propósito: agentes de codificação com permissão escopada à pasta aberta
117
+ conseguem ler/executar sem pedir autorização. Para voltar ao diretório global, use
118
+ `worktrees: { location: "home" }` no `houndsman.config.ts`. A UI (`houndsman server`)
119
+ mostra o worktree ativo de cada PR e permite criá-lo pela sidebar.
120
+
101
121
  ---
102
122
 
103
123
  ## O fluxo de trabalho
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@payconductor/houndsman",
3
- "version": "0.7.0",
3
+ "version": "0.10.0",
4
4
  "description": "Orquestrador de revisão de Pull Requests: classifica o diff, despacha watchdogs especializados por domínio e consolida o relatório.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -5,14 +5,23 @@
5
5
  * exit code. O engine é carregado sob demanda (comandos sem projeto não pagam).
6
6
  */
7
7
 
8
+ import type { HoundsmanConfig } from "../../core/config";
8
9
  import type { HoundsmanEngine } from "../../core/engine/houndsman-engine";
9
10
  import type { LoggerLike } from "../../core/logging";
10
11
 
12
+ /** Raiz operacional pedida ao engine (worktree/clone principal). */
13
+ export type EngineLoadOptions = {
14
+ root?: string;
15
+ mainRoot?: string;
16
+ };
17
+
11
18
  /** Contexto de execução de um comando. */
12
19
  export type CliContext = {
13
20
  cwd: string;
14
21
  /** Carrega a config do projeto e devolve o engine (lazy). */
15
- engine: () => Promise<HoundsmanEngine>;
22
+ engine: (options?: EngineLoadOptions) => Promise<HoundsmanEngine>;
23
+ /** Config do projeto (mesmo carregamento cacheado do engine). */
24
+ config: () => Promise<HoundsmanConfig>;
16
25
  /** Logger único do CLI (cores/spinner/progresso). */
17
26
  logger: LoggerLike;
18
27
  /** @deprecated use `logger.info`. */
@@ -1,6 +1,7 @@
1
1
  /** Comando `context` — coleta o contexto da PR via `gh` (Passo 0). */
2
2
 
3
3
  import { runContextCommand } from "../../core/commands/context";
4
+ import { resolveReviewWorkspace } from "../../core/engine/review-workspace";
4
5
  import { displayPath } from "../../core/paths";
5
6
  import { ArgParser } from "../arg-parser";
6
7
  import type { CliContext, Command } from "./command";
@@ -9,13 +10,50 @@ export class ContextCommand implements Command {
9
10
  readonly name = "context";
10
11
 
11
12
  async run(args: string[], context: CliContext): Promise<number> {
12
- const engine = await context.engine();
13
13
  const parser = ArgParser.from(args);
14
+ const prFlag = parser.value("--pr");
15
+ const worktreeFlag = parser.value("--worktree");
16
+ const prNumber = prFlag ? Number.parseInt(prFlag, 10) : undefined;
17
+ if (prFlag && (!Number.isFinite(prNumber) || (prNumber ?? 0) <= 0)) {
18
+ context.err(`--pr inválido: '${prFlag}'.`);
19
+ return 1;
20
+ }
21
+ if (prNumber !== undefined && worktreeFlag !== undefined) {
22
+ context.err("Use --pr ou --worktree, não os dois.");
23
+ return 1;
24
+ }
25
+
26
+ const config = await context.config();
27
+ const workspace =
28
+ prNumber !== undefined || worktreeFlag !== undefined
29
+ ? resolveReviewWorkspace(
30
+ {
31
+ cwd: context.cwd,
32
+ base: "main",
33
+ head: "HEAD",
34
+ branch: parser.value("--branch"),
35
+ pr: prNumber,
36
+ worktree: worktreeFlag,
37
+ },
38
+ { worktreesLocation: config.worktrees?.location },
39
+ )
40
+ : undefined;
41
+ for (const message of workspace?.messages ?? []) context.out(message);
42
+
43
+ const engine = await context.engine(
44
+ workspace
45
+ ? { root: workspace.root, mainRoot: workspace.mainRoot }
46
+ : undefined,
47
+ );
14
48
  const { paths, context: pr } = runContextCommand(
15
49
  engine.config,
16
50
  engine.storage,
17
51
  engine.repo,
18
- { branch: parser.value("--branch") },
52
+ {
53
+ branch: workspace?.branch ?? parser.value("--branch"),
54
+ worktreePath: workspace?.worktreePath,
55
+ prNumber: workspace?.prNumber,
56
+ },
19
57
  );
20
58
 
21
59
  if (pr.available) {
@@ -1,5 +1,6 @@
1
1
  /** Comando `hunt` — revisão nativa com IA (harness próprio). */
2
2
 
3
+ import { resolveReviewWorkspace } from "../../core/engine/review-workspace";
3
4
  import { HuntHarness } from "../../harness";
4
5
  import { parseHuntArgs } from "../../harness/hunt/hunt-options";
5
6
  import type { CliContext, Command } from "./command";
@@ -10,9 +11,34 @@ export class HuntCommand implements Command {
10
11
 
11
12
  async run(args: string[], context: CliContext): Promise<number> {
12
13
  const options = parseHuntArgs(args);
13
- const engine = await context.engine();
14
+ const config = await context.config();
15
+ const workspace = resolveReviewWorkspace(
16
+ {
17
+ cwd: context.cwd,
18
+ base: options.base,
19
+ head: options.head,
20
+ branch: options.branch,
21
+ pr: options.pr,
22
+ worktree: options.worktree,
23
+ },
24
+ { worktreesLocation: config.worktrees?.location },
25
+ );
26
+ for (const message of workspace.messages) context.logger.info(message);
27
+
28
+ const engine = await context.engine({
29
+ root: workspace.root,
30
+ mainRoot: workspace.mainRoot,
31
+ });
32
+ const resolved = {
33
+ ...options,
34
+ branch: workspace.branch,
35
+ base: workspace.base,
36
+ head: workspace.head,
37
+ pr: workspace.prNumber,
38
+ worktreePath: workspace.worktreePath,
39
+ };
14
40
  try {
15
- const harness = await HuntHarness.from(engine, options, context.logger);
41
+ const harness = await HuntHarness.from(engine, resolved, context.logger);
16
42
  const result = await harness.run();
17
43
  for (const message of result.messages) context.logger.info(message);
18
44
  if (result.status === "failed") return 1;
@@ -1,15 +1,43 @@
1
1
  /** Comando default — prepara ou finaliza a revisão. */
2
2
 
3
3
  import { parseReviewArgs } from "../../core/commands/review";
4
+ import { resolveReviewWorkspace } from "../../core/engine/review-workspace";
4
5
  import type { CliContext, Command } from "./command";
5
6
 
6
7
  export class ReviewCommand implements Command {
7
8
  readonly name = "";
8
9
 
9
10
  async run(args: string[], context: CliContext): Promise<number> {
10
- const engine = await context.engine();
11
11
  const options = parseReviewArgs(args);
12
- const result = await engine.session(options).run();
12
+ const config = await context.config();
13
+ const workspace = resolveReviewWorkspace(
14
+ {
15
+ cwd: context.cwd,
16
+ base: options.base,
17
+ head: options.head,
18
+ branch: options.branch,
19
+ pr: options.pr,
20
+ worktree: options.worktree,
21
+ },
22
+ { worktreesLocation: config.worktrees?.location },
23
+ );
24
+ for (const message of workspace.messages) context.out(message);
25
+
26
+ // Sem `--pr`/`--worktree`, o engine usa o workspace gravado da review
27
+ // (finalize após um prepare feito em worktree, por exemplo).
28
+ const explicit = options.pr !== undefined || options.worktree !== undefined;
29
+ const engine = await context.engine(
30
+ explicit ? { root: workspace.root, mainRoot: workspace.mainRoot } : undefined,
31
+ );
32
+ const resolved = {
33
+ ...options,
34
+ branch: workspace.branch,
35
+ base: workspace.base,
36
+ head: workspace.head,
37
+ pr: workspace.prNumber,
38
+ worktreePath: workspace.worktreePath,
39
+ };
40
+ const result = await engine.session(resolved).run();
13
41
 
14
42
  for (const message of result.messages) context.out(message);
15
43
 
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Comando `worktree` — gerencia os worktrees de revisão.
3
+ *
4
+ * `list` (default) · `add --pr <n>` · `add --branch <b> [--ref <r>]` ·
5
+ * `remove <branch|path> [--force]` · `prune` · `path <branch>`.
6
+ */
7
+
8
+ import { resolveReviewWorkspace } from "../../core/engine/review-workspace";
9
+ import { WorktreeManager } from "../../core/services/worktree-manager";
10
+ import { resolveWorktreesRoot } from "../../core/storage/paths";
11
+ import { ArgParser } from "../arg-parser";
12
+ import type { CliContext, Command } from "./command";
13
+
14
+ /** Executa a manutenção dos worktrees. */
15
+ export class WorktreeCommand implements Command {
16
+ readonly name = "worktree";
17
+
18
+ async run(args: string[], context: CliContext): Promise<number> {
19
+ const parser = ArgParser.from(args);
20
+ const action = parser.command() ?? "list";
21
+ const config = await context.config();
22
+ const engine = await context.engine();
23
+ const manager = new WorktreeManager(engine.git, {
24
+ repoKey: engine.repo.key,
25
+ repoRoot: engine.mainRoot,
26
+ baseDir: resolveWorktreesRoot(config.worktrees?.location, engine.mainRoot),
27
+ });
28
+
29
+ switch (action) {
30
+ case "list": {
31
+ const entries = manager.managed();
32
+ if (entries.length === 0) {
33
+ context.out(`Nenhum worktree em ${manager.baseDir}.`);
34
+ return 0;
35
+ }
36
+ context.out(`Worktrees em ${manager.baseDir}:`);
37
+ for (const entry of entries) {
38
+ const branch = entry.branch
39
+ ? entry.branch.replace(/^refs\/heads\//, "")
40
+ : "(detached)";
41
+ context.out(
42
+ `- ${branch} · ${entry.path}${entry.prunable ? " · órfão" : ""}`,
43
+ );
44
+ }
45
+ return 0;
46
+ }
47
+ case "add": {
48
+ const pr = parser.value("--pr");
49
+ if (pr) {
50
+ const number = Number.parseInt(pr, 10);
51
+ if (!Number.isFinite(number) || number <= 0) {
52
+ context.err(`--pr inválido: '${pr}'.`);
53
+ return 1;
54
+ }
55
+ const workspace = resolveReviewWorkspace(
56
+ {
57
+ cwd: context.cwd,
58
+ base: "main",
59
+ head: "HEAD",
60
+ pr: number,
61
+ },
62
+ { worktreesLocation: config.worktrees?.location },
63
+ );
64
+ for (const message of workspace.messages) context.out(message);
65
+ context.out(workspace.worktreePath ?? workspace.root);
66
+ return 0;
67
+ }
68
+
69
+ const branch = parser.value("--branch");
70
+ if (!branch) {
71
+ context.err("Informe --pr <n> ou --branch <nome>.");
72
+ return 1;
73
+ }
74
+ const ref = parser.value("--ref") ?? branch;
75
+ const result = manager.ensure({ branch, ref });
76
+ context.out(
77
+ `${result.created ? "Criado" : "Reutilizado"}: ${result.path} @ ${result.head.slice(0, 7)}`,
78
+ );
79
+ return 0;
80
+ }
81
+ case "remove": {
82
+ const target = parser.positionals()[1] ?? parser.value("--branch");
83
+ if (!target) {
84
+ context.err("Informe a branch ou o caminho do worktree.");
85
+ return 1;
86
+ }
87
+ const result = manager.remove(target, { force: parser.has("--force") });
88
+ if (!result.removed) {
89
+ context.err(
90
+ `Worktree não removido: ${result.detail ?? "desconhecido"}.`,
91
+ );
92
+ return 1;
93
+ }
94
+ context.out(`Worktree removido: ${target}.`);
95
+ return 0;
96
+ }
97
+ case "prune": {
98
+ manager.prune();
99
+ context.out(`Worktrees órfãos removidos em ${manager.baseDir}.`);
100
+ return 0;
101
+ }
102
+ case "path": {
103
+ const branch = parser.positionals()[1] ?? parser.value("--branch");
104
+ if (!branch) {
105
+ context.err("Informe a branch.");
106
+ return 1;
107
+ }
108
+ context.out(manager.pathFor(branch));
109
+ return 0;
110
+ }
111
+ default:
112
+ context.err(
113
+ `Ação desconhecida em 'worktree': '${action}'. Use list, add, remove, prune ou path.`,
114
+ );
115
+ return 1;
116
+ }
117
+ }
118
+ }
package/src/cli/help.ts CHANGED
@@ -4,10 +4,11 @@ export const HELP = `Uso: houndsman [comando] [flags]
4
4
  Comandos:
5
5
  (nenhum) Prepara a revisão (classification + briefs + manifesto)
6
6
  --finalize Consolida os findings em review.md
7
- context Coleta o contexto da PR via gh (Passo 0)
7
+ context Coleta o contexto da PR via gh (Passo 0; --pr/--worktree)
8
8
  hunt Revisão nativa com IA (harness próprio): prepare + agentes + finalize
9
9
  findings <sub> Triagem/correção dos findings (list · show · set · done · reset · sync · summary · batch · verify · submit · path)
10
10
  status [ping] Mostra o status da run (ping renova o heartbeat da execução)
11
+ worktree <ação> Lista/cria/remove worktrees de revisão (list · add · remove · prune · path)
11
12
  server Sobe a UI local de triagem (--port, --host, --open, --editor)
12
13
  setup Cria a configuração inicial do projeto (config + skills)
13
14
  update Sincroniza os arquivos auto-gerados (.houndsman/core)
@@ -20,6 +21,8 @@ Flags:
20
21
  --base <branch> Base do diff (default: main)
21
22
  --head <ref> Head do diff (default: HEAD)
22
23
  --branch <nome> Branch alvo (default: detecta do git)
24
+ --pr <n> Revisa a PR em um worktree dedicado (cria/reusa automaticamente)
25
+ --worktree <path> Revisa em um worktree já existente
23
26
  --clean Descarta findings brutos do staging anterior
24
27
  --full Força review completo (ignora o modo incremental)
25
28
  --no-jev Desativa a pré-análise Jev
@@ -28,7 +31,7 @@ Flags:
28
31
  --fail-on-blocking Exit 1 se houver bloqueantes (com --finalize)
29
32
 
30
33
  Flags do hunt:
31
- --base <b> · --head <ref> · --branch <nome> · --watchdog <id>
34
+ --base <b> · --head <ref> · --branch <nome> · --watchdog <id> · --pr <n> · --worktree <path>
32
35
  --full · --clean · --no-ast · --with-jev
33
36
  --resume · --allow-partial · --dry-run · --fail-on-blocking
34
37
  --max-calls <n> · --concurrency <n> · --provider <p> · --model <m>
@@ -52,6 +55,13 @@ Flags de findings:
52
55
  --dry-run (submit) Só a prévia do envio, sem postar
53
56
  --no-ping (doctor) Pula a validação de rede das credenciais
54
57
 
58
+ Worktrees:
59
+ houndsman worktree list
60
+ houndsman worktree add --pr 123
61
+ houndsman worktree remove <branch> [--force]
62
+ Os worktrees ficam em <repo>/.houndsman/worktrees/<repo>/<branch> (detached).
63
+ Use worktrees.location: "home" na config para voltar ao HOUNDSMAN_HOME.
64
+
55
65
  Persistência:
56
66
  Runs, findings e artefatos ficam no storage global (SQLite em
57
67
  <HOUNDSMAN_HOME>/houndsman.db). Use storage.provider: "file" na config para
package/src/cli/index.ts CHANGED
@@ -27,6 +27,7 @@
27
27
  * `--clean` · `--full` · `--no-jev` · `--no-ast` · `--fail-on-blocking`.
28
28
  */
29
29
 
30
+ import type { HoundsmanConfig } from "../core/config";
30
31
  import { loadConfig } from "../core/config-loader";
31
32
  import { HoundsmanEngine } from "../core/engine/houndsman-engine";
32
33
  import { logger } from "../core/logging";
@@ -43,6 +44,7 @@ import { ServerCommand } from "./commands/server-command";
43
44
  import { SetupCommand, TestCommand, UpdateCommand } from "./commands/setup-command";
44
45
  import { StatusCommand } from "./commands/status-command";
45
46
  import { WatchdogCommand } from "./commands/watchdog-command";
47
+ import { WorktreeCommand } from "./commands/worktree-command";
46
48
  import { HELP } from "./help";
47
49
 
48
50
  // ---------------------------------------------------------------------------
@@ -60,6 +62,7 @@ function buildRegistry(): CommandRegistry {
60
62
  .register(new FindingsCommand())
61
63
  .register(new StatusCommand())
62
64
  .register(new WatchdogCommand())
65
+ .register(new WorktreeCommand())
63
66
  .register(new ServerCommand())
64
67
  .register(new SetupCommand())
65
68
  .register(new UpdateCommand())
@@ -85,16 +88,23 @@ export async function main(): Promise<void> {
85
88
  process.exit(1);
86
89
  }
87
90
 
91
+ let loaded: Awaited<ReturnType<typeof loadConfig>> | null = null;
92
+ const config = async (): Promise<HoundsmanConfig> => {
93
+ loaded ??= await loadConfig({ configPath: parser.value("--config") });
94
+ return loaded.config;
95
+ };
96
+
88
97
  const context: CliContext = {
89
98
  cwd: process.cwd(),
90
99
  logger,
91
100
  out: (message) => logger.info(message),
92
101
  err: (message) => logger.error(message),
93
- engine: async () => {
94
- const { config } = await loadConfig({
95
- configPath: parser.value("--config"),
102
+ config,
103
+ engine: async (options = {}) => {
104
+ return new HoundsmanEngine(await config(), {
105
+ root: options.root,
106
+ mainRoot: options.mainRoot,
96
107
  });
97
- return new HoundsmanEngine(config);
98
108
  },
99
109
  };
100
110
 
@@ -7,6 +7,7 @@
7
7
  */
8
8
 
9
9
  import type { ZodType } from "zod";
10
+ import type { AiProviderOptions } from "../config";
10
11
  import type { ProjectConfig } from "../project-config";
11
12
  import { AiConfigurationError, errorMessage } from "./errors";
12
13
  import { ModelFactory } from "./model-factory";
@@ -33,6 +34,8 @@ export type ObjectRequest<T> = {
33
34
  maxSteps?: number;
34
35
  maxOutputTokens?: number;
35
36
  temperature?: number;
37
+ /** Opções do AI SDK por provider (mescladas com a config do projeto). */
38
+ providerOptions?: AiProviderOptions;
36
39
  signal?: AbortSignal;
37
40
  };
38
41
 
@@ -188,6 +191,10 @@ export class AiClient {
188
191
  const provider = await this.requireProvider(model);
189
192
  const response = await provider.generate({
190
193
  ...request,
194
+ providerOptions: mergeProviderOptions(
195
+ this.project.aiProviderOptions,
196
+ request.providerOptions,
197
+ ),
191
198
  signal: combineSignals(request.signal, this.project.aiTimeoutMs),
192
199
  });
193
200
  this.budget.record(response.usage);
@@ -204,6 +211,20 @@ export class AiClient {
204
211
  }
205
212
  }
206
213
 
214
+ /** Mescla opções por provider (o override do chamador vence chave a chave). */
215
+ function mergeProviderOptions(
216
+ base: AiProviderOptions | undefined,
217
+ override: AiProviderOptions | undefined,
218
+ ): AiProviderOptions | undefined {
219
+ if (!base) return override;
220
+ if (!override) return base;
221
+ const merged: AiProviderOptions = { ...base };
222
+ for (const [provider, values] of Object.entries(override)) {
223
+ merged[provider] = { ...base[provider], ...values };
224
+ }
225
+ return merged;
226
+ }
227
+
207
228
  /** Combina o sinal do chamador com o timeout configurado. */
208
229
  function combineSignals(
209
230
  signal: AbortSignal | undefined,
@@ -10,6 +10,7 @@ import type { LanguageModel, ToolSet } from "ai";
10
10
  import { generateText, Output, stepCountIs, tool } from "ai";
11
11
  import type { AiProviderKind } from "../config";
12
12
  import { AiProviderError } from "./errors";
13
+ import { temperatureApplies } from "./provider-capabilities";
13
14
  import {
14
15
  type AiTool,
15
16
  type AiUsage,
@@ -45,6 +46,47 @@ function numberOrZero(value: unknown): number {
45
46
  return typeof value === "number" && Number.isFinite(value) ? value : 0;
46
47
  }
47
48
 
49
+ /** Argumentos aceitos por `generateText` (inclui `providerOptions` do SDK). */
50
+ type GenerateTextArgs = Parameters<typeof generateText>[0];
51
+
52
+ /**
53
+ * Argumentos de `generateText` derivados do pedido (puro — testável sem rede).
54
+ *
55
+ * `temperature` só é enviada quando o provider de fato a aplica: em thinking
56
+ * mode (DeepSeek V4 default) o AI SDK a descarta e emite warning por chamada.
57
+ */
58
+ export function buildGenerateTextArgs<T>(
59
+ model: LanguageModel,
60
+ request: ModelRequest<T>,
61
+ provider: AiProviderKind,
62
+ modelId: string,
63
+ ) {
64
+ const tools = request.tools ? toToolSet(request.tools) : undefined;
65
+ const hasTools = tools !== undefined && Object.keys(tools).length > 0;
66
+ const maxSteps = request.maxSteps ?? 1;
67
+ const providerOptions = request.providerOptions
68
+ ? (request.providerOptions as GenerateTextArgs["providerOptions"])
69
+ : undefined;
70
+ const temperature =
71
+ request.temperature !== undefined &&
72
+ temperatureApplies(provider, modelId, request.providerOptions)
73
+ ? request.temperature
74
+ : undefined;
75
+ return {
76
+ model,
77
+ system: request.instructions,
78
+ prompt: request.prompt,
79
+ ...(hasTools ? { tools, stopWhen: stepCountIs(maxSteps) } : {}),
80
+ ...(request.outputSchema
81
+ ? { output: Output.object({ schema: request.outputSchema }) }
82
+ : {}),
83
+ ...(request.maxOutputTokens ? { maxOutputTokens: request.maxOutputTokens } : {}),
84
+ ...(providerOptions ? { providerOptions } : {}),
85
+ ...(temperature !== undefined ? { temperature } : {}),
86
+ maxRetries: 0,
87
+ };
88
+ }
89
+
48
90
  /** Provider de modelo apoiado no AI SDK. */
49
91
  export class AiSdkModelProvider implements ModelProvider {
50
92
  constructor(
@@ -54,27 +96,10 @@ export class AiSdkModelProvider implements ModelProvider {
54
96
  ) {}
55
97
 
56
98
  async generate<T>(request: ModelRequest<T>): Promise<ModelResponse<T>> {
57
- const tools = request.tools ? toToolSet(request.tools) : undefined;
58
- const hasTools = tools !== undefined && Object.keys(tools).length > 0;
59
- const maxSteps = request.maxSteps ?? 1;
60
-
61
99
  try {
62
100
  const result = await generateText({
63
- model: this.model,
64
- system: request.instructions,
65
- prompt: request.prompt,
66
- ...(hasTools ? { tools, stopWhen: stepCountIs(maxSteps) } : {}),
67
- ...(request.outputSchema
68
- ? { output: Output.object({ schema: request.outputSchema }) }
69
- : {}),
70
- ...(request.maxOutputTokens
71
- ? { maxOutputTokens: request.maxOutputTokens }
72
- : {}),
73
- ...(request.temperature !== undefined
74
- ? { temperature: request.temperature }
75
- : {}),
101
+ ...buildGenerateTextArgs(this.model, request, this.name, this.modelId),
76
102
  ...(request.signal ? { abortSignal: request.signal } : {}),
77
- maxRetries: 0,
78
103
  });
79
104
 
80
105
  const output = request.outputSchema
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Pontos técnicos estruturais via IA (fallback ao builder determinístico).
3
+ */
4
+
5
+ import {
6
+ buildTechnicalPromptPayload,
7
+ parseTechnicalSummary,
8
+ type TechnicalPromptInput,
9
+ type TechnicalSummary,
10
+ } from "../../technical-summary";
11
+ import type { AiClient } from "../ai-client";
12
+ import { technicalInstructions, technicalPrompt } from "../prompts/technical";
13
+ import { type TechnicalSummaryPlan, TechnicalSummarySchema } from "../schemas";
14
+
15
+ /** Resume a estrutura da mudança em destaques curtos. */
16
+ export class AiTechnicalSummarizer {
17
+ constructor(private readonly client: AiClient) {}
18
+
19
+ /** `true` quando a feature está ligada e há IA disponível. */
20
+ get enabled(): boolean {
21
+ return this.client.available && this.client.features.technical;
22
+ }
23
+
24
+ /** Falha-aberto: sem IA ou saída inválida → `null`. */
25
+ async summarize(input: TechnicalPromptInput): Promise<TechnicalSummary | null> {
26
+ if (!this.enabled) return null;
27
+ const result = await this.client.generateObject<TechnicalSummaryPlan>(
28
+ "technical",
29
+ {
30
+ instructions: technicalInstructions(),
31
+ prompt: technicalPrompt(buildTechnicalPromptPayload(input)),
32
+ outputSchema: TechnicalSummarySchema,
33
+ maxSteps: 1,
34
+ },
35
+ );
36
+ if (!result.ok) return null;
37
+ return parseTechnicalSummary({
38
+ ...result.output,
39
+ source: "ai",
40
+ headSha: input.headSha,
41
+ });
42
+ }
43
+ }
@@ -2,7 +2,7 @@
2
2
  * Barrel da camada de IA (`core/ai`).
3
3
  */
4
4
 
5
- export type { AiProviderKind } from "../config";
5
+ export type { AiProviderKind, AiProviderOptions } from "../config";
6
6
  export type {
7
7
  AiClientOptions,
8
8
  AiCredentialCheck,
@@ -21,6 +21,7 @@ export { AiBriefEnricher } from "./features/brief-enricher";
21
21
  export { AiClassifier } from "./features/classifier";
22
22
  export { AiContextSummarizer } from "./features/context-summarizer";
23
23
  export { AiPreAnalyzer } from "./features/pre-analyzer";
24
+ export { AiTechnicalSummarizer } from "./features/technical-summarizer";
24
25
  export { AiVerifier } from "./features/verifier";
25
26
  export { hasProviderCredential, ModelFactory } from "./model-factory";
26
27
  export {
@@ -35,6 +36,8 @@ export {
35
36
  type ContextSummaryPlan,
36
37
  ContextSummarySchema,
37
38
  SeveritySchema,
39
+ type TechnicalSummaryPlan,
40
+ TechnicalSummarySchema,
38
41
  VerifySchema,
39
42
  WatchdogReportSchema,
40
43
  } from "./schemas";
@@ -9,9 +9,9 @@ export function contextInstructions(): string {
9
9
  return buildSystem(
10
10
  "Você resume o contexto de uma Pull Request para revisores técnicos.",
11
11
  [
12
- "Objetivo em uma frase (máx. 200 caracteres).",
13
- "Motivação em uma frase (máx. 240 caracteres).",
14
- "Resumo das alterações em até 4 bullets (máx. 160 caracteres cada).",
12
+ "Objetivo em até 2 frases (máx. 400 caracteres).",
13
+ "Motivação em até 3 frases (máx. 480 caracteres), com o problema e o impacto esperado.",
14
+ "Resumo das alterações em até 6 bullets (máx. 240 caracteres cada), focado em comportamento, fluxos e impacto.",
15
15
  "Não invente informação ausente do contexto.",
16
16
  ],
17
17
  );
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Prompt dos pontos técnicos estruturais (`AiTechnicalSummarizer`).
3
+ */
4
+
5
+ import { buildSystem, clip } from "./base";
6
+
7
+ /** Instruções de sistema dos pontos técnicos. */
8
+ export function technicalInstructions(): string {
9
+ return buildSystem(
10
+ "Você descreve a estrutura técnica de uma Pull Request para revisores.",
11
+ [
12
+ "Foque em arquitetura e contratos: novos módulos/services, interfaces, providers, repositories, camadas, exports, dependências e impacto.",
13
+ "NÃO avalie qualidade, NÃO liste achados, bugs ou riscos de revisão.",
14
+ "NÃO repita a motivação de negócio: isso pertence ao resumo de negócio.",
15
+ "Use no máximo 6 destaques; `area` é um rótulo curto e `detail` cita paths/símbolos concretos.",
16
+ "Não invente módulos, símbolos ou dependências ausentes dos dados.",
17
+ ],
18
+ );
19
+ }
20
+
21
+ /** Prompt do usuário com o payload estrutural já compactado. */
22
+ export function technicalPrompt(payload: unknown): string {
23
+ return [
24
+ "Descreva os pontos técnicos estruturais da mudança abaixo.",
25
+ "",
26
+ "```json",
27
+ clip(JSON.stringify(payload), 14_000),
28
+ "```",
29
+ ].join("\n");
30
+ }