@payconductor/houndsman 0.5.0 → 0.6.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 (153) hide show
  1. package/README.md +24 -4
  2. package/assets/grammars/tree-sitter-javascript.wasm +0 -0
  3. package/assets/grammars/tree-sitter-tsx.wasm +0 -0
  4. package/assets/grammars/tree-sitter-typescript.wasm +0 -0
  5. package/assets/grammars/web-tree-sitter.wasm +0 -0
  6. package/package.json +11 -3
  7. package/src/cli/commands/command.ts +5 -0
  8. package/src/cli/commands/doctor-command.ts +116 -24
  9. package/src/cli/commands/hunt-command.ts +25 -0
  10. package/src/cli/help.ts +17 -1
  11. package/src/cli/index.ts +10 -6
  12. package/src/core/ai/ai-client.ts +221 -0
  13. package/src/core/ai/ai-sdk-model-provider.ts +101 -0
  14. package/src/core/ai/errors.ts +36 -0
  15. package/src/core/ai/features/brief-enricher.ts +62 -0
  16. package/src/core/ai/features/classifier.ts +50 -0
  17. package/src/core/ai/features/context-summarizer.ts +31 -0
  18. package/src/core/ai/features/pre-analyzer.ts +51 -0
  19. package/src/core/ai/features/verifier.ts +45 -0
  20. package/src/core/ai/index.ts +56 -0
  21. package/src/core/ai/model-factory.ts +137 -0
  22. package/src/core/ai/prompts/base.ts +36 -0
  23. package/src/core/ai/prompts/brief.ts +87 -0
  24. package/src/core/ai/prompts/classification.ts +36 -0
  25. package/src/core/ai/prompts/context.ts +29 -0
  26. package/src/core/ai/prompts/pre-analysis.ts +49 -0
  27. package/src/core/ai/prompts/verify.ts +40 -0
  28. package/src/core/ai/schemas.ts +110 -0
  29. package/src/core/ai/types.ts +76 -0
  30. package/src/core/ai/usage-budget.ts +75 -0
  31. package/src/core/ai/usage-recorder.ts +79 -0
  32. package/src/core/assets.ts +16 -0
  33. package/src/core/ast/diff.ts +220 -0
  34. package/src/core/ast/impact.ts +127 -0
  35. package/src/core/ast/parser.ts +167 -0
  36. package/src/core/ast/service.ts +233 -0
  37. package/src/core/ast/symbols.ts +294 -0
  38. package/src/core/ast/types.ts +125 -0
  39. package/src/core/brief.ts +138 -0
  40. package/src/core/commands/findings.ts +59 -1
  41. package/src/core/commands/review.ts +2 -0
  42. package/src/core/commands/scaffold.ts +159 -10
  43. package/src/core/commands/update.ts +5 -3
  44. package/src/core/config.ts +85 -0
  45. package/src/core/defaults.ts +27 -0
  46. package/src/core/domain/watchdog.ts +43 -5
  47. package/src/core/engine/houndsman-engine.ts +16 -1
  48. package/src/core/engine/review-scope.ts +52 -6
  49. package/src/core/engine/review-session.ts +27 -2
  50. package/src/core/env.ts +101 -0
  51. package/src/core/file-classifier.ts +25 -4
  52. package/src/core/jev/jev-client.ts +8 -7
  53. package/src/core/jev/jev-verify.ts +2 -1
  54. package/src/core/logging/index.ts +17 -0
  55. package/src/core/logging/logger.ts +187 -0
  56. package/src/core/orchestrator.ts +357 -9
  57. package/src/core/project-config.ts +123 -1
  58. package/src/core/registry-check.ts +148 -6
  59. package/src/core/renderers/manifest-writer.ts +33 -0
  60. package/src/core/rule-text.ts +97 -0
  61. package/src/core/services/file-classifier.ts +10 -3
  62. package/src/core/services/verifier.ts +14 -3
  63. package/src/core/storage/paths.ts +6 -5
  64. package/src/core/storage/types.ts +6 -0
  65. package/src/core/storage/work-area.ts +12 -0
  66. package/src/core/types.ts +37 -1
  67. package/src/core/verify.ts +17 -5
  68. package/src/harness/README.md +15 -0
  69. package/src/harness/agent/agent-context.ts +33 -0
  70. package/src/harness/agent/agent-errors.ts +30 -0
  71. package/src/harness/agent/agent-factory.ts +45 -0
  72. package/src/harness/agent/agent-reporter.ts +34 -0
  73. package/src/harness/agent/agent-result.ts +16 -0
  74. package/src/harness/agent/agent-runner.ts +32 -0
  75. package/src/harness/agent/agent-scheduler.ts +37 -0
  76. package/src/harness/agent/review-agent.ts +391 -0
  77. package/src/harness/config/harness-config.ts +84 -0
  78. package/src/harness/hunt/hunt-harness.ts +83 -0
  79. package/src/harness/hunt/hunt-options.ts +75 -0
  80. package/src/harness/hunt/hunt-result.ts +17 -0
  81. package/src/harness/hunt/hunt-session.ts +372 -0
  82. package/src/harness/hunt/review-planner.ts +98 -0
  83. package/src/harness/index.ts +36 -0
  84. package/src/harness/investigation/agent-state.ts +134 -0
  85. package/src/harness/investigation/evidence-verifier.ts +129 -0
  86. package/src/harness/investigation/evidence.ts +110 -0
  87. package/src/harness/investigation/finding-critic.ts +96 -0
  88. package/src/harness/investigation/investigation-planner.ts +118 -0
  89. package/src/harness/investigation/investigation-task.ts +10 -0
  90. package/src/harness/investigation/review-journal.ts +87 -0
  91. package/src/harness/investigation/rule-analyzer.ts +178 -0
  92. package/src/harness/investigation/rule-signals.ts +110 -0
  93. package/src/harness/prompts/agent-schemas.ts +57 -0
  94. package/src/harness/prompts/reviewer-prompt-builder.ts +101 -0
  95. package/src/harness/prompts/states/conclude-prompt.ts +16 -0
  96. package/src/harness/prompts/states/investigate-prompt.ts +37 -0
  97. package/src/harness/prompts/states/orient-prompt.ts +15 -0
  98. package/src/harness/prompts/states/verify-prompt.ts +22 -0
  99. package/src/harness/tools/agent-tool.ts +102 -0
  100. package/src/harness/tools/builtin/changed-symbols-tool.ts +48 -0
  101. package/src/harness/tools/builtin/doc-context-tool.ts +74 -0
  102. package/src/harness/tools/builtin/file-outline-tool.ts +66 -0
  103. package/src/harness/tools/builtin/find-references-tool.ts +66 -0
  104. package/src/harness/tools/builtin/find-symbol-tool.ts +91 -0
  105. package/src/harness/tools/builtin/helpers.ts +115 -0
  106. package/src/harness/tools/builtin/impact-tool.ts +48 -0
  107. package/src/harness/tools/builtin/imports-exports-tool.ts +53 -0
  108. package/src/harness/tools/builtin/index.ts +59 -0
  109. package/src/harness/tools/builtin/list-files-tool.ts +85 -0
  110. package/src/harness/tools/builtin/pr-context-tool.ts +41 -0
  111. package/src/harness/tools/builtin/read-file-range-tool.ts +73 -0
  112. package/src/harness/tools/builtin/rule-signals-tool.ts +36 -0
  113. package/src/harness/tools/builtin/search-code-tool.ts +65 -0
  114. package/src/harness/tools/builtin/symbol-context-tool.ts +106 -0
  115. package/src/harness/tools/builtin/tests-for-symbol-tool.ts +61 -0
  116. package/src/harness/tools/path-guard.ts +90 -0
  117. package/src/harness/tools/result-limiter.ts +49 -0
  118. package/src/harness/tools/secret-redactor.ts +54 -0
  119. package/src/harness/tools/tool-cache.ts +50 -0
  120. package/src/harness/tools/tool-call-budget.ts +60 -0
  121. package/src/harness/tools/tool-context.ts +5 -0
  122. package/src/harness/tools/tool-middleware.ts +105 -0
  123. package/src/harness/tools/tool-policy.ts +51 -0
  124. package/src/harness/tools/tool-registry.ts +112 -0
  125. package/src/harness/tools/tool-result.ts +15 -0
  126. package/src/harness/tools/toolbox.ts +36 -0
  127. package/src/harness/validation/finding-validator.ts +119 -0
  128. package/src/index.ts +46 -0
  129. package/src/server/client/utils/constants.ts +27 -5
  130. package/src/server/client/utils/types.ts +6 -0
  131. package/src/server/index.ts +3 -2
  132. package/src/skill/OUTPUT-FORMAT.md +12 -0
  133. package/src/skill/SETUP.md +6 -2
  134. package/src/skill/hunt.md +14 -2
  135. package/src/skill/verify.md +1 -1
  136. package/src/tests/__snapshots__/baseline.test.ts.snap +96 -14
  137. package/src/tests/ai-core.test.ts +120 -0
  138. package/src/tests/ai-features.test.ts +183 -0
  139. package/src/tests/ast.test.ts +335 -0
  140. package/src/tests/env.test.ts +69 -0
  141. package/src/tests/fallback.test.ts +172 -0
  142. package/src/tests/fixtures/fake-agent-tools.ts +69 -0
  143. package/src/tests/fixtures/fake-investigation.ts +64 -0
  144. package/src/tests/fixtures/fake-model-provider.ts +48 -0
  145. package/src/tests/harness-agent.test.ts +122 -0
  146. package/src/tests/harness-investigation.test.ts +206 -0
  147. package/src/tests/harness-tools.test.ts +148 -0
  148. package/src/tests/hunt-command.test.ts +212 -0
  149. package/src/tests/logger.test.ts +83 -0
  150. package/src/tests/review-scope.test.ts +11 -11
  151. package/src/tests/rule-analyzer.test.ts +61 -0
  152. package/src/tests/tool-contracts.test.ts +82 -0
  153. package/src/tests/verify.test.ts +1 -1
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Tool `read_file_range` — lê um trecho explícito (nunca o arquivo inteiro).
3
+ */
4
+
5
+ import { z } from "zod";
6
+ import type { AgentTool, ToolMetadata, ToolResult } from "../agent-tool";
7
+ import { PathGuard } from "../path-guard";
8
+ import { TOOL_LIMITS } from "../result-limiter";
9
+ import { readLines, readRange } from "./helpers";
10
+
11
+ const Input = z.object({
12
+ path: z.string().min(1),
13
+ startLine: z.number().int().min(1),
14
+ endLine: z.number().int().min(1),
15
+ });
16
+
17
+ /** Leitura por faixa, com no máximo 200 linhas/32 KB. */
18
+ export class ReadFileRangeTool implements AgentTool<z.infer<typeof Input>, unknown> {
19
+ readonly metadata: ToolMetadata = {
20
+ name: "read_file_range",
21
+ description: "Lê um trecho de arquivo (faixa obrigatória, até 200 linhas).",
22
+ category: "code",
23
+ capabilities: ["read"],
24
+ readOnly: true,
25
+ cost: 3,
26
+ cacheTtlMs: 60_000,
27
+ };
28
+
29
+ readonly inputSchema = Input;
30
+
31
+ async execute(
32
+ input: z.infer<typeof Input>,
33
+ context: import("../tool-context").ToolContext,
34
+ ): Promise<ToolResult<unknown>> {
35
+ const guard = new PathGuard(context.repoRoot);
36
+ try {
37
+ const range = readRange(guard, input.path, input.startLine, input.endLine);
38
+ if (!range) return { ok: false, error: "arquivo ilegível." };
39
+ const lines = readLines(guard.assertReadable(input.path)) ?? [];
40
+ const evidence = {
41
+ id: "",
42
+ file: range.path,
43
+ startLine: range.startLine,
44
+ endLine: range.endLine,
45
+ snippet: lines.slice(range.startLine - 1, range.endLine).join("\n"),
46
+ kind: "diff" as const,
47
+ sourceTool: "read_file_range",
48
+ confidence: 0.6,
49
+ };
50
+ return {
51
+ ok: true,
52
+ data: range,
53
+ ...(range.truncated
54
+ ? {
55
+ truncated: true,
56
+ hint: `Faixa limitada a ${TOOL_LIMITS.maxLines} linhas; refine o intervalo.`,
57
+ }
58
+ : {}),
59
+ evidence: [
60
+ {
61
+ ...evidence,
62
+ snippet: evidence.snippet.slice(0, TOOL_LIMITS.maxBytes),
63
+ },
64
+ ],
65
+ };
66
+ } catch (error) {
67
+ return {
68
+ ok: false,
69
+ error: error instanceof Error ? error.message : String(error),
70
+ };
71
+ }
72
+ }
73
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Tool `rule_signals` — traduz uma regra em sinais e checks.
3
+ */
4
+
5
+ import { z } from "zod";
6
+ import type { AgentTool, ToolMetadata, ToolResult } from "../agent-tool";
7
+
8
+ const Input = z.object({ rule: z.string().min(3) });
9
+
10
+ /** Devolve a `RuleAnalysis` determinística da regra. */
11
+ export class RuleSignalsTool implements AgentTool<z.infer<typeof Input>, unknown> {
12
+ readonly metadata: ToolMetadata = {
13
+ name: "rule_signals",
14
+ description: "Sinais, keywords e checks sugeridos para uma regra do watchdog.",
15
+ category: "rules",
16
+ capabilities: ["rules"],
17
+ readOnly: true,
18
+ cost: 1,
19
+ cacheTtlMs: 300_000,
20
+ };
21
+
22
+ readonly inputSchema = Input;
23
+
24
+ async execute(
25
+ input: z.infer<typeof Input>,
26
+ context: import("../tool-context").ToolContext,
27
+ ): Promise<ToolResult<unknown>> {
28
+ const analysis =
29
+ context.rules.get(input.rule) ??
30
+ [...context.rules.values()].find((item) => item.rule.includes(input.rule));
31
+ if (!analysis) {
32
+ return { ok: false, error: "regra não encontrada no watchdog." };
33
+ }
34
+ return { ok: true, data: analysis };
35
+ }
36
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Tool `search_code` — localiza padrões via `git grep` e devolve snippets.
3
+ */
4
+
5
+ import { z } from "zod";
6
+ import type { AgentTool, ToolMetadata, ToolResult } from "../agent-tool";
7
+ import { TOOL_LIMITS } from "../result-limiter";
8
+ import { gitGrep, parseGrepLine } from "./helpers";
9
+
10
+ const Input = z.object({
11
+ query: z.string().min(2),
12
+ mode: z.enum(["fixed", "regex"]).optional(),
13
+ path: z.string().optional(),
14
+ contextLines: z.number().int().min(0).max(3).optional(),
15
+ maxResults: z.number().int().min(1).max(50).optional(),
16
+ });
17
+
18
+ /** Busca com limite de resultados e snippets curtos. */
19
+ export class SearchCodeTool implements AgentTool<z.infer<typeof Input>, unknown> {
20
+ readonly metadata: ToolMetadata = {
21
+ name: "search_code",
22
+ description: "Procura um padrão no código (até 50 resultados com snippet).",
23
+ category: "code",
24
+ capabilities: ["search"],
25
+ readOnly: true,
26
+ cost: 2,
27
+ cacheTtlMs: 60_000,
28
+ };
29
+
30
+ readonly inputSchema = Input;
31
+
32
+ async execute(
33
+ input: z.infer<typeof Input>,
34
+ context: import("../tool-context").ToolContext,
35
+ ): Promise<ToolResult<unknown>> {
36
+ const mode = input.mode === "regex" ? "-E" : "-F";
37
+ const args = ["-n", "-I", mode, "-e", input.query];
38
+ if (input.path) args.push("--", input.path);
39
+ const result = gitGrep(context.repoRoot, args);
40
+ if (!result.ok) {
41
+ // `git grep` retorna 1 quando não há match (não é erro).
42
+ if (result.stderr.includes("exit") || result.stdout === "") {
43
+ return { ok: true, data: { results: [], truncated: false } };
44
+ }
45
+ return { ok: false, error: result.stderr || "falha no git grep." };
46
+ }
47
+ const max = input.maxResults ?? TOOL_LIMITS.maxResults;
48
+ const results = result.stdout
49
+ .split("\n")
50
+ .map(parseGrepLine)
51
+ .filter((line): line is NonNullable<typeof line> => line !== null)
52
+ .slice(0, max)
53
+ .map((line) => ({
54
+ file: line.file,
55
+ line: line.line,
56
+ text: line.text.slice(0, 240),
57
+ }));
58
+ const truncated = result.stdout.trim().split("\n").length > results.length;
59
+ return {
60
+ ok: true,
61
+ data: { results, truncated },
62
+ ...(truncated ? { truncated: true, hint: "Refine a query/path." } : {}),
63
+ };
64
+ }
65
+ }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Tool `symbol_context` — definição + janela curta do corpo + referências.
3
+ */
4
+
5
+ import { z } from "zod";
6
+ import type { AgentTool, ToolMetadata, ToolResult } from "../agent-tool";
7
+ import { PathGuard } from "../path-guard";
8
+ import { TOOL_LIMITS } from "../result-limiter";
9
+ import { gitGrep, numberedRange, parseGrepLine, readLines, snippet } from "./helpers";
10
+
11
+ const Input = z.object({
12
+ symbol: z.string().min(2),
13
+ file: z.string().optional(),
14
+ });
15
+
16
+ /** Devolve no máximo ~120 linhas do símbolo (nunca o arquivo inteiro). */
17
+ export class SymbolContextTool implements AgentTool<z.infer<typeof Input>, unknown> {
18
+ readonly metadata: ToolMetadata = {
19
+ name: "symbol_context",
20
+ description: "Definição + assinatura + corpo curto + referências de um símbolo.",
21
+ category: "symbol",
22
+ capabilities: ["symbol", "read"],
23
+ readOnly: true,
24
+ cost: 3,
25
+ cacheTtlMs: 60_000,
26
+ };
27
+
28
+ readonly inputSchema = Input;
29
+
30
+ async execute(
31
+ input: z.infer<typeof Input>,
32
+ context: import("../tool-context").ToolContext,
33
+ ): Promise<ToolResult<unknown>> {
34
+ const guard = new PathGuard(context.repoRoot);
35
+ const files = input.file
36
+ ? [input.file]
37
+ : [
38
+ ...new Set([
39
+ ...context.agent.files,
40
+ ...(context.agent.impact?.affectedFiles ?? []),
41
+ ]),
42
+ ];
43
+ const maxLines = Math.min(TOOL_LIMITS.maxLines, 120);
44
+
45
+ for (const file of files.slice(0, 50)) {
46
+ let relative: string;
47
+ try {
48
+ relative = guard.relativePath(file);
49
+ } catch {
50
+ continue;
51
+ }
52
+ const facts = await context.ast.factsOf(relative);
53
+ if (!facts) continue;
54
+ const symbol = facts.symbols.find((item) => item.name === input.symbol);
55
+ if (!symbol) continue;
56
+
57
+ const lines = readLines(guard.assertReadable(relative)) ?? [];
58
+ const endLine = Math.min(symbol.endLine, symbol.startLine + maxLines - 1);
59
+ const references = referencesOf(context.repoRoot, input.symbol);
60
+ return {
61
+ ok: true,
62
+ data: {
63
+ file: relative,
64
+ name: symbol.name,
65
+ kind: symbol.kind,
66
+ exported: symbol.exported,
67
+ signature: symbol.signature,
68
+ startLine: symbol.startLine,
69
+ endLine,
70
+ truncatedBody: symbol.endLine > endLine,
71
+ body: numberedRange(lines, symbol.startLine, endLine),
72
+ references,
73
+ },
74
+ evidence: [
75
+ {
76
+ id: "",
77
+ file: relative,
78
+ startLine: symbol.startLine,
79
+ endLine,
80
+ snippet: snippet(lines, symbol.startLine, endLine),
81
+ kind: "definition",
82
+ sourceTool: "symbol_context",
83
+ confidence: 0.9,
84
+ },
85
+ ],
86
+ };
87
+ }
88
+ return {
89
+ ok: false,
90
+ error: `símbolo '${input.symbol}' não encontrado (AST).`,
91
+ hint: "Use search_code/find_references.",
92
+ };
93
+ }
94
+ }
95
+
96
+ /** Referências resumidas (arquivo:linha). */
97
+ function referencesOf(root: string, symbol: string): { file: string; line: number }[] {
98
+ const result = gitGrep(root, ["-n", "-I", "-F", "-w", "-e", symbol]);
99
+ if (!result.ok) return [];
100
+ return result.stdout
101
+ .split("\n")
102
+ .map(parseGrepLine)
103
+ .filter((line): line is NonNullable<typeof line> => line !== null)
104
+ .slice(0, 20)
105
+ .map((line) => ({ file: line.file, line: line.line }));
106
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Tool `tests_for_symbol` — arquivos de teste relacionados a um símbolo.
3
+ */
4
+
5
+ import { z } from "zod";
6
+ import type { AgentTool, ToolMetadata, ToolResult } from "../agent-tool";
7
+ import { TOOL_LIMITS } from "../result-limiter";
8
+ import { gitGrep, parseGrepLine } from "./helpers";
9
+
10
+ const Input = z.object({ symbol: z.string().min(2), file: z.string().optional() });
11
+
12
+ const TEST_PATHSPECS = [
13
+ "*.test.*",
14
+ "*.spec.*",
15
+ "**/__tests__/**",
16
+ "**/tests/**",
17
+ "**/test/**",
18
+ ];
19
+
20
+ /** Referências em arquivos de teste (até 20 arquivos). */
21
+ export class TestsForSymbolTool implements AgentTool<z.infer<typeof Input>, unknown> {
22
+ readonly metadata: ToolMetadata = {
23
+ name: "tests_for_symbol",
24
+ description: "Arquivos de teste que mencionam um símbolo.",
25
+ category: "evidence",
26
+ capabilities: ["tests", "search"],
27
+ readOnly: true,
28
+ cost: 2,
29
+ cacheTtlMs: 120_000,
30
+ };
31
+
32
+ readonly inputSchema = Input;
33
+
34
+ async execute(
35
+ input: z.infer<typeof Input>,
36
+ context: import("../tool-context").ToolContext,
37
+ ): Promise<ToolResult<unknown>> {
38
+ const args = ["-n", "-I", "-F", "-w", "-e", input.symbol];
39
+ if (input.file) args.push("--", input.file);
40
+ else args.push("--", ...TEST_PATHSPECS);
41
+ const result = gitGrep(context.repoRoot, args);
42
+ if (!result.ok) return { ok: true, data: { files: [], references: [] } };
43
+ const references = result.stdout
44
+ .split("\n")
45
+ .map(parseGrepLine)
46
+ .filter((line): line is NonNullable<typeof line> => line !== null)
47
+ .slice(0, TOOL_LIMITS.maxResults);
48
+ const files = [...new Set(references.map((line) => line.file))].slice(0, 20);
49
+ const evidence = references.slice(0, 10).map((line) => ({
50
+ id: "",
51
+ file: line.file,
52
+ startLine: line.line,
53
+ endLine: line.line,
54
+ snippet: line.text,
55
+ kind: "test" as const,
56
+ sourceTool: "tests_for_symbol",
57
+ confidence: 0.6,
58
+ }));
59
+ return { ok: true, data: { files, references }, evidence };
60
+ }
61
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Guarda de caminhos das tools — impede leitura fora do repositório e de
3
+ * arquivos sensíveis (`..`, `.git`, `.env*`, binários, arquivos enormes).
4
+ */
5
+
6
+ import { statSync } from "node:fs";
7
+ import { extname, isAbsolute, relative, resolve, sep } from "node:path";
8
+
9
+ /** Erro de caminho bloqueado (mensagem segura). */
10
+ export class PathGuardError extends Error {
11
+ constructor(message: string) {
12
+ super(message);
13
+ this.name = "PathGuardError";
14
+ }
15
+ }
16
+
17
+ /** Extensões consideradas binárias. */
18
+ const BINARY_EXTENSIONS = new Set([
19
+ ".png",
20
+ ".jpg",
21
+ ".jpeg",
22
+ ".gif",
23
+ ".webp",
24
+ ".ico",
25
+ ".pdf",
26
+ ".zip",
27
+ ".gz",
28
+ ".tgz",
29
+ ".wasm",
30
+ ".node",
31
+ ".so",
32
+ ".dll",
33
+ ".exe",
34
+ ".lockb",
35
+ ]);
36
+
37
+ /** Nomes/diretórios bloqueados. */
38
+ function isBlockedPath(path: string): boolean {
39
+ const normalized = path.replace(/\\/g, "/");
40
+ if (/(^|\/)\.git(\/|$)/.test(normalized)) return true;
41
+ if (/(^|\/)node_modules(\/|$)/.test(normalized)) return true;
42
+ if (/(^|\/)\.env(\.|$)/.test(normalized)) return true;
43
+ if (/(^|\/)\.houndsman\/core\/grammars(\/|$)/.test(normalized)) return true;
44
+ return false;
45
+ }
46
+
47
+ /** Guarda de caminhos com raiz fixa. */
48
+ export class PathGuard {
49
+ readonly root: string;
50
+ private readonly maxFileBytes: number;
51
+
52
+ constructor(root: string, maxFileBytes = 1_000_000) {
53
+ this.root = resolve(root);
54
+ this.maxFileBytes = maxFileBytes;
55
+ }
56
+
57
+ /** Resolve um caminho relativo dentro da raiz; lança se escapar. */
58
+ resolvePath(input: string): string {
59
+ if (!input.trim()) throw new PathGuardError("caminho vazio");
60
+ const absolute = isAbsolute(input) ? resolve(input) : resolve(this.root, input);
61
+ const rel = relative(this.root, absolute);
62
+ if (rel.startsWith("..") || rel.split(sep).includes("..")) {
63
+ throw new PathGuardError("caminho fora do repositório");
64
+ }
65
+ if (rel === "") throw new PathGuardError("caminho inválido");
66
+ if (isBlockedPath(rel)) throw new PathGuardError("caminho bloqueado");
67
+ if (BINARY_EXTENSIONS.has(extname(absolute).toLowerCase())) {
68
+ throw new PathGuardError("arquivo binário");
69
+ }
70
+ return absolute;
71
+ }
72
+
73
+ /** Resolve e valida tamanho/tipo; devolve o caminho absoluto. */
74
+ assertReadable(input: string): string {
75
+ const absolute = this.resolvePath(input);
76
+ let size = 0;
77
+ try {
78
+ size = statSync(absolute).size;
79
+ } catch {
80
+ throw new PathGuardError("arquivo inexistente");
81
+ }
82
+ if (size > this.maxFileBytes) throw new PathGuardError("arquivo muito grande");
83
+ return absolute;
84
+ }
85
+
86
+ /** Caminho relativo normalizado (para respostas). */
87
+ relativePath(input: string): string {
88
+ return relative(this.root, this.resolvePath(input)).replace(/\\/g, "/");
89
+ }
90
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Limites de resultado das tools (linhas, bytes, resultados).
3
+ *
4
+ * Nenhuma tool devolve arquivo inteiro por default. Os limites são parte do
5
+ * contrato: o agente recebe `truncated: true` e um `hint` para refinar.
6
+ */
7
+
8
+ /** Limites canônicos das tools. */
9
+ export const TOOL_LIMITS = {
10
+ maxLines: 200,
11
+ maxBytes: 32 * 1024,
12
+ maxResults: 50,
13
+ maxFiles: 200,
14
+ maxSnippetLines: 60,
15
+ } as const;
16
+
17
+ /** Resultado de um corte de linhas. */
18
+ export type LimitedLines = { lines: string[]; truncated: boolean };
19
+
20
+ /** Mantém no máximo `max` linhas. */
21
+ export function limitLines(
22
+ lines: readonly string[],
23
+ max: number = TOOL_LIMITS.maxLines,
24
+ ): LimitedLines {
25
+ if (lines.length <= max) return { lines: [...lines], truncated: false };
26
+ return { lines: lines.slice(0, max), truncated: true };
27
+ }
28
+
29
+ /** Corta um texto em bytes (UTF-8) sem estourar o orçamento. */
30
+ export function limitText(
31
+ text: string,
32
+ maxBytes: number = TOOL_LIMITS.maxBytes,
33
+ ): {
34
+ text: string;
35
+ truncated: boolean;
36
+ } {
37
+ const encoder = new TextEncoder();
38
+ if (encoder.encode(text).length <= maxBytes) return { text, truncated: false };
39
+ let end = Math.min(text.length, maxBytes);
40
+ while (end > 0 && encoder.encode(text.slice(0, end)).length > maxBytes) end--;
41
+ return { text: text.slice(0, Math.max(0, end)), truncated: true };
42
+ }
43
+
44
+ /** Numera as linhas a partir de `startLine` (1-based), como `cat -n`. */
45
+ export function numberLines(startLine: number, lines: readonly string[]): string {
46
+ return lines
47
+ .map((line, index) => `${String(startLine + index).padStart(4, " ")}\t${line}`)
48
+ .join("\n");
49
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Redação de segredos em resultados de tools/logs.
3
+ *
4
+ * Cobre chaves do ambiente do houndsman, tokens comuns (`sk-...`, `Bearer`) e
5
+ * pares `chave=valor` sensíveis. Nunca loga prompt nem conteúdo de `.env`.
6
+ */
7
+
8
+ import { env } from "../../core/env";
9
+
10
+ /** Padrões genéricos de segredo. */
11
+ const PATTERNS: readonly [RegExp, string][] = [
12
+ [/\bsk-[A-Za-z0-9_-]{8,}\b/g, "[REDACTED]"],
13
+ [/\bBearer\s+[A-Za-z0-9._~+/-]{8,}=*/gi, "Bearer [REDACTED]"],
14
+ [
15
+ /\b(api[_-]?key|token|secret|password|passwd|authorization)\b\s*[:=]\s*["']?[^\s"',}]+/gi,
16
+ "$1: [REDACTED]",
17
+ ],
18
+ [/\b[A-Fa-f0-9]{32,}\b/g, "[REDACTED]"],
19
+ ];
20
+
21
+ /** Substitui os valores reais do ambiente (sem revelá-los). */
22
+ function redactEnvValues(text: string): string {
23
+ let result = text;
24
+ for (const value of [env.deepSeekApiKey, env.typeSafeApiKey]) {
25
+ if (!value || value.length < 6) continue;
26
+ result = result.split(value).join("[REDACTED]");
27
+ }
28
+ return result;
29
+ }
30
+
31
+ /** Redige segredos de um texto. */
32
+ export function redactSecrets(text: string): string {
33
+ let result = redactEnvValues(text);
34
+ for (const [pattern, replacement] of PATTERNS) {
35
+ result = result.replace(pattern, replacement);
36
+ }
37
+ return result;
38
+ }
39
+
40
+ /** Redige recursivamente strings de um valor (preserva a forma). */
41
+ export function redactValue<T>(value: T): T {
42
+ if (typeof value === "string") return redactSecrets(value) as unknown as T;
43
+ if (Array.isArray(value)) {
44
+ return value.map((item) => redactValue(item)) as unknown as T;
45
+ }
46
+ if (value && typeof value === "object") {
47
+ const output: Record<string, unknown> = {};
48
+ for (const [key, item] of Object.entries(value as Record<string, unknown>)) {
49
+ output[key] = redactValue(item);
50
+ }
51
+ return output as unknown as T;
52
+ }
53
+ return value;
54
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Cache de resultados de tools por execução de agente.
3
+ *
4
+ * Chave = tool + input serializado. Respeita TTL declarado no metadata. Evita
5
+ * reler o mesmo trecho em estados diferentes.
6
+ */
7
+
8
+ /** Entrada do cache. */
9
+ type CacheEntry = { value: unknown; expiresAt: number };
10
+
11
+ /** Cache em memória (escopo de uma review/agente). */
12
+ export class ToolCache {
13
+ private readonly entries = new Map<string, CacheEntry>();
14
+
15
+ /** Chave estável (JSON com chaves ordenadas). */
16
+ static key(tool: string, input: unknown): string {
17
+ return `${tool}:${stableStringify(input)}`;
18
+ }
19
+
20
+ get<T>(key: string): T | undefined {
21
+ const entry = this.entries.get(key);
22
+ if (!entry) return undefined;
23
+ if (entry.expiresAt <= Date.now()) {
24
+ this.entries.delete(key);
25
+ return undefined;
26
+ }
27
+ return entry.value as T;
28
+ }
29
+
30
+ set(key: string, value: unknown, ttlMs = 60_000): void {
31
+ this.entries.set(key, { value, expiresAt: Date.now() + ttlMs });
32
+ }
33
+
34
+ clear(): void {
35
+ this.entries.clear();
36
+ }
37
+ }
38
+
39
+ /** Serializa um valor com chaves ordenadas. */
40
+ function stableStringify(value: unknown): string {
41
+ if (value === null || typeof value !== "object")
42
+ return JSON.stringify(value) ?? "null";
43
+ if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
44
+ const obj = value as Record<string, unknown>;
45
+ const body = Object.keys(obj)
46
+ .sort()
47
+ .map((key) => `${JSON.stringify(key)}:${stableStringify(obj[key])}`)
48
+ .join(",");
49
+ return `{${body}}`;
50
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Orçamento de chamadas de tool por agente.
3
+ */
4
+
5
+ /** Erro de orçamento de tools. */
6
+ export class ToolBudgetError extends Error {
7
+ constructor(message: string) {
8
+ super(message);
9
+ this.name = "ToolBudgetError";
10
+ }
11
+ }
12
+
13
+ /** Orçamento de chamadas (total e por tool). */
14
+ export class ToolCallBudget {
15
+ private readonly maxCalls: number;
16
+ private readonly maxPerTool: number;
17
+ private used = 0;
18
+ private readonly perTool = new Map<string, number>();
19
+
20
+ constructor(maxCalls = 30, maxPerTool = 12) {
21
+ this.maxCalls = maxCalls;
22
+ this.maxPerTool = maxPerTool;
23
+ }
24
+
25
+ get total(): number {
26
+ return this.used;
27
+ }
28
+
29
+ get remaining(): number {
30
+ return Math.max(0, this.maxCalls - this.used);
31
+ }
32
+
33
+ /** `true` quando ainda cabe a chamada. */
34
+ canCall(name: string): boolean {
35
+ if (this.used >= this.maxCalls) return false;
36
+ return (this.perTool.get(name) ?? 0) < this.maxPerTool;
37
+ }
38
+
39
+ /** Consome uma chamada; lança quando o orçamento acabou. */
40
+ consume(name: string): void {
41
+ if (this.used >= this.maxCalls) {
42
+ throw new ToolBudgetError(
43
+ `Orçamento de ${this.maxCalls} chamada(s) de tool por agente esgotado.`,
44
+ );
45
+ }
46
+ const calls = this.perTool.get(name) ?? 0;
47
+ if (calls >= this.maxPerTool) {
48
+ throw new ToolBudgetError(
49
+ `Limite de ${this.maxPerTool} chamada(s) da tool '${name}' atingido.`,
50
+ );
51
+ }
52
+ this.used++;
53
+ this.perTool.set(name, calls + 1);
54
+ }
55
+
56
+ /** Contagem por tool (transcript). */
57
+ snapshot(): Record<string, number> {
58
+ return Object.fromEntries(this.perTool);
59
+ }
60
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Contexto de execução das tools (reexport do contrato).
3
+ */
4
+
5
+ export type { ToolContext } from "./agent-tool";