@payconductor/houndsman 0.1.0 → 0.1.3

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 (61) hide show
  1. package/README.md +68 -89
  2. package/assets/logo-legacy.png +0 -0
  3. package/assets/logo.png +0 -0
  4. package/package.json +3 -2
  5. package/src/cli/index.ts +101 -10
  6. package/src/core/assets.ts +11 -0
  7. package/src/core/brief.ts +27 -5
  8. package/src/core/commands/findings.ts +618 -0
  9. package/src/core/commands/review.ts +2 -0
  10. package/src/core/commands/scaffold.ts +33 -44
  11. package/src/core/config.ts +42 -0
  12. package/src/core/diff-source.ts +140 -0
  13. package/src/core/file-classifier.ts +50 -2
  14. package/src/core/finding-anchor.ts +147 -0
  15. package/src/core/findings-state.ts +556 -0
  16. package/src/core/fix-batch.ts +236 -0
  17. package/src/core/git-revision.ts +105 -0
  18. package/src/core/jev/index.ts +1 -0
  19. package/src/core/jev/jev-client.ts +72 -11
  20. package/src/core/jev/jev-detector.ts +21 -19
  21. package/src/core/jev/jev-verify.ts +178 -0
  22. package/src/core/orchestrator.ts +362 -23
  23. package/src/core/paths.ts +9 -0
  24. package/src/core/registry-check.ts +15 -0
  25. package/src/core/report-builder.ts +118 -9
  26. package/src/core/run-status.ts +123 -0
  27. package/src/core/types.ts +10 -0
  28. package/src/core/verify.ts +175 -0
  29. package/src/core/watchdog-registry.ts +1 -2
  30. package/src/index.ts +78 -2
  31. package/src/server/artifacts.ts +67 -0
  32. package/src/server/client/App.tsx +158 -0
  33. package/src/server/client/ArtifactsPanel.tsx +48 -0
  34. package/src/server/client/Filters.tsx +83 -0
  35. package/src/server/client/FindingCard.tsx +122 -0
  36. package/src/server/client/FindingsList.tsx +54 -0
  37. package/src/server/client/RunList.tsx +52 -0
  38. package/src/server/client/SummaryBar.tsx +21 -0
  39. package/src/server/client/api.ts +37 -0
  40. package/src/server/client/constants.ts +94 -0
  41. package/src/server/client/index.tsx +12 -0
  42. package/src/server/client/types.ts +52 -0
  43. package/src/server/client-build.ts +47 -0
  44. package/src/server/index.ts +376 -0
  45. package/src/skill/AUDIT-PROTOCOL.md +2 -2
  46. package/src/skill/README.md +43 -11
  47. package/src/skill/SETUP.md +16 -4
  48. package/src/skill/SKILL.md +48 -0
  49. package/src/skill/fix.md +206 -0
  50. package/src/skill/{houndsman.md → hunt.md} +83 -33
  51. package/src/skill/{houndsman-update.md → train.md} +12 -12
  52. package/src/skill/verify.md +70 -0
  53. package/src/tests/findings-anchor.test.ts +120 -0
  54. package/src/tests/findings-commands.test.ts +476 -0
  55. package/src/tests/findings-state.test.ts +325 -0
  56. package/src/tests/fix-batch.test.ts +257 -0
  57. package/src/tests/review-scripts.test.ts +299 -0
  58. package/src/tests/run-status.test.ts +147 -0
  59. package/src/tests/scaffold.test.ts +59 -0
  60. package/src/tests/server.test.ts +242 -0
  61. package/src/tests/verify.test.ts +170 -0
@@ -6,13 +6,23 @@ import type { AxiosInstance } from "axios";
6
6
  import { renderBrief } from "../core/brief";
7
7
  import { parseReviewArgs } from "../core/commands/review";
8
8
  import { renderWatchdogContext } from "../core/commands/watchdog";
9
+ import {
10
+ DEFAULT_JEV_CONCURRENCY,
11
+ DEFAULT_MAX_FILES_PER_BRIEF,
12
+ resolveJevConcurrency,
13
+ resolveMaxFilesPerBrief,
14
+ } from "../core/config";
15
+ import { parseDiffIndex, parseNumstat } from "../core/diff-source";
9
16
  import { discoverDocs } from "../core/doc-discoverer";
10
17
  import {
11
18
  type ClassificationResult,
19
+ churnOf,
12
20
  classifyEntries,
13
21
  isExcludedPath,
14
22
  matchWatchdogs,
15
23
  parseEntries,
24
+ selectByBudget,
25
+ sortByRisk,
16
26
  } from "../core/file-classifier";
17
27
  import {
18
28
  type JevCandidatesFile,
@@ -27,6 +37,7 @@ import {
27
37
  dedupeFindings,
28
38
  parseContextSummary,
29
39
  parseWatchdogReport,
40
+ renderDomainReport,
30
41
  renderReport,
31
42
  sanitizeBranch,
32
43
  type WatchdogReport,
@@ -306,6 +317,136 @@ describe("isExcludedPath", () => {
306
317
  });
307
318
  });
308
319
 
320
+ // ---------------------------------------------------------------------------
321
+ // diff-source (diff único: patch + churn)
322
+ // ---------------------------------------------------------------------------
323
+
324
+ describe("diff-source", () => {
325
+ test("parseNumstat lê adições/remoções por arquivo", () => {
326
+ const map = parseNumstat("3\t1\tsrc/a.ts\0");
327
+ expect(map.get("src/a.ts")).toEqual({ additions: 3, deletions: 1 });
328
+ });
329
+
330
+ test("parseNumstat trata rename (path vazio) e binário ('-')", () => {
331
+ const nul = String.fromCharCode(0);
332
+ const raw = [
333
+ "2 0 ",
334
+ "src/old.ts",
335
+ "src/new.ts",
336
+ "10 - dist/x.png",
337
+ "",
338
+ ].join(nul);
339
+ const map = parseNumstat(raw);
340
+ expect(map.get("src/new.ts")).toEqual({ additions: 2, deletions: 0 });
341
+ expect(map.get("dist/x.png")).toEqual({ additions: 10, deletions: 0 });
342
+ });
343
+
344
+ test("parseDiffIndex fatia o patch e extrai o path novo (renames)", () => {
345
+ const patch = [
346
+ "diff --git a/src/a.ts b/src/a.ts",
347
+ "index 111..222 100644",
348
+ "--- a/src/a.ts",
349
+ "+++ b/src/a.ts",
350
+ "@@ -1 +1 @@",
351
+ "-old",
352
+ "+new",
353
+ "diff --git a/src/old.ts b/src/new.ts",
354
+ "similarity index 90%",
355
+ "rename from src/old.ts",
356
+ "rename to src/new.ts",
357
+ "--- a/src/old.ts",
358
+ "+++ b/src/new.ts",
359
+ "@@ -1 +1 @@",
360
+ "-a",
361
+ "+b",
362
+ ].join("\n");
363
+
364
+ const map = parseDiffIndex(patch);
365
+ expect([...map.keys()]).toEqual(["src/a.ts", "src/new.ts"]);
366
+ expect(map.get("src/a.ts")).toContain("+new");
367
+ expect(map.get("src/new.ts")).toContain("+b");
368
+ });
369
+
370
+ test("parseDiffIndex não confunde linha de conteúdo com o header", () => {
371
+ const patch = [
372
+ "diff --git a/x.ts b/x.ts",
373
+ "--- a/x.ts",
374
+ "+++ b/x.ts",
375
+ "@@ -1 +1,2 @@",
376
+ " keep",
377
+ "+++ b/fake.ts",
378
+ ].join("\n");
379
+ expect([...parseDiffIndex(patch).keys()]).toEqual(["x.ts"]);
380
+ });
381
+
382
+ test("parseDiffIndex respeita o teto por arquivo", () => {
383
+ const patch = "diff --git a/x.ts b/x.ts\n--- a/x.ts\n+++ b/x.ts\n@@ -1 +1 @@\n+a";
384
+ expect(parseDiffIndex(patch, 10).get("x.ts")).toHaveLength(10);
385
+ });
386
+ });
387
+
388
+ // ---------------------------------------------------------------------------
389
+ // triage (churn + orçamento por brief)
390
+ // ---------------------------------------------------------------------------
391
+
392
+ describe("triage", () => {
393
+ const churn = {
394
+ "a.ts": { additions: 1, deletions: 0 },
395
+ "b.ts": { additions: 50, deletions: 50 },
396
+ "c.ts": { additions: 10, deletions: 0 },
397
+ };
398
+
399
+ test("churnOf soma adições/remoções e trata ausente", () => {
400
+ expect(churnOf("b.ts", churn)).toBe(100);
401
+ expect(churnOf("ausente.ts", churn)).toBe(0);
402
+ });
403
+
404
+ test("sortByRisk ordena por churn desc", () => {
405
+ expect(sortByRisk(["a.ts", "b.ts", "c.ts"], churn)).toEqual([
406
+ "b.ts",
407
+ "c.ts",
408
+ "a.ts",
409
+ ]);
410
+ });
411
+
412
+ test("selectByBudget mantém os de maior risco e devolve o excedente", () => {
413
+ const { selected, overflow } = selectByBudget(["a.ts", "b.ts", "c.ts"], churn, 2);
414
+ expect(selected).toEqual(["b.ts", "c.ts"]);
415
+ expect(overflow).toEqual(["a.ts"]);
416
+ });
417
+
418
+ test("maxFiles não-positivo desativa o orçamento", () => {
419
+ const { selected, overflow } = selectByBudget(["a.ts", "b.ts"], churn, 0);
420
+ expect(selected).toHaveLength(2);
421
+ expect(overflow).toEqual([]);
422
+ });
423
+
424
+ test("resolveMaxFilesPerBrief usa default e sobrepõe", () => {
425
+ expect(resolveMaxFilesPerBrief(fakeHoundsmanConfig)).toBe(
426
+ DEFAULT_MAX_FILES_PER_BRIEF,
427
+ );
428
+ expect(
429
+ resolveMaxFilesPerBrief({
430
+ ...fakeHoundsmanConfig,
431
+ budgets: { maxFilesPerBrief: 5 },
432
+ }),
433
+ ).toBe(5);
434
+ expect(
435
+ resolveMaxFilesPerBrief({
436
+ ...fakeHoundsmanConfig,
437
+ budgets: { maxFilesPerBrief: 0 },
438
+ }),
439
+ ).toBe(DEFAULT_MAX_FILES_PER_BRIEF);
440
+ });
441
+
442
+ test("resolveJevConcurrency usa default e sobrepõe", () => {
443
+ expect(resolveJevConcurrency(fakeHoundsmanConfig)).toBe(DEFAULT_JEV_CONCURRENCY);
444
+ expect(
445
+ resolveJevConcurrency({ ...fakeHoundsmanConfig, concurrency: { jev: 9 } }),
446
+ ).toBe(9);
447
+ });
448
+ });
449
+
309
450
  // ---------------------------------------------------------------------------
310
451
  // doc-discoverer
311
452
  // ---------------------------------------------------------------------------
@@ -440,6 +581,79 @@ describe("report-builder", () => {
440
581
  );
441
582
  expect(md).toContain("Watchdog Alpha");
442
583
  });
584
+
585
+ test("renderReport agrupa achados por arquivo", () => {
586
+ const merged = [
587
+ {
588
+ rule: "R1",
589
+ severity: "blocking" as const,
590
+ file: "a.ts",
591
+ line: 1,
592
+ message: "m",
593
+ action: "a",
594
+ watchdogs: ["alpha"],
595
+ },
596
+ {
597
+ rule: "R2",
598
+ severity: "blocking" as const,
599
+ file: "b.ts",
600
+ line: 2,
601
+ message: "m",
602
+ action: "a",
603
+ watchdogs: ["alpha"],
604
+ },
605
+ ];
606
+ const md = renderReport(
607
+ "main",
608
+ [{ watchdogId: "alpha", reviewedFiles: [], skipped: [], findings: [] }],
609
+ merged,
610
+ {},
611
+ );
612
+ expect(md).toContain("### `a.ts`");
613
+ expect(md).toContain("### `b.ts`");
614
+ });
615
+
616
+ test("renderReport indexa os relatórios por domínio", () => {
617
+ const md = renderReport(
618
+ "main",
619
+ [{ watchdogId: "alpha", reviewedFiles: [], skipped: [], findings: [] }],
620
+ [],
621
+ {},
622
+ undefined,
623
+ undefined,
624
+ undefined,
625
+ [
626
+ {
627
+ id: "alpha",
628
+ name: "Alpha",
629
+ file: "review.alpha.md",
630
+ findings: 2,
631
+ reviewedFiles: 3,
632
+ expectedFiles: 5,
633
+ },
634
+ ],
635
+ );
636
+ expect(md).toContain("## Relatórios por domínio");
637
+ expect(md).toContain("review.alpha.md");
638
+ });
639
+
640
+ test("renderDomainReport mostra cobertura e não revisados", () => {
641
+ const md = renderDomainReport(
642
+ "main",
643
+ {
644
+ watchdogId: "alpha",
645
+ reviewedFiles: ["a.ts"],
646
+ skipped: ["b.ts"],
647
+ findings: [],
648
+ },
649
+ [],
650
+ 2,
651
+ null,
652
+ );
653
+ expect(md).toContain("1/2");
654
+ expect(md).toContain("Não revisados");
655
+ expect(md).toContain("Nenhum achado");
656
+ });
443
657
  });
444
658
 
445
659
  // ---------------------------------------------------------------------------
@@ -571,6 +785,24 @@ describe("houndsman brief", () => {
571
785
  expect(brief).toContain("**Objetivo:** Entregar X");
572
786
  });
573
787
 
788
+ test("mostra churn e lista arquivos fora do orçamento", () => {
789
+ const brief = renderBrief(alphaWatchdog, {
790
+ watchdogId: "alpha",
791
+ files: ["src/alpha/keep.ts"],
792
+ overflow: ["src/alpha/skip.ts"],
793
+ churn: { "src/alpha/keep.ts": { additions: 12, deletions: 3 } },
794
+ branch: "feature/x",
795
+ outputPath: "o.json",
796
+ outputFormatPath: OUTPUT_FORMAT,
797
+ docs: [],
798
+ statuses: { "src/alpha/keep.ts": { status: "modified" } },
799
+ deleted: [],
800
+ });
801
+ expect(brief).toContain("+12/−3");
802
+ expect(brief).toContain("Fora do orçamento");
803
+ expect(brief).toContain("src/alpha/skip.ts");
804
+ });
805
+
574
806
  test("hook custom de renderBrief é usado no lugar do default", () => {
575
807
  const custom = { ...alphaWatchdog, renderBrief: () => ["# Custom"] };
576
808
  const brief = renderBrief(custom, {
@@ -691,6 +923,72 @@ describe("jev-client", () => {
691
923
  });
692
924
  });
693
925
 
926
+ describe("jev-client — validateCredential", () => {
927
+ /** Erro no formato do axios (isAxiosError + response.status). */
928
+ function axiosError(status: number): Error {
929
+ return Object.assign(new Error(`HTTP ${status}`), {
930
+ isAxiosError: true,
931
+ response: { status, data: "unauthorized" },
932
+ });
933
+ }
934
+
935
+ test("sem chave → unreachable e não faz chamada", async () => {
936
+ let called = false;
937
+ const http = {
938
+ post: async () => {
939
+ called = true;
940
+ return { data: { model: "x", answers: {} } };
941
+ },
942
+ } as unknown as AxiosInstance;
943
+ const check = await new JevClient({ apiKey: "", http }).validateCredential();
944
+ expect(check.ok).toBe(false);
945
+ if (!check.ok) expect(check.kind).toBe("unreachable");
946
+ expect(called).toBe(false);
947
+ });
948
+
949
+ test("2xx → credencial válida com o modelo", async () => {
950
+ const http = {
951
+ post: async () => ({ data: { model: "jev-test", answers: {} } }),
952
+ } as unknown as AxiosInstance;
953
+ const check = await new JevClient({ apiKey: "k", http }).validateCredential();
954
+ expect(check).toEqual({ ok: true, model: "jev-test" });
955
+ });
956
+
957
+ test("401 → credencial inválida", async () => {
958
+ const http = {
959
+ post: async () => {
960
+ throw axiosError(401);
961
+ },
962
+ } as unknown as AxiosInstance;
963
+ const check = await new JevClient({ apiKey: "k", http }).validateCredential();
964
+ expect(check.ok).toBe(false);
965
+ if (!check.ok) expect(check.kind).toBe("invalid");
966
+ });
967
+
968
+ test("falha de rede → indeterminada (unreachable)", async () => {
969
+ const http = {
970
+ post: async () => {
971
+ throw new Error("network down");
972
+ },
973
+ } as unknown as AxiosInstance;
974
+ const check = await new JevClient({ apiKey: "k", http }).validateCredential();
975
+ expect(check.ok).toBe(false);
976
+ if (!check.ok) expect(check.kind).toBe("unreachable");
977
+ });
978
+
979
+ test("evaluate preserva o status HTTP no JevError", async () => {
980
+ const http = {
981
+ post: async () => {
982
+ throw axiosError(403);
983
+ },
984
+ } as unknown as AxiosInstance;
985
+ const client = new JevClient({ apiKey: "k", http });
986
+ await expect(
987
+ client.evaluate({ state: "s", questions: {} }),
988
+ ).rejects.toMatchObject({ status: 403 });
989
+ });
990
+ });
991
+
694
992
  // ---------------------------------------------------------------------------
695
993
  // Jev — detector (fail-open)
696
994
  // ---------------------------------------------------------------------------
@@ -699,6 +997,7 @@ describe("jev-detector", () => {
699
997
  const classification: ClassificationResult = {
700
998
  watchdogs: { alpha: ["src/alpha/x.ts"] },
701
999
  files: { "src/alpha/x.ts": { status: "modified" } },
1000
+ churn: {},
702
1001
  unclassified: [],
703
1002
  excluded: [],
704
1003
  deleted: [],
@@ -0,0 +1,147 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { mkdtempSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import type { FindingState, FindingsStateFile } from "../core/findings-state";
6
+ import {
7
+ pingFindingsStatus,
8
+ readRunStatus,
9
+ statusAfterFindingsChange,
10
+ writeRunStatus,
11
+ } from "../core/run-status";
12
+
13
+ function findingState(partial: Partial<FindingState>): FindingState {
14
+ return {
15
+ id: partial.id ?? "id",
16
+ fingerprint: partial.fingerprint ?? "fp",
17
+ watchdogIds: partial.watchdogIds ?? ["alpha"],
18
+ rule: partial.rule ?? "r",
19
+ severity: partial.severity ?? "attention",
20
+ file: partial.file ?? "a.ts",
21
+ line: partial.line ?? 1,
22
+ message: partial.message ?? "m",
23
+ action: partial.action ?? "a",
24
+ evidence: partial.evidence,
25
+ anchor: null,
26
+ count: partial.count ?? 1,
27
+ present: partial.present ?? true,
28
+ decision: partial.decision ?? "execute",
29
+ comment: partial.comment,
30
+ execState: partial.execState ?? "pending",
31
+ createdAt: "",
32
+ updatedAt: "",
33
+ history: [],
34
+ };
35
+ }
36
+
37
+ function stateOf(findings: FindingState[]): FindingsStateFile {
38
+ return {
39
+ version: 2,
40
+ branch: "feature/x",
41
+ slug: "feature-x",
42
+ updatedAt: "",
43
+ order: findings.map((item) => item.id),
44
+ findings: Object.fromEntries(findings.map((item) => [item.id, item])),
45
+ };
46
+ }
47
+
48
+ /** Diretório temporário isolado. */
49
+ function withTempDir(fn: (dir: string) => void): void {
50
+ const dir = mkdtempSync(join(tmpdir(), "houndsman-run-status-"));
51
+ try {
52
+ fn(dir);
53
+ } finally {
54
+ rmSync(dir, { recursive: true, force: true });
55
+ }
56
+ }
57
+
58
+ describe("run-status — arquivo", () => {
59
+ test("grava, lê e preserva/restaura o startedAt", () => {
60
+ withTempDir((dir) => {
61
+ writeRunStatus(dir, {
62
+ branch: "feature/x",
63
+ status: "reviewing",
64
+ resetStartedAt: true,
65
+ now: "2026-01-01T00:00:00.000Z",
66
+ });
67
+ const first = readRunStatus(dir);
68
+ expect(first?.status).toBe("reviewing");
69
+ expect(first?.slug).toBe("feature-x");
70
+ expect(first?.startedAt).toBe("2026-01-01T00:00:00.000Z");
71
+
72
+ writeRunStatus(dir, {
73
+ branch: "feature/x",
74
+ status: "review_done",
75
+ now: "2026-01-01T00:10:00.000Z",
76
+ });
77
+ const second = readRunStatus(dir);
78
+ expect(second?.status).toBe("review_done");
79
+ expect(second?.startedAt).toBe("2026-01-01T00:00:00.000Z");
80
+ expect(second?.updatedAt).toBe("2026-01-01T00:10:00.000Z");
81
+
82
+ writeRunStatus(dir, {
83
+ branch: "feature/x",
84
+ status: "reviewing",
85
+ resetStartedAt: true,
86
+ now: "2026-01-01T00:20:00.000Z",
87
+ });
88
+ expect(readRunStatus(dir)?.startedAt).toBe("2026-01-01T00:20:00.000Z");
89
+ });
90
+ });
91
+
92
+ test("readRunStatus devolve null sem arquivo", () => {
93
+ withTempDir((dir) => {
94
+ expect(readRunStatus(dir)).toBeNull();
95
+ });
96
+ });
97
+ });
98
+
99
+ describe("statusAfterFindingsChange", () => {
100
+ test("in_progress → fixing", () => {
101
+ const state = stateOf([findingState({ execState: "in_progress" })]);
102
+ expect(statusAfterFindingsChange("review_done", state)).toBe("fixing");
103
+ });
104
+
105
+ test("review_done + fila (triado, não iniciado) → mantém review_done", () => {
106
+ const state = stateOf([
107
+ findingState({ decision: "execute", execState: "pending" }),
108
+ ]);
109
+ expect(statusAfterFindingsChange("review_done", state)).toBe("review_done");
110
+ });
111
+
112
+ test("fixing + fila → fixing", () => {
113
+ const state = stateOf([
114
+ findingState({ id: "a", execState: "done" }),
115
+ findingState({ id: "b", execState: "pending" }),
116
+ ]);
117
+ expect(statusAfterFindingsChange("fixing", state)).toBe("fixing");
118
+ });
119
+
120
+ test("fixing + fila vazia → fix_done", () => {
121
+ const state = stateOf([findingState({ execState: "done" })]);
122
+ expect(statusAfterFindingsChange("fixing", state)).toBe("fix_done");
123
+ });
124
+
125
+ test("done sem nunca ter iniciado → fix_done", () => {
126
+ const state = stateOf([findingState({ execState: "done" })]);
127
+ expect(statusAfterFindingsChange("review_done", state)).toBe("fix_done");
128
+ });
129
+
130
+ test("sem run.json e sem fix → fallback review_done", () => {
131
+ const state = stateOf([
132
+ findingState({ decision: "pending", execState: "pending" }),
133
+ ]);
134
+ expect(statusAfterFindingsChange(null, state)).toBe("review_done");
135
+ });
136
+ });
137
+
138
+ describe("pingFindingsStatus", () => {
139
+ test("deriva do estado e grava o run.json", () => {
140
+ withTempDir((dir) => {
141
+ const state = stateOf([findingState({ execState: "in_progress" })]);
142
+ const file = pingFindingsStatus(dir, state);
143
+ expect(file.status).toBe("fixing");
144
+ expect(readRunStatus(dir)?.status).toBe("fixing");
145
+ });
146
+ });
147
+ });
@@ -0,0 +1,59 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { createScaffoldContext, syncGeneratedFiles } from "../core/commands/scaffold";
6
+
7
+ /** Cria um diretório temporário, roda `fn` e limpa ao final. */
8
+ function withTempDir(fn: (cwd: string) => void): void {
9
+ const cwd = mkdtempSync(join(tmpdir(), "houndsman-scaffold-"));
10
+ try {
11
+ fn(cwd);
12
+ } finally {
13
+ rmSync(cwd, { recursive: true, force: true });
14
+ }
15
+ }
16
+
17
+ describe("scaffold — ponteiros de skill", () => {
18
+ test("gera 1 skill 'houndsman' por harness e os docs de capability", () => {
19
+ withTempDir((cwd) => {
20
+ syncGeneratedFiles(createScaffoldContext(cwd, [], false));
21
+
22
+ for (const harness of ["agents", "claude"]) {
23
+ expect(
24
+ existsSync(join(cwd, `.${harness}/skills/houndsman/SKILL.md`)),
25
+ ).toBe(true);
26
+ }
27
+
28
+ for (const name of ["hunt.md", "train.md", "fix.md"]) {
29
+ expect(existsSync(join(cwd, ".houndsman/core/skill", name))).toBe(true);
30
+ }
31
+ // O entry point é o SKILL.md do harness — não um doc da camada.
32
+ expect(existsSync(join(cwd, ".houndsman/core/skill/SKILL.md"))).toBe(false);
33
+ });
34
+ });
35
+
36
+ test("o SKILL.md descreve as capabilities e resolve o projectName", () => {
37
+ withTempDir((cwd) => {
38
+ syncGeneratedFiles(createScaffoldContext(cwd, ["--name", "acme"], false));
39
+
40
+ const skill = readFileSync(
41
+ join(cwd, ".agents/skills/houndsman/SKILL.md"),
42
+ "utf8",
43
+ );
44
+ for (const capability of ["hunt", "train", "fix"]) {
45
+ expect(skill).toContain(capability);
46
+ }
47
+ expect(skill).toContain("acme");
48
+ expect(skill).not.toContain("{{projectName}}");
49
+ });
50
+ });
51
+
52
+ test("--harness none não gera ponteiros de harness", () => {
53
+ withTempDir((cwd) => {
54
+ syncGeneratedFiles(createScaffoldContext(cwd, ["--harness", "none"], false));
55
+ expect(existsSync(join(cwd, ".agents"))).toBe(false);
56
+ expect(existsSync(join(cwd, ".claude"))).toBe(false);
57
+ });
58
+ });
59
+ });