@payconductor/houndsman 0.11.0 → 0.13.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 (140) hide show
  1. package/README.md +14 -13
  2. package/package.json +1 -1
  3. package/src/cli/commands/doctor-command.ts +22 -0
  4. package/src/cli/commands/hunt-command.ts +440 -29
  5. package/src/cli/commands/review-command.ts +20 -2
  6. package/src/cli/help.ts +16 -8
  7. package/src/cli/index.ts +15 -2
  8. package/src/cli/progress.ts +45 -0
  9. package/src/core/ai/ai-client.ts +3 -7
  10. package/src/core/ai/ai-sdk-model-provider.ts +43 -24
  11. package/src/core/ai/features/brief-enricher.ts +0 -1
  12. package/src/core/ai/features/classifier.ts +0 -1
  13. package/src/core/ai/features/context-summarizer.ts +0 -1
  14. package/src/core/ai/features/pre-analyzer.ts +0 -1
  15. package/src/core/ai/features/technical-summarizer.ts +0 -1
  16. package/src/core/ai/features/verifier.ts +0 -1
  17. package/src/core/ai/index.ts +6 -4
  18. package/src/core/ai/prompts/pre-analysis.ts +3 -2
  19. package/src/core/ai/schemas.ts +1 -23
  20. package/src/core/ai/types.ts +4 -15
  21. package/src/core/ai/usage-budget.ts +1 -1
  22. package/src/core/ast/diff.ts +37 -2
  23. package/src/core/ast/service.ts +1 -1
  24. package/src/core/brief.ts +54 -5
  25. package/src/core/changed-scope.ts +207 -0
  26. package/src/core/commands/scaffold.ts +12 -18
  27. package/src/core/config.ts +30 -15
  28. package/src/core/defaults.ts +14 -5
  29. package/src/core/engine/review-scope.ts +131 -17
  30. package/src/core/engine/review-session.ts +7 -3
  31. package/src/core/engine/review-workspace.ts +14 -0
  32. package/src/core/env.ts +14 -0
  33. package/src/core/hunt/hunt-runner.ts +328 -0
  34. package/src/core/hunt/index.ts +59 -0
  35. package/src/core/hunt/maki-install.ts +331 -0
  36. package/src/core/hunt/maki-model.ts +101 -0
  37. package/src/core/hunt/maki-prompt.ts +71 -0
  38. package/src/core/hunt/maki-release.ts +104 -0
  39. package/src/core/hunt/maki-runner.ts +276 -0
  40. package/src/core/hunt/pool.ts +30 -0
  41. package/src/core/jev/jev-detector.ts +7 -2
  42. package/src/core/logging/index.ts +3 -1
  43. package/src/core/logging/logger.ts +22 -3
  44. package/src/core/logging/progress.ts +282 -0
  45. package/src/core/orchestrator.ts +173 -59
  46. package/src/core/project-config.ts +34 -24
  47. package/src/core/registry-check.ts +2 -12
  48. package/src/core/rule-text.ts +3 -55
  49. package/src/core/services/git-repository.ts +8 -0
  50. package/src/core/storage/types.ts +1 -1
  51. package/src/core/types.ts +3 -14
  52. package/src/index.ts +74 -3
  53. package/src/server/client/utils/constants.ts +3 -6
  54. package/src/server/client/utils/types.ts +0 -1
  55. package/src/skill/OUTPUT-FORMAT.md +5 -0
  56. package/src/skill/README.md +14 -1
  57. package/src/skill/SETUP.md +22 -2
  58. package/src/skill/SKILL.md +4 -0
  59. package/src/skill/fix.md +1 -1
  60. package/src/skill/hunt.md +38 -6
  61. package/src/tests/__snapshots__/baseline.test.ts.snap +69 -32
  62. package/src/tests/ai-core.test.ts +38 -1
  63. package/src/tests/changed-scope.test.ts +197 -0
  64. package/src/tests/cli.test.ts +1 -0
  65. package/src/tests/fallback.test.ts +1 -96
  66. package/src/tests/fixtures/fake-model-provider.ts +0 -1
  67. package/src/tests/hunt-cli.test.ts +185 -0
  68. package/src/tests/hunt-install.test.ts +153 -0
  69. package/src/tests/hunt-maki.test.ts +467 -0
  70. package/src/tests/progress.test.ts +117 -0
  71. package/src/tests/review-scope.test.ts +95 -0
  72. package/src/tests/worktree.test.ts +1 -3
  73. package/src/harness/README.md +0 -15
  74. package/src/harness/agent/agent-context.ts +0 -33
  75. package/src/harness/agent/agent-errors.ts +0 -30
  76. package/src/harness/agent/agent-factory.ts +0 -46
  77. package/src/harness/agent/agent-reporter.ts +0 -34
  78. package/src/harness/agent/agent-result.ts +0 -16
  79. package/src/harness/agent/agent-runner.ts +0 -32
  80. package/src/harness/agent/agent-scheduler.ts +0 -37
  81. package/src/harness/agent/review-agent.ts +0 -401
  82. package/src/harness/config/harness-config.ts +0 -89
  83. package/src/harness/hunt/hunt-harness.ts +0 -83
  84. package/src/harness/hunt/hunt-options.ts +0 -92
  85. package/src/harness/hunt/hunt-result.ts +0 -17
  86. package/src/harness/hunt/hunt-session.ts +0 -359
  87. package/src/harness/hunt/review-planner.ts +0 -98
  88. package/src/harness/index.ts +0 -36
  89. package/src/harness/investigation/agent-state.ts +0 -134
  90. package/src/harness/investigation/evidence-verifier.ts +0 -129
  91. package/src/harness/investigation/evidence.ts +0 -110
  92. package/src/harness/investigation/finding-critic.ts +0 -96
  93. package/src/harness/investigation/investigation-planner.ts +0 -118
  94. package/src/harness/investigation/investigation-task.ts +0 -10
  95. package/src/harness/investigation/review-journal.ts +0 -87
  96. package/src/harness/investigation/rule-analyzer.ts +0 -178
  97. package/src/harness/investigation/rule-signals.ts +0 -110
  98. package/src/harness/prompts/agent-schemas.ts +0 -57
  99. package/src/harness/prompts/reviewer-prompt-builder.ts +0 -101
  100. package/src/harness/prompts/states/conclude-prompt.ts +0 -16
  101. package/src/harness/prompts/states/investigate-prompt.ts +0 -37
  102. package/src/harness/prompts/states/orient-prompt.ts +0 -15
  103. package/src/harness/prompts/states/verify-prompt.ts +0 -22
  104. package/src/harness/tools/agent-tool.ts +0 -102
  105. package/src/harness/tools/builtin/changed-symbols-tool.ts +0 -48
  106. package/src/harness/tools/builtin/doc-context-tool.ts +0 -74
  107. package/src/harness/tools/builtin/file-outline-tool.ts +0 -66
  108. package/src/harness/tools/builtin/find-references-tool.ts +0 -66
  109. package/src/harness/tools/builtin/find-symbol-tool.ts +0 -91
  110. package/src/harness/tools/builtin/helpers.ts +0 -115
  111. package/src/harness/tools/builtin/impact-tool.ts +0 -48
  112. package/src/harness/tools/builtin/imports-exports-tool.ts +0 -53
  113. package/src/harness/tools/builtin/index.ts +0 -59
  114. package/src/harness/tools/builtin/list-files-tool.ts +0 -85
  115. package/src/harness/tools/builtin/pr-context-tool.ts +0 -41
  116. package/src/harness/tools/builtin/read-file-range-tool.ts +0 -73
  117. package/src/harness/tools/builtin/rule-signals-tool.ts +0 -36
  118. package/src/harness/tools/builtin/search-code-tool.ts +0 -65
  119. package/src/harness/tools/builtin/symbol-context-tool.ts +0 -106
  120. package/src/harness/tools/builtin/tests-for-symbol-tool.ts +0 -61
  121. package/src/harness/tools/path-guard.ts +0 -90
  122. package/src/harness/tools/result-limiter.ts +0 -49
  123. package/src/harness/tools/secret-redactor.ts +0 -54
  124. package/src/harness/tools/tool-cache.ts +0 -50
  125. package/src/harness/tools/tool-call-budget.ts +0 -60
  126. package/src/harness/tools/tool-context.ts +0 -5
  127. package/src/harness/tools/tool-middleware.ts +0 -105
  128. package/src/harness/tools/tool-policy.ts +0 -51
  129. package/src/harness/tools/tool-registry.ts +0 -112
  130. package/src/harness/tools/tool-result.ts +0 -15
  131. package/src/harness/tools/toolbox.ts +0 -36
  132. package/src/harness/validation/finding-validator.ts +0 -119
  133. package/src/tests/fixtures/fake-agent-tools.ts +0 -69
  134. package/src/tests/fixtures/fake-investigation.ts +0 -64
  135. package/src/tests/harness-agent.test.ts +0 -141
  136. package/src/tests/harness-investigation.test.ts +0 -206
  137. package/src/tests/harness-tools.test.ts +0 -148
  138. package/src/tests/hunt-command.test.ts +0 -212
  139. package/src/tests/rule-analyzer.test.ts +0 -61
  140. package/src/tests/tool-contracts.test.ts +0 -82
@@ -7,6 +7,7 @@
7
7
 
8
8
  import type { AstService } from "../ast/service";
9
9
  import type { AstAnalysis, AstFacts } from "../ast/types";
10
+ import { buildChangedScope, type ChangedScope, type ScopeEntry } from "../changed-scope";
10
11
  import type { HoundsmanConfig } from "../config";
11
12
  import { churnToRecord, type DiffSource } from "../diff-source";
12
13
  import { type ClassificationResult, parseEntries } from "../file-classifier";
@@ -34,6 +35,8 @@ export type RunClassification = ClassificationResult & {
34
35
  reviewedFrom?: string;
35
36
  /** Findings preservados do review anterior (arquivos inalterados). */
36
37
  carryover?: MergedFinding[];
38
+ /** Faixas revisáveis por arquivo (símbolos alterados; hunks como fallback). */
39
+ changedScope?: ChangedScope;
37
40
  };
38
41
 
39
42
  /** Resultado da resolução de escopo: revisar ou nada a fazer. */
@@ -54,23 +57,37 @@ export type ReviewScope =
54
57
  ast?: Map<string, AstAnalysis>;
55
58
  };
56
59
 
60
+ /** Etapas da resolução de escopo reportadas ao CLI (sem log no core). */
61
+ export type ScopeStage = "diff" | "ast" | "classify";
62
+
63
+ /** Evento de progresso de uma etapa do escopo. */
64
+ export type ScopeProgressEvent =
65
+ | { type: "start"; stage: ScopeStage }
66
+ | { type: "done"; stage: ScopeStage; durationMs: number };
67
+
68
+ /** Callback de progresso (falhas do callback são ignoradas). */
69
+ export type ScopeProgress = (event: ScopeProgressEvent) => void;
70
+
57
71
  /** Decide entre review completo e incremental. */
58
72
  export class ReviewScopeResolver {
59
73
  private readonly options: ScopeOptions;
60
74
  private readonly classifier: FileClassifier;
61
75
  private readonly ast?: AstService;
62
76
  private readonly git: GitRepository;
77
+ private readonly onProgress?: ScopeProgress;
63
78
 
64
79
  constructor(
65
80
  options: ScopeOptions,
66
81
  config: HoundsmanConfig,
67
82
  ast?: AstService,
68
83
  git: GitRepository = new GitRepository(),
84
+ onProgress?: ScopeProgress,
69
85
  ) {
70
86
  this.options = options;
71
87
  this.classifier = new FileClassifier(config);
72
88
  this.ast = ast;
73
89
  this.git = git;
90
+ this.onProgress = onProgress;
74
91
  }
75
92
 
76
93
  /**
@@ -97,48 +114,131 @@ export class ReviewScopeResolver {
97
114
  !prevSha ||
98
115
  !this.git.isAncestor(prevSha, headSha)
99
116
  ) {
100
- const diff = this.git.collectDiff(this.options.base, this.options.head);
101
- const entries = parseEntries(
102
- this.git.diffNameStatus(this.options.base, this.options.head),
103
- );
104
- const baseRef =
105
- this.git.mergeBase(this.options.base, this.options.head) ??
106
- this.options.base;
107
- const ast = this.ast
108
- ? await this.ast.analyze(entries, diff, baseRef)
117
+ const diffData = this.step("diff", () => {
118
+ const diff = this.git.collectDiff(this.options.base, this.options.head);
119
+ const entries = parseEntries(
120
+ this.git.diffNameStatus(this.options.base, this.options.head),
121
+ );
122
+ const baseRef =
123
+ this.git.mergeBase(this.options.base, this.options.head) ??
124
+ this.options.base;
125
+ // Patch `-U0` sem truncar: mais preciso para delta AST e escopo.
126
+ const scopeDiff = this.zeroContext(
127
+ `${this.options.base}...${this.options.head}`,
128
+ diff,
129
+ );
130
+ return { diff, entries, baseRef, scopeDiff };
131
+ });
132
+ const { diff, entries, baseRef, scopeDiff } = diffData;
133
+ const astService = this.ast;
134
+ const ast = astService
135
+ ? await this.stepAsync("ast", () =>
136
+ astService.analyze(entries, scopeDiff, baseRef),
137
+ )
109
138
  : undefined;
139
+ const result = this.step("classify", () =>
140
+ this.classifier
141
+ .classify(entries, churnToRecord(diff.churn), factsOf(ast))
142
+ .toJSON(),
143
+ );
110
144
  return {
111
145
  kind: "review",
112
146
  diff,
113
147
  ast,
114
148
  classification: {
115
- ...this.classifier
116
- .classify(entries, churnToRecord(diff.churn), factsOf(ast))
117
- .toJSON(),
149
+ ...result,
118
150
  headSha,
119
151
  mode: "full",
152
+ changedScope: buildChangedScope({
153
+ entries: scopeEntries(entries, result),
154
+ diff: scopeDiff,
155
+ ast,
156
+ }),
120
157
  },
121
158
  };
122
159
  }
123
160
 
124
161
  const range = `${prevSha}..${headSha}`;
125
- const diff = this.git.collectDiffSince(prevSha, headSha);
126
- const entries = parseEntries(this.git.run(["diff", range, "--name-status"]));
162
+ const diffData = this.step("diff", () => {
163
+ const diff = this.git.collectDiffSince(prevSha, headSha);
164
+ const entries = parseEntries(this.git.run(["diff", range, "--name-status"]));
165
+ const scopeDiff = this.zeroContext(range, diff);
166
+ return { diff, entries, scopeDiff };
167
+ });
168
+ const { diff, entries, scopeDiff } = diffData;
127
169
  const changedPaths = new Set(entries.map((entry) => entry.path));
128
- const ast = this.ast ? await this.ast.analyze(entries, diff, prevSha) : undefined;
129
- const classification: RunClassification = {
130
- ...this.classifier
170
+ const astService = this.ast;
171
+ const ast = astService
172
+ ? await this.stepAsync("ast", () =>
173
+ astService.analyze(entries, scopeDiff, prevSha),
174
+ )
175
+ : undefined;
176
+ const result = this.step("classify", () =>
177
+ this.classifier
131
178
  .classify(entries, churnToRecord(diff.churn), factsOf(ast))
132
179
  .toJSON(),
180
+ );
181
+ const classification: RunClassification = {
182
+ ...result,
133
183
  base: prevSha,
134
184
  head: this.options.head,
135
185
  headSha,
136
186
  mode: "incremental",
137
187
  reviewedFrom: prevSha,
138
188
  carryover: buildCarryover(previous, changedPaths),
189
+ changedScope: buildChangedScope({
190
+ entries: scopeEntries(entries, result),
191
+ diff: scopeDiff,
192
+ ast,
193
+ }),
139
194
  };
140
195
  return { kind: "review", classification, diff, reviewedFrom: prevSha, ast };
141
196
  }
197
+
198
+ /**
199
+ * Executa uma etapa síncrona reportando início/fim. O callback de progresso
200
+ * é diagnóstico: nunca derruba a resolução.
201
+ */
202
+ private step<T>(stage: ScopeStage, run: () => T): T {
203
+ if (!this.onProgress) return run();
204
+ const started = Date.now();
205
+ this.report({ type: "start", stage });
206
+ try {
207
+ return run();
208
+ } finally {
209
+ this.report({ type: "done", stage, durationMs: Date.now() - started });
210
+ }
211
+ }
212
+
213
+ /** Versão assíncrona de `step`. */
214
+ private async stepAsync<T>(stage: ScopeStage, run: () => Promise<T> | T): Promise<T> {
215
+ if (!this.onProgress) return run();
216
+ const started = Date.now();
217
+ this.report({ type: "start", stage });
218
+ try {
219
+ return await run();
220
+ } finally {
221
+ this.report({ type: "done", stage, durationMs: Date.now() - started });
222
+ }
223
+ }
224
+
225
+ private report(event: ScopeProgressEvent): void {
226
+ try {
227
+ this.onProgress?.(event);
228
+ } catch {
229
+ // Progresso nunca derruba a revisão.
230
+ }
231
+ }
232
+
233
+ /** Patch `-U0` do intervalo; falha aberta cai no diff já coletado. */
234
+ private zeroContext(range: string, fallback: DiffSource): DiffSource {
235
+ try {
236
+ const byFile = this.git.patchZeroContext(range);
237
+ return byFile.size > 0 ? { byFile, churn: fallback.churn } : fallback;
238
+ } catch {
239
+ return fallback;
240
+ }
241
+ }
142
242
  }
143
243
 
144
244
  /** Fatos apenas dos arquivos presentes (deletados não classificam). */
@@ -152,3 +252,17 @@ function factsOf(
152
252
  }
153
253
  return facts;
154
254
  }
255
+
256
+ /**
257
+ * Entradas revisáveis do `--name-status`: exclui arquivos do `exclusions` e
258
+ * deletados (não há código atual para ancorar finding).
259
+ */
260
+ function scopeEntries(
261
+ entries: ReturnType<typeof parseEntries>,
262
+ result: ClassificationResult,
263
+ ): ScopeEntry[] {
264
+ const excluded = new Set(result.excluded);
265
+ return entries
266
+ .filter((entry) => entry.status !== "deleted" && !excluded.has(entry.path))
267
+ .map((entry) => ({ path: entry.path, status: entry.status }));
268
+ }
@@ -11,6 +11,7 @@ import { createGitRunner } from "../git-runner";
11
11
  import {
12
12
  type FinalizeResult,
13
13
  finalize,
14
+ type PrepareProgress,
14
15
  type PrepareResult,
15
16
  prepare,
16
17
  type ReviewContext,
@@ -104,7 +105,7 @@ export class ReviewSession {
104
105
  }
105
106
 
106
107
  /** Passo 1 — preparar. */
107
- prepare(): Promise<PrepareResult> {
108
+ prepare(onProgress?: PrepareProgress): Promise<PrepareResult> {
108
109
  return prepare(
109
110
  this.options,
110
111
  this.branch,
@@ -112,6 +113,7 @@ export class ReviewSession {
112
113
  this.context,
113
114
  this.ast,
114
115
  this.ai,
116
+ onProgress,
115
117
  );
116
118
  }
117
119
 
@@ -121,7 +123,9 @@ export class ReviewSession {
121
123
  }
122
124
 
123
125
  /** Executa o passo escolhido em `--finalize`. */
124
- run(): Promise<PrepareResult | FinalizeResult> {
125
- return this.options.finalize ? Promise.resolve(this.finalize()) : this.prepare();
126
+ run(onProgress?: PrepareProgress): Promise<PrepareResult | FinalizeResult> {
127
+ return this.options.finalize
128
+ ? Promise.resolve(this.finalize())
129
+ : this.prepare(onProgress);
126
130
  }
127
131
  }
@@ -35,6 +35,8 @@ export type WorkspaceDeps = {
35
35
  worktreesLocation?: string;
36
36
  /** Override explícito da raiz de worktrees (testes). */
37
37
  worktreesRoot?: string;
38
+ /** Passos longos (gh/worktree) reportados ao CLI; nunca lança. */
39
+ onProgress?: (message: string) => void;
38
40
  };
39
41
 
40
42
  /** Workspace resolvido para a revisão. */
@@ -85,6 +87,14 @@ export function resolveReviewWorkspace(
85
87
  deps: WorkspaceDeps = {},
86
88
  ): ResolvedWorkspace {
87
89
  const messages: string[] = [];
90
+ /** Progresso é diagnóstico: nunca derruba a resolução. */
91
+ const report = (message: string): void => {
92
+ try {
93
+ deps.onProgress?.(message);
94
+ } catch {
95
+ // Progresso nunca derruba a revisão.
96
+ }
97
+ };
88
98
  const cwdGit = repoAt(request.cwd);
89
99
  const cwdRoot = resolve(cwdGit.rootPath() ?? request.cwd);
90
100
  const mainRoot = resolve(mainRootOf(cwdGit, cwdRoot));
@@ -108,6 +118,7 @@ export function resolveReviewWorkspace(
108
118
  // A camada gerada não vem no checkout; sem ela os comandos no worktree
109
119
  // não carregam a config (`../core/types`).
110
120
  const explicitMainRoot = mainRootOf(git, repo.root);
121
+ report("Sincronizando a camada gerada no worktree…");
111
122
  syncProjectLayer(explicitMainRoot, root);
112
123
  return {
113
124
  root,
@@ -129,6 +140,7 @@ export function resolveReviewWorkspace(
129
140
  deps.ghRunner ?? createGhRunner(mainRoot),
130
141
  mainGit,
131
142
  );
143
+ report(`Consultando a PR #${request.pr} via gh…`);
132
144
  const resolved = resolver.resolve(request.pr);
133
145
  if (!resolved.ok) {
134
146
  throw new Error(
@@ -136,6 +148,7 @@ export function resolveReviewWorkspace(
136
148
  );
137
149
  }
138
150
  const { pr } = resolved;
151
+ report("Buscando as refs da PR…");
139
152
  const refs = resolver.prepareRefs(pr);
140
153
  if (refs.warning) messages.push(`GitHub: ${refs.warning}`);
141
154
 
@@ -146,6 +159,7 @@ export function resolveReviewWorkspace(
146
159
  deps.worktreesRoot ??
147
160
  resolveWorktreesRoot(deps.worktreesLocation, mainRoot),
148
161
  });
162
+ report("Criando/reutilizando o worktree da PR…");
149
163
  const worktree = manager.ensure({ branch: pr.headRefName, ref: refs.head });
150
164
  const worktreePath = resolve(worktree.path);
151
165
  messages.push(
package/src/core/env.ts CHANGED
@@ -19,6 +19,8 @@ export const ENV_NAMES = {
19
19
  deepSeekApiKey: "HOUNDSMAN_DEEP_SEEK_API_KEY",
20
20
  openAiApiKey: "HOUNDSMAN_OPENAI_API_KEY",
21
21
  anthropicApiKey: "HOUNDSMAN_ANTHROPIC_API_KEY",
22
+ makiPath: "HOUNDSMAN_MAKI_PATH",
23
+ makiVersion: "HOUNDSMAN_MAKI_VERSION",
22
24
  home: "HOUNDSMAN_HOME",
23
25
  localAppData: "LOCALAPPDATA",
24
26
  xdgDataHome: "XDG_DATA_HOME",
@@ -33,6 +35,10 @@ export type HoundsmanEnv = {
33
35
  deepSeekApiKey?: string;
34
36
  openAiApiKey?: string;
35
37
  anthropicApiKey?: string;
38
+ /** Caminho explícito do binário do maki (harness local `hunt`). */
39
+ makiPath?: string;
40
+ /** Versão do maki a instalar (default: a fixada pelo pacote). */
41
+ makiVersion?: string;
36
42
  home?: string;
37
43
  localAppData?: string;
38
44
  xdgDataHome?: string;
@@ -54,6 +60,8 @@ export function readEnv(): HoundsmanEnv {
54
60
  deepSeekApiKey: read(ENV_NAMES.deepSeekApiKey),
55
61
  openAiApiKey: read(ENV_NAMES.openAiApiKey),
56
62
  anthropicApiKey: read(ENV_NAMES.anthropicApiKey),
63
+ makiPath: read(ENV_NAMES.makiPath),
64
+ makiVersion: read(ENV_NAMES.makiVersion),
57
65
  home: read(ENV_NAMES.home),
58
66
  localAppData: read(ENV_NAMES.localAppData),
59
67
  xdgDataHome: read(ENV_NAMES.xdgDataHome),
@@ -80,6 +88,12 @@ export const env = {
80
88
  get anthropicApiKey(): string | undefined {
81
89
  return read(ENV_NAMES.anthropicApiKey);
82
90
  },
91
+ get makiPath(): string | undefined {
92
+ return read(ENV_NAMES.makiPath);
93
+ },
94
+ get makiVersion(): string | undefined {
95
+ return read(ENV_NAMES.makiVersion);
96
+ },
83
97
  get home(): string | undefined {
84
98
  return read(ENV_NAMES.home);
85
99
  },
@@ -0,0 +1,328 @@
1
+ /**
2
+ * Orquestração do harness local (`houndsman hunt`).
3
+ *
4
+ * Recebe os briefs já preparados e despacha um processo maki por watchdog. O
5
+ * watchdog devolve o JSON na resposta final; validamos com o mesmo parser do
6
+ * `--finalize` e gravamos em `findings/<id>.json`. Falhas são reportadas por
7
+ * watchdog — a política de finalizar ou não fica com o chamador (CLI).
8
+ */
9
+
10
+ import { readFileSync } from "node:fs";
11
+ import { parseWatchdogReport } from "../report-builder";
12
+ import { systemClock } from "../services/clock";
13
+ import type { ReviewWorkArea } from "../storage/work-area";
14
+ import { renderHuntPrompt, renderRetrySuffix } from "./maki-prompt";
15
+ import {
16
+ extractJsonObject,
17
+ type MakiProcessRunner,
18
+ type MakiRunMetrics,
19
+ type MakiUsage,
20
+ } from "./maki-runner";
21
+ import { mapPool } from "./pool";
22
+
23
+ /** Evento de progresso (CLI loga; core não escreve saída). */
24
+ export type HuntEvent =
25
+ | { type: "start"; watchdogId: string }
26
+ | {
27
+ type: "retry";
28
+ watchdogId: string;
29
+ attempt: number;
30
+ maxAttempts: number;
31
+ error: string;
32
+ }
33
+ | {
34
+ type: "done";
35
+ watchdogId: string;
36
+ findings: number;
37
+ reviewedFiles: number;
38
+ attempts: number;
39
+ metrics: MakiRunMetrics;
40
+ }
41
+ | {
42
+ type: "failed";
43
+ watchdogId: string;
44
+ error: string;
45
+ attempts: number;
46
+ metrics: MakiRunMetrics;
47
+ };
48
+
49
+ /** Resultado de um watchdog. */
50
+ export type HuntWatchdogResult = {
51
+ watchdogId: string;
52
+ status: "done" | "failed";
53
+ findings: number;
54
+ reviewedFiles: number;
55
+ attempts: number;
56
+ durationMs: number;
57
+ sessionId?: string;
58
+ numTurns?: number;
59
+ costUsd?: number;
60
+ usage?: MakiUsage;
61
+ error?: string;
62
+ };
63
+
64
+ /** Agregados do relatório. */
65
+ export type HuntTotals = {
66
+ watchdogs: number;
67
+ done: number;
68
+ failed: number;
69
+ findings: number;
70
+ costUsd: number;
71
+ inputTokens: number;
72
+ outputTokens: number;
73
+ totalTokens: number;
74
+ };
75
+
76
+ /** Relatório persistido como `hunt-report.json`. */
77
+ export type HuntReport = {
78
+ version: 1;
79
+ engine: "maki";
80
+ model: string;
81
+ startedAt: string;
82
+ finishedAt: string;
83
+ durationMs: number;
84
+ watchdogs: HuntWatchdogResult[];
85
+ totals: HuntTotals;
86
+ };
87
+
88
+ /** Dependências do runner. */
89
+ export type HuntRunnerOptions = {
90
+ /** Staging da review (briefs/findings). */
91
+ work: ReviewWorkArea;
92
+ /** Raiz de leitura dos arquivos da review. */
93
+ root: string;
94
+ projectName: string;
95
+ /** Spec `provider/model-id`. */
96
+ model: string;
97
+ apiKeyEnv: string;
98
+ apiKey: string;
99
+ concurrency: number;
100
+ timeoutMs: number;
101
+ retries: number;
102
+ runner: MakiProcessRunner;
103
+ onEvent?: (event: HuntEvent) => void;
104
+ };
105
+
106
+ /** Despacha os briefs via maki e devolve o relatório consolidado. */
107
+ export class HuntRunner {
108
+ private readonly options: HuntRunnerOptions;
109
+
110
+ constructor(options: HuntRunnerOptions) {
111
+ this.options = options;
112
+ }
113
+
114
+ async run(activeIds: readonly string[]): Promise<HuntReport> {
115
+ const startedAt = systemClock();
116
+ const startedMs = Date.now();
117
+ const watchdogs = await mapPool(activeIds, this.options.concurrency, (id) =>
118
+ this.runOne(id),
119
+ );
120
+ const finishedAt = systemClock();
121
+ return {
122
+ version: 1,
123
+ engine: "maki",
124
+ model: this.options.model,
125
+ startedAt,
126
+ finishedAt,
127
+ durationMs: Date.now() - startedMs,
128
+ watchdogs,
129
+ totals: totalsOf(watchdogs),
130
+ };
131
+ }
132
+
133
+ private async runOne(watchdogId: string): Promise<HuntWatchdogResult> {
134
+ const briefPath = this.options.work.briefFile(watchdogId);
135
+ let brief: string;
136
+ try {
137
+ brief = readFileSync(briefPath, "utf8");
138
+ } catch {
139
+ const error = `Brief ausente: ${briefPath}`;
140
+ this.emit({
141
+ type: "failed",
142
+ watchdogId,
143
+ error,
144
+ attempts: 0,
145
+ metrics: { durationMs: 0 },
146
+ });
147
+ return failedResult(watchdogId, error, 0, { durationMs: 0 });
148
+ }
149
+
150
+ const basePrompt = renderHuntPrompt({
151
+ projectName: this.options.projectName,
152
+ watchdogId,
153
+ brief,
154
+ });
155
+
156
+ this.emit({ type: "start", watchdogId });
157
+ let attempts = 0;
158
+ let lastError = "";
159
+ const accumulated: MakiRunMetrics = { durationMs: 0 };
160
+
161
+ while (attempts <= this.options.retries) {
162
+ attempts++;
163
+ const prompt =
164
+ attempts === 1
165
+ ? basePrompt
166
+ : `${basePrompt}\n${renderRetrySuffix(lastError)}`;
167
+ const result = await this.options.runner.run({
168
+ prompt,
169
+ cwd: this.options.root,
170
+ model: this.options.model,
171
+ apiKeyEnv: this.options.apiKeyEnv,
172
+ apiKey: this.options.apiKey,
173
+ timeoutMs: this.options.timeoutMs,
174
+ });
175
+ mergeMetrics(accumulated, result.metrics);
176
+
177
+ if (!result.ok) {
178
+ lastError = result.error;
179
+ this.emitRetry(watchdogId, attempts, lastError);
180
+ continue;
181
+ }
182
+
183
+ const raw = extractJsonObject(result.text);
184
+ if (raw === null) {
185
+ lastError = "a resposta final não contém um objeto JSON.";
186
+ this.emitRetry(watchdogId, attempts, lastError);
187
+ continue;
188
+ }
189
+
190
+ const warnings: string[] = [];
191
+ const report = parseWatchdogReport(raw, `maki:${watchdogId}`, (message) =>
192
+ warnings.push(message),
193
+ );
194
+ if (!report) {
195
+ lastError = warnings[0] ?? "JSON fora do schema do watchdog.";
196
+ this.emitRetry(watchdogId, attempts, lastError);
197
+ continue;
198
+ }
199
+ if (report.watchdogId !== watchdogId) {
200
+ lastError = `watchdogId '${report.watchdogId}' não corresponde a '${watchdogId}'.`;
201
+ this.emitRetry(watchdogId, attempts, lastError);
202
+ continue;
203
+ }
204
+
205
+ this.options.work.writeFinding(
206
+ watchdogId,
207
+ `${JSON.stringify(report, null, 2)}\n`,
208
+ );
209
+ const done: HuntWatchdogResult = {
210
+ watchdogId,
211
+ status: "done",
212
+ findings: report.findings.length,
213
+ reviewedFiles: report.reviewedFiles.length,
214
+ attempts,
215
+ durationMs: accumulated.durationMs,
216
+ sessionId: accumulated.sessionId,
217
+ numTurns: accumulated.numTurns,
218
+ costUsd: accumulated.costUsd,
219
+ usage: accumulated.usage,
220
+ };
221
+ this.emit({
222
+ type: "done",
223
+ watchdogId,
224
+ findings: done.findings,
225
+ reviewedFiles: done.reviewedFiles,
226
+ attempts,
227
+ metrics: accumulated,
228
+ });
229
+ return done;
230
+ }
231
+
232
+ const error = lastError || "falha desconhecida.";
233
+ this.emit({ type: "failed", watchdogId, error, attempts, metrics: accumulated });
234
+ return failedResult(watchdogId, error, attempts, accumulated);
235
+ }
236
+
237
+ private emit(event: HuntEvent): void {
238
+ try {
239
+ this.options.onEvent?.(event);
240
+ } catch {
241
+ // Progresso nunca derruba a revisão.
242
+ }
243
+ }
244
+
245
+ /** Avisa a próxima tentativa (só quando ainda há retry). */
246
+ private emitRetry(watchdogId: string, attempt: number, error: string): void {
247
+ if (attempt > this.options.retries) return;
248
+ this.emit({
249
+ type: "retry",
250
+ watchdogId,
251
+ attempt,
252
+ maxAttempts: this.options.retries + 1,
253
+ error,
254
+ });
255
+ }
256
+ }
257
+
258
+ /** Soma métricas de várias tentativas no acumulado. */
259
+ function mergeMetrics(target: MakiRunMetrics, source: MakiRunMetrics): void {
260
+ target.durationMs += source.durationMs;
261
+ target.sessionId = source.sessionId ?? target.sessionId;
262
+ if (source.numTurns !== undefined) {
263
+ target.numTurns = (target.numTurns ?? 0) + source.numTurns;
264
+ }
265
+ if (source.costUsd !== undefined) {
266
+ target.costUsd = (target.costUsd ?? 0) + source.costUsd;
267
+ }
268
+ if (source.usage) {
269
+ target.usage = {
270
+ inputTokens:
271
+ (target.usage?.inputTokens ?? 0) + (source.usage.inputTokens ?? 0),
272
+ outputTokens:
273
+ (target.usage?.outputTokens ?? 0) + (source.usage.outputTokens ?? 0),
274
+ totalTokens:
275
+ (target.usage?.totalTokens ?? 0) + (source.usage.totalTokens ?? 0),
276
+ cacheReadInputTokens:
277
+ (target.usage?.cacheReadInputTokens ?? 0) +
278
+ (source.usage.cacheReadInputTokens ?? 0),
279
+ };
280
+ }
281
+ }
282
+
283
+ /** Resultado de falha de um watchdog. */
284
+ function failedResult(
285
+ watchdogId: string,
286
+ error: string,
287
+ attempts: number,
288
+ metrics: MakiRunMetrics,
289
+ ): HuntWatchdogResult {
290
+ return {
291
+ watchdogId,
292
+ status: "failed",
293
+ findings: 0,
294
+ reviewedFiles: 0,
295
+ attempts,
296
+ durationMs: metrics.durationMs,
297
+ sessionId: metrics.sessionId,
298
+ numTurns: metrics.numTurns,
299
+ costUsd: metrics.costUsd,
300
+ usage: metrics.usage,
301
+ error,
302
+ };
303
+ }
304
+
305
+ /** Agrega os resultados por watchdog. */
306
+ function totalsOf(watchdogs: readonly HuntWatchdogResult[]): HuntTotals {
307
+ const totals: HuntTotals = {
308
+ watchdogs: watchdogs.length,
309
+ done: 0,
310
+ failed: 0,
311
+ findings: 0,
312
+ costUsd: 0,
313
+ inputTokens: 0,
314
+ outputTokens: 0,
315
+ totalTokens: 0,
316
+ };
317
+ for (const watchdog of watchdogs) {
318
+ if (watchdog.status === "done") totals.done++;
319
+ else totals.failed++;
320
+ totals.findings += watchdog.findings;
321
+ totals.costUsd += watchdog.costUsd ?? 0;
322
+ totals.inputTokens += watchdog.usage?.inputTokens ?? 0;
323
+ totals.outputTokens += watchdog.usage?.outputTokens ?? 0;
324
+ totals.totalTokens += watchdog.usage?.totalTokens ?? 0;
325
+ }
326
+ totals.costUsd = Number(totals.costUsd.toFixed(6));
327
+ return totals;
328
+ }