patchwarden 0.4.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.
- package/Check-PatchWarden-Health.cmd +6 -0
- package/LICENSE +21 -0
- package/README.md +526 -0
- package/Reset-PatchWarden-Tunnel-Key.cmd +6 -0
- package/Restart-PatchWarden.cmd +19 -0
- package/Start-PatchWarden-Tunnel.cmd +7 -0
- package/dist/config.d.ts +24 -0
- package/dist/config.js +131 -0
- package/dist/doctor.d.ts +10 -0
- package/dist/doctor.js +388 -0
- package/dist/errors.d.ts +16 -0
- package/dist/errors.js +27 -0
- package/dist/httpServer.d.ts +17 -0
- package/dist/httpServer.js +255 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +24 -0
- package/dist/runner/changeCapture.d.ts +43 -0
- package/dist/runner/changeCapture.js +245 -0
- package/dist/runner/cli.d.ts +9 -0
- package/dist/runner/cli.js +26 -0
- package/dist/runner/runTask.d.ts +8 -0
- package/dist/runner/runTask.js +821 -0
- package/dist/runner/watch.d.ts +19 -0
- package/dist/runner/watch.js +136 -0
- package/dist/security/commandGuard.d.ts +22 -0
- package/dist/security/commandGuard.js +56 -0
- package/dist/security/contentRedaction.d.ts +12 -0
- package/dist/security/contentRedaction.js +65 -0
- package/dist/security/pathGuard.d.ts +3 -0
- package/dist/security/pathGuard.js +98 -0
- package/dist/security/planGuard.d.ts +1 -0
- package/dist/security/planGuard.js +65 -0
- package/dist/security/sensitiveGuard.d.ts +2 -0
- package/dist/security/sensitiveGuard.js +60 -0
- package/dist/smoke-test.d.ts +15 -0
- package/dist/smoke-test.js +989 -0
- package/dist/taskProgress.d.ts +6 -0
- package/dist/taskProgress.js +36 -0
- package/dist/taskRuntime.d.ts +10 -0
- package/dist/taskRuntime.js +25 -0
- package/dist/tools/auditTask.d.ts +19 -0
- package/dist/tools/auditTask.js +307 -0
- package/dist/tools/cancelTask.d.ts +30 -0
- package/dist/tools/cancelTask.js +64 -0
- package/dist/tools/createTask.d.ts +42 -0
- package/dist/tools/createTask.js +188 -0
- package/dist/tools/getPlan.d.ts +12 -0
- package/dist/tools/getPlan.js +29 -0
- package/dist/tools/getTaskFile.d.ts +24 -0
- package/dist/tools/getTaskFile.js +84 -0
- package/dist/tools/getTaskProgress.d.ts +1 -0
- package/dist/tools/getTaskProgress.js +4 -0
- package/dist/tools/getTaskStatus.d.ts +46 -0
- package/dist/tools/getTaskStatus.js +36 -0
- package/dist/tools/getTaskStdoutTail.d.ts +9 -0
- package/dist/tools/getTaskStdoutTail.js +56 -0
- package/dist/tools/getTaskSummary.d.ts +66 -0
- package/dist/tools/getTaskSummary.js +237 -0
- package/dist/tools/healthCheck.d.ts +79 -0
- package/dist/tools/healthCheck.js +230 -0
- package/dist/tools/killTask.d.ts +15 -0
- package/dist/tools/killTask.js +4 -0
- package/dist/tools/listAgents.d.ts +12 -0
- package/dist/tools/listAgents.js +42 -0
- package/dist/tools/listTasks.d.ts +36 -0
- package/dist/tools/listTasks.js +94 -0
- package/dist/tools/listWorkspace.d.ts +10 -0
- package/dist/tools/listWorkspace.js +54 -0
- package/dist/tools/readWorkspaceFile.d.ts +8 -0
- package/dist/tools/readWorkspaceFile.js +26 -0
- package/dist/tools/registry.d.ts +24 -0
- package/dist/tools/registry.js +566 -0
- package/dist/tools/retryTask.d.ts +8 -0
- package/dist/tools/retryTask.js +45 -0
- package/dist/tools/savePlan.d.ts +10 -0
- package/dist/tools/savePlan.js +28 -0
- package/dist/tools/taskOutputs.d.ts +49 -0
- package/dist/tools/taskOutputs.js +136 -0
- package/dist/tools/taskTemplates.d.ts +15 -0
- package/dist/tools/taskTemplates.js +116 -0
- package/dist/tools/toolCatalog.d.ts +19 -0
- package/dist/tools/toolCatalog.js +71 -0
- package/dist/tools/waitForTask.d.ts +25 -0
- package/dist/tools/waitForTask.js +59 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.js +2 -0
- package/dist/watcherStatus.d.ts +20 -0
- package/dist/watcherStatus.js +77 -0
- package/docs/assets/patchwarden-chatgpt-demo.svg +30 -0
- package/docs/demo.md +58 -0
- package/docs/migration-from-safe-bifrost.md +31 -0
- package/docs/release-v0.3.0.md +43 -0
- package/docs/release-v0.4.0.md +74 -0
- package/examples/config.example.json +40 -0
- package/examples/openai-tunnel/README.md +48 -0
- package/examples/openai-tunnel/chatgpt-test-prompt.md +53 -0
- package/examples/openai-tunnel/tunnel-client.example.yaml +39 -0
- package/package.json +73 -0
- package/scripts/brand-check.js +44 -0
- package/scripts/doctor-smoke.js +52 -0
- package/scripts/get-patchwarden-health.ps1 +114 -0
- package/scripts/http-mcp-smoke.js +351 -0
- package/scripts/lifecycle-smoke.js +453 -0
- package/scripts/mcp-manifest-check.js +120 -0
- package/scripts/mcp-smoke.js +336 -0
- package/scripts/pack-clean.js +142 -0
- package/scripts/patchwarden-mcp-stdio.cmd +7 -0
- package/scripts/restart-patchwarden.ps1 +242 -0
- package/scripts/start-patchwarden-tunnel.ps1 +503 -0
- package/scripts/tunnel-supervisor-smoke.js +77 -0
- package/scripts/watcher-supervisor-smoke.js +195 -0
- package/src/config.ts +165 -0
- package/src/doctor.ts +438 -0
- package/src/errors.ts +26 -0
- package/src/httpServer.ts +277 -0
- package/src/index.ts +34 -0
- package/src/runner/changeCapture.ts +304 -0
- package/src/runner/cli.ts +34 -0
- package/src/runner/runTask.ts +935 -0
- package/src/runner/watch.ts +152 -0
- package/src/security/commandGuard.ts +97 -0
- package/src/security/contentRedaction.ts +85 -0
- package/src/security/pathGuard.ts +175 -0
- package/src/security/planGuard.ts +93 -0
- package/src/security/sensitiveGuard.ts +72 -0
- package/src/smoke-test.ts +1078 -0
- package/src/taskProgress.ts +41 -0
- package/src/taskRuntime.ts +37 -0
- package/src/tools/auditTask.ts +340 -0
- package/src/tools/cancelTask.ts +70 -0
- package/src/tools/createTask.ts +337 -0
- package/src/tools/getPlan.ts +48 -0
- package/src/tools/getTaskFile.ts +111 -0
- package/src/tools/getTaskProgress.ts +5 -0
- package/src/tools/getTaskStatus.ts +85 -0
- package/src/tools/getTaskStdoutTail.ts +61 -0
- package/src/tools/getTaskSummary.ts +307 -0
- package/src/tools/healthCheck.ts +234 -0
- package/src/tools/killTask.ts +5 -0
- package/src/tools/listAgents.ts +54 -0
- package/src/tools/listTasks.ts +139 -0
- package/src/tools/listWorkspace.ts +71 -0
- package/src/tools/readWorkspaceFile.ts +41 -0
- package/src/tools/registry.ts +666 -0
- package/src/tools/retryTask.ts +53 -0
- package/src/tools/savePlan.ts +46 -0
- package/src/tools/taskOutputs.ts +155 -0
- package/src/tools/taskTemplates.ts +162 -0
- package/src/tools/toolCatalog.ts +92 -0
- package/src/tools/waitForTask.ts +90 -0
- package/src/version.ts +2 -0
- package/src/watcherStatus.ts +102 -0
- package/tsconfig.json +17 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { getTasksDir, getConfig } from "../config.js";
|
|
4
|
+
import { guardReadPath } from "../security/pathGuard.js";
|
|
5
|
+
import { createTask } from "./createTask.js";
|
|
6
|
+
|
|
7
|
+
export function retryTask(taskId: string) {
|
|
8
|
+
const config = getConfig();
|
|
9
|
+
const tasksDir = getTasksDir(config);
|
|
10
|
+
const taskDir = join(tasksDir, taskId);
|
|
11
|
+
const statusFile = join(taskDir, "status.json");
|
|
12
|
+
|
|
13
|
+
guardReadPath(statusFile, config.workspaceRoot, config.tasksDir);
|
|
14
|
+
|
|
15
|
+
if (!existsSync(statusFile)) {
|
|
16
|
+
throw new Error(`Task not found: "${taskId}"`);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const data = JSON.parse(readFileSync(statusFile, "utf-8"));
|
|
20
|
+
|
|
21
|
+
if (!data.plan_id || !data.agent) {
|
|
22
|
+
throw new Error(`Task "${taskId}" is missing plan_id or agent. Cannot retry.`);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Create a new task with the same parameters
|
|
26
|
+
const newTask = createTask({
|
|
27
|
+
plan_id: data.plan_id,
|
|
28
|
+
agent: data.agent,
|
|
29
|
+
repo_path: data.repo_path,
|
|
30
|
+
test_command: data.test_command,
|
|
31
|
+
verify_commands: data.verify_commands,
|
|
32
|
+
timeout_seconds: data.timeout_seconds,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// Record retry relationship in the new task
|
|
36
|
+
const newStatusFile = join(newTask.path, "status.json");
|
|
37
|
+
const newData = JSON.parse(readFileSync(newStatusFile, "utf-8"));
|
|
38
|
+
newData.retry_of = taskId;
|
|
39
|
+
newData.retry_count = (data.retry_count || 0) + 1;
|
|
40
|
+
newData.plan_source = data.plan_source || "saved";
|
|
41
|
+
newData.template = data.template || null;
|
|
42
|
+
newData.change_policy = data.change_policy || "repo_scoped_changes";
|
|
43
|
+
writeFileSync(newStatusFile, JSON.stringify(newData, null, 2), "utf-8");
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
original_task_id: taskId,
|
|
47
|
+
new_task_id: newTask.task_id,
|
|
48
|
+
plan_id: newTask.plan_id,
|
|
49
|
+
agent: newTask.agent,
|
|
50
|
+
status: newTask.status,
|
|
51
|
+
message: `New task created from retry of ${taskId}. Original task is unchanged.`,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { resolve, join } from "node:path";
|
|
3
|
+
import { getPlansDir, getConfig } from "../config.js";
|
|
4
|
+
import { guardPath } from "../security/pathGuard.js";
|
|
5
|
+
import { guardPlanContent } from "../security/planGuard.js";
|
|
6
|
+
|
|
7
|
+
export interface SavePlanInput {
|
|
8
|
+
title: string;
|
|
9
|
+
content: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface SavePlanOutput {
|
|
13
|
+
plan_id: string;
|
|
14
|
+
path: string;
|
|
15
|
+
title: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function savePlan(input: SavePlanInput): SavePlanOutput {
|
|
19
|
+
const config = getConfig();
|
|
20
|
+
const plansDir = getPlansDir(config);
|
|
21
|
+
|
|
22
|
+
guardPlanContent(input.title, input.content);
|
|
23
|
+
|
|
24
|
+
const planId = `plan_${Date.now()}_${sanitizeTitle(input.title)}`;
|
|
25
|
+
const planDir = resolve(plansDir, planId);
|
|
26
|
+
|
|
27
|
+
// Guards: plan dir & file must stay inside workspace
|
|
28
|
+
guardPath(planDir, config.workspaceRoot, config.plansDir);
|
|
29
|
+
mkdirSync(planDir, { recursive: true });
|
|
30
|
+
|
|
31
|
+
const planFile = join(planDir, "plan.md");
|
|
32
|
+
const header = `# ${input.title}\n\n> Plan ID: ${planId}\n> Created: ${new Date().toISOString()}\n\n`;
|
|
33
|
+
writeFileSync(planFile, header + input.content, "utf-8");
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
plan_id: planId,
|
|
37
|
+
path: planFile,
|
|
38
|
+
title: input.title,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function sanitizeTitle(title: string): string {
|
|
43
|
+
return title
|
|
44
|
+
.replace(/[^a-zA-Z0-9\u4e00-\u9fff_-]/g, "_")
|
|
45
|
+
.slice(0, 64);
|
|
46
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { getTaskFile, GetTaskFileOutput } from "./getTaskFile.js";
|
|
2
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
|
+
import { getConfig, getTasksDir } from "../config.js";
|
|
5
|
+
import { guardPath } from "../security/pathGuard.js";
|
|
6
|
+
import { redactSensitiveContent } from "../security/contentRedaction.js";
|
|
7
|
+
|
|
8
|
+
export function getResult(taskId: string): GetTaskFileOutput {
|
|
9
|
+
return getTaskFile(taskId, "result.md");
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function getResultJson(taskId: string): GetTaskFileOutput {
|
|
13
|
+
return getTaskFile(taskId, "result.json");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function getDiff(taskId: string): GetTaskFileOutput {
|
|
17
|
+
const config = getConfig();
|
|
18
|
+
const taskDir = resolve(getTasksDir(config), taskId);
|
|
19
|
+
const preferred = join(taskDir, "diff.patch");
|
|
20
|
+
const fallback = join(taskDir, "git.diff");
|
|
21
|
+
if (!existsSync(preferred) && !existsSync(fallback)) {
|
|
22
|
+
return getTaskFile(taskId, "diff.patch");
|
|
23
|
+
}
|
|
24
|
+
const filePath = existsSync(preferred) ? preferred : fallback;
|
|
25
|
+
guardPath(filePath, config.workspaceRoot, config.tasksDir);
|
|
26
|
+
const size = statSync(filePath).size;
|
|
27
|
+
const raw = readFileSync(filePath, "utf-8");
|
|
28
|
+
const patchHead = raw.slice(0, config.maxReadFileBytes);
|
|
29
|
+
const rawReturnedBytes = Buffer.byteLength(patchHead, "utf-8");
|
|
30
|
+
const redaction = redactSensitiveContent(patchHead);
|
|
31
|
+
const content = redaction.content;
|
|
32
|
+
const diff = {
|
|
33
|
+
task_id: taskId,
|
|
34
|
+
filename: existsSync(preferred) ? "diff.patch" : "git.diff",
|
|
35
|
+
content,
|
|
36
|
+
path: filePath,
|
|
37
|
+
available: true,
|
|
38
|
+
truncated: size > rawReturnedBytes,
|
|
39
|
+
total_bytes: size,
|
|
40
|
+
returned_bytes: Buffer.byteLength(content, "utf-8"),
|
|
41
|
+
patch_head: content,
|
|
42
|
+
diff_patch_path: filePath,
|
|
43
|
+
redacted: redaction.redacted,
|
|
44
|
+
redaction_categories: redaction.redaction_categories,
|
|
45
|
+
} as GetTaskFileOutput;
|
|
46
|
+
try {
|
|
47
|
+
const evidence = JSON.parse(getTaskFile(taskId, "changed-files.json").content);
|
|
48
|
+
return {
|
|
49
|
+
...diff,
|
|
50
|
+
changed_files: evidence.changed_files || [],
|
|
51
|
+
diff_available: Boolean(evidence.diff_available),
|
|
52
|
+
workspace_dirty_before: Boolean(evidence.workspace_dirty_before),
|
|
53
|
+
workspace_dirty_after: Boolean(evidence.workspace_dirty_after),
|
|
54
|
+
diff_truncated: Boolean(evidence.diff_truncated || (diff as any).truncated),
|
|
55
|
+
diff_size_bytes: Number(evidence.diff_size_bytes || size),
|
|
56
|
+
additions: Number(evidence.additions || 0),
|
|
57
|
+
deletions: Number(evidence.deletions || 0),
|
|
58
|
+
file_stats: evidence.file_stats || [],
|
|
59
|
+
patch_mode: evidence.patch_mode || (evidence.diff_available ? "hash_only" : "no_changes"),
|
|
60
|
+
unavailable_reason: evidence.unavailable_reason || null,
|
|
61
|
+
message: evidence.diff_available ? "Task diff available" : "No task file changes detected",
|
|
62
|
+
} as GetTaskFileOutput;
|
|
63
|
+
} catch {
|
|
64
|
+
return diff;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function getTestLog(taskId: string, options?: { tailLines?: number; maxBytes?: number }): GetTaskFileOutput {
|
|
69
|
+
const maxBytes = options?.maxBytes ?? 0;
|
|
70
|
+
const tailLines = options?.tailLines ?? 0;
|
|
71
|
+
if (tailLines > 0 || maxBytes > 0) {
|
|
72
|
+
return getTaskFileTail(taskId, "test.log", { tailLines, maxBytes });
|
|
73
|
+
}
|
|
74
|
+
return getTaskFile(taskId, "test.log");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Read the tail of a task log file with automatic redaction.
|
|
79
|
+
* Supports test.log, stdout.log, stderr.log, verify.log.
|
|
80
|
+
*/
|
|
81
|
+
export function getTaskLogTail(
|
|
82
|
+
taskId: string,
|
|
83
|
+
file: "stdout" | "stderr" | "test" | "verify",
|
|
84
|
+
options?: { lines?: number; redact?: boolean }
|
|
85
|
+
) {
|
|
86
|
+
const config = getConfig();
|
|
87
|
+
const taskDir = resolve(getTasksDir(config), taskId);
|
|
88
|
+
const filename = file === "stdout" ? "stdout.log"
|
|
89
|
+
: file === "stderr" ? "stderr.log"
|
|
90
|
+
: file === "test" ? "test.log"
|
|
91
|
+
: "verify.log";
|
|
92
|
+
const filePath = join(taskDir, filename);
|
|
93
|
+
guardPath(filePath, config.workspaceRoot, config.tasksDir);
|
|
94
|
+
|
|
95
|
+
const maxLines = Math.min(options?.lines ?? 80, 200);
|
|
96
|
+
const applyRedact = options?.redact !== false; // default true
|
|
97
|
+
|
|
98
|
+
if (!existsSync(filePath)) {
|
|
99
|
+
return { ...getTaskFile(taskId, filename), file, lines: 0, total_bytes: 0 };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const stat = statSync(filePath);
|
|
103
|
+
const totalBytes = stat.size;
|
|
104
|
+
const raw = readFileSync(filePath, "utf-8");
|
|
105
|
+
const tail = raw.split("\n").slice(-maxLines).join("\n");
|
|
106
|
+
const content = applyRedact ? redactSensitiveContent(tail) : { content: tail, redacted: false, redaction_categories: [] as string[] };
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
task_id: taskId,
|
|
110
|
+
file,
|
|
111
|
+
filename,
|
|
112
|
+
content: content.content,
|
|
113
|
+
available: true,
|
|
114
|
+
lines: tail.split("\n").length,
|
|
115
|
+
total_bytes: totalBytes,
|
|
116
|
+
truncated: totalBytes > Buffer.byteLength(tail, "utf-8"),
|
|
117
|
+
redacted: content.redacted,
|
|
118
|
+
redaction_categories: content.redaction_categories,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function getTaskFileTail(
|
|
123
|
+
taskId: string,
|
|
124
|
+
filename: string,
|
|
125
|
+
options: { tailLines: number; maxBytes: number }
|
|
126
|
+
): GetTaskFileOutput {
|
|
127
|
+
const config = getConfig();
|
|
128
|
+
const taskDir = resolve(getTasksDir(config), taskId);
|
|
129
|
+
const filePath = join(taskDir, filename);
|
|
130
|
+
guardPath(filePath, config.workspaceRoot, config.tasksDir);
|
|
131
|
+
|
|
132
|
+
if (!existsSync(filePath)) {
|
|
133
|
+
return getTaskFile(taskId, filename);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const stat = statSync(filePath);
|
|
137
|
+
const raw = readFileSync(filePath, "utf-8");
|
|
138
|
+
let content = raw;
|
|
139
|
+
if (options.tailLines > 0) {
|
|
140
|
+
content = raw.split("\n").slice(-options.tailLines).join("\n");
|
|
141
|
+
}
|
|
142
|
+
if (options.maxBytes > 0 && Buffer.byteLength(content, "utf-8") > options.maxBytes) {
|
|
143
|
+
content = content.slice(0, options.maxBytes);
|
|
144
|
+
}
|
|
145
|
+
const redaction = redactSensitiveContent(content);
|
|
146
|
+
return {
|
|
147
|
+
task_id: taskId,
|
|
148
|
+
filename,
|
|
149
|
+
content: redaction.content,
|
|
150
|
+
path: filePath,
|
|
151
|
+
available: true,
|
|
152
|
+
redacted: redaction.redacted,
|
|
153
|
+
redaction_categories: redaction.redaction_categories,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
import { getConfig, getTasksDir } from "../config.js";
|
|
4
|
+
import { PatchWardenError } from "../errors.js";
|
|
5
|
+
import { guardReadPath } from "../security/pathGuard.js";
|
|
6
|
+
|
|
7
|
+
export const TASK_TEMPLATE_NAMES = [
|
|
8
|
+
"inspect_only",
|
|
9
|
+
"feature_small",
|
|
10
|
+
"fix_tests",
|
|
11
|
+
"release_check",
|
|
12
|
+
"rollback_scope_violation",
|
|
13
|
+
] as const;
|
|
14
|
+
|
|
15
|
+
export type TaskTemplateName = typeof TASK_TEMPLATE_NAMES[number];
|
|
16
|
+
export type ChangePolicy = "repo_scoped_changes" | "no_changes";
|
|
17
|
+
|
|
18
|
+
export interface TaskTemplateInput {
|
|
19
|
+
template: TaskTemplateName;
|
|
20
|
+
goal: string;
|
|
21
|
+
source_task_id?: string;
|
|
22
|
+
verify_commands: string[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface ExpandedTaskTemplate {
|
|
26
|
+
title: string;
|
|
27
|
+
content: string;
|
|
28
|
+
change_policy: ChangePolicy;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function expandTaskTemplate(input: TaskTemplateInput): ExpandedTaskTemplate {
|
|
32
|
+
const goal = input.goal.trim();
|
|
33
|
+
if (!goal) {
|
|
34
|
+
throw new PatchWardenError(
|
|
35
|
+
"template_goal_required",
|
|
36
|
+
"Template tasks require a non-empty goal.",
|
|
37
|
+
"Pass a concise goal describing the desired inspection or repository-local change."
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const common = [
|
|
42
|
+
`## Goal\n${goal}`,
|
|
43
|
+
"## Safety boundaries",
|
|
44
|
+
"- Work only inside the resolved repository path.",
|
|
45
|
+
"- Do not commit, push, publish, expose credentials, or modify unrelated files.",
|
|
46
|
+
"- Report exact files inspected or changed and any remaining uncertainty.",
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
switch (input.template) {
|
|
50
|
+
case "inspect_only":
|
|
51
|
+
return {
|
|
52
|
+
title: `Inspect only: ${shortTitle(goal)}`,
|
|
53
|
+
change_policy: "no_changes",
|
|
54
|
+
content: [
|
|
55
|
+
...common,
|
|
56
|
+
"## Execution contract",
|
|
57
|
+
"- Perform read-only inspection only.",
|
|
58
|
+
"- Do not create, edit, delete, rename, format, or generate repository files.",
|
|
59
|
+
"- Return findings, evidence, and the smallest safe next action.",
|
|
60
|
+
].join("\n\n"),
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
case "feature_small":
|
|
64
|
+
return {
|
|
65
|
+
title: `Small feature: ${shortTitle(goal)}`,
|
|
66
|
+
change_policy: "repo_scoped_changes",
|
|
67
|
+
content: [
|
|
68
|
+
...common,
|
|
69
|
+
"## Execution contract",
|
|
70
|
+
"- Read the repository instructions, README, and package metadata first.",
|
|
71
|
+
"- Make the smallest coherent implementation; avoid framework, dependency, and directory changes unless required.",
|
|
72
|
+
"- Run only the independently configured verification commands.",
|
|
73
|
+
].join("\n\n"),
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
case "fix_tests":
|
|
77
|
+
if (input.verify_commands.length === 0) {
|
|
78
|
+
throw new PatchWardenError(
|
|
79
|
+
"template_verification_required",
|
|
80
|
+
"The fix_tests template requires at least one allow-listed verify_commands entry.",
|
|
81
|
+
"Pass the failing test or check command in verify_commands."
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
return {
|
|
85
|
+
title: `Fix tests: ${shortTitle(goal)}`,
|
|
86
|
+
change_policy: "repo_scoped_changes",
|
|
87
|
+
content: [
|
|
88
|
+
...common,
|
|
89
|
+
"## Execution contract",
|
|
90
|
+
"- Reproduce the relevant failure before editing when possible.",
|
|
91
|
+
"- Fix the root cause without deleting tests, weakening checks, or changing unrelated behavior.",
|
|
92
|
+
`- PatchWarden will independently run: ${input.verify_commands.join(", ")}.`,
|
|
93
|
+
].join("\n\n"),
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
case "release_check":
|
|
97
|
+
return {
|
|
98
|
+
title: `Release check: ${shortTitle(goal)}`,
|
|
99
|
+
change_policy: "repo_scoped_changes",
|
|
100
|
+
content: [
|
|
101
|
+
...common,
|
|
102
|
+
"## Execution contract",
|
|
103
|
+
"- Perform local release-readiness checks and inspect package contents and generated artifacts.",
|
|
104
|
+
"- Do not publish, push, create tags/releases, or claim remote completion without live remote evidence.",
|
|
105
|
+
"- Clearly separate local readiness from remote publication state.",
|
|
106
|
+
].join("\n\n"),
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
case "rollback_scope_violation": {
|
|
110
|
+
const evidence = readRollbackEvidence(input.source_task_id);
|
|
111
|
+
return {
|
|
112
|
+
title: `Scope violation review: ${shortTitle(goal)}`,
|
|
113
|
+
change_policy: "no_changes",
|
|
114
|
+
content: [
|
|
115
|
+
...common,
|
|
116
|
+
"## Execution contract",
|
|
117
|
+
`- Review scope-violation evidence from source task ${input.source_task_id}.`,
|
|
118
|
+
"- Do not perform an automatic rollback, deletion, reset, checkout, or cross-repository edit.",
|
|
119
|
+
"- Produce a file-by-file recovery proposal with backup and verification steps for user approval.",
|
|
120
|
+
"## Source task recovery evidence",
|
|
121
|
+
evidence,
|
|
122
|
+
].join("\n\n"),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function readRollbackEvidence(sourceTaskId?: string): string {
|
|
129
|
+
if (!sourceTaskId?.trim()) {
|
|
130
|
+
throw new PatchWardenError(
|
|
131
|
+
"source_task_required",
|
|
132
|
+
"rollback_scope_violation requires source_task_id.",
|
|
133
|
+
"Pass the task ID that ended with failed_scope_violation."
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
const config = getConfig();
|
|
137
|
+
const taskDir = resolve(getTasksDir(config), sourceTaskId);
|
|
138
|
+
const statusFile = join(taskDir, "status.json");
|
|
139
|
+
const rollbackFile = join(taskDir, "rollback_scope_violation_plan.md");
|
|
140
|
+
guardReadPath(statusFile, config.workspaceRoot, config.tasksDir);
|
|
141
|
+
guardReadPath(rollbackFile, config.workspaceRoot, config.tasksDir);
|
|
142
|
+
if (!existsSync(statusFile) || !existsSync(rollbackFile)) {
|
|
143
|
+
throw new PatchWardenError(
|
|
144
|
+
"scope_violation_evidence_missing",
|
|
145
|
+
`Source task "${sourceTaskId}" does not contain scope-violation recovery evidence.`,
|
|
146
|
+
"Use a task that ended with failed_scope_violation and has rollback_scope_violation_plan.md."
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
const status = JSON.parse(readFileSync(statusFile, "utf-8"));
|
|
150
|
+
if (status.status !== "failed_scope_violation") {
|
|
151
|
+
throw new PatchWardenError(
|
|
152
|
+
"source_task_not_scope_violation",
|
|
153
|
+
`Source task "${sourceTaskId}" has status "${status.status}", not failed_scope_violation.`,
|
|
154
|
+
"Use the scope-violating task as source_task_id."
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
return readFileSync(rollbackFile, "utf-8").slice(0, 20_000);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function shortTitle(goal: string): string {
|
|
161
|
+
return goal.replace(/\s+/g, " ").slice(0, 80);
|
|
162
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { PATCHWARDEN_VERSION, TOOL_SCHEMA_EPOCH } from "../version.js";
|
|
3
|
+
|
|
4
|
+
export type ToolProfile = "full" | "chatgpt_core";
|
|
5
|
+
|
|
6
|
+
export interface CatalogTool {
|
|
7
|
+
name: string;
|
|
8
|
+
description: string;
|
|
9
|
+
inputSchema: unknown;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface ToolCatalogSnapshot {
|
|
13
|
+
server_version: string;
|
|
14
|
+
schema_epoch: string;
|
|
15
|
+
tool_profile: ToolProfile;
|
|
16
|
+
tool_count: number;
|
|
17
|
+
tool_names: string[];
|
|
18
|
+
tool_manifest_sha256: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const CHATGPT_CORE_TOOL_NAMES = [
|
|
22
|
+
"health_check",
|
|
23
|
+
"list_agents",
|
|
24
|
+
"list_workspace",
|
|
25
|
+
"read_workspace_file",
|
|
26
|
+
"save_plan",
|
|
27
|
+
"create_task",
|
|
28
|
+
"wait_for_task",
|
|
29
|
+
"get_task_summary",
|
|
30
|
+
"get_diff",
|
|
31
|
+
"get_result",
|
|
32
|
+
"get_result_json",
|
|
33
|
+
"get_test_log",
|
|
34
|
+
"get_task_status",
|
|
35
|
+
"list_tasks",
|
|
36
|
+
"cancel_task",
|
|
37
|
+
"audit_task",
|
|
38
|
+
] as const;
|
|
39
|
+
|
|
40
|
+
let lastSnapshot: ToolCatalogSnapshot | null = null;
|
|
41
|
+
|
|
42
|
+
export function resolveToolProfile(configProfile?: string): ToolProfile {
|
|
43
|
+
const raw = (process.env.PATCHWARDEN_TOOL_PROFILE || configProfile || "full").trim();
|
|
44
|
+
if (raw !== "full" && raw !== "chatgpt_core") {
|
|
45
|
+
throw new Error(`Invalid tool profile "${raw}". Expected "full" or "chatgpt_core".`);
|
|
46
|
+
}
|
|
47
|
+
return raw;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function selectToolsForProfile<T extends CatalogTool>(tools: T[], profile: ToolProfile): T[] {
|
|
51
|
+
if (profile === "full") return tools;
|
|
52
|
+
const byName = new Map(tools.map((tool) => [tool.name, tool]));
|
|
53
|
+
return CHATGPT_CORE_TOOL_NAMES.map((name) => {
|
|
54
|
+
const tool = byName.get(name);
|
|
55
|
+
if (!tool) throw new Error(`chatgpt_core tool profile requires missing tool "${name}".`);
|
|
56
|
+
return tool;
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function buildToolCatalogSnapshot(tools: CatalogTool[], profile: ToolProfile): ToolCatalogSnapshot {
|
|
61
|
+
const manifestInput = tools.map((tool) => ({
|
|
62
|
+
name: tool.name,
|
|
63
|
+
description: tool.description,
|
|
64
|
+
inputSchema: tool.inputSchema,
|
|
65
|
+
}));
|
|
66
|
+
const tool_manifest_sha256 = createHash("sha256")
|
|
67
|
+
.update(stableJson(manifestInput))
|
|
68
|
+
.digest("hex");
|
|
69
|
+
lastSnapshot = {
|
|
70
|
+
server_version: PATCHWARDEN_VERSION,
|
|
71
|
+
schema_epoch: TOOL_SCHEMA_EPOCH,
|
|
72
|
+
tool_profile: profile,
|
|
73
|
+
tool_count: tools.length,
|
|
74
|
+
tool_names: tools.map((tool) => tool.name),
|
|
75
|
+
tool_manifest_sha256,
|
|
76
|
+
};
|
|
77
|
+
return lastSnapshot;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function getLastToolCatalogSnapshot(): ToolCatalogSnapshot | null {
|
|
81
|
+
return lastSnapshot ? { ...lastSnapshot, tool_names: [...lastSnapshot.tool_names] } : null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function stableJson(value: unknown): string {
|
|
85
|
+
if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
|
|
86
|
+
if (value && typeof value === "object") {
|
|
87
|
+
const entries = Object.entries(value as Record<string, unknown>)
|
|
88
|
+
.sort(([left], [right]) => left.localeCompare(right));
|
|
89
|
+
return `{${entries.map(([key, entry]) => `${JSON.stringify(key)}:${stableJson(entry)}`).join(",")}}`;
|
|
90
|
+
}
|
|
91
|
+
return JSON.stringify(value);
|
|
92
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
2
|
+
import { getTaskStatus } from "./getTaskStatus.js";
|
|
3
|
+
import { getTaskSummary, type TaskSummaryOutput } from "./getTaskSummary.js";
|
|
4
|
+
|
|
5
|
+
const TERMINAL_STATUSES = new Set([
|
|
6
|
+
"done",
|
|
7
|
+
"failed",
|
|
8
|
+
"failed_verification",
|
|
9
|
+
"failed_scope_violation",
|
|
10
|
+
"failed_policy_violation",
|
|
11
|
+
"canceled",
|
|
12
|
+
]);
|
|
13
|
+
|
|
14
|
+
export interface WaitForTaskProgressSummary {
|
|
15
|
+
phase: string;
|
|
16
|
+
heartbeat_at: string;
|
|
17
|
+
elapsed_seconds: number;
|
|
18
|
+
current_command: string | null;
|
|
19
|
+
hint: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface WaitForTaskOutput {
|
|
23
|
+
task_id: string;
|
|
24
|
+
status: string;
|
|
25
|
+
phase: string;
|
|
26
|
+
terminal: boolean;
|
|
27
|
+
timed_out: boolean;
|
|
28
|
+
continuation_required: boolean;
|
|
29
|
+
waited_ms: number;
|
|
30
|
+
next_action: string;
|
|
31
|
+
next_tool_call: {
|
|
32
|
+
name: "wait_for_task" | "audit_task" | "health_check";
|
|
33
|
+
arguments: Record<string, unknown>;
|
|
34
|
+
};
|
|
35
|
+
summary?: TaskSummaryOutput;
|
|
36
|
+
progress_summary?: WaitForTaskProgressSummary;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function waitForTask(taskId: string, waitSeconds = 25): Promise<WaitForTaskOutput> {
|
|
40
|
+
if (!Number.isInteger(waitSeconds) || waitSeconds < 1 || waitSeconds > 30) {
|
|
41
|
+
throw new Error("wait_seconds must be an integer from 1 to 30.");
|
|
42
|
+
}
|
|
43
|
+
const started = Date.now();
|
|
44
|
+
const deadline = started + waitSeconds * 1000;
|
|
45
|
+
let status = getTaskStatus(taskId);
|
|
46
|
+
|
|
47
|
+
while (!TERMINAL_STATUSES.has(status.status) && Date.now() < deadline) {
|
|
48
|
+
await sleep(Math.min(500, Math.max(1, deadline - Date.now())));
|
|
49
|
+
status = getTaskStatus(taskId);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const terminal = TERMINAL_STATUSES.has(status.status);
|
|
53
|
+
const executionBlocked = !terminal && status.execution_blocked;
|
|
54
|
+
const elapsed = Math.round((Date.now() - started) / 1000);
|
|
55
|
+
|
|
56
|
+
const progressSummary: WaitForTaskProgressSummary = {
|
|
57
|
+
phase: status.phase,
|
|
58
|
+
heartbeat_at: status.last_heartbeat_at || status.updated_at || "",
|
|
59
|
+
elapsed_seconds: elapsed,
|
|
60
|
+
current_command: status.current_command || null,
|
|
61
|
+
hint: executionBlocked
|
|
62
|
+
? `Watcher is ${status.watcher_status}. Call health_check and restart the owned watcher; do not keep polling this task yet.`
|
|
63
|
+
: status.phase === "queued"
|
|
64
|
+
? "Watcher is healthy and has not picked up this task yet. Continue waiting."
|
|
65
|
+
: status.phase === "executing_agent"
|
|
66
|
+
? `Agent "${status.agent}" is running. Continue waiting, or call get_task_status for phase and current_command.`
|
|
67
|
+
: `Phase "${status.phase}". Task is still in progress.`,
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
task_id: taskId,
|
|
72
|
+
status: status.status,
|
|
73
|
+
phase: status.phase,
|
|
74
|
+
terminal,
|
|
75
|
+
timed_out: !terminal,
|
|
76
|
+
continuation_required: !terminal && !executionBlocked,
|
|
77
|
+
waited_ms: Date.now() - started,
|
|
78
|
+
next_action: terminal
|
|
79
|
+
? "Review the returned summary, then call audit_task for independent acceptance evidence."
|
|
80
|
+
: executionBlocked
|
|
81
|
+
? "Call health_check and restore the owned watcher. The saved task will remain queued for recovery."
|
|
82
|
+
: `Call wait_for_task again immediately for task_id ${taskId}; do not end the assistant turn while continuation_required is true.`,
|
|
83
|
+
next_tool_call: terminal
|
|
84
|
+
? { name: "audit_task", arguments: { task_id: taskId } }
|
|
85
|
+
: executionBlocked
|
|
86
|
+
? { name: "health_check", arguments: {} }
|
|
87
|
+
: { name: "wait_for_task", arguments: { task_id: taskId, timeout_seconds: waitSeconds } },
|
|
88
|
+
...(terminal ? { summary: getTaskSummary(taskId) } : { progress_summary: progressSummary }),
|
|
89
|
+
};
|
|
90
|
+
}
|
package/src/version.ts
ADDED