@payconductor/houndsman 0.8.0 → 0.11.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 (55) hide show
  1. package/package.json +1 -1
  2. package/src/cli/index.ts +9 -1
  3. package/src/core/ai/ai-client.ts +21 -0
  4. package/src/core/ai/ai-sdk-model-provider.ts +43 -18
  5. package/src/core/ai/features/technical-summarizer.ts +43 -0
  6. package/src/core/ai/index.ts +4 -1
  7. package/src/core/ai/prompts/base.ts +7 -0
  8. package/src/core/ai/prompts/context.ts +5 -4
  9. package/src/core/ai/prompts/technical.ts +31 -0
  10. package/src/core/ai/provider-capabilities.ts +45 -0
  11. package/src/core/ai/schemas.ts +19 -3
  12. package/src/core/ai/types.ts +3 -1
  13. package/src/core/brief.ts +25 -4
  14. package/src/core/commands/scaffold.ts +5 -0
  15. package/src/core/config-loader.ts +18 -4
  16. package/src/core/config.ts +17 -1
  17. package/src/core/engine/review-workspace.ts +6 -2
  18. package/src/core/git-runner.ts +8 -1
  19. package/src/core/orchestrator.ts +114 -2
  20. package/src/core/paths.ts +3 -0
  21. package/src/core/pr-context.ts +11 -6
  22. package/src/core/project-config.ts +9 -1
  23. package/src/core/renderers/resume-renderer.ts +17 -34
  24. package/src/core/report-builder.ts +6 -6
  25. package/src/core/services/worktree-manager.ts +90 -7
  26. package/src/core/storage/types.ts +1 -0
  27. package/src/core/technical-summary.ts +398 -0
  28. package/src/harness/agent/agent-factory.ts +1 -0
  29. package/src/harness/agent/review-agent.ts +11 -1
  30. package/src/harness/config/harness-config.ts +6 -1
  31. package/src/index.ts +2 -0
  32. package/src/server/client/App.tsx +2 -1
  33. package/src/server/client/PrsSection.tsx +296 -0
  34. package/src/server/client/ResumePanel.tsx +10 -13
  35. package/src/server/client/RunsSection.tsx +73 -0
  36. package/src/server/client/Sidebar.tsx +171 -413
  37. package/src/server/client/sidebar-sections.tsx +73 -0
  38. package/src/server/client/utils/constants.ts +7 -2
  39. package/src/server/client/utils/sidebar-prefs.ts +64 -0
  40. package/src/server/client/utils/types.ts +1 -0
  41. package/src/server/index.ts +23 -409
  42. package/src/server/styles.css +454 -0
  43. package/src/tests/__snapshots__/baseline.test.ts.snap +5 -0
  44. package/src/tests/ai-core.test.ts +71 -0
  45. package/src/tests/ai-features.test.ts +47 -0
  46. package/src/tests/baseline.test.ts +4 -1
  47. package/src/tests/harness-agent.test.ts +19 -0
  48. package/src/tests/review-resume.test.ts +36 -8
  49. package/src/tests/review-scope.test.ts +67 -0
  50. package/src/tests/review-scripts.test.ts +2 -2
  51. package/src/tests/server.test.ts +16 -1
  52. package/src/tests/sidebar-sections.test.ts +94 -0
  53. package/src/tests/technical-summary.test.ts +176 -0
  54. package/src/tests/worktree.test.ts +110 -5
  55. package/assets/logo-legacy.png +0 -0
@@ -23,6 +23,7 @@ import { AiBriefEnricher } from "./ai/features/brief-enricher";
23
23
  import { AiClassifier } from "./ai/features/classifier";
24
24
  import { AiContextSummarizer } from "./ai/features/context-summarizer";
25
25
  import { AiPreAnalyzer } from "./ai/features/pre-analyzer";
26
+ import { AiTechnicalSummarizer } from "./ai/features/technical-summarizer";
26
27
  import type { BriefPlan } from "./ai/schemas";
27
28
  import { assetPath, OUTPUT_FORMAT_FILE } from "./assets";
28
29
  import type { AstService } from "./ast/service";
@@ -56,6 +57,7 @@ import {
56
57
  OUTPUT_FORMAT_PATH,
57
58
  REVIEW_FILE,
58
59
  REVIEW_RESUME_FILE,
60
+ TECHNICAL_SUMMARY_FILE,
59
61
  } from "./paths";
60
62
  import { ProjectConfig } from "./project-config";
61
63
  import { BriefRenderer } from "./renderers/brief-renderer";
@@ -79,6 +81,11 @@ import { systemClock } from "./services/clock";
79
81
  import { GitRepository } from "./services/git-repository";
80
82
  import type { ReviewRecord, StorageProvider } from "./storage/types";
81
83
  import { ReviewWorkArea } from "./storage/work-area";
84
+ import {
85
+ buildTechnicalSummary,
86
+ parseTechnicalSummary,
87
+ type TechnicalSummary,
88
+ } from "./technical-summary";
82
89
  import type { WatchdogMeta } from "./types";
83
90
  import { findWatchdog } from "./watchdog-registry";
84
91
 
@@ -483,6 +490,50 @@ async function resolveContextSummary(
483
490
  return summary;
484
491
  }
485
492
 
493
+ /**
494
+ * Pontos técnicos estruturais: IA quando disponível e, sempre, um fallback
495
+ * determinístico a partir da classificação/AST. Persistido para auditoria —
496
+ * o `finalize` apenas o consome.
497
+ */
498
+ async function resolveTechnicalSummary(
499
+ ctx: ReviewContext,
500
+ ai: AiClient | undefined,
501
+ opts: ReviewOptions,
502
+ input: {
503
+ branch: string;
504
+ classification: RunClassification;
505
+ context: ContextSummary | null;
506
+ ast?: ReadonlyMap<string, AstAnalysis>;
507
+ impact?: AstImpact;
508
+ domains: readonly string[];
509
+ },
510
+ ): Promise<TechnicalSummary> {
511
+ const fallback = buildTechnicalSummary({
512
+ classification: input.classification,
513
+ ast: input.ast,
514
+ impact: input.impact,
515
+ headSha: input.classification.headSha,
516
+ });
517
+ const summary =
518
+ ai && !opts.noAi
519
+ ? ((await new AiTechnicalSummarizer(ai).summarize({
520
+ branch: input.branch,
521
+ context: input.context,
522
+ classification: input.classification,
523
+ ast: input.ast,
524
+ impact: input.impact,
525
+ headSha: input.classification.headSha,
526
+ domains: input.domains,
527
+ })) ?? fallback)
528
+ : fallback;
529
+ ctx.storage.writeArtifact(ctx.review.id, {
530
+ name: TECHNICAL_SUMMARY_FILE,
531
+ kind: "technical",
532
+ content: `${JSON.stringify(summary, null, 2)}\n`,
533
+ });
534
+ return summary;
535
+ }
536
+
486
537
  /**
487
538
  * Planos de revisão por watchdog (uma chamada por brief, concorrência
488
539
  * limitada). Sem IA/chave/feature, devolve mapa vazio e o brief fica igual.
@@ -574,13 +625,30 @@ export async function prepare(
574
625
  return { messages };
575
626
  }
576
627
 
628
+ const { classification, diff } = scope;
629
+
630
+ // Run recém-criada e sem nenhum arquivo alterado: não persiste uma "run
631
+ // vazia" na lista. Acontece quando o comando roda no clone principal sem
632
+ // `--pr`/`--worktree` (ex.: `houndsman` na `main` logo após revisar uma PR).
633
+ // Run com artefatos anteriores (contexto, revisão) continua existindo.
634
+ if (Object.keys(classification.files).length === 0) {
635
+ const fresh = ctx.storage.listArtifacts(ctx.review.id).length === 0;
636
+ if (fresh) {
637
+ ctx.work.clean();
638
+ ctx.storage.deleteReview(ctx.review.id);
639
+ messages.push(
640
+ `Nenhuma alteração entre ${opts.base}...${opts.head} — nenhuma run criada.`,
641
+ );
642
+ return { messages };
643
+ }
644
+ }
645
+
577
646
  // Marca o run como "revisando" (reinicia o ciclo) — a UI mostra o status.
578
647
  writeRunStatus(ctx.storage, ctx.review.id, {
579
648
  status: "reviewing",
580
649
  resetStartedAt: true,
581
650
  });
582
651
 
583
- const { classification, diff } = scope;
584
652
  await applyAiClassification(classification, config, ai, opts);
585
653
  ctx.storage.writeArtifact(ctx.review.id, {
586
654
  name: "classification.json",
@@ -645,6 +713,14 @@ export async function prepare(
645
713
 
646
714
  persistContext(ctx);
647
715
  const context = await resolveContextSummary(ctx, ai, opts);
716
+ await resolveTechnicalSummary(ctx, ai, opts, {
717
+ branch,
718
+ classification,
719
+ context,
720
+ ast: astAnalyses,
721
+ impact: astImpact,
722
+ domains: activeIds.map((id) => findWatchdog(config, id)?.name ?? id),
723
+ });
648
724
 
649
725
  const manifestAst: ManifestAst | undefined = astAnalyses
650
726
  ? {
@@ -853,6 +929,40 @@ function readPersistedContextSummary(ctx: ReviewContext): ContextSummary | null
853
929
  }
854
930
  }
855
931
 
932
+ /** JSON persistido, ou `null` quando ausente/inválido. */
933
+ function readArtifactJson<T>(ctx: ReviewContext, name: string): T | null {
934
+ const artifact = ctx.storage.readArtifact(ctx.review.id, name);
935
+ if (!artifact) return null;
936
+ try {
937
+ return JSON.parse(artifact.content) as T;
938
+ } catch {
939
+ return null;
940
+ }
941
+ }
942
+
943
+ /** Pontos técnicos persistidos pelo `prepare`; `null` quando ausentes. */
944
+ function readPersistedTechnicalSummary(ctx: ReviewContext): TechnicalSummary | null {
945
+ const raw = readArtifactJson<unknown>(ctx, TECHNICAL_SUMMARY_FILE);
946
+ return raw ? parseTechnicalSummary(raw) : null;
947
+ }
948
+
949
+ /** Fallback para runs antigas: reconstrói os destaques dos artefatos de AST. */
950
+ function buildTechnicalFallback(
951
+ ctx: ReviewContext,
952
+ classification: RunClassification,
953
+ ): TechnicalSummary {
954
+ const ast = readArtifactJson<{ files?: Record<string, AstAnalysis> }>(
955
+ ctx,
956
+ "ast-summary.json",
957
+ );
958
+ return buildTechnicalSummary({
959
+ classification,
960
+ ast: ast?.files ? new Map(Object.entries(ast.files)) : undefined,
961
+ impact: readArtifactJson<AstImpact>(ctx, "ast-impact.json") ?? undefined,
962
+ headSha: classification.headSha,
963
+ });
964
+ }
965
+
856
966
  /**
857
967
  * Grava um `review.<watchdog>.md` por watchdog com achados e devolve o índice.
858
968
  * Mantém o `review.md` consolidado navegável em PRs grandes (centenas de achados).
@@ -1028,6 +1138,8 @@ export function finalize(
1028
1138
 
1029
1139
  persistContext(ctx);
1030
1140
  const context = ctx.work.readContextSummary() ?? readPersistedContextSummary(ctx);
1141
+ const technical =
1142
+ readPersistedTechnicalSummary(ctx) ?? buildTechnicalFallback(ctx, classification);
1031
1143
  const nameOf = (id: string): string => findWatchdog(config, id)?.name ?? id;
1032
1144
  const domains = writeDomainReports(
1033
1145
  ctx,
@@ -1061,7 +1173,7 @@ export function finalize(
1061
1173
  context,
1062
1174
  classification,
1063
1175
  findings: merged,
1064
- nameOf,
1176
+ technical,
1065
1177
  }),
1066
1178
  });
1067
1179
 
package/src/core/paths.ts CHANGED
@@ -55,3 +55,6 @@ export const REVIEW_FILE = "review.md";
55
55
 
56
56
  /** Resumo final da review (artefato `review-resume.md`) — leitura humana apenas. */
57
57
  export const REVIEW_RESUME_FILE = "review-resume.md";
58
+
59
+ /** Pontos técnicos estruturais usados no resumo final (`technical-summary.json`). */
60
+ export const TECHNICAL_SUMMARY_FILE = "technical-summary.json";
@@ -229,13 +229,17 @@ export function renderContextBrief(
229
229
 
230
230
  lines.push("## Regras", "");
231
231
  lines.push(
232
- "- **Seja curto** — o resumo abre o relatório e entra em **todos** os briefs.",
232
+ "- O resumo abre o relatório e o resumo final; nos briefs ele é recortado.",
233
233
  );
234
234
  lines.push("- Baseie-se **somente** no `context.json` — não invente intenção.");
235
- lines.push("- `objective`: 1 frase (≤ 200 caracteres) sobre o que a PR entrega.");
236
- lines.push("- `motivation`: 1 frase (≤ 240 caracteres) sobre o porquê.");
237
235
  lines.push(
238
- "- `changeSummary`: no máximo 4 bullets (≤ 160 caracteres cada), focados em comportamento — não liste arquivos.",
236
+ "- `objective`: até 2 frases (≤ 400 caracteres) sobre o que a PR entrega.",
237
+ );
238
+ lines.push(
239
+ "- `motivation`: até 3 frases (≤ 480 caracteres) sobre o porquê e o impacto.",
240
+ );
241
+ lines.push(
242
+ "- `changeSummary`: no máximo 6 bullets (≤ 240 caracteres cada), focados em comportamento — não liste arquivos.",
239
243
  );
240
244
  lines.push(
241
245
  "- NÃO copie a descrição da PR: sintetize. Copie `source` do contexto bruto.",
@@ -253,7 +257,8 @@ const CONTEXT_SUMMARY_TEMPLATE = {
253
257
  base: "main",
254
258
  head: "feature/x",
255
259
  },
256
- objective: "O que a PR entrega, em 1 frase.",
257
- motivation: "Por que a mudança é necessária, em 1-2 frases.",
260
+ objective: "O que a PR entrega, em 1-2 frases.",
261
+ motivation:
262
+ "Por que a mudança é necessária, com o problema e o impacto (até 3 frases).",
258
263
  changeSummary: ["Bullet de comportamento 1", "Bullet de comportamento 2"],
259
264
  };
@@ -8,6 +8,7 @@
8
8
 
9
9
  import type {
10
10
  AiProviderKind,
11
+ AiProviderOptions,
11
12
  HoundsmanAi,
12
13
  HoundsmanAiFeatures,
13
14
  HoundsmanAst,
@@ -138,6 +139,12 @@ export class ProjectConfig<TId extends string = string> {
138
139
  return typeof value === "number" && Number.isFinite(value) ? value : 0;
139
140
  }
140
141
 
142
+ /** Opções do AI SDK por provider (undefined quando ausentes/vazias). */
143
+ get aiProviderOptions(): AiProviderOptions | undefined {
144
+ const value = this.ai.providerOptions;
145
+ return value && Object.keys(value).length > 0 ? value : undefined;
146
+ }
147
+
141
148
  get aiTimeoutMs(): number {
142
149
  return positive(this.ai.timeoutMs, DEFAULT_AI_TIMEOUT_MS);
143
150
  }
@@ -152,12 +159,13 @@ export class ProjectConfig<TId extends string = string> {
152
159
  return value !== undefined && value > 0 ? value : undefined;
153
160
  }
154
161
 
155
- /** Features resolvidas com defaults (`context`/`brief` ligadas). */
162
+ /** Features resolvidas com defaults (`context`/`brief`/`technical` ligadas). */
156
163
  get aiFeatures(): Required<HoundsmanAiFeatures> {
157
164
  const features = this.ai.features ?? {};
158
165
  return {
159
166
  context: features.context !== false,
160
167
  brief: features.brief !== false,
168
+ technical: features.technical !== false,
161
169
  classification: features.classification === true,
162
170
  preAnalysis: features.preAnalysis === true,
163
171
  verify: features.verify === true,
@@ -3,19 +3,16 @@
3
3
  *
4
4
  * Artefato somente-leitura para humanos: não entra em briefs, não é lido pelos
5
5
  * watchdogs e não participa de nenhuma etapa seguinte. Une o contexto de
6
- * negócio da PR aos números técnicos diretos da revisão, com limite de
6
+ * negócio da PR aos pontos técnicos estruturais da revisão, com limite de
7
7
  * `RESUME_MAX_CHARS`.
8
8
  */
9
9
 
10
- import type {
11
- ContextSummary,
12
- MergedFinding,
13
- WatchdogNameResolver,
14
- } from "../report-builder";
10
+ import type { ContextSummary, MergedFinding } from "../report-builder";
15
11
  import { systemClock } from "../services/clock";
12
+ import type { TechnicalSummary } from "../technical-summary";
16
13
 
17
14
  /** Teto de caracteres do artefato (inclui cabeçalho e quebras de linha). */
18
- export const RESUME_MAX_CHARS = 2500;
15
+ export const RESUME_MAX_CHARS = 5000;
19
16
 
20
17
  /** Dados de classificação usados no resumo. */
21
18
  export type ResumeClassification = {
@@ -30,11 +27,10 @@ export type ReviewResumeInput = {
30
27
  context?: ContextSummary | null;
31
28
  classification: ResumeClassification;
32
29
  findings: readonly MergedFinding[];
33
- nameOf?: WatchdogNameResolver;
30
+ /** Pontos técnicos estruturais (não achados). */
31
+ technical?: TechnicalSummary | null;
34
32
  };
35
33
 
36
- const identityName: WatchdogNameResolver = (id) => id;
37
-
38
34
  const STATUS_BY_SEVERITY: Record<"blocking" | "attention" | "clean", string> = {
39
35
  blocking: "❌ Mudanças necessárias",
40
36
  attention: "⚠️ Aprovado com ressalvas",
@@ -80,36 +76,24 @@ function businessSection(input: ReviewResumeInput): string[] {
80
76
  if (context.objective) lines.push(context.objective, "");
81
77
  if (context.motivation) lines.push(`**Por quê:** ${context.motivation}`, "");
82
78
  if (context.changeSummary && context.changeSummary.length > 0) {
83
- for (const item of context.changeSummary.slice(0, 4)) {
84
- lines.push(`- ${truncate(item, 180)}`);
79
+ for (const item of context.changeSummary.slice(0, 6)) {
80
+ lines.push(`- ${truncate(item, 240)}`);
85
81
  }
86
82
  lines.push("");
87
83
  }
88
84
  return lines;
89
85
  }
90
86
 
91
- /** Achados que exigem ação, em formato direto (máximo 5). */
92
- function technicalSection(
93
- findings: readonly MergedFinding[],
94
- nameOf: WatchdogNameResolver,
95
- ): string[] {
96
- const relevant = findings
97
- .filter(
98
- (finding) =>
99
- finding.severity === "blocking" || finding.severity === "attention",
100
- )
101
- .slice(0, 5);
87
+ /** Pontos técnicos estruturais (módulos, contratos, impacto) — não achados. */
88
+ function technicalSection(summary: TechnicalSummary | null | undefined): string[] {
102
89
  const lines = ["## Pontos técnicos", ""];
103
- if (relevant.length === 0) {
104
- lines.push("- Nenhum bloqueio ou ponto de atenção.", "");
90
+ if (!summary || (!summary.overview && summary.highlights.length === 0)) {
91
+ lines.push("Sem mudanças estruturais detectadas nesta revisão.", "");
105
92
  return lines;
106
93
  }
107
- for (const finding of relevant) {
108
- const location = finding.line ? `${finding.file}:${finding.line}` : finding.file;
109
- const watchdogs = finding.watchdogs.map((id) => nameOf(id)).join(", ");
110
- lines.push(
111
- `- \`${location}\` — ${truncate(finding.message, 130)}${watchdogs ? ` _(${watchdogs})_` : ""}`,
112
- );
94
+ if (summary.overview) lines.push(summary.overview, "");
95
+ for (const highlight of summary.highlights) {
96
+ lines.push(`- **${highlight.area}** — ${truncate(highlight.detail, 300)}`);
113
97
  }
114
98
  lines.push("");
115
99
  return lines;
@@ -125,10 +109,9 @@ function clamp(markdown: string, max: number): string {
125
109
 
126
110
  /**
127
111
  * Renderiza o resumo final. Determinístico e sem chamadas externas: o texto
128
- * nasce do contexto já disponível e dos findings consolidados.
112
+ * nasce do contexto já disponível e dos pontos técnicos persistidos.
129
113
  */
130
114
  export function renderReviewResume(input: ReviewResumeInput): string {
131
- const nameOf = input.nameOf ?? identityName;
132
115
  const blockers = input.findings.filter((f) => f.severity === "blocking").length;
133
116
  const attention = input.findings.filter((f) => f.severity === "attention").length;
134
117
  const status =
@@ -144,7 +127,7 @@ export function renderReviewResume(input: ReviewResumeInput): string {
144
127
  `**Data:** ${input.date ?? systemClock().slice(0, 10)} · **Status:** ${status}`,
145
128
  "",
146
129
  ...businessSection(input),
147
- ...technicalSection(input.findings, nameOf),
130
+ ...technicalSection(input.technical),
148
131
  ];
149
132
 
150
133
  return clamp(`${lines.join("\n").trimEnd()}\n`, RESUME_MAX_CHARS);
@@ -63,14 +63,14 @@ export type ContextSummary = {
63
63
  const VALID_SEVERITIES = new Set<string>(SEVERITY_ORDER);
64
64
 
65
65
  /**
66
- * Limites do resumo da PR: mantém o relatório e os briefs enxutos — evita
67
- * confundir o watchdog humano e estourar o contexto da IA watchdog.
66
+ * Limites do resumo da PR: o contexto abre o relatório e o resumo final, mas
67
+ * não pode inflar os briefs — a injeção nos briefs é recortada em `brief.ts`.
68
68
  */
69
69
  const CONTEXT_LIMITS = {
70
- objective: 200,
71
- motivation: 240,
72
- changeSummaryItem: 160,
73
- changeSummaryItems: 4,
70
+ objective: 400,
71
+ motivation: 480,
72
+ changeSummaryItem: 240,
73
+ changeSummaryItems: 6,
74
74
  } as const;
75
75
 
76
76
  /** Corta `value` em `max` caracteres, sinalizando com reticências. */
@@ -4,13 +4,23 @@
4
4
  * Um worktree por branch revisada, criado em **detached HEAD** no commit da PR.
5
5
  * Detached evita o conflito "branch já checada" com o clone principal e é o
6
6
  * modo correto para revisão (somente leitura). Vive fora do projeto, em
7
- * `<HOUNDSMAN_HOME>/worktrees/<repoSlug>/<branchSlug>`.
7
+ * `<baseDir>/<repoSlug>/<branchSlug>`.
8
+ *
9
+ * Os dois segmentos são `<trecho-legível>-<hash8>`: o trecho legível é
10
+ * truncado porque o checkout de uma PR herda a profundidade dos arquivos do
11
+ * projeto, e o Windows (MAX_PATH = 260) estoura "Filename too long" com
12
+ * repo+branch longos. O hash mantém o nome determinístico e único.
13
+ *
14
+ * Cada worktree recebe o espelho da camada gerada (`.houndsman/core`) do clone
15
+ * principal — o checkout não traz arquivos ignorados e, sem ela, a config do
16
+ * projeto não importa (`../core/types`).
8
17
  */
9
18
 
10
19
  import { createHash } from "node:crypto";
11
- import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
20
+ import { cpSync, existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
12
21
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
13
22
  import { createGitRunner } from "../git-runner";
23
+ import { CORE_DIR } from "../paths";
14
24
  import { sanitizeBranch } from "../report-builder";
15
25
  import { worktreesRoot } from "../storage/paths";
16
26
  import { GitRepository } from "./git-repository";
@@ -54,7 +64,7 @@ export type RemoveWorktreeResult = {
54
64
  /** Opções do gerenciador. */
55
65
  export type WorktreeManagerOptions = {
56
66
  repoKey: string;
57
- /** Raiz do clone principal. */
67
+ /** Raiz do clone principal (fonte da camada gerada espelhada). */
58
68
  repoRoot: string;
59
69
  /**
60
70
  * Raiz onde os diretórios por repositório são criados.
@@ -64,6 +74,22 @@ export type WorktreeManagerOptions = {
64
74
  baseDir?: string;
65
75
  };
66
76
 
77
+ /** Limite do trecho legível no diretório do repositório. */
78
+ const REPO_SLUG_MAX = 24;
79
+ /** Limite do trecho legível no diretório da branch. */
80
+ const BRANCH_SLUG_MAX = 20;
81
+
82
+ /**
83
+ * `<trecho legível truncado>-<hash8>`: curto o bastante para o MAX_PATH do
84
+ * Windows e estável para o mesmo par (repositório, branch).
85
+ */
86
+ function hashName(key: string, readable: string, max: number, fallback: string): string {
87
+ const hash = createHash("sha1").update(key).digest("hex").slice(0, 8);
88
+ // Ponto/traço no fim do nome é descartado pelo Windows; corte antes do hash.
89
+ const head = readable.slice(0, max).replace(/[-._]+$/, "");
90
+ return `${head || fallback}-${hash}`;
91
+ }
92
+
67
93
  /** Slug estável e legível para o diretório do repositório. */
68
94
  export function repoSlug(repoKey: string): string {
69
95
  const clean = repoKey
@@ -71,8 +97,12 @@ export function repoSlug(repoKey: string): string {
71
97
  .replace(/[^a-zA-Z0-9._-]+/g, "-")
72
98
  .replace(/^-+|-+$/g, "")
73
99
  .toLowerCase();
74
- const hash = createHash("sha1").update(repoKey).digest("hex").slice(0, 8);
75
- return `${clean.slice(-60) || "repo"}-${hash}`;
100
+ return hashName(repoKey, clean.slice(-REPO_SLUG_MAX), REPO_SLUG_MAX, "repo");
101
+ }
102
+
103
+ /** Slug estável e curto para o diretório de uma branch. */
104
+ export function branchSlug(branch: string): string {
105
+ return hashName(branch, sanitizeBranch(branch), BRANCH_SLUG_MAX, "branch");
76
106
  }
77
107
 
78
108
  /** Normaliza um path para comparação (Windows é case-insensitive). */
@@ -87,6 +117,54 @@ function isInside(root: string, path: string): boolean {
87
117
  return rel === "" || (!rel.startsWith("..") && !rel.split(sep).includes(".."));
88
118
  }
89
119
 
120
+ /** Raiz do clone principal a partir do diretório git comum (ou `null`). */
121
+ function mainRootOf(git: GitRepository): string | null {
122
+ const common = git.gitCommonDir();
123
+ if (!common) return null;
124
+ const gitDir = resolve(common);
125
+ return basename(gitDir).toLowerCase() === ".git" ? dirname(gitDir) : null;
126
+ }
127
+
128
+ /**
129
+ * Espelha a camada gerada (`.houndsman/core`) do clone principal no worktree.
130
+ *
131
+ * O checkout de uma PR só traz arquivos versionados e `core/` é gerado (e
132
+ * ignorado pelo git). Sem ele, qualquer comando dentro do worktree falha ao
133
+ * importar `../core/types` a partir dos watchdogs. Fail-open: nunca bloqueia
134
+ * a revisão.
135
+ */
136
+ export function syncProjectLayer(mainRoot: string, worktreeRoot: string): boolean {
137
+ const source = join(mainRoot, CORE_DIR);
138
+ const dest = join(worktreeRoot, CORE_DIR);
139
+ if (normalizePath(source) === normalizePath(dest)) return false;
140
+ if (!existsSync(source)) return false;
141
+ try {
142
+ mkdirSync(dirname(dest), { recursive: true });
143
+ cpSync(source, dest, { recursive: true, force: true });
144
+ return true;
145
+ } catch {
146
+ return false;
147
+ }
148
+ }
149
+
150
+ /**
151
+ * Auto-reparo para comandos executados **dentro** de um worktree: repõe a
152
+ * camada gerada a partir do clone principal quando ela não existe ali. No
153
+ * clone principal (camada presente) é no-op. Fail-open.
154
+ */
155
+ export function syncProjectLayerForRoot(root: string): boolean {
156
+ try {
157
+ const git = new GitRepository(createGitRunner(root));
158
+ const gitRoot = git.rootPath();
159
+ if (!gitRoot || existsSync(join(gitRoot, CORE_DIR))) return false;
160
+ const mainRoot = mainRootOf(git);
161
+ if (!mainRoot) return false;
162
+ return syncProjectLayer(mainRoot, gitRoot);
163
+ } catch {
164
+ return false;
165
+ }
166
+ }
167
+
90
168
  /** Fatia a saída de `git worktree list --porcelain` em entradas. */
91
169
  export function parseWorktreeList(raw: string): WorktreeEntry[] {
92
170
  const entries: WorktreeEntry[] = [];
@@ -125,11 +203,13 @@ export function parseWorktreeList(raw: string): WorktreeEntry[] {
125
203
  export class WorktreeManager {
126
204
  private readonly git: GitRepository;
127
205
  private readonly repoKey: string;
206
+ private readonly repoRoot: string;
128
207
  private readonly rootDir: string;
129
208
 
130
209
  constructor(git: GitRepository, options: WorktreeManagerOptions) {
131
210
  this.git = git;
132
211
  this.repoKey = options.repoKey;
212
+ this.repoRoot = options.repoRoot;
133
213
  this.rootDir = join(
134
214
  options.baseDir ?? worktreesRoot(),
135
215
  repoSlug(options.repoKey),
@@ -154,8 +234,7 @@ export class WorktreeManager {
154
234
 
155
235
  /** Caminho determinístico do worktree de uma branch. */
156
236
  pathFor(branch: string): string {
157
- const slug = sanitizeBranch(branch) || "branch";
158
- return join(this.rootDir, slug);
237
+ return join(this.rootDir, branchSlug(branch));
159
238
  }
160
239
 
161
240
  /** Todos os worktrees registrados no repositório. */
@@ -190,6 +269,7 @@ export class WorktreeManager {
190
269
 
191
270
  if (existing) {
192
271
  if (existing.head === target) {
272
+ syncProjectLayer(this.repoRoot, path);
193
273
  return { path, head: target, created: false, reused: true };
194
274
  }
195
275
  const checkout = new GitRepository(createGitRunner(path)).tryRun([
@@ -198,6 +278,7 @@ export class WorktreeManager {
198
278
  target,
199
279
  ]);
200
280
  if (checkout.ok) {
281
+ syncProjectLayer(this.repoRoot, path);
201
282
  return { path, head: target, created: false, reused: true };
202
283
  }
203
284
  // Worktree quebrado/inacessível: remove e recria do zero.
@@ -227,6 +308,8 @@ export class WorktreeManager {
227
308
  );
228
309
  }
229
310
  }
311
+ // A camada gerada é ignorada pelo git e não vem no checkout da PR.
312
+ syncProjectLayer(this.repoRoot, path);
230
313
  return { path, head: target, created: true, reused: false };
231
314
  }
232
315
 
@@ -102,6 +102,7 @@ export type ArtifactKind =
102
102
  | "finding-report"
103
103
  | "review"
104
104
  | "resume"
105
+ | "technical"
105
106
  | "domain-review"
106
107
  | "verify"
107
108
  | "ast"