patchwarden 1.1.0 → 1.5.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 (70) hide show
  1. package/README.en.md +41 -7
  2. package/README.md +36 -9
  3. package/dist/controlCenter.js +197 -1
  4. package/dist/direct/directSessionStore.d.ts +2 -0
  5. package/dist/direct/directVerification.js +7 -0
  6. package/dist/doctor.js +1 -1
  7. package/dist/policy/projectPolicy.d.ts +55 -0
  8. package/dist/policy/projectPolicy.js +286 -0
  9. package/dist/smoke-test.js +8 -8
  10. package/dist/test/unit/evidence-pack.test.d.ts +1 -0
  11. package/dist/test/unit/evidence-pack.test.js +130 -0
  12. package/dist/test/unit/project-policy-release-mode.test.d.ts +1 -0
  13. package/dist/test/unit/project-policy-release-mode.test.js +125 -0
  14. package/dist/test/unit/run-task-loop.test.d.ts +1 -0
  15. package/dist/test/unit/run-task-loop.test.js +380 -0
  16. package/dist/test/unit/schema-drift-check.test.js +10 -9
  17. package/dist/tools/evidencePack.d.ts +39 -0
  18. package/dist/tools/evidencePack.js +168 -0
  19. package/dist/tools/recommendAgentForTask.d.ts +19 -0
  20. package/dist/tools/recommendAgentForTask.js +56 -0
  21. package/dist/tools/registry.js +376 -2
  22. package/dist/tools/releaseMode.d.ts +50 -0
  23. package/dist/tools/releaseMode.js +370 -0
  24. package/dist/tools/runDirectVerificationBundle.d.ts +26 -0
  25. package/dist/tools/runDirectVerificationBundle.js +64 -0
  26. package/dist/tools/runTaskLoop.d.ts +57 -0
  27. package/dist/tools/runTaskLoop.js +417 -0
  28. package/dist/tools/runVerification.d.ts +4 -0
  29. package/dist/tools/runVerification.js +4 -0
  30. package/dist/tools/safeViews.d.ts +6 -0
  31. package/dist/tools/safeViews.js +2 -0
  32. package/dist/tools/taskLineage.d.ts +91 -0
  33. package/dist/tools/taskLineage.js +175 -0
  34. package/dist/tools/toolCatalog.d.ts +2 -2
  35. package/dist/tools/toolCatalog.js +6 -0
  36. package/dist/tools/toolRegistry.js +110 -0
  37. package/dist/version.d.ts +2 -2
  38. package/dist/version.js +2 -2
  39. package/docs/chatgpt-usage.md +31 -0
  40. package/docs/control-center/README.md +9 -0
  41. package/package.json +2 -2
  42. package/scripts/checks/control-center-smoke.js +87 -0
  43. package/scripts/checks/control-smoke.js +2 -2
  44. package/scripts/checks/mcp-manifest-check.js +12 -0
  45. package/scripts/checks/mcp-smoke.js +31 -7
  46. package/scripts/checks/watcher-supervisor-smoke.js +1 -1
  47. package/src/controlCenter.ts +198 -1
  48. package/src/direct/directSessionStore.ts +2 -0
  49. package/src/direct/directVerification.ts +7 -0
  50. package/src/doctor.ts +1 -1
  51. package/src/policy/projectPolicy.ts +344 -0
  52. package/src/smoke-test.ts +5 -5
  53. package/src/test/unit/evidence-pack.test.ts +142 -0
  54. package/src/test/unit/project-policy-release-mode.test.ts +156 -0
  55. package/src/test/unit/run-task-loop.test.ts +425 -0
  56. package/src/test/unit/schema-drift-check.test.ts +11 -9
  57. package/src/tools/evidencePack.ts +205 -0
  58. package/src/tools/listWorkspace.ts +71 -71
  59. package/src/tools/recommendAgentForTask.ts +79 -0
  60. package/src/tools/registry.ts +405 -2
  61. package/src/tools/releaseMode.ts +450 -0
  62. package/src/tools/runDirectVerificationBundle.ts +98 -0
  63. package/src/tools/runTaskLoop.ts +526 -0
  64. package/src/tools/runVerification.ts +8 -0
  65. package/src/tools/safeViews.ts +2 -0
  66. package/src/tools/taskLineage.ts +300 -0
  67. package/src/tools/toolCatalog.ts +6 -0
  68. package/src/tools/toolRegistry.ts +110 -0
  69. package/src/version.ts +2 -2
  70. package/ui/pages/dashboard.html +143 -2
@@ -0,0 +1,205 @@
1
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { join, resolve } from "node:path";
3
+ import { getConfig } from "../config.js";
4
+ import { guardReadPath, guardWorkspacePath } from "../security/pathGuard.js";
5
+ import { redactSensitiveValue } from "../security/contentRedaction.js";
6
+ import { getProjectPolicySummary } from "../policy/projectPolicy.js";
7
+ import { getTaskLineage, type SafeTaskLineage } from "./taskLineage.js";
8
+ import { getLastToolCatalogSnapshot } from "./toolCatalog.js";
9
+ import { PATCHWARDEN_VERSION, TOOL_SCHEMA_EPOCH } from "../version.js";
10
+ import { PatchWardenError } from "../errors.js";
11
+
12
+ export interface SafeEvidencePack {
13
+ evidence_pack_id: string;
14
+ lineage_id: string;
15
+ generated_at: string;
16
+ path: string;
17
+ files: {
18
+ json: string;
19
+ markdown: string;
20
+ };
21
+ lineage: SafeTaskLineage;
22
+ policy: {
23
+ valid: boolean;
24
+ issue_count: number;
25
+ release_readiness: unknown;
26
+ };
27
+ catalog: {
28
+ server_version: string;
29
+ schema_epoch: string;
30
+ tool_profile: string | null;
31
+ tool_count: number | null;
32
+ tool_manifest_sha256: string | null;
33
+ };
34
+ omitted: string[];
35
+ next_action: string;
36
+ bounded: true;
37
+ }
38
+
39
+ export function exportTaskEvidencePack(input: { lineage_id: string; max_items?: number }): SafeEvidencePack {
40
+ const lineageId = normalizeLineageId(input.lineage_id);
41
+ const maxItems = normalizeMaxItems(input.max_items);
42
+ const config = getConfig();
43
+ const lineage = getTaskLineage(lineageId, { max_items: maxItems });
44
+ const packDir = resolve(config.workspaceRoot, ".patchwarden", "evidence-packs", lineageId);
45
+ guardWorkspacePath(packDir, config.workspaceRoot);
46
+ mkdirSync(packDir, { recursive: true });
47
+
48
+ const policySummary = readPolicySummary(lineage.repo_path);
49
+ const catalog = safeCatalog();
50
+ const generatedAt = new Date().toISOString();
51
+ const safePack = redactSensitiveValue({
52
+ evidence_pack_id: `evidence_${lineageId}`,
53
+ lineage_id: lineageId,
54
+ generated_at: generatedAt,
55
+ path: packDir,
56
+ files: {
57
+ json: join(packDir, "evidence.json"),
58
+ markdown: join(packDir, "EVIDENCE.md"),
59
+ },
60
+ lineage,
61
+ policy: {
62
+ valid: policySummary.valid,
63
+ issue_count: policySummary.issues.length,
64
+ release_readiness: policySummary.release_readiness,
65
+ },
66
+ catalog,
67
+ omitted: ["stdout", "stderr", "diff", "verification command logs", "full artifact markdown", "sensitive file contents"],
68
+ next_action: buildNextAction(lineage),
69
+ bounded: true,
70
+ }).value as SafeEvidencePack;
71
+
72
+ writeFileSync(safePack.files.json, JSON.stringify(safePack, null, 2) + "\n", "utf-8");
73
+ writeFileSync(safePack.files.markdown, buildEvidenceMarkdown(safePack), "utf-8");
74
+ return safePack;
75
+ }
76
+
77
+ export function listEvidencePacks(options: { max_items?: number } = {}): { evidence_packs: SafeEvidencePack[]; total: number; truncated: boolean } {
78
+ const maxItems = normalizeMaxItems(options.max_items);
79
+ const config = getConfig();
80
+ const root = resolve(config.workspaceRoot, ".patchwarden", "evidence-packs");
81
+ guardWorkspacePath(root, config.workspaceRoot);
82
+ if (!existsSync(root)) return { evidence_packs: [], total: 0, truncated: false };
83
+ const packs = readdirSync(root, { withFileTypes: true })
84
+ .filter((entry) => entry.isDirectory())
85
+ .map((entry) => readEvidencePack(entry.name))
86
+ .filter((entry): entry is SafeEvidencePack => entry !== null)
87
+ .sort((a, b) => b.generated_at.localeCompare(a.generated_at));
88
+ return {
89
+ evidence_packs: packs.slice(0, maxItems),
90
+ total: packs.length,
91
+ truncated: packs.length > maxItems,
92
+ };
93
+ }
94
+
95
+ export function readEvidencePack(lineageId: string): SafeEvidencePack | null {
96
+ const safeLineageId = normalizeLineageId(lineageId);
97
+ const config = getConfig();
98
+ const filePath = resolve(config.workspaceRoot, ".patchwarden", "evidence-packs", safeLineageId, "evidence.json");
99
+ guardReadPath(filePath, config.workspaceRoot, ".patchwarden/evidence-packs");
100
+ if (!existsSync(filePath)) return null;
101
+ const raw = readFileSync(filePath, "utf-8").replace(/^\uFEFF/, "");
102
+ return redactSensitiveValue(JSON.parse(raw)).value as SafeEvidencePack;
103
+ }
104
+
105
+ function normalizeLineageId(value: string): string {
106
+ const lineageId = String(value || "").trim();
107
+ if (!/^[A-Za-z0-9_-]+$/.test(lineageId)) {
108
+ throw new PatchWardenError(
109
+ "invalid_lineage_id",
110
+ "lineage_id may contain only letters, numbers, underscores, and hyphens.",
111
+ "Pass a lineage_id returned by run_task_loop.",
112
+ true,
113
+ { lineage_id: lineageId }
114
+ );
115
+ }
116
+ return lineageId;
117
+ }
118
+
119
+ function normalizeMaxItems(value: number | undefined): number {
120
+ if (value === undefined) return 12;
121
+ if (!Number.isInteger(value) || value < 1 || value > 50) {
122
+ throw new Error("max_items must be an integer from 1 to 50.");
123
+ }
124
+ return value;
125
+ }
126
+
127
+ function readPolicySummary(repoPath: string): { valid: boolean; issues: unknown[]; release_readiness: unknown } {
128
+ try {
129
+ const policy = getProjectPolicySummary(repoPath || ".");
130
+ return {
131
+ valid: Boolean(policy.valid),
132
+ issues: Array.isArray(policy.issues) ? policy.issues : [],
133
+ release_readiness: policy.release_readiness || null,
134
+ };
135
+ } catch (err) {
136
+ return {
137
+ valid: false,
138
+ issues: [{ code: "policy_unavailable", message: err instanceof Error ? err.message : String(err) }],
139
+ release_readiness: null,
140
+ };
141
+ }
142
+ }
143
+
144
+ function safeCatalog(): SafeEvidencePack["catalog"] {
145
+ try {
146
+ const catalog = getLastToolCatalogSnapshot();
147
+ if (!catalog) throw new Error("catalog snapshot unavailable");
148
+ return {
149
+ server_version: catalog.server_version,
150
+ schema_epoch: catalog.schema_epoch,
151
+ tool_profile: catalog.tool_profile,
152
+ tool_count: catalog.tool_count,
153
+ tool_manifest_sha256: catalog.tool_manifest_sha256,
154
+ };
155
+ } catch {
156
+ return {
157
+ server_version: PATCHWARDEN_VERSION,
158
+ schema_epoch: TOOL_SCHEMA_EPOCH,
159
+ tool_profile: null,
160
+ tool_count: null,
161
+ tool_manifest_sha256: null,
162
+ };
163
+ }
164
+ }
165
+
166
+ function buildNextAction(lineage: SafeTaskLineage): string {
167
+ if (lineage.stop_reason === "success") return "Use this evidence pack for review or release readiness.";
168
+ return lineage.next_action || "Review lineage before exporting release evidence.";
169
+ }
170
+
171
+ function buildEvidenceMarkdown(pack: SafeEvidencePack): string {
172
+ const direct = pack.lineage.tasks.direct_sessions
173
+ .map((entry) => `- ${entry.session_id}: status=${entry.status || "unknown"}, audit=${entry.audit_decision || "unknown"}`)
174
+ .join("\n");
175
+ const rounds = pack.lineage.rounds
176
+ .map((round) => `- ${round.role} ${round.task_id}: status=${round.status}, verification=${round.verification_status}, audit=${round.audit_verdict}`)
177
+ .join("\n");
178
+ return [
179
+ "# PatchWarden Evidence Pack",
180
+ "",
181
+ `- Evidence pack: ${pack.evidence_pack_id}`,
182
+ `- Lineage: ${pack.lineage_id}`,
183
+ `- Generated: ${pack.generated_at}`,
184
+ `- Final status: ${pack.lineage.final_status}`,
185
+ `- Stop reason: ${pack.lineage.stop_reason}`,
186
+ `- Worktree: ${pack.lineage.worktree.isolation_mode} (${pack.lineage.worktree.status})`,
187
+ `- Tool manifest: ${pack.catalog.tool_manifest_sha256 || "unavailable"}`,
188
+ "",
189
+ "## Verification Rounds",
190
+ rounds || "- None.",
191
+ "",
192
+ "## Direct Evidence",
193
+ direct || "- None.",
194
+ "",
195
+ "## Policy And Release",
196
+ `- Policy valid: ${pack.policy.valid}`,
197
+ `- Policy issue count: ${pack.policy.issue_count}`,
198
+ "",
199
+ "## Omitted",
200
+ ...pack.omitted.map((item) => `- ${item}`),
201
+ "",
202
+ `Next action: ${pack.next_action}`,
203
+ "",
204
+ ].join("\n");
205
+ }
@@ -1,71 +1,71 @@
1
- import { readdirSync, statSync, existsSync } from "node:fs";
2
- import { resolve } from "node:path";
3
- import { getConfig } from "../config.js";
4
- import { guardPath } from "../security/pathGuard.js";
5
- import { guardSensitivePath } from "../security/sensitiveGuard.js";
6
-
7
- export interface ListEntry {
8
- name: string;
9
- type: "file" | "directory";
10
- size?: number;
11
- }
12
-
13
- export interface ListWorkspaceOutput {
14
- path: string;
15
- entries: ListEntry[];
16
- }
17
-
18
- const MAX_LIST_FILES = 200;
19
-
20
- export function listWorkspace(relativePath?: string): ListWorkspaceOutput {
21
- const config = getConfig();
22
- const targetPath = relativePath
23
- ? resolve(config.workspaceRoot, relativePath)
24
- : config.workspaceRoot;
25
-
26
- const guarded = guardPath(targetPath, config.workspaceRoot);
27
-
28
- if (!existsSync(guarded)) {
29
- throw new Error(`Path not found: "${relativePath || "."}"`);
30
- }
31
-
32
- const stat = statSync(guarded);
33
- if (!stat.isDirectory()) {
34
- throw new Error(`Not a directory: "${relativePath || "."}"`);
35
- }
36
-
37
- const entries: ListEntry[] = [];
38
- const raw = readdirSync(guarded);
39
-
40
- for (const name of raw) {
41
- // Skip hidden files and sensitive paths
42
- const fullPath = resolve(guarded, name);
43
- try {
44
- guardSensitivePath(fullPath);
45
- } catch {
46
- continue; // skip sensitive files silently
47
- }
48
-
49
- try {
50
- const s = statSync(fullPath);
51
- entries.push({
52
- name,
53
- type: s.isDirectory() ? "directory" : "file",
54
- size: s.isFile() ? s.size : undefined,
55
- });
56
- } catch {
57
- // can't stat, skip
58
- }
59
-
60
- if (entries.length >= MAX_LIST_FILES) break;
61
- }
62
-
63
- return {
64
- path: relativePath || ".",
65
- entries: entries.sort((a, b) => {
66
- // directories first, then alphabetically
67
- if (a.type !== b.type) return a.type === "directory" ? -1 : 1;
68
- return a.name.localeCompare(b.name);
69
- }),
70
- };
71
- }
1
+ import { readdirSync, statSync, existsSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ import { getConfig } from "../config.js";
4
+ import { guardPath } from "../security/pathGuard.js";
5
+ import { guardSensitivePath } from "../security/sensitiveGuard.js";
6
+
7
+ export interface ListEntry {
8
+ name: string;
9
+ type: "file" | "directory";
10
+ size?: number;
11
+ }
12
+
13
+ export interface ListWorkspaceOutput {
14
+ path: string;
15
+ entries: ListEntry[];
16
+ }
17
+
18
+ const MAX_LIST_FILES = 200;
19
+
20
+ export function listWorkspace(relativePath?: string): ListWorkspaceOutput {
21
+ const config = getConfig();
22
+ const targetPath = relativePath
23
+ ? resolve(config.workspaceRoot, relativePath)
24
+ : config.workspaceRoot;
25
+
26
+ const guarded = guardPath(targetPath, config.workspaceRoot);
27
+
28
+ if (!existsSync(guarded)) {
29
+ throw new Error(`Path not found: "${relativePath || "."}"`);
30
+ }
31
+
32
+ const stat = statSync(guarded);
33
+ if (!stat.isDirectory()) {
34
+ throw new Error(`Not a directory: "${relativePath || "."}"`);
35
+ }
36
+
37
+ const entries: ListEntry[] = [];
38
+ const raw = readdirSync(guarded);
39
+
40
+ for (const name of raw) {
41
+ // Skip hidden files and sensitive paths
42
+ const fullPath = resolve(guarded, name);
43
+ try {
44
+ guardSensitivePath(fullPath);
45
+ } catch {
46
+ continue; // skip sensitive files silently
47
+ }
48
+
49
+ try {
50
+ const s = statSync(fullPath);
51
+ entries.push({
52
+ name,
53
+ type: s.isDirectory() ? "directory" : "file",
54
+ size: s.isFile() ? s.size : undefined,
55
+ });
56
+ } catch {
57
+ // can't stat, skip
58
+ }
59
+
60
+ if (entries.length >= MAX_LIST_FILES) break;
61
+ }
62
+
63
+ return {
64
+ path: relativePath || ".",
65
+ entries: entries.sort((a, b) => {
66
+ // directories first, then alphabetically
67
+ if (a.type !== b.type) return a.type === "directory" ? -1 : 1;
68
+ return a.name.localeCompare(b.name);
69
+ }),
70
+ };
71
+ }
@@ -0,0 +1,79 @@
1
+ import { getConfig } from "../config.js";
2
+ import { guardWorkspacePath } from "../security/pathGuard.js";
3
+ import { routeAgent } from "../agents/agentRouter.js";
4
+
5
+ export interface RecommendAgentForTaskInput {
6
+ repo_path: string;
7
+ goal: string;
8
+ scope_files?: string[];
9
+ template?: string;
10
+ risk_hint?: string;
11
+ }
12
+
13
+ export interface RecommendAgentForTaskOutput {
14
+ repo_path: string;
15
+ resolved_repo_path: string;
16
+ recommended_agent: string;
17
+ fallback_agent: string | null;
18
+ fallback: boolean;
19
+ reason: string;
20
+ risk_notes: string[];
21
+ suggested_verify_commands: string[];
22
+ bounded: true;
23
+ }
24
+
25
+ export function recommendAgentForTask(input: RecommendAgentForTaskInput): RecommendAgentForTaskOutput {
26
+ const config = getConfig();
27
+ const repoPath = String(input.repo_path || "").trim();
28
+ const goal = String(input.goal || "").trim();
29
+ if (!repoPath) throw new Error("repo_path is required.");
30
+ if (!goal) throw new Error("goal is required.");
31
+
32
+ const resolvedRepoPath = guardWorkspacePath(repoPath, config.workspaceRoot);
33
+ const configuredAgents = Object.keys(config.agents);
34
+ const route = routeAgent({
35
+ goal,
36
+ scope: normalizeScope(input.scope_files),
37
+ inline_plan: input.risk_hint,
38
+ template: input.template,
39
+ configuredAgents,
40
+ });
41
+
42
+ return {
43
+ repo_path: repoPath,
44
+ resolved_repo_path: resolvedRepoPath,
45
+ recommended_agent: route.recommended_agent,
46
+ fallback_agent: route.fallback ? route.recommended_agent : configuredAgents.find((name) => name !== route.recommended_agent) || null,
47
+ fallback: route.fallback,
48
+ reason: route.reason,
49
+ risk_notes: buildRiskNotes(goal, input.risk_hint),
50
+ suggested_verify_commands: suggestVerifyCommands(config.allowedTestCommands),
51
+ bounded: true,
52
+ };
53
+ }
54
+
55
+ function normalizeScope(value: string[] | undefined): string[] | undefined {
56
+ if (!Array.isArray(value)) return undefined;
57
+ return value.map((entry) => String(entry).trim()).filter(Boolean).slice(0, 50);
58
+ }
59
+
60
+ function buildRiskNotes(goal: string, riskHint: string | undefined): string[] {
61
+ const text = `${goal} ${riskHint || ""}`.toLowerCase();
62
+ const notes: string[] = [];
63
+ if (/release|publish|push|tag|npm/.test(text)) {
64
+ notes.push("release_or_remote_write_language_detected");
65
+ }
66
+ if (/secret|token|cookie|\.env|ssh/.test(text)) {
67
+ notes.push("sensitive_file_language_detected");
68
+ }
69
+ if (/refactor|rewrite|redesign|migration/.test(text)) {
70
+ notes.push("broad_change_language_detected");
71
+ }
72
+ return notes.slice(0, 8);
73
+ }
74
+
75
+ function suggestVerifyCommands(commands: string[]): string[] {
76
+ const preferred = ["npm test", "npm run test", "npm run build", "npm run lint"];
77
+ const configured = new Set(commands);
78
+ return preferred.filter((command) => configured.has(command)).slice(0, 4);
79
+ }