@payconductor/houndsman 0.3.0 → 0.4.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 (69) hide show
  1. package/README.md +18 -6
  2. package/package.json +1 -1
  3. package/src/cli/commands/context-command.ts +11 -9
  4. package/src/cli/commands/doctor-command.ts +5 -0
  5. package/src/cli/commands/findings-command.ts +13 -3
  6. package/src/cli/commands/review-command.ts +1 -2
  7. package/src/cli/help.ts +7 -3
  8. package/src/cli/index.ts +2 -2
  9. package/src/core/commands/context.ts +29 -13
  10. package/src/core/commands/findings.ts +155 -120
  11. package/src/core/commands/review.ts +7 -8
  12. package/src/core/commands/scaffold.ts +15 -16
  13. package/src/core/config.ts +16 -7
  14. package/src/core/engine/houndsman-engine.ts +15 -1
  15. package/src/core/engine/review-session.ts +37 -9
  16. package/src/core/findings-state.ts +32 -71
  17. package/src/core/jev/jev-candidates.ts +49 -35
  18. package/src/core/orchestrator.ts +175 -128
  19. package/src/core/paths.ts +23 -17
  20. package/src/core/pr-context.ts +19 -5
  21. package/src/core/project-config.ts +4 -5
  22. package/src/core/renderers/manifest-writer.ts +29 -18
  23. package/src/core/run-layout.ts +4 -104
  24. package/src/core/run-status.ts +28 -53
  25. package/src/core/services/git-repository.ts +16 -0
  26. package/src/core/services/project-files.ts +13 -1
  27. package/src/core/storage/factory.ts +24 -0
  28. package/src/core/storage/file/file-storage.ts +269 -0
  29. package/src/core/storage/paths.ts +64 -0
  30. package/src/core/storage/repo-identity.ts +70 -0
  31. package/src/core/storage/sqlite/mappers.ts +160 -0
  32. package/src/core/storage/sqlite/schema.ts +108 -0
  33. package/src/core/storage/sqlite/sqlite-storage.ts +427 -0
  34. package/src/core/storage/types.ts +143 -0
  35. package/src/core/storage/work-area.ts +191 -0
  36. package/src/index.ts +27 -6
  37. package/src/server/artifacts.ts +8 -61
  38. package/src/server/client/ArtifactsPanel.tsx +137 -23
  39. package/src/server/client/FindingCard.tsx +37 -6
  40. package/src/server/client/MarkdownText.tsx +101 -0
  41. package/src/server/client/SubmitModal.tsx +9 -4
  42. package/src/server/client/code-highlight.ts +422 -0
  43. package/src/server/client/constants.ts +130 -24
  44. package/src/server/client/json-highlight.ts +55 -0
  45. package/src/server/client/markdown.ts +153 -0
  46. package/src/server/client/types.ts +23 -0
  47. package/src/server/index.ts +95 -15
  48. package/src/server/run-repository.ts +144 -136
  49. package/src/skill/OUTPUT-FORMAT.md +6 -4
  50. package/src/skill/README.md +22 -14
  51. package/src/skill/SETUP.md +4 -4
  52. package/src/skill/fix.md +5 -5
  53. package/src/skill/hunt.md +27 -18
  54. package/src/skill/train.md +1 -1
  55. package/src/skill/verify.md +5 -5
  56. package/src/tests/__snapshots__/baseline.test.ts.snap +44 -33
  57. package/src/tests/baseline.test.ts +76 -34
  58. package/src/tests/cli.test.ts +40 -19
  59. package/src/tests/code-highlight.test.ts +123 -0
  60. package/src/tests/domain.test.ts +1 -1
  61. package/src/tests/findings-commands.test.ts +153 -131
  62. package/src/tests/findings-state.test.ts +6 -3
  63. package/src/tests/fixtures/fake-project.ts +0 -1
  64. package/src/tests/infrastructure.test.ts +33 -38
  65. package/src/tests/markdown.test.ts +89 -0
  66. package/src/tests/review-scripts.test.ts +8 -5
  67. package/src/tests/run-status.test.ts +33 -23
  68. package/src/tests/server.test.ts +108 -75
  69. package/src/tests/storage.test.ts +386 -0
@@ -2,17 +2,16 @@
2
2
  * Configuração do projeto (domínio) — resolve defaults e expõe o registry.
3
3
  *
4
4
  * Evita o "data clump" de passar `HoundsmanConfig` para dezenas de funções:
5
- * quem precisa de `artifactDir`/orçamento/concorrência/watchdogs pede aqui.
5
+ * quem precisa de orçamento/concorrência/watchdogs pede aqui.
6
6
  * O contrato do consumidor (`houndsman.config.ts`) não muda.
7
7
  */
8
8
 
9
- import type { HoundsmanConfig } from "./config";
9
+ import type { HoundsmanConfig, HoundsmanStorage } from "./config";
10
10
  import {
11
11
  DEFAULT_ALWAYS_INCLUDE_DOCS,
12
12
  DEFAULT_JEV_CONCURRENCY,
13
13
  DEFAULT_MAX_FILES_PER_BRIEF,
14
14
  } from "./defaults";
15
- import { ARTIFACT_DIR } from "./paths";
16
15
  import { WatchdogRegistry } from "./watchdog-registry";
17
16
 
18
17
  /** Configuração resolvida do projeto. */
@@ -29,8 +28,8 @@ export class ProjectConfig<TId extends string = string> {
29
28
  return this.raw.projectName;
30
29
  }
31
30
 
32
- get artifactDir(): string {
33
- return this.raw.artifactDir ?? ARTIFACT_DIR;
31
+ get storage(): HoundsmanStorage {
32
+ return this.raw.storage ?? {};
34
33
  }
35
34
 
36
35
  get maxFilesPerBrief(): number {
@@ -1,20 +1,20 @@
1
1
  /**
2
- * Manifesto da run (biblioteca) — monta e grava o `manifest.json`.
2
+ * Manifesto da run (biblioteca) — monta e grava o artefato `manifest.json`.
3
3
  *
4
4
  * Concentra o resumo do que foi ativado (watchdogs, orçamento, churn, contexto,
5
- * Jev e incremental), hoje espalhado em helpers do orquestrador.
5
+ * Jev e incremental). O manifesto vive no storage; os caminhos de briefs e
6
+ * findings apontam para o staging da review.
6
7
  */
7
8
 
8
9
  import { Classification } from "../domain/classification";
9
10
  import type { ClassificationResult } from "../file-classifier";
10
11
  import type { JevCandidatesFile } from "../jev";
11
- import { OUTPUT_FORMAT_PATH, relFromRoot } from "../paths";
12
- import { readContextSummary } from "../pr-context";
12
+ import { OUTPUT_FORMAT_PATH } from "../paths";
13
13
  import type { ProjectConfig } from "../project-config";
14
14
  import type { MergedFinding } from "../report-builder";
15
- import type { RunLayout } from "../run-layout";
16
15
  import { systemClock } from "../services/clock";
17
- import { ProjectFiles } from "../services/project-files";
16
+ import type { ReviewRecord, StorageProvider } from "../storage/types";
17
+ import type { ReviewWorkArea } from "../storage/work-area";
18
18
 
19
19
  /** Opções do manifesto (subset do `ReviewOptions`). */
20
20
  export type ManifestOptions = {
@@ -32,12 +32,22 @@ export type ManifestClassification = ClassificationResult & {
32
32
 
33
33
  /** Escreve o `manifest.json` de uma run. */
34
34
  export class ManifestWriter {
35
- private readonly layout: RunLayout;
35
+ private readonly storage: StorageProvider;
36
+ private readonly review: ReviewRecord;
37
+ private readonly work: ReviewWorkArea;
36
38
  private readonly project: ProjectConfig;
37
39
  private readonly options: ManifestOptions;
38
40
 
39
- constructor(layout: RunLayout, project: ProjectConfig, options: ManifestOptions) {
40
- this.layout = layout;
41
+ constructor(
42
+ storage: StorageProvider,
43
+ review: ReviewRecord,
44
+ work: ReviewWorkArea,
45
+ project: ProjectConfig,
46
+ options: ManifestOptions,
47
+ ) {
48
+ this.storage = storage;
49
+ this.review = review;
50
+ this.work = work;
41
51
  this.project = project;
42
52
  this.options = options;
43
53
  }
@@ -60,16 +70,16 @@ export class ManifestWriter {
60
70
  name: this.project.watchdogs.nameOf(id),
61
71
  files: selection.selected,
62
72
  overflow: selection.overflow.length,
63
- brief: relFromRoot(this.layout.briefFile(id)),
64
- findings: relFromRoot(this.layout.findingFile(id)),
73
+ brief: this.work.briefFile(id),
74
+ findings: this.work.findingFile(id),
65
75
  status: pending.includes(id) ? "pending" : "done",
66
76
  };
67
77
  });
68
78
 
69
- const summary = readContextSummary(this.layout.contextPaths.summary);
79
+ const summary = this.work.readContextSummary();
70
80
  const manifest = {
71
- branch: this.layout.branch,
72
- slug: this.layout.slug,
81
+ branch: this.review.branch,
82
+ slug: this.review.slug,
73
83
  base: this.options.base,
74
84
  head: this.options.head,
75
85
  headSha: classification.headSha,
@@ -104,9 +114,10 @@ export class ManifestWriter {
104
114
  excludedCount: classification.excludedCount,
105
115
  };
106
116
 
107
- new ProjectFiles().write(
108
- this.layout.manifestFile,
109
- `${JSON.stringify(manifest, null, 2)}\n`,
110
- );
117
+ this.storage.writeArtifact(this.review.id, {
118
+ name: "manifest.json",
119
+ kind: "manifest",
120
+ content: `${JSON.stringify(manifest, null, 2)}\n`,
121
+ });
111
122
  }
112
123
  }
@@ -1,108 +1,8 @@
1
1
  /**
2
- * Layout de uma run (biblioteca) — único ponto que conhece os nomes/estrutura
3
- * dos artefatos em `.houndsman/.runs/<slug>/`.
2
+ * Removido: o layout de artefatos do projeto deu lugar ao staging global por
3
+ * review (`ReviewWorkArea` em `core/storage/work-area.ts`).
4
4
  *
5
- * Evita o "data clump" de `(dir, branch)`/`(outDir, branch)` espalhado pelo
6
- * motor: quem precisa de um caminho pede ao layout.
5
+ * Mantido apenas para não quebrar imports antigos de tipos.
7
6
  */
8
7
 
9
- import { join } from "node:path";
10
- import {
11
- CONTEXT_BRIEF_FILE,
12
- CONTEXT_FILE,
13
- CONTEXT_SUMMARY_FILE,
14
- RUN_STATUS_FILE,
15
- STATE_FILE,
16
- VERIFY_FILE,
17
- } from "./paths";
18
- import { sanitizeBranch } from "./report-builder";
19
-
20
- /** Caminhos dos artefatos de contexto da PR. */
21
- export type ContextPaths = {
22
- dir: string;
23
- context: string;
24
- summary: string;
25
- brief: string;
26
- };
27
-
28
- /** Diretório e caminhos de uma run (branch-slug). */
29
- export class RunLayout {
30
- readonly artifactDir: string;
31
- readonly branch: string;
32
-
33
- constructor(artifactDir: string, branch: string) {
34
- this.artifactDir = artifactDir;
35
- this.branch = branch;
36
- }
37
-
38
- get slug(): string {
39
- return sanitizeBranch(this.branch);
40
- }
41
-
42
- get outDir(): string {
43
- return join(this.artifactDir, this.slug);
44
- }
45
-
46
- get briefsDir(): string {
47
- return join(this.outDir, "briefs");
48
- }
49
-
50
- get findingsDir(): string {
51
- return join(this.outDir, "findings");
52
- }
53
-
54
- /** Caminho de um arquivo dentro da pasta da run. */
55
- file(name: string): string {
56
- return join(this.outDir, name);
57
- }
58
-
59
- briefFile(id: string): string {
60
- return join(this.briefsDir, `${id}.md`);
61
- }
62
-
63
- findingFile(id: string): string {
64
- return join(this.findingsDir, `${id}.json`);
65
- }
66
-
67
- get classificationFile(): string {
68
- return this.file("classification.json");
69
- }
70
-
71
- get manifestFile(): string {
72
- return this.file("manifest.json");
73
- }
74
-
75
- get candidatesFile(): string {
76
- return this.file("candidates.json");
77
- }
78
-
79
- get reviewFile(): string {
80
- return this.file("review.md");
81
- }
82
-
83
- get stateFile(): string {
84
- return this.file(STATE_FILE);
85
- }
86
-
87
- get runStatusFile(): string {
88
- return this.file(RUN_STATUS_FILE);
89
- }
90
-
91
- get verifyFile(): string {
92
- return this.file(VERIFY_FILE);
93
- }
94
-
95
- /** Relatório isolado de um watchdog (`review.<id>.md`). */
96
- domainReportFile(id: string): string {
97
- return this.file(`review.${sanitizeBranch(id)}.md`);
98
- }
99
-
100
- get contextPaths(): ContextPaths {
101
- return {
102
- dir: this.outDir,
103
- context: this.file(CONTEXT_FILE),
104
- summary: this.file(CONTEXT_SUMMARY_FILE),
105
- brief: this.file(CONTEXT_BRIEF_FILE),
106
- };
107
- }
108
- }
8
+ export type { ContextPaths } from "./storage/work-area";
@@ -1,23 +1,20 @@
1
1
  /**
2
- * Status do run (biblioteca) — persistido em `.houndsman/.runs/<slug>/run.json`.
2
+ * Status do run (biblioteca) — colunas da review no storage.
3
3
  *
4
4
  * Diz à UI em que pé está uma run: `reviewing` (revisando) → `review_done`
5
5
  * (revisão finalizada) → `fixing` (corrigindo) → `fix_done` (correção finalizada).
6
6
  *
7
- * Toda ação que muda o estado faz um **ping** no `updatedAt`, mantendo o arquivo
8
- * "quente". Timeout é **display-only** (a UI compara `updatedAt` com o agora) —
9
- * não é um status persistido.
7
+ * Toda ação que muda o estado faz um **ping** no `updatedAt`. Timeout é
8
+ * **display-only** (a UI compara `updatedAt` com o agora) — não é um status
9
+ * persistido.
10
10
  */
11
11
 
12
- import { join } from "node:path";
13
12
  import type { FindingsStateFile } from "./findings-state";
14
- import { RUN_STATUS_FILE } from "./paths";
15
- import { sanitizeBranch } from "./report-builder";
16
13
  import { systemClock } from "./services/clock";
17
- import { ProjectFiles } from "./services/project-files";
14
+ import type { ReviewRecord, ReviewStatus, StorageProvider } from "./storage/types";
18
15
 
19
16
  /** Estados de execução de uma run (persistidos). */
20
- export type RunStatus = "reviewing" | "review_done" | "fixing" | "fix_done";
17
+ export type RunStatus = ReviewStatus;
21
18
 
22
19
  /** Valores canônicos de status (ordem de exibição/lifecycle). */
23
20
  export const RUN_STATUSES: readonly RunStatus[] = [
@@ -27,56 +24,34 @@ export const RUN_STATUSES: readonly RunStatus[] = [
27
24
  "fix_done",
28
25
  ];
29
26
 
30
- export const RUN_STATUS_VERSION = 1;
31
-
32
- /** Conteúdo do `run.json`. */
33
- export type RunStatusFile = {
34
- version: 1;
35
- slug: string;
36
- branch: string;
37
- status: RunStatus;
38
- /** Início do ciclo de revisão atual (reiniciado a cada `prepare`). */
39
- startedAt: string;
40
- /** Última ação importante (ping). */
41
- updatedAt: string;
42
- };
43
-
44
- /** Caminho do `run.json` dentro da pasta da run. */
45
- export function runStatusPath(runDir: string): string {
46
- return join(runDir, RUN_STATUS_FILE);
47
- }
48
-
49
- /** Lê o `run.json` da run (null se ausente/inválido). */
50
- export function readRunStatus(runDir: string): RunStatusFile | null {
51
- return new ProjectFiles().readJson<RunStatusFile>(runStatusPath(runDir));
27
+ /** Lê o status atual (null se a review não existir). */
28
+ export function readRunStatus(
29
+ storage: StorageProvider,
30
+ reviewId: string,
31
+ ): ReviewRecord | null {
32
+ return storage.getReview(reviewId);
52
33
  }
53
34
 
54
35
  export type WriteRunStatusOptions = {
55
- branch: string;
56
36
  status: RunStatus;
57
37
  /** Reinicia o `startedAt` — usado no início de uma nova revisão. */
58
38
  resetStartedAt?: boolean;
59
39
  now?: string;
60
40
  };
61
41
 
62
- /** Grava/atualiza o `run.json` (ping no `updatedAt`). */
42
+ /** Grava/atualiza o status da review (ping no `updatedAt`). */
63
43
  export function writeRunStatus(
64
- runDir: string,
44
+ storage: StorageProvider,
45
+ reviewId: string,
65
46
  options: WriteRunStatusOptions,
66
- ): RunStatusFile {
67
- const files = new ProjectFiles();
68
- const now = options.now ?? systemClock();
69
- const previous = readRunStatus(runDir);
70
- const file: RunStatusFile = {
71
- version: RUN_STATUS_VERSION,
72
- slug: sanitizeBranch(options.branch),
73
- branch: options.branch,
47
+ ): ReviewRecord {
48
+ const updated = storage.updateReview(reviewId, {
74
49
  status: options.status,
75
- startedAt: options.resetStartedAt ? now : (previous?.startedAt ?? now),
76
- updatedAt: now,
77
- };
78
- files.write(runStatusPath(runDir), `${JSON.stringify(file, null, 2)}\n`);
79
- return file;
50
+ resetStartedAt: options.resetStartedAt,
51
+ updatedAt: options.now ?? systemClock(),
52
+ });
53
+ if (!updated) throw new Error(`Review '${reviewId}' não encontrada.`);
54
+ return updated;
80
55
  }
81
56
 
82
57
  /**
@@ -105,14 +80,14 @@ export function statusAfterFindingsChange(
105
80
  return previous ?? "review_done";
106
81
  }
107
82
 
108
- /** Ping: deriva o status da mudança nos findings e grava o `run.json`. */
83
+ /** Ping: deriva o status da mudança nos findings e grava na review. */
109
84
  export function pingFindingsStatus(
110
- runDir: string,
85
+ storage: StorageProvider,
86
+ reviewId: string,
111
87
  state: FindingsStateFile,
112
- ): RunStatusFile {
113
- const previous = readRunStatus(runDir)?.status ?? null;
114
- return writeRunStatus(runDir, {
115
- branch: state.branch,
88
+ ): ReviewRecord {
89
+ const previous = storage.getReview(reviewId)?.status ?? null;
90
+ return writeRunStatus(storage, reviewId, {
116
91
  status: statusAfterFindingsChange(previous, state),
117
92
  });
118
93
  }
@@ -52,6 +52,22 @@ export class GitRepository {
52
52
  return branch.length > 0 ? branch : null;
53
53
  }
54
54
 
55
+ /** Raiz do repositório; `null` fora de um repo git. */
56
+ rootPath(): string | null {
57
+ const result = this.tryRun(["rev-parse", "--show-toplevel"]);
58
+ if (!result.ok) return null;
59
+ const root = result.stdout.trim();
60
+ return root.length > 0 ? root : null;
61
+ }
62
+
63
+ /** URL de um remote (default `origin`); `null` se ausente. */
64
+ remoteUrl(name = "origin"): string | null {
65
+ const result = this.tryRun(["remote", "get-url", name]);
66
+ if (!result.ok) return null;
67
+ const url = result.stdout.trim();
68
+ return url.length > 0 ? url : null;
69
+ }
70
+
55
71
  /**
56
72
  * `true` se `ancestor` é ancestral de `descendant`. Qualquer outro desfecho
57
73
  * (ref desconhecida, exit ≠ 0) devolve `false` — cai no review completo.
@@ -6,7 +6,14 @@
6
6
  * manter o comportamento fail-open do motor.
7
7
  */
8
8
 
9
- import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
9
+ import {
10
+ existsSync,
11
+ mkdirSync,
12
+ readFileSync,
13
+ renameSync,
14
+ rmSync,
15
+ writeFileSync,
16
+ } from "node:fs";
10
17
  import { dirname, isAbsolute, join } from "node:path";
11
18
 
12
19
  /** Leitor de arquivos relativos à raiz do projeto (injetável nos testes). */
@@ -67,6 +74,11 @@ export class ProjectFiles {
67
74
  mkdirSync(this.resolve(path), { recursive: true });
68
75
  }
69
76
 
77
+ /** Remove um diretório (recursivo, best-effort). */
78
+ removeDir(path: string): void {
79
+ rmSync(this.resolve(path), { recursive: true, force: true });
80
+ }
81
+
70
82
  /** `true` se o caminho existe. */
71
83
  exists(path: string): boolean {
72
84
  return existsSync(this.resolve(path));
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Fábrica do provider de persistência (biblioteca).
3
+ *
4
+ * Default: SQLite no diretório global. `storage.provider: "file"` mantém o
5
+ * backend em arquivos sob o mesmo home — útil para debug ou rollback.
6
+ */
7
+
8
+ import type { HoundsmanConfig } from "../config";
9
+ import { FileStorageProvider } from "./file/file-storage";
10
+ import { databasePath, fileRoot } from "./paths";
11
+ import { SQLiteStorageProvider } from "./sqlite/sqlite-storage";
12
+ import type { StorageProvider } from "./types";
13
+
14
+ /** Cria o provider configurado pelo projeto. */
15
+ export function createStorage(config: HoundsmanConfig): StorageProvider {
16
+ return config.storage?.provider === "file"
17
+ ? new FileStorageProvider(fileRoot(config))
18
+ : new SQLiteStorageProvider(databasePath(config));
19
+ }
20
+
21
+ /** Provider SQLite em memória (testes). */
22
+ export function createMemoryStorage(): StorageProvider {
23
+ return new SQLiteStorageProvider(":memory:");
24
+ }