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,344 @@
1
+ import { existsSync, readFileSync, statSync } from "node:fs";
2
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
3
+ import {
4
+ getAllConfiguredDirectCommands,
5
+ getAllConfiguredTestCommands,
6
+ getConfig,
7
+ type PatchWardenConfig,
8
+ } from "../config.js";
9
+ import { guardTestCommand } from "../security/commandGuard.js";
10
+ import { guardWorkspacePath } from "../security/pathGuard.js";
11
+ import { isSensitivePath } from "../security/sensitiveGuard.js";
12
+
13
+ export interface ProjectPolicy {
14
+ allowed_commands: string[];
15
+ auto_cleanup: {
16
+ enabled: boolean;
17
+ patterns: string[];
18
+ exclude: string[];
19
+ };
20
+ high_risk_commands: string[];
21
+ protected_paths: string[];
22
+ release_mode: {
23
+ version_source: string;
24
+ required_commands: string[];
25
+ };
26
+ }
27
+
28
+ export interface ProjectPolicyIssue {
29
+ code: string;
30
+ severity: "error" | "warn";
31
+ field: string;
32
+ message: string;
33
+ }
34
+
35
+ export interface ReleaseReadinessSummary {
36
+ version_source: string;
37
+ version: string | null;
38
+ package_json_version: string | null;
39
+ package_name: string | null;
40
+ version_consistent: boolean | null;
41
+ required_commands: Array<{
42
+ command: string;
43
+ allowed: boolean;
44
+ reason: string | null;
45
+ }>;
46
+ }
47
+
48
+ export interface ProjectPolicySummary {
49
+ repo_path: string;
50
+ resolved_repo_path: string;
51
+ policy_path: string;
52
+ exists: boolean;
53
+ valid: boolean;
54
+ effective_policy: ProjectPolicy;
55
+ issues: ProjectPolicyIssue[];
56
+ release_readiness: ReleaseReadinessSummary;
57
+ }
58
+
59
+ const DEFAULT_POLICY: ProjectPolicy = {
60
+ allowed_commands: [],
61
+ auto_cleanup: {
62
+ enabled: true,
63
+ patterns: ["release-artifact-manifest.json", "frontend/dist", "release_packages"],
64
+ exclude: [".git", ".patchwarden", "node_modules", "docs", "samples"],
65
+ },
66
+ high_risk_commands: ["npm publish", "git push", "git tag", "gh release create"],
67
+ protected_paths: [".env", ".env.*", ".ssh", ".npmrc", ".pypirc", "patchwarden.config.json"],
68
+ release_mode: {
69
+ version_source: "package.json",
70
+ required_commands: ["npm run build", "npm test"],
71
+ },
72
+ };
73
+
74
+ const DANGEROUS_COMMAND_RE = /\b(?:publish|push|tag|release\s+create|deploy)\b/i;
75
+ const DANGEROUS_PATTERN_RE = /(^|[\\/])(?:\.git|node_modules)([\\/]|$)|^\*\*$|^\/|^[A-Za-z]:[\\/]/i;
76
+
77
+ export function getProjectPolicySummary(repoPathInput: string): ProjectPolicySummary {
78
+ const config = getConfig();
79
+ const repoPath = guardWorkspacePath(repoPathInput, config.workspaceRoot);
80
+ const policyPath = join(repoPath, ".patchwarden", "project-policy.json");
81
+ const issues: ProjectPolicyIssue[] = [];
82
+ let rawPolicy: Partial<ProjectPolicy> = {};
83
+ let exists = false;
84
+
85
+ if (existsSync(policyPath)) {
86
+ exists = true;
87
+ try {
88
+ const raw = readFileSync(policyPath, "utf-8").replace(/^\uFEFF/, "");
89
+ rawPolicy = JSON.parse(raw) as Partial<ProjectPolicy>;
90
+ } catch (err) {
91
+ issues.push({
92
+ code: "policy_json_invalid",
93
+ severity: "error",
94
+ field: ".patchwarden/project-policy.json",
95
+ message: `Project policy is not valid JSON: ${err instanceof Error ? err.message : String(err)}`,
96
+ });
97
+ }
98
+ }
99
+
100
+ const effective = normalizePolicy(rawPolicy, issues);
101
+ validateProjectPolicy(repoPath, effective, issues, config);
102
+
103
+ return {
104
+ repo_path: repoPathInput,
105
+ resolved_repo_path: repoPath,
106
+ policy_path: ".patchwarden/project-policy.json",
107
+ exists,
108
+ valid: !issues.some((issue) => issue.severity === "error"),
109
+ effective_policy: effective,
110
+ issues,
111
+ release_readiness: buildReleaseReadiness(repoPath, effective, config),
112
+ };
113
+ }
114
+
115
+ export function getDefaultProjectPolicy(): ProjectPolicy {
116
+ return clonePolicy(DEFAULT_POLICY);
117
+ }
118
+
119
+ export function commandAllowedByProjectPolicy(command: string, summary: ProjectPolicySummary): { allowed: boolean; reason: string | null } {
120
+ const trimmed = command.trim();
121
+ if (!trimmed) return { allowed: false, reason: "empty_command" };
122
+ if (summary.effective_policy.high_risk_commands.includes(trimmed) || DANGEROUS_COMMAND_RE.test(trimmed)) {
123
+ return { allowed: false, reason: "high_risk_command" };
124
+ }
125
+ if (
126
+ summary.effective_policy.allowed_commands.length > 0 &&
127
+ !summary.effective_policy.allowed_commands.includes(trimmed)
128
+ ) {
129
+ return { allowed: false, reason: "not_in_project_policy_allowed_commands" };
130
+ }
131
+ const readiness = summary.release_readiness.required_commands.find((entry) => entry.command === trimmed);
132
+ if (readiness && !readiness.allowed) {
133
+ return { allowed: false, reason: readiness.reason || "not_allowlisted_by_patchwarden_config" };
134
+ }
135
+ return { allowed: true, reason: null };
136
+ }
137
+
138
+ export function isProtectedByProjectPolicy(relPath: string, policy: ProjectPolicy): boolean {
139
+ const normalized = normalizeRelPath(relPath);
140
+ if (!normalized) return true;
141
+ if (isSensitivePath(normalized)) return true;
142
+ return policy.protected_paths.some((pattern) => pathMatchesPattern(normalized, pattern));
143
+ }
144
+
145
+ export function resolveVersionFromPolicy(repoPath: string, policy: ProjectPolicy): string | null {
146
+ const source = policy.release_mode.version_source || "package.json";
147
+ if (source === "package.json") return readPackageJson(repoPath).version;
148
+ if (source === "src/version.ts") {
149
+ const versionFile = safeRepoFile(repoPath, "src/version.ts");
150
+ if (!versionFile) return null;
151
+ const text = readFileSync(versionFile, "utf-8");
152
+ const match = text.match(/PATCHWARDEN_VERSION\s*=\s*["']([^"']+)["']/);
153
+ return match ? match[1] : null;
154
+ }
155
+ const file = safeRepoFile(repoPath, source);
156
+ if (!file) return null;
157
+ const text = readFileSync(file, "utf-8").replace(/^\uFEFF/, "").trim();
158
+ return text.split(/\r?\n/)[0]?.trim() || null;
159
+ }
160
+
161
+ export function readPackageJson(repoPath: string): { name: string | null; version: string | null; githubRepo: string | null } {
162
+ const packagePath = join(repoPath, "package.json");
163
+ if (!existsSync(packagePath)) return { name: null, version: null, githubRepo: null };
164
+ try {
165
+ const data = JSON.parse(readFileSync(packagePath, "utf-8").replace(/^\uFEFF/, ""));
166
+ return {
167
+ name: typeof data.name === "string" ? data.name : null,
168
+ version: typeof data.version === "string" ? data.version : null,
169
+ githubRepo: parseGithubRepo(data.repository),
170
+ };
171
+ } catch {
172
+ return { name: null, version: null, githubRepo: null };
173
+ }
174
+ }
175
+
176
+ function normalizePolicy(raw: Partial<ProjectPolicy>, issues: ProjectPolicyIssue[]): ProjectPolicy {
177
+ const policy = clonePolicy(DEFAULT_POLICY);
178
+ const object = raw && typeof raw === "object" && !Array.isArray(raw) ? raw : {};
179
+ policy.allowed_commands = stringArrayOrDefault(object.allowed_commands, policy.allowed_commands, "allowed_commands", issues);
180
+ policy.high_risk_commands = stringArrayOrDefault(object.high_risk_commands, policy.high_risk_commands, "high_risk_commands", issues);
181
+ policy.protected_paths = stringArrayOrDefault(object.protected_paths, policy.protected_paths, "protected_paths", issues);
182
+
183
+ if (object.auto_cleanup && typeof object.auto_cleanup === "object" && !Array.isArray(object.auto_cleanup)) {
184
+ const cleanup = object.auto_cleanup as Partial<ProjectPolicy["auto_cleanup"]>;
185
+ if (cleanup.enabled !== undefined && typeof cleanup.enabled !== "boolean") {
186
+ issues.push({ code: "invalid_type", severity: "error", field: "auto_cleanup.enabled", message: "auto_cleanup.enabled must be a boolean." });
187
+ } else if (cleanup.enabled !== undefined) {
188
+ policy.auto_cleanup.enabled = cleanup.enabled;
189
+ }
190
+ policy.auto_cleanup.patterns = stringArrayOrDefault(cleanup.patterns, policy.auto_cleanup.patterns, "auto_cleanup.patterns", issues);
191
+ policy.auto_cleanup.exclude = stringArrayOrDefault(cleanup.exclude, policy.auto_cleanup.exclude, "auto_cleanup.exclude", issues);
192
+ }
193
+
194
+ if (object.release_mode && typeof object.release_mode === "object" && !Array.isArray(object.release_mode)) {
195
+ const releaseMode = object.release_mode as Partial<ProjectPolicy["release_mode"]>;
196
+ if (releaseMode.version_source !== undefined && typeof releaseMode.version_source !== "string") {
197
+ issues.push({ code: "invalid_type", severity: "error", field: "release_mode.version_source", message: "release_mode.version_source must be a string." });
198
+ } else if (releaseMode.version_source) {
199
+ policy.release_mode.version_source = releaseMode.version_source.trim();
200
+ }
201
+ policy.release_mode.required_commands = stringArrayOrDefault(releaseMode.required_commands, policy.release_mode.required_commands, "release_mode.required_commands", issues);
202
+ }
203
+
204
+ return policy;
205
+ }
206
+
207
+ function validateProjectPolicy(repoPath: string, policy: ProjectPolicy, issues: ProjectPolicyIssue[], config: PatchWardenConfig): void {
208
+ for (const [field, commands] of [
209
+ ["allowed_commands", policy.allowed_commands],
210
+ ["high_risk_commands", policy.high_risk_commands],
211
+ ["release_mode.required_commands", policy.release_mode.required_commands],
212
+ ] as const) {
213
+ for (const command of commands) {
214
+ if (field !== "high_risk_commands" && DANGEROUS_COMMAND_RE.test(command)) {
215
+ issues.push({ code: "high_risk_command", severity: "error", field, message: `Command is release/destructive risk and cannot be auto-executed: ${command}` });
216
+ }
217
+ if (field !== "high_risk_commands") {
218
+ try {
219
+ guardTestCommand(command, config, repoPath);
220
+ } catch {
221
+ issues.push({ code: "command_not_allowlisted", severity: "warn", field, message: `Command is not allowed by existing PatchWarden config: ${command}` });
222
+ }
223
+ }
224
+ }
225
+ }
226
+
227
+ for (const field of ["auto_cleanup.patterns", "auto_cleanup.exclude", "protected_paths"] as const) {
228
+ const values = field === "auto_cleanup.patterns"
229
+ ? policy.auto_cleanup.patterns
230
+ : field === "auto_cleanup.exclude"
231
+ ? policy.auto_cleanup.exclude
232
+ : policy.protected_paths;
233
+ for (const value of values) {
234
+ if (!value.trim()) {
235
+ issues.push({ code: "empty_path_pattern", severity: "error", field, message: "Path patterns must be non-empty." });
236
+ continue;
237
+ }
238
+ const blocksCriticalDirectory = field === "auto_cleanup.patterns" && DANGEROUS_PATTERN_RE.test(value);
239
+ if (value.includes("\0") || value.includes("..") || isAbsolute(value) || blocksCriticalDirectory) {
240
+ issues.push({ code: "unsafe_path_pattern", severity: "error", field, message: `Unsafe path pattern rejected: ${value}` });
241
+ }
242
+ }
243
+ }
244
+
245
+ const versionSource = policy.release_mode.version_source;
246
+ if (versionSource && versionSource !== "package.json" && versionSource !== "src/version.ts") {
247
+ const resolved = safeRepoFile(repoPath, versionSource);
248
+ if (!resolved) {
249
+ issues.push({ code: "version_source_invalid", severity: "error", field: "release_mode.version_source", message: `Version source is outside repo, sensitive, or missing: ${versionSource}` });
250
+ }
251
+ }
252
+ }
253
+
254
+ function buildReleaseReadiness(repoPath: string, policy: ProjectPolicy, config: PatchWardenConfig): ReleaseReadinessSummary {
255
+ const packageJson = readPackageJson(repoPath);
256
+ let version: string | null = null;
257
+ try {
258
+ version = resolveVersionFromPolicy(repoPath, policy);
259
+ } catch {
260
+ version = null;
261
+ }
262
+ return {
263
+ version_source: policy.release_mode.version_source,
264
+ version,
265
+ package_json_version: packageJson.version,
266
+ package_name: packageJson.name,
267
+ version_consistent: version && packageJson.version ? version === packageJson.version : null,
268
+ required_commands: policy.release_mode.required_commands.map((command) => {
269
+ try {
270
+ guardTestCommand(command, config, repoPath);
271
+ return { command, allowed: true, reason: null };
272
+ } catch (err) {
273
+ return { command, allowed: false, reason: err instanceof Error ? err.message : String(err) };
274
+ }
275
+ }),
276
+ };
277
+ }
278
+
279
+ function safeRepoFile(repoPath: string, relPath: string): string | null {
280
+ const normalized = normalizeRelPath(relPath);
281
+ if (!normalized || isSensitivePath(normalized)) return null;
282
+ const candidate = resolve(repoPath, normalized);
283
+ const rel = relative(repoPath, candidate);
284
+ if (isAbsolute(rel) || rel === ".." || rel.startsWith(`..${sep}`)) return null;
285
+ if (!existsSync(candidate) || !statSync(candidate).isFile()) return null;
286
+ const parent = dirname(candidate);
287
+ const parentRel = relative(repoPath, parent);
288
+ if (parentRel === ".." || parentRel.startsWith(`..${sep}`)) return null;
289
+ return candidate;
290
+ }
291
+
292
+ function stringArrayOrDefault(value: unknown, fallback: string[], field: string, issues: ProjectPolicyIssue[]): string[] {
293
+ if (value === undefined) return [...fallback];
294
+ if (!Array.isArray(value) || value.some((entry) => typeof entry !== "string")) {
295
+ issues.push({ code: "invalid_type", severity: "error", field, message: `${field} must be an array of strings.` });
296
+ return [...fallback];
297
+ }
298
+ return [...new Set(value.map((entry) => entry.trim()).filter(Boolean))];
299
+ }
300
+
301
+ function clonePolicy(policy: ProjectPolicy): ProjectPolicy {
302
+ return {
303
+ allowed_commands: [...policy.allowed_commands],
304
+ auto_cleanup: {
305
+ enabled: policy.auto_cleanup.enabled,
306
+ patterns: [...policy.auto_cleanup.patterns],
307
+ exclude: [...policy.auto_cleanup.exclude],
308
+ },
309
+ high_risk_commands: [...policy.high_risk_commands],
310
+ protected_paths: [...policy.protected_paths],
311
+ release_mode: {
312
+ version_source: policy.release_mode.version_source,
313
+ required_commands: [...policy.release_mode.required_commands],
314
+ },
315
+ };
316
+ }
317
+
318
+ function normalizeRelPath(value: string): string {
319
+ return value.trim().replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/+$/, "");
320
+ }
321
+
322
+ function pathMatchesPattern(relPath: string, pattern: string): boolean {
323
+ const normalizedPattern = normalizeRelPath(pattern);
324
+ if (!normalizedPattern) return false;
325
+ if (normalizedPattern.includes("*")) {
326
+ const escaped = normalizedPattern
327
+ .split("*")
328
+ .map((part) => part.replace(/[.+?^${}()|[\]\\]/g, "\\$&"))
329
+ .join("[^/]*");
330
+ return new RegExp(`^${escaped}$`, "i").test(relPath);
331
+ }
332
+ return relPath.toLowerCase() === normalizedPattern.toLowerCase() ||
333
+ relPath.toLowerCase().startsWith(`${normalizedPattern.toLowerCase()}/`);
334
+ }
335
+
336
+ function parseGithubRepo(repository: unknown): string | null {
337
+ const raw = typeof repository === "string"
338
+ ? repository
339
+ : repository && typeof repository === "object" && typeof (repository as any).url === "string"
340
+ ? (repository as any).url
341
+ : "";
342
+ const match = raw.match(/github\.com[:/](.+?\/.+?)(?:\.git)?(?:[#?].*)?$/i);
343
+ return match ? match[1] : null;
344
+ }
package/src/smoke-test.ts CHANGED
@@ -592,7 +592,7 @@ test("D8b. tool profiles are exact and schema changes alter the manifest hash",
592
592
  try {
593
593
  process.env.PATCHWARDEN_TOOL_PROFILE = "full";
594
594
  const fullTools = getToolDefs();
595
- if (fullTools.length !== 54) throw new Error(`Expected 54 full tools, got ${fullTools.length}`);
595
+ if (fullTools.length !== 64) throw new Error(`Expected 64 full tools, got ${fullTools.length}`);
596
596
 
597
597
  const coreTools = selectToolsForProfile(fullTools, "chatgpt_core", getConfig().enableDirectProfile);
598
598
  const names = coreTools.map((tool) => tool.name);
@@ -1934,10 +1934,10 @@ reloadConfig();
1934
1934
 
1935
1935
  let directSessionId = "";
1936
1936
 
1937
- test("M1. chatgpt_core still has 21 tools", () => {
1937
+ test("M1. chatgpt_core still has the expected tool manifest", () => {
1938
1938
  const tools = getToolDefs();
1939
1939
  const coreTools = selectToolsForProfile(tools, "chatgpt_core", true);
1940
- if (coreTools.length !== 21) throw new Error(`Expected 21, got ${coreTools.length}`);
1940
+ if (coreTools.length !== CHATGPT_CORE_TOOL_NAMES.length) throw new Error(`Expected ${CHATGPT_CORE_TOOL_NAMES.length}, got ${coreTools.length}`);
1941
1941
  if (JSON.stringify(coreTools.map((t) => t.name)) !== JSON.stringify(CHATGPT_CORE_TOOL_NAMES)) {
1942
1942
  throw new Error("Tool names mismatch");
1943
1943
  }
@@ -1950,10 +1950,10 @@ test("M2. chatgpt_direct disabled exposes only health_check", () => {
1950
1950
  if (disabledTools[0].name !== "health_check") throw new Error(`Expected health_check, got ${disabledTools[0].name}`);
1951
1951
  });
1952
1952
 
1953
- test("M3. chatgpt_direct enabled has 13 tools", () => {
1953
+ test("M3. chatgpt_direct enabled has 14 tools", () => {
1954
1954
  const tools = getToolDefs();
1955
1955
  const directTools = selectToolsForProfile(tools, "chatgpt_direct", true);
1956
- if (directTools.length !== 13) throw new Error(`Expected 13, got ${directTools.length}`);
1956
+ if (directTools.length !== CHATGPT_DIRECT_TOOL_NAMES.length) throw new Error(`Expected ${CHATGPT_DIRECT_TOOL_NAMES.length}, got ${directTools.length}`);
1957
1957
  if (JSON.stringify(directTools.map((t) => t.name)) !== JSON.stringify(CHATGPT_DIRECT_TOOL_NAMES)) {
1958
1958
  throw new Error("Tool names mismatch");
1959
1959
  }
@@ -0,0 +1,142 @@
1
+ import { describe, it, beforeEach, afterEach } from "node:test";
2
+ import { strict as assert } from "node:assert";
3
+ import { mkdtempSync, rmSync, writeFileSync, readFileSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { tmpdir } from "node:os";
6
+ import { reloadConfig } from "../../config.js";
7
+ import { recommendAgentForTask } from "../../tools/recommendAgentForTask.js";
8
+ import { exportTaskEvidencePack, listEvidencePacks, readEvidencePack } from "../../tools/evidencePack.js";
9
+ import { writeTaskLineage } from "../../tools/taskLineage.js";
10
+
11
+ let tempDir: string;
12
+ let prevConfigEnv: string | undefined;
13
+
14
+ function writeConfig() {
15
+ const configPath = join(tempDir, "patchwarden.config.json");
16
+ writeFileSync(
17
+ configPath,
18
+ JSON.stringify({
19
+ workspaceRoot: tempDir,
20
+ tasksDir: ".patchwarden/tasks",
21
+ plansDir: ".patchwarden/plans",
22
+ assessmentsDir: ".patchwarden/assessments",
23
+ agents: {
24
+ codex: { command: "codex", args: [] },
25
+ opencode: { command: "opencode", args: [] },
26
+ },
27
+ allowedTestCommands: ["npm test", "npm run build"],
28
+ defaultTaskTimeoutSeconds: 30,
29
+ maxTaskTimeoutSeconds: 120,
30
+ }),
31
+ "utf-8"
32
+ );
33
+ prevConfigEnv = process.env.PATCHWARDEN_CONFIG;
34
+ process.env.PATCHWARDEN_CONFIG = configPath;
35
+ reloadConfig();
36
+ }
37
+
38
+ describe("v1.5 evidence packs and agent recommendations", () => {
39
+ beforeEach(() => {
40
+ tempDir = mkdtempSync(join(tmpdir(), "pw-evidence-"));
41
+ writeConfig();
42
+ });
43
+
44
+ afterEach(() => {
45
+ if (prevConfigEnv === undefined) delete process.env.PATCHWARDEN_CONFIG;
46
+ else process.env.PATCHWARDEN_CONFIG = prevConfigEnv;
47
+ reloadConfig();
48
+ rmSync(tempDir, { recursive: true, force: true });
49
+ });
50
+
51
+ it("recommends an agent without creating task evidence", () => {
52
+ const recommendation = recommendAgentForTask({
53
+ repo_path: ".",
54
+ goal: "Refactor two modules safely",
55
+ scope_files: ["src/a.ts", "src/b.ts"],
56
+ });
57
+
58
+ assert.equal(recommendation.bounded, true);
59
+ assert.ok(["codex", "opencode"].includes(recommendation.recommended_agent));
60
+ assert.ok(Array.isArray(recommendation.suggested_verify_commands));
61
+ const payload = JSON.stringify(recommendation);
62
+ assert.ok(!payload.includes("stdout"));
63
+ assert.ok(!payload.includes("stderr"));
64
+ assert.ok(!payload.includes("diff"));
65
+ });
66
+
67
+ it("exports bounded BOM-free evidence pack from lineage", () => {
68
+ writeTaskLineage({
69
+ lineage_id: "lineage_v15_pack",
70
+ goal: "Export evidence",
71
+ repo_path: ".",
72
+ created_at: "2026-07-05T12:00:00.000Z",
73
+ updated_at: "2026-07-05T12:01:00.000Z",
74
+ final_status: "accepted",
75
+ stop_reason: "success",
76
+ next_action: "accept",
77
+ main_task: "task-main",
78
+ fix_tasks: [],
79
+ cleanup_tasks: [],
80
+ direct_sessions: [{
81
+ session_id: "direct-one",
82
+ status: "passed",
83
+ command_count: 1,
84
+ passed_commands: 1,
85
+ failed_commands: 0,
86
+ timed_out_commands: 0,
87
+ audit_decision: "pass",
88
+ changed_files_total: 0,
89
+ next_action: "accept",
90
+ }],
91
+ rounds: [{
92
+ iteration: 1,
93
+ task_id: "task-main",
94
+ role: "main",
95
+ status: "done_by_agent",
96
+ terminal: true,
97
+ verification_status: "passed",
98
+ audit_verdict: "pass",
99
+ fail_checks: [],
100
+ warn_checks: [],
101
+ next_action: "accept",
102
+ }],
103
+ warnings: [],
104
+ errors: [],
105
+ worktree: {
106
+ isolation_mode: "worktree",
107
+ worktree_id: "wt-one",
108
+ worktree_path: join(tempDir, "_workspacetrees", "wt-one"),
109
+ branch: "pw-test",
110
+ cleanup: "keep",
111
+ status: "active",
112
+ next_action: "merge or discard explicitly",
113
+ },
114
+ agent_routing: {
115
+ requested_agent: "auto",
116
+ selected_agent: "codex",
117
+ reason: "test route",
118
+ fallback: false,
119
+ },
120
+ });
121
+
122
+ const pack = exportTaskEvidencePack({ lineage_id: "lineage_v15_pack" });
123
+ assert.equal(pack.bounded, true);
124
+ assert.equal(pack.lineage.worktree.worktree_id, "wt-one");
125
+ assert.equal(pack.lineage.agent_routing?.selected_agent, "codex");
126
+ const raw = readFileSync(pack.files.json);
127
+ assert.notEqual(raw[0], 0xef);
128
+ JSON.parse(raw.toString("utf-8"));
129
+
130
+ const payload = JSON.stringify(pack);
131
+ assert.ok(!payload.includes("stdout_tail"));
132
+ assert.ok(!payload.includes("stderr_tail"));
133
+ assert.ok(!payload.includes("diff.patch"));
134
+ assert.ok(!payload.includes("verification.log"));
135
+
136
+ const readBack = readEvidencePack("lineage_v15_pack");
137
+ assert.equal(readBack?.lineage_id, "lineage_v15_pack");
138
+ const listed = listEvidencePacks();
139
+ assert.equal(listed.total, 1);
140
+ assert.equal(listed.evidence_packs[0].lineage_id, "lineage_v15_pack");
141
+ });
142
+ });
@@ -0,0 +1,156 @@
1
+ import { describe, it, beforeEach, afterEach } from "node:test";
2
+ import { strict as assert } from "node:assert";
3
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { tmpdir } from "node:os";
6
+ import { reloadConfig } from "../../config.js";
7
+ import { getProjectPolicySummary } from "../../policy/projectPolicy.js";
8
+ import { releaseCleanup, releasePrepare, releaseVerify } from "../../tools/releaseMode.js";
9
+
10
+ let tempDir: string;
11
+ let repoPath: string;
12
+ let prevConfigEnv: string | undefined;
13
+
14
+ function writeConfig(allowedTestCommands = ["npm test", "npm run build"]): void {
15
+ const configPath = join(tempDir, "patchwarden.config.json");
16
+ writeFileSync(
17
+ configPath,
18
+ JSON.stringify({
19
+ workspaceRoot: tempDir,
20
+ plansDir: ".patchwarden/plans",
21
+ tasksDir: ".patchwarden/tasks",
22
+ assessmentsDir: ".patchwarden/assessments",
23
+ allowedTestCommands,
24
+ agents: {},
25
+ defaultTaskTimeoutSeconds: 30,
26
+ maxTaskTimeoutSeconds: 120,
27
+ }),
28
+ "utf-8",
29
+ );
30
+ prevConfigEnv = process.env.PATCHWARDEN_CONFIG;
31
+ process.env.PATCHWARDEN_CONFIG = configPath;
32
+ reloadConfig(configPath);
33
+ }
34
+
35
+ function writePackage(version = "1.3.0"): void {
36
+ writeFileSync(
37
+ join(repoPath, "package.json"),
38
+ JSON.stringify({
39
+ name: "patchwarden-test",
40
+ version,
41
+ repository: { type: "git", url: "git+https://github.com/example/patchwarden-test.git" },
42
+ }),
43
+ "utf-8",
44
+ );
45
+ }
46
+
47
+ describe("project policy and release mode", () => {
48
+ beforeEach(() => {
49
+ tempDir = mkdtempSync(join(tmpdir(), "pw-policy-"));
50
+ repoPath = join(tempDir, "repo");
51
+ mkdirSync(repoPath, { recursive: true });
52
+ writePackage();
53
+ writeConfig();
54
+ });
55
+
56
+ afterEach(() => {
57
+ if (prevConfigEnv === undefined) {
58
+ delete process.env.PATCHWARDEN_CONFIG;
59
+ } else {
60
+ process.env.PATCHWARDEN_CONFIG = prevConfigEnv;
61
+ }
62
+ reloadConfig();
63
+ rmSync(tempDir, { recursive: true, force: true });
64
+ });
65
+
66
+ it("returns safe defaults when project-policy.json is missing", () => {
67
+ const summary = getProjectPolicySummary(repoPath);
68
+ assert.equal(summary.exists, false);
69
+ assert.equal(summary.valid, true);
70
+ assert.equal(summary.effective_policy.release_mode.version_source, "package.json");
71
+ assert.equal(summary.release_readiness.version, "1.3.0");
72
+ });
73
+
74
+ it("parses BOM-prefixed policy JSON and reports allowlist issues without granting permission", () => {
75
+ mkdirSync(join(repoPath, ".patchwarden"), { recursive: true });
76
+ writeFileSync(
77
+ join(repoPath, ".patchwarden", "project-policy.json"),
78
+ "\uFEFF" + JSON.stringify({
79
+ allowed_commands: ["npm run build", "npm publish"],
80
+ release_mode: {
81
+ version_source: "package.json",
82
+ required_commands: ["npm run build", "not-allowed"],
83
+ },
84
+ }),
85
+ "utf-8",
86
+ );
87
+ const summary = getProjectPolicySummary(repoPath);
88
+ assert.equal(summary.exists, true);
89
+ assert.equal(summary.valid, false);
90
+ assert.ok(summary.issues.some((issue) => issue.code === "high_risk_command"));
91
+ assert.ok(summary.issues.some((issue) => issue.code === "command_not_allowlisted"));
92
+ });
93
+
94
+ it("rejects unsafe path patterns and sensitive protected paths block cleanup", () => {
95
+ mkdirSync(join(repoPath, ".patchwarden"), { recursive: true });
96
+ writeFileSync(
97
+ join(repoPath, ".patchwarden", "project-policy.json"),
98
+ JSON.stringify({
99
+ auto_cleanup: { enabled: true, patterns: ["../escape", ".git", "release_packages"], exclude: [] },
100
+ protected_paths: [".env", "release_packages"],
101
+ }),
102
+ "utf-8",
103
+ );
104
+ mkdirSync(join(repoPath, "release_packages"), { recursive: true });
105
+ const summary = getProjectPolicySummary(repoPath);
106
+ assert.equal(summary.valid, false);
107
+ assert.ok(summary.issues.some((issue) => issue.code === "unsafe_path_pattern"));
108
+ const cleanup = releaseCleanup({ repo_path: repoPath, dry_run: true });
109
+ const skipped = cleanup.summary.skipped as Array<{ path: string; reason: string }>;
110
+ assert.ok(skipped.some((entry) => entry.path === "release_packages" && entry.reason === "protected_or_sensitive"));
111
+ });
112
+
113
+ it("release_cleanup defaults to dry run and writes BOM-free JSON summary", () => {
114
+ mkdirSync(join(repoPath, "release_packages"), { recursive: true });
115
+ const result = releaseCleanup({ repo_path: repoPath });
116
+ assert.equal(result.mode, "release_cleanup");
117
+ assert.equal((result.summary as any).dry_run, true);
118
+ assert.equal(existsSync(join(repoPath, "release_packages")), true);
119
+ const reportPath = join(repoPath, String((result.summary as any).report_path));
120
+ const raw = readFileSync(reportPath, "utf-8");
121
+ assert.notEqual(raw.charCodeAt(0), 0xfeff);
122
+ assert.doesNotThrow(() => JSON.parse(raw));
123
+ });
124
+
125
+ it("release_cleanup honors auto_cleanup.enabled=false before deleting", () => {
126
+ mkdirSync(join(repoPath, ".patchwarden"), { recursive: true });
127
+ writeFileSync(
128
+ join(repoPath, ".patchwarden", "project-policy.json"),
129
+ JSON.stringify({
130
+ auto_cleanup: { enabled: false, patterns: ["release_packages"], exclude: [] },
131
+ }),
132
+ "utf-8",
133
+ );
134
+ mkdirSync(join(repoPath, "release_packages"), { recursive: true });
135
+ const result = releaseCleanup({ repo_path: repoPath, dry_run: false });
136
+ assert.equal((result.summary as any).cleanup_disabled_by_policy, true);
137
+ assert.deepEqual((result.summary as any).removed, []);
138
+ assert.equal(existsSync(join(repoPath, "release_packages")), true);
139
+ });
140
+
141
+ it("release_verify fails when required metadata cannot be inferred", async () => {
142
+ writeFileSync(join(repoPath, "package.json"), JSON.stringify({ name: "", version: "" }), "utf-8");
143
+ const result = await releaseVerify({ repo_path: repoPath });
144
+ assert.equal(result.ok, false);
145
+ const stages = result.summary.stages as Record<string, string>;
146
+ assert.equal(stages.published_verified, "failed");
147
+ assert.equal(stages.github_release_verified, "failed");
148
+ assert.equal(stages.ci_verified, "failed");
149
+ });
150
+
151
+ it("release_prepare blocks commands not accepted by the existing command guard", () => {
152
+ const result = releasePrepare({ repo_path: repoPath, required_commands: ["npm run build", "npm run secret"] });
153
+ const commands = result.summary.commands as Array<{ command: string; status: string }>;
154
+ assert.equal(commands.find((entry) => entry.command === "npm run secret")?.status, "blocked");
155
+ });
156
+ });