@payconductor/houndsman 0.1.1 → 0.1.4

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 (44) hide show
  1. package/README.md +32 -14
  2. package/package.json +1 -1
  3. package/src/cli/index.ts +22 -11
  4. package/src/core/assets.ts +3 -0
  5. package/src/core/brief.ts +2 -3
  6. package/src/core/commands/findings.ts +232 -4
  7. package/src/core/commands/review.ts +2 -0
  8. package/src/core/commands/scaffold.ts +13 -44
  9. package/src/core/diff-source.ts +4 -4
  10. package/src/core/findings-state.ts +20 -2
  11. package/src/core/fix-batch.ts +236 -0
  12. package/src/core/git-revision.ts +105 -0
  13. package/src/core/jev/index.ts +1 -0
  14. package/src/core/jev/jev-detector.ts +2 -2
  15. package/src/core/jev/jev-verify.ts +178 -0
  16. package/src/core/orchestrator.ts +202 -15
  17. package/src/core/paths.ts +6 -0
  18. package/src/core/registry-check.ts +53 -0
  19. package/src/core/report-builder.ts +5 -0
  20. package/src/core/run-status.ts +123 -0
  21. package/src/core/verify.ts +175 -0
  22. package/src/core/watchdog-registry.ts +1 -2
  23. package/src/index.ts +1 -1
  24. package/src/server/artifacts.ts +1 -0
  25. package/src/server/client/RunList.tsx +22 -13
  26. package/src/server/client/constants.ts +32 -1
  27. package/src/server/client/types.ts +4 -0
  28. package/src/server/index.ts +12 -1
  29. package/src/skill/AUDIT-PROTOCOL.md +7 -2
  30. package/src/skill/README.md +32 -6
  31. package/src/skill/SETUP.md +11 -2
  32. package/src/skill/SKILL.md +48 -0
  33. package/src/skill/fix.md +206 -0
  34. package/src/skill/{houndsman.md → hunt.md} +42 -68
  35. package/src/skill/{houndsman-update.md → train.md} +38 -17
  36. package/src/skill/verify.md +70 -0
  37. package/src/tests/findings-commands.test.ts +252 -2
  38. package/src/tests/findings-state.test.ts +52 -1
  39. package/src/tests/fix-batch.test.ts +257 -0
  40. package/src/tests/review-scripts.test.ts +36 -0
  41. package/src/tests/run-status.test.ts +147 -0
  42. package/src/tests/scaffold.test.ts +59 -0
  43. package/src/tests/server.test.ts +30 -0
  44. package/src/tests/verify.test.ts +170 -0
@@ -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
+ });
@@ -110,6 +110,36 @@ describe("server API", () => {
110
110
  }
111
111
  });
112
112
 
113
+ test("PATCH expõe o run status em /api/runs", async () => {
114
+ const env = makeEnv();
115
+ try {
116
+ const handler = createRequestHandler(env.config);
117
+ const before = (await (await get(handler, "/api/runs")).json()) as {
118
+ runs: { status: string | null }[];
119
+ };
120
+ expect(before.runs[0].status).toBeNull();
121
+
122
+ const list = (await (
123
+ await get(handler, "/api/runs/main/findings?all=1")
124
+ ).json()) as { findings: { id: string }[] };
125
+ const id = list.findings[0].id;
126
+ await handler(
127
+ new Request(`http://localhost/api/runs/main/findings/${id}`, {
128
+ method: "PATCH",
129
+ headers: { "content-type": "application/json" },
130
+ body: JSON.stringify({ execState: "in_progress" }),
131
+ }),
132
+ );
133
+
134
+ const after = (await (await get(handler, "/api/runs")).json()) as {
135
+ runs: { status: string | null }[];
136
+ };
137
+ expect(after.runs[0].status).toBe("fixing");
138
+ } finally {
139
+ env.cleanup();
140
+ }
141
+ });
142
+
113
143
  test("POST sync devolve o resultado da reancoragem", async () => {
114
144
  const env = makeEnv();
115
145
  try {
@@ -0,0 +1,170 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { buildAnchor } from "../core/finding-anchor";
3
+ import {
4
+ emptyState,
5
+ type FindingState,
6
+ type FindingsStateFile,
7
+ } from "../core/findings-state";
8
+ import { type JevClient, JevVerifier } from "../core/jev";
9
+ import {
10
+ applyJev,
11
+ applyResolutions,
12
+ selectVerifiable,
13
+ summarizeVerify,
14
+ verifyLocal,
15
+ } from "../core/verify";
16
+
17
+ const CONTENT = ["function a() {", '\tconsole.log("x");', "}"].join("\n");
18
+ const FIXED = ["function a() {", "}"].join("\n");
19
+ const ANCHOR = buildAnchor(CONTENT, 2);
20
+
21
+ /** Finding mínimo, com overrides. */
22
+ function makeFinding(overrides: Partial<FindingState> = {}): FindingState {
23
+ return {
24
+ id: "f1",
25
+ fingerprint: "fp",
26
+ watchdogIds: ["alpha"],
27
+ rule: "Evitar console.log",
28
+ severity: "attention",
29
+ file: "f.ts",
30
+ line: 2,
31
+ message: "console.log",
32
+ action: "remover",
33
+ anchor: ANCHOR,
34
+ count: 1,
35
+ present: true,
36
+ decision: "execute",
37
+ execState: "in_progress",
38
+ createdAt: "2026-01-01T00:00:00.000Z",
39
+ updatedAt: "2026-01-01T00:00:00.000Z",
40
+ history: [],
41
+ ...overrides,
42
+ };
43
+ }
44
+
45
+ function withFindings(findings: FindingState[]): FindingsStateFile {
46
+ const state = emptyState("main");
47
+ for (const finding of findings) state.findings[finding.id] = finding;
48
+ return state;
49
+ }
50
+
51
+ describe("verifyLocal", () => {
52
+ test("âncora presente ⇒ persisting (definitivo)", () => {
53
+ const [entry] = verifyLocal([makeFinding()], () => CONTENT);
54
+ expect(entry.status).toBe("persisting");
55
+ expect(entry.basis).toBe("anchor");
56
+ expect(entry.ambiguous).toBe(false);
57
+ });
58
+
59
+ test("âncora sumida ⇒ resolved provisório e ambíguo", () => {
60
+ const [entry] = verifyLocal([makeFinding()], () => FIXED);
61
+ expect(entry.status).toBe("resolved");
62
+ expect(entry.ambiguous).toBe(true);
63
+ });
64
+
65
+ test("arquivo ausente ⇒ file-missing", () => {
66
+ const [entry] = verifyLocal([makeFinding()], () => null);
67
+ expect(entry.status).toBe("file-missing");
68
+ expect(entry.ambiguous).toBe(false);
69
+ });
70
+
71
+ test("sem âncora ⇒ uncertain e ambíguo", () => {
72
+ const [entry] = verifyLocal([makeFinding({ anchor: null })], () => CONTENT);
73
+ expect(entry.status).toBe("uncertain");
74
+ expect(entry.ambiguous).toBe(true);
75
+ });
76
+ });
77
+
78
+ describe("applyJev", () => {
79
+ test("alta confiança confirma persistência; baixa confirma resolução", () => {
80
+ const entries = verifyLocal(
81
+ [
82
+ makeFinding({ id: "removed", anchor: buildAnchor(CONTENT, 2) }),
83
+ makeFinding({ id: "noanchor", anchor: null }),
84
+ ],
85
+ () => FIXED,
86
+ );
87
+ const applied = applyJev(
88
+ entries,
89
+ new Map([
90
+ ["removed", 0.9],
91
+ ["noanchor", 0.1],
92
+ ]),
93
+ 0.6,
94
+ );
95
+ expect(applied).toBe(2);
96
+ const byId = (id: string) => entries.find((entry) => entry.id === id);
97
+ expect(byId("removed")?.status).toBe("persisting");
98
+ expect(byId("removed")?.basis).toBe("jev");
99
+ expect(byId("removed")?.confidence).toBe(0.9);
100
+ expect(byId("noanchor")?.status).toBe("resolved");
101
+ });
102
+
103
+ test("id sem resposta mantém o provisório local (fail-open)", () => {
104
+ const entries = verifyLocal([makeFinding()], () => FIXED);
105
+ applyJev(entries, new Map(), 0.6);
106
+ expect(entries[0].status).toBe("resolved");
107
+ expect(entries[0].basis).toBe("anchor");
108
+ });
109
+ });
110
+
111
+ describe("summarizeVerify / applyResolutions", () => {
112
+ test("conta por status e marca os resolved como done", () => {
113
+ const findings = [
114
+ makeFinding({ id: "keeps", file: "keeps.ts" }),
115
+ makeFinding({ id: "fixed", file: "fixed.ts" }),
116
+ makeFinding({ id: "gone", file: "gone.ts" }),
117
+ ];
118
+ const reader = (path: string): string | null =>
119
+ path === "gone.ts" ? null : path === "fixed.ts" ? FIXED : CONTENT;
120
+ const entries = verifyLocal(findings, reader);
121
+
122
+ const summary = summarizeVerify(entries);
123
+ expect(summary.persisting).toBe(1);
124
+ expect(summary.resolved).toBe(1);
125
+ expect(summary["file-missing"]).toBe(1);
126
+
127
+ const state = withFindings(findings);
128
+ const changed = applyResolutions(state, entries);
129
+ expect(changed).toEqual(["fixed"]);
130
+ expect(state.findings.fixed.execState).toBe("done");
131
+ expect(state.findings.fixed.history.at(-1)?.by).toBe("verify");
132
+ expect(state.findings.keeps.execState).toBe("in_progress");
133
+ expect(state.findings.gone.execState).toBe("in_progress");
134
+ });
135
+ });
136
+
137
+ describe("selectVerifiable", () => {
138
+ test("inclui presentes e exclui ignore/ausentes", () => {
139
+ const state = withFindings([
140
+ makeFinding({ id: "p", decision: "pending" }),
141
+ makeFinding({ id: "e", decision: "execute" }),
142
+ makeFinding({ id: "i", decision: "ignore" }),
143
+ makeFinding({ id: "absent", present: false }),
144
+ ]);
145
+ expect(
146
+ selectVerifiable(state)
147
+ .map((finding) => finding.id)
148
+ .sort(),
149
+ ).toEqual(["e", "p"]);
150
+ });
151
+ });
152
+
153
+ describe("JevVerifier", () => {
154
+ test("sem chave ⇒ nada confirmado (fail-open)", async () => {
155
+ const client = { isConfigured: () => false } as unknown as JevClient;
156
+ const outcome = await new JevVerifier(client).verify([
157
+ { id: "a", file: "f.ts", rule: "r", foundAtSha: "old" },
158
+ ]);
159
+ expect(outcome.checked).toBe(0);
160
+ expect(outcome.skipped).toBe(1);
161
+ expect(outcome.reason).toContain("TYPESAFE_API_KEY");
162
+ });
163
+
164
+ test("sem casos ⇒ vazio", async () => {
165
+ const client = { isConfigured: () => true } as unknown as JevClient;
166
+ const outcome = await new JevVerifier(client).verify([]);
167
+ expect(outcome.checked).toBe(0);
168
+ expect(outcome.skipped).toBe(0);
169
+ });
170
+ });