@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.
- package/package.json +1 -1
- package/src/cli/index.ts +9 -1
- package/src/core/ai/ai-client.ts +21 -0
- package/src/core/ai/ai-sdk-model-provider.ts +43 -18
- package/src/core/ai/features/technical-summarizer.ts +43 -0
- package/src/core/ai/index.ts +4 -1
- package/src/core/ai/prompts/base.ts +7 -0
- package/src/core/ai/prompts/context.ts +5 -4
- package/src/core/ai/prompts/technical.ts +31 -0
- package/src/core/ai/provider-capabilities.ts +45 -0
- package/src/core/ai/schemas.ts +19 -3
- package/src/core/ai/types.ts +3 -1
- package/src/core/brief.ts +25 -4
- package/src/core/commands/scaffold.ts +5 -0
- package/src/core/config-loader.ts +18 -4
- package/src/core/config.ts +17 -1
- package/src/core/engine/review-workspace.ts +6 -2
- package/src/core/git-runner.ts +8 -1
- package/src/core/orchestrator.ts +114 -2
- package/src/core/paths.ts +3 -0
- package/src/core/pr-context.ts +11 -6
- package/src/core/project-config.ts +9 -1
- package/src/core/renderers/resume-renderer.ts +17 -34
- package/src/core/report-builder.ts +6 -6
- package/src/core/services/worktree-manager.ts +90 -7
- package/src/core/storage/types.ts +1 -0
- package/src/core/technical-summary.ts +398 -0
- package/src/harness/agent/agent-factory.ts +1 -0
- package/src/harness/agent/review-agent.ts +11 -1
- package/src/harness/config/harness-config.ts +6 -1
- package/src/index.ts +2 -0
- package/src/server/client/App.tsx +2 -1
- package/src/server/client/PrsSection.tsx +296 -0
- package/src/server/client/ResumePanel.tsx +10 -13
- package/src/server/client/RunsSection.tsx +73 -0
- package/src/server/client/Sidebar.tsx +171 -413
- package/src/server/client/sidebar-sections.tsx +73 -0
- package/src/server/client/utils/constants.ts +7 -2
- package/src/server/client/utils/sidebar-prefs.ts +64 -0
- package/src/server/client/utils/types.ts +1 -0
- package/src/server/index.ts +23 -409
- package/src/server/styles.css +454 -0
- package/src/tests/__snapshots__/baseline.test.ts.snap +5 -0
- package/src/tests/ai-core.test.ts +71 -0
- package/src/tests/ai-features.test.ts +47 -0
- package/src/tests/baseline.test.ts +4 -1
- package/src/tests/harness-agent.test.ts +19 -0
- package/src/tests/review-resume.test.ts +36 -8
- package/src/tests/review-scope.test.ts +67 -0
- package/src/tests/review-scripts.test.ts +2 -2
- package/src/tests/server.test.ts +16 -1
- package/src/tests/sidebar-sections.test.ts +94 -0
- package/src/tests/technical-summary.test.ts +176 -0
- package/src/tests/worktree.test.ts +110 -5
- package/assets/logo-legacy.png +0 -0
package/src/core/orchestrator.ts
CHANGED
|
@@ -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
|
-
|
|
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";
|
package/src/core/pr-context.ts
CHANGED
|
@@ -229,13 +229,17 @@ export function renderContextBrief(
|
|
|
229
229
|
|
|
230
230
|
lines.push("## Regras", "");
|
|
231
231
|
lines.push(
|
|
232
|
-
"-
|
|
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
|
-
"- `
|
|
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
|
|
257
|
-
motivation:
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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,
|
|
84
|
-
lines.push(`- ${truncate(item,
|
|
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
|
-
/**
|
|
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 (
|
|
104
|
-
lines.push("
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
|
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.
|
|
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:
|
|
67
|
-
*
|
|
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:
|
|
71
|
-
motivation:
|
|
72
|
-
changeSummaryItem:
|
|
73
|
-
changeSummaryItems:
|
|
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
|
-
* `<
|
|
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
|
-
|
|
75
|
-
|
|
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
|
-
|
|
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
|
|