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