@payconductor/houndsman 0.6.0 → 0.8.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 (71) 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/server-command.ts +7 -1
  8. package/src/cli/commands/worktree-command.ts +118 -0
  9. package/src/cli/help.ts +13 -3
  10. package/src/cli/index.ts +14 -4
  11. package/src/core/ast/service.ts +7 -2
  12. package/src/core/brief.ts +1 -0
  13. package/src/core/commands/context.ts +9 -1
  14. package/src/core/commands/findings.ts +17 -6
  15. package/src/core/commands/review.ts +13 -0
  16. package/src/core/commands/scaffold.ts +4 -2
  17. package/src/core/config.ts +13 -0
  18. package/src/core/defaults.ts +8 -1
  19. package/src/core/engine/houndsman-engine.ts +26 -3
  20. package/src/core/engine/review-scope.ts +17 -22
  21. package/src/core/engine/review-session.ts +25 -2
  22. package/src/core/engine/review-workspace.ts +204 -0
  23. package/src/core/git-runner.ts +25 -13
  24. package/src/core/github-review.ts +10 -7
  25. package/src/core/jev/jev-detector.ts +10 -2
  26. package/src/core/orchestrator.ts +62 -13
  27. package/src/core/project-config.ts +12 -0
  28. package/src/core/services/gh-client.ts +38 -22
  29. package/src/core/services/git-repository.ts +46 -0
  30. package/src/core/services/pr-resolver.ts +147 -0
  31. package/src/core/services/worktree-manager.ts +269 -0
  32. package/src/core/storage/file/file-storage.ts +14 -1
  33. package/src/core/storage/paths.ts +30 -1
  34. package/src/core/storage/sqlite/mappers.ts +4 -0
  35. package/src/core/storage/sqlite/schema.ts +13 -3
  36. package/src/core/storage/sqlite/sqlite-storage.ts +28 -8
  37. package/src/core/storage/types.ts +12 -0
  38. package/src/core/storage/work-area.ts +20 -1
  39. package/src/core/types.ts +2 -0
  40. package/src/harness/hunt/hunt-options.ts +17 -0
  41. package/src/harness/hunt/hunt-session.ts +7 -20
  42. package/src/index.ts +31 -0
  43. package/src/server/client/App.tsx +86 -22
  44. package/src/server/client/CommentsPanel.tsx +255 -0
  45. package/src/server/client/DeleteRunModal.tsx +163 -0
  46. package/src/server/client/FindingCard.tsx +12 -4
  47. package/src/server/client/FindingsList.tsx +4 -1
  48. package/src/server/client/ResumePanel.tsx +8 -8
  49. package/src/server/client/Sidebar.tsx +452 -0
  50. package/src/server/client/SubmitModal.tsx +61 -5
  51. package/src/server/client/icons.tsx +29 -0
  52. package/src/server/client/index.tsx +1 -1
  53. package/src/server/client/utils/constants.ts +2 -0
  54. package/src/server/client/utils/format.ts +47 -2
  55. package/src/server/client/utils/types.ts +108 -0
  56. package/src/server/editor.ts +129 -0
  57. package/src/server/index.ts +205 -3
  58. package/src/server/pr-comments.ts +256 -0
  59. package/src/server/pr-inbox.ts +213 -0
  60. package/src/server/run-repository.ts +136 -3
  61. package/src/skill/README.md +11 -3
  62. package/src/skill/SKILL.md +44 -10
  63. package/src/skill/hunt.md +33 -7
  64. package/src/tests/__snapshots__/baseline.test.ts.snap +94 -24
  65. package/src/tests/ai-core.test.ts +2 -2
  66. package/src/tests/cli.test.ts +35 -1
  67. package/src/tests/github-review.test.ts +9 -0
  68. package/src/tests/server-editor.test.ts +100 -0
  69. package/src/tests/server.test.ts +333 -2
  70. package/src/tests/worktree.test.ts +369 -0
  71. package/src/server/client/RunList.tsx +0 -75
@@ -19,6 +19,13 @@ export const DEFAULT_MAX_FILES_PER_BRIEF = 40;
19
19
  /** Concorrência default das chamadas simultâneas ao Jev. */
20
20
  export const DEFAULT_JEV_CONCURRENCY = 5;
21
21
 
22
+ /**
23
+ * Localização default dos worktrees: dentro do workspace aberto no editor.
24
+ * Mantém leitura/execução dentro da pasta do projeto — agentes com permissão
25
+ * escopada ao workspace não pedem autorização a cada acesso.
26
+ */
27
+ export const DEFAULT_WORKTREES_LOCATION = "workspace";
28
+
22
29
  /** Teto default de bytes por arquivo no AST (evita bundles/minificados). */
23
30
  export const DEFAULT_AST_MAX_FILE_BYTES = 512_000;
24
31
 
@@ -29,7 +36,7 @@ export const DEFAULT_AST_MAX_IMPACT_FILES = 200;
29
36
  export const DEFAULT_AI_PROVIDER = "deepseek" as const;
30
37
 
31
38
  /** Modelo default da camada de IA. */
32
- export const DEFAULT_AI_MODEL = "deepseek-v4.1-flash";
39
+ export const DEFAULT_AI_MODEL = "deepseek-v4-pro";
33
40
 
34
41
  /** Concorrência default das chamadas de IA. */
35
42
  export const DEFAULT_AI_CONCURRENCY = 2;
@@ -6,9 +6,11 @@
6
6
  * de instanciar/importar tudo diretamente.
7
7
  */
8
8
 
9
+ import { resolve } from "node:path";
9
10
  import { AiClient } from "../ai/ai-client";
10
11
  import { AstService } from "../ast/service";
11
12
  import type { HoundsmanConfig } from "../config";
13
+ import { createGitRunner } from "../git-runner";
12
14
  import type { ReviewOptions } from "../orchestrator";
13
15
  import { ProjectConfig } from "../project-config";
14
16
  import { FileClassifier } from "../services/file-classifier";
@@ -19,9 +21,14 @@ import { createStorage } from "../storage/factory";
19
21
  import { resolveRepoIdentity } from "../storage/repo-identity";
20
22
  import type { RepoIdentity, StorageProvider } from "../storage/types";
21
23
  import { ReviewSession } from "./review-session";
24
+ import { mainRootOf } from "./review-workspace";
22
25
 
23
26
  /** Colaboradores injetáveis (testes/CLI). */
24
27
  export type EngineDeps = {
28
+ /** Raiz operacional (worktree ou clone principal). */
29
+ root?: string;
30
+ /** Raiz do clone principal (identidade/config). */
31
+ mainRoot?: string;
25
32
  git?: GitRepository;
26
33
  classifier?: FileClassifier;
27
34
  verifier?: Verifier;
@@ -35,6 +42,10 @@ export type EngineDeps = {
35
42
  /** Composition root do motor. */
36
43
  export class HoundsmanEngine {
37
44
  readonly project: ProjectConfig;
45
+ /** Onde os arquivos da revisão são lidos (worktree ou clone principal). */
46
+ readonly root: string;
47
+ /** Clone principal (identidade estável + camada/config). */
48
+ readonly mainRoot: string;
38
49
  readonly git: GitRepository;
39
50
  readonly classifier: FileClassifier;
40
51
  readonly verifier: Verifier;
@@ -46,16 +57,27 @@ export class HoundsmanEngine {
46
57
 
47
58
  constructor(raw: HoundsmanConfig, deps: EngineDeps = {}) {
48
59
  this.project = new ProjectConfig(raw);
49
- this.git = deps.git ?? new GitRepository();
60
+ this.root = resolve(deps.root ?? process.cwd());
61
+ this.git = deps.git ?? new GitRepository(createGitRunner(this.root));
62
+ this.mainRoot = resolve(
63
+ deps.mainRoot ?? mainRootOf(this.git, this.repoRootFallback()),
64
+ );
50
65
  this.classifier = deps.classifier ?? new FileClassifier(raw);
51
66
  this.verifier = deps.verifier ?? new Verifier();
52
67
  this.fixBatch = deps.fixBatch ?? new FixBatchBuilder();
53
68
  this.storage = deps.storage ?? createStorage(raw);
54
- this.repo = deps.repo ?? resolveRepoIdentity(this.git);
55
- this.ast = deps.ast ?? new AstService(raw, this.git);
69
+ this.repo =
70
+ deps.repo ??
71
+ resolveRepoIdentity(new GitRepository(createGitRunner(this.mainRoot)));
72
+ this.ast = deps.ast ?? new AstService(raw, this.git, this.root);
56
73
  this.ai = deps.ai ?? new AiClient({ project: this.project });
57
74
  }
58
75
 
76
+ /** Raiz do clone principal a partir do git atual (fallback: `root`). */
77
+ private repoRootFallback(): string {
78
+ return this.git.rootPath() ?? this.root;
79
+ }
80
+
59
81
  /** Configuração crua (contrato do consumidor). */
60
82
  get config(): HoundsmanConfig {
61
83
  return this.project.raw;
@@ -70,6 +92,7 @@ export class HoundsmanEngine {
70
92
  this.repo,
71
93
  this.ast,
72
94
  this.ai,
95
+ { root: this.root, mainRoot: this.mainRoot, git: this.git },
73
96
  );
74
97
  }
75
98
 
@@ -8,13 +8,12 @@
8
8
  import type { AstService } from "../ast/service";
9
9
  import type { AstAnalysis, AstFacts } from "../ast/types";
10
10
  import type { HoundsmanConfig } from "../config";
11
- import { churnToRecord, collectDiff, type DiffSource } from "../diff-source";
11
+ import { churnToRecord, type DiffSource } from "../diff-source";
12
12
  import { type ClassificationResult, parseEntries } from "../file-classifier";
13
13
  import type { FindingsStateFile } from "../findings-state";
14
- import { collectDiffSince, diffEntries, isAncestor } from "../git-revision";
15
- import { runGit, tryGit } from "../git-runner";
16
14
  import type { MergedFinding } from "../report-builder";
17
15
  import { FileClassifier } from "../services/file-classifier";
16
+ import { GitRepository } from "../services/git-repository";
18
17
  import { buildCarryover } from "./carryover";
19
18
 
20
19
  /** Opções que afetam o escopo (subset do `ReviewOptions`). */
@@ -60,11 +59,18 @@ export class ReviewScopeResolver {
60
59
  private readonly options: ScopeOptions;
61
60
  private readonly classifier: FileClassifier;
62
61
  private readonly ast?: AstService;
62
+ private readonly git: GitRepository;
63
63
 
64
- constructor(options: ScopeOptions, config: HoundsmanConfig, ast?: AstService) {
64
+ constructor(
65
+ options: ScopeOptions,
66
+ config: HoundsmanConfig,
67
+ ast?: AstService,
68
+ git: GitRepository = new GitRepository(),
69
+ ) {
65
70
  this.options = options;
66
71
  this.classifier = new FileClassifier(config);
67
72
  this.ast = ast;
73
+ this.git = git;
68
74
  }
69
75
 
70
76
  /**
@@ -89,18 +95,15 @@ export class ReviewScopeResolver {
89
95
  this.options.full ||
90
96
  this.options.clean ||
91
97
  !prevSha ||
92
- !isAncestor(prevSha, headSha)
98
+ !this.git.isAncestor(prevSha, headSha)
93
99
  ) {
94
- const diff = collectDiff(this.options.base, this.options.head);
100
+ const diff = this.git.collectDiff(this.options.base, this.options.head);
95
101
  const entries = parseEntries(
96
- runGit([
97
- "diff",
98
- `${this.options.base}...${this.options.head}`,
99
- "--name-status",
100
- ]),
102
+ this.git.diffNameStatus(this.options.base, this.options.head),
101
103
  );
102
104
  const baseRef =
103
- mergeBase(this.options.base, this.options.head) ?? this.options.base;
105
+ this.git.mergeBase(this.options.base, this.options.head) ??
106
+ this.options.base;
104
107
  const ast = this.ast
105
108
  ? await this.ast.analyze(entries, diff, baseRef)
106
109
  : undefined;
@@ -119,8 +122,8 @@ export class ReviewScopeResolver {
119
122
  }
120
123
 
121
124
  const range = `${prevSha}..${headSha}`;
122
- const diff = collectDiffSince(prevSha, headSha);
123
- const entries = diffEntries(range);
125
+ const diff = this.git.collectDiffSince(prevSha, headSha);
126
+ const entries = parseEntries(this.git.run(["diff", range, "--name-status"]));
124
127
  const changedPaths = new Set(entries.map((entry) => entry.path));
125
128
  const ast = this.ast ? await this.ast.analyze(entries, diff, prevSha) : undefined;
126
129
  const classification: RunClassification = {
@@ -149,11 +152,3 @@ function factsOf(
149
152
  }
150
153
  return facts;
151
154
  }
152
-
153
- /** SHA do merge-base entre base e head (best effort). */
154
- function mergeBase(base: string, head: string): string | null {
155
- const result = tryGit(["merge-base", base, head]);
156
- if (!result.ok) return null;
157
- const sha = result.stdout.trim();
158
- return sha.length > 0 ? sha : null;
159
- }
@@ -7,6 +7,7 @@
7
7
  import type { AiClient } from "../ai/ai-client";
8
8
  import type { AstService } from "../ast/service";
9
9
  import type { HoundsmanConfig } from "../config";
10
+ import { createGitRunner } from "../git-runner";
10
11
  import {
11
12
  type FinalizeResult,
12
13
  finalize,
@@ -14,10 +15,12 @@ import {
14
15
  prepare,
15
16
  type ReviewContext,
16
17
  type ReviewOptions,
18
+ type ReviewWorkspaceContext,
17
19
  resolveBranch,
18
20
  reviewContext,
19
21
  } from "../orchestrator";
20
22
  import { sanitizeBranch } from "../report-builder";
23
+ import { GitRepository } from "../services/git-repository";
21
24
  import type { RepoIdentity, StorageProvider } from "../storage/types";
22
25
 
23
26
  /** Uma revisão em andamento (prepare ou finalize). */
@@ -37,6 +40,7 @@ export class ReviewSession {
37
40
  repo: RepoIdentity,
38
41
  ast?: AstService,
39
42
  ai?: AiClient,
43
+ workspace?: ReviewWorkspaceContext,
40
44
  ) {
41
45
  this.config = config;
42
46
  this.options = options;
@@ -51,13 +55,30 @@ export class ReviewSession {
51
55
  slug: sanitizeBranch(branch),
52
56
  base: options.base,
53
57
  head: options.head,
58
+ worktreePath: options.worktreePath,
59
+ prNumber: options.pr,
54
60
  });
55
61
  if (!review) {
56
62
  throw new Error(
57
63
  `Nenhuma revisão para '${branch}'. Rode \`houndsman\` (preparar) primeiro.`,
58
64
  );
59
65
  }
60
- this.context = reviewContext(storage, review);
66
+ // Um prepare no clone principal invalida um worktree usado antes; o finalize
67
+ // não mexe (pode ser rodado de outro lugar depois do prepare no worktree).
68
+ const current =
69
+ !options.finalize && !options.worktreePath && review.worktreePath
70
+ ? (storage.updateReview(review.id, { resetWorkspace: true }) ?? review)
71
+ : review;
72
+ // Finalize sem flag explícita reusa o worktree gravado no prepare.
73
+ const effective =
74
+ options.finalize && !options.worktreePath && current.worktreePath
75
+ ? {
76
+ root: current.worktreePath,
77
+ mainRoot: workspace?.mainRoot,
78
+ git: new GitRepository(createGitRunner(current.worktreePath)),
79
+ }
80
+ : workspace;
81
+ this.context = reviewContext(storage, current, effective);
61
82
  }
62
83
 
63
84
  /** Resolve a branch (opção explícita ou a atual do git). */
@@ -68,15 +89,17 @@ export class ReviewSession {
68
89
  repo: RepoIdentity,
69
90
  ast?: AstService,
70
91
  ai?: AiClient,
92
+ workspace?: ReviewWorkspaceContext,
71
93
  ): ReviewSession {
72
94
  return new ReviewSession(
73
95
  config,
74
96
  options,
75
- resolveBranch(options),
97
+ resolveBranch(options, workspace?.git),
76
98
  storage,
77
99
  repo,
78
100
  ast,
79
101
  ai,
102
+ workspace,
80
103
  );
81
104
  }
82
105
 
@@ -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
+ }
@@ -16,20 +16,32 @@ export type GitResult = {
16
16
  /** Executa `git` e devolve o resultado cru. Injetável nos testes. */
17
17
  export type GitRunner = (args: string[]) => GitResult;
18
18
 
19
- /** Runner padrão: `git -c core.quotepath=false <args>` a partir do cwd. */
20
- export const defaultGitRunner: GitRunner = (args) => {
21
- const result = Bun.spawnSync(["git", "-c", "core.quotepath=false", ...args], {
22
- stdout: "pipe",
23
- stderr: "pipe",
24
- });
25
- const code = result.exitCode ?? 1;
26
- return {
27
- ok: code === 0,
28
- code,
29
- stdout: new TextDecoder().decode(result.stdout),
30
- stderr: new TextDecoder().decode(result.stderr),
19
+ /**
20
+ * Cria um runner do git com diretório de trabalho fixo.
21
+ *
22
+ * O servidor roda a partir da raiz do repositório, mas o checkout pode ser
23
+ * disparado de outra sessão — o `cwd` garante que os comandos atinjam o repo
24
+ * certo. Sem `cwd`, herda o diretório do processo (default).
25
+ */
26
+ export function createGitRunner(cwd?: string): GitRunner {
27
+ return (args) => {
28
+ const result = Bun.spawnSync(["git", "-c", "core.quotepath=false", ...args], {
29
+ cwd,
30
+ stdout: "pipe",
31
+ stderr: "pipe",
32
+ });
33
+ const code = result.exitCode ?? 1;
34
+ return {
35
+ ok: code === 0,
36
+ code,
37
+ stdout: new TextDecoder().decode(result.stdout),
38
+ stderr: new TextDecoder().decode(result.stderr),
39
+ };
31
40
  };
32
- };
41
+ }
42
+
43
+ /** Runner padrão: `git -c core.quotepath=false <args>` a partir do cwd. */
44
+ export const defaultGitRunner: GitRunner = createGitRunner();
33
45
 
34
46
  /** Executa e devolve o stdout; lança com o stderr quando o git falha. */
35
47
  export function runGit(args: string[], runner: GitRunner = defaultGitRunner): string {
@@ -24,7 +24,12 @@ import {
24
24
  type FindingsStateFile,
25
25
  } from "./findings-state";
26
26
  import { systemClock } from "./services/clock";
27
- import { defaultGhRunner, type GhResult, type GhRunner } from "./services/gh-client";
27
+ import {
28
+ defaultGhRunner,
29
+ describeGhError,
30
+ type GhResult,
31
+ type GhRunner,
32
+ } from "./services/gh-client";
28
33
  import { SEVERITY_EMOJI, SEVERITY_ORDER, type Severity } from "./types";
29
34
 
30
35
  export { type GhResult, type GhRunner, defaultGhRunner };
@@ -54,12 +59,7 @@ function repoFromUrl(url: string | undefined): string | null {
54
59
 
55
60
  /** Mensagem curta para um erro do `gh`. */
56
61
  function shortError(result: GhResult): string {
57
- if (result.code === 127 || /not found|ENOENT/i.test(result.stderr)) {
58
- return "gh não instalado.";
59
- }
60
- const last = result.stderr.split("\n").filter(Boolean).pop() ?? "";
61
- if (/auth|login|logged/i.test(last)) return `gh sem autenticação (${last}).`;
62
- return last || `gh saiu com código ${result.code}.`;
62
+ return describeGhError(result);
63
63
  }
64
64
 
65
65
  /**
@@ -118,6 +118,8 @@ export type SubmitCandidate = {
118
118
  file: string;
119
119
  line: number | null;
120
120
  severity: Severity;
121
+ /** Decisão da triagem (`execute` = "a executar"/fila de correção). */
122
+ decision: FindingDecision;
121
123
  execState: FindingExecState;
122
124
  rule: string;
123
125
  message: string;
@@ -159,6 +161,7 @@ export function selectSubmitCandidates(
159
161
  file: finding.file,
160
162
  line: finding.line,
161
163
  severity: finding.severity,
164
+ decision: finding.decision,
162
165
  execState: finding.execState,
163
166
  rule: finding.rule,
164
167
  message: finding.message,
@@ -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");