patchwarden 0.4.0 → 0.6.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/PatchWarden.cmd +51 -0
- package/README.en.md +1022 -0
- package/README.md +897 -358
- package/dist/assessments/agentAssessor.d.ts +15 -0
- package/dist/assessments/agentAssessor.js +293 -0
- package/dist/assessments/assessmentStore.d.ts +133 -0
- package/dist/assessments/assessmentStore.js +238 -0
- package/dist/assessments/confirmCli.d.ts +9 -0
- package/dist/assessments/confirmCli.js +26 -0
- package/dist/config.d.ts +22 -1
- package/dist/config.js +156 -3
- package/dist/direct/directAudit.d.ts +23 -0
- package/dist/direct/directAudit.js +309 -0
- package/dist/direct/directGuards.d.ts +20 -0
- package/dist/direct/directGuards.js +137 -0
- package/dist/direct/directPatch.d.ts +17 -0
- package/dist/direct/directPatch.js +113 -0
- package/dist/direct/directSessionStore.d.ts +63 -0
- package/dist/direct/directSessionStore.js +192 -0
- package/dist/direct/directVerification.d.ts +12 -0
- package/dist/direct/directVerification.js +96 -0
- package/dist/doctor.js +73 -5
- package/dist/runner/agentInvocation.d.ts +23 -0
- package/dist/runner/agentInvocation.js +106 -0
- package/dist/runner/changeCapture.d.ts +29 -0
- package/dist/runner/changeCapture.js +107 -5
- package/dist/runner/runTask.js +70 -44
- package/dist/runner/simpleProcess.d.ts +21 -0
- package/dist/runner/simpleProcess.js +206 -0
- package/dist/runner/watch.js +17 -2
- package/dist/security/commandGuard.d.ts +2 -1
- package/dist/security/commandGuard.js +25 -3
- package/dist/security/riskEngine.d.ts +27 -0
- package/dist/security/riskEngine.js +118 -0
- package/dist/security/runtimeGuard.d.ts +6 -0
- package/dist/security/runtimeGuard.js +28 -0
- package/dist/smoke-test.js +1353 -7
- package/dist/tools/applyPatch.d.ts +16 -0
- package/dist/tools/applyPatch.js +41 -0
- package/dist/tools/auditSession.d.ts +5 -0
- package/dist/tools/auditSession.js +12 -0
- package/dist/tools/auditTask.d.ts +7 -0
- package/dist/tools/auditTask.js +94 -2
- package/dist/tools/createDirectSession.d.ts +14 -0
- package/dist/tools/createDirectSession.js +54 -0
- package/dist/tools/createTask.d.ts +48 -1
- package/dist/tools/createTask.js +298 -47
- package/dist/tools/finalizeDirectSession.d.ts +19 -0
- package/dist/tools/finalizeDirectSession.js +84 -0
- package/dist/tools/getTaskSummary.d.ts +47 -0
- package/dist/tools/getTaskSummary.js +64 -1
- package/dist/tools/healthCheck.d.ts +22 -12
- package/dist/tools/healthCheck.js +23 -6
- package/dist/tools/readWorkspaceFile.d.ts +7 -1
- package/dist/tools/readWorkspaceFile.js +48 -2
- package/dist/tools/registry.js +217 -19
- package/dist/tools/runVerification.d.ts +16 -0
- package/dist/tools/runVerification.js +32 -0
- package/dist/tools/savePlan.d.ts +1 -0
- package/dist/tools/savePlan.js +38 -7
- package/dist/tools/searchWorkspace.d.ts +19 -0
- package/dist/tools/searchWorkspace.js +205 -0
- package/dist/tools/taskTemplates.js +2 -1
- package/dist/tools/toolCatalog.d.ts +3 -2
- package/dist/tools/toolCatalog.js +32 -5
- package/dist/tools/waitForTask.d.ts +2 -2
- package/dist/tools/waitForTask.js +1 -1
- package/dist/version.d.ts +2 -2
- package/dist/version.js +2 -2
- package/docs/chatgpt-usage.md +101 -0
- package/docs/release-checklist.md +14 -0
- package/docs/release-v0.6.0.md +71 -0
- package/examples/config.example.json +5 -0
- package/examples/openai-tunnel/README.md +11 -4
- package/examples/openai-tunnel/chatgpt-test-prompt.md +18 -14
- package/examples/openai-tunnel/tunnel-client.example.yaml +8 -0
- package/package.json +12 -8
- package/scripts/brand-check.js +58 -12
- package/scripts/control-smoke.js +206 -0
- package/scripts/launchers/Check-PatchWarden-Health.cmd +6 -0
- package/scripts/launchers/Reset-PatchWarden-Tunnel-Key.cmd +6 -0
- package/scripts/launchers/Restart-PatchWarden.cmd +6 -0
- package/scripts/launchers/Start-PatchWarden-Direct-Tunnel.cmd +7 -0
- package/scripts/launchers/Start-PatchWarden-Tunnel.cmd +7 -0
- package/scripts/lifecycle-smoke.js +66 -6
- package/scripts/manage-patchwarden.ps1 +639 -0
- package/scripts/mcp-manifest-check.js +100 -58
- package/scripts/mcp-smoke.js +157 -2
- package/scripts/pack-clean.js +3 -4
- package/scripts/package-manifest-check.js +78 -0
- package/scripts/patchwarden-mcp-direct.cmd +7 -0
- package/scripts/patchwarden-mcp-stdio.cmd +1 -1
- package/scripts/restart-patchwarden.ps1 +5 -4
- package/scripts/start-patchwarden-tunnel.ps1 +261 -30
- package/scripts/tunnel-supervisor-smoke.js +36 -4
- package/scripts/watcher-supervisor-smoke.js +10 -6
- package/src/assessments/agentAssessor.ts +324 -0
- package/src/assessments/assessmentStore.ts +426 -0
- package/src/assessments/confirmCli.ts +29 -0
- package/src/config.ts +178 -4
- package/src/direct/directAudit.ts +400 -0
- package/src/direct/directGuards.ts +279 -0
- package/src/direct/directPatch.ts +258 -0
- package/src/direct/directSessionStore.ts +345 -0
- package/src/direct/directVerification.ts +138 -0
- package/src/doctor.ts +103 -7
- package/src/runner/agentInvocation.ts +125 -0
- package/src/runner/changeCapture.ts +140 -5
- package/src/runner/runTask.ts +61 -43
- package/src/runner/simpleProcess.ts +223 -0
- package/src/runner/watch.ts +18 -2
- package/src/security/commandGuard.ts +46 -4
- package/src/security/riskEngine.ts +160 -0
- package/src/security/runtimeGuard.ts +41 -0
- package/src/smoke-test.ts +1291 -4
- package/src/tools/applyPatch.ts +86 -0
- package/src/tools/auditSession.ts +28 -0
- package/src/tools/auditTask.ts +100 -2
- package/src/tools/createDirectSession.ts +113 -0
- package/src/tools/createTask.ts +405 -55
- package/src/tools/finalizeDirectSession.ts +144 -0
- package/src/tools/getTaskSummary.ts +111 -1
- package/src/tools/healthCheck.ts +23 -6
- package/src/tools/readWorkspaceFile.ts +85 -2
- package/src/tools/registry.ts +242 -19
- package/src/tools/runVerification.ts +58 -0
- package/src/tools/savePlan.ts +57 -7
- package/src/tools/searchWorkspace.ts +275 -0
- package/src/tools/taskTemplates.ts +2 -1
- package/src/tools/toolCatalog.ts +35 -6
- package/src/tools/waitForTask.ts +3 -3
- package/src/version.ts +2 -2
- package/tsconfig.json +18 -17
- package/Check-PatchWarden-Health.cmd +0 -6
- package/Reset-PatchWarden-Tunnel-Key.cmd +0 -6
- package/Restart-PatchWarden.cmd +0 -19
- package/Start-PatchWarden-Tunnel.cmd +0 -7
- package/docs/release-v0.3.0.md +0 -43
- package/docs/release-v0.4.0.md +0 -74
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { basename } from "node:path";
|
|
2
|
+
import { PatchWardenConfig } from "../config.js";
|
|
3
|
+
import { guardAgentCommand, sanitizePromptArg, type AllowedCommand } from "../security/commandGuard.js";
|
|
4
|
+
|
|
5
|
+
export interface AgentInvocation {
|
|
6
|
+
command: string;
|
|
7
|
+
args: string[];
|
|
8
|
+
cwd: string;
|
|
9
|
+
commandLabel: string;
|
|
10
|
+
promptMode: "inline" | "file";
|
|
11
|
+
promptFilePath?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Build agent invocation parameters from config.
|
|
16
|
+
* Replaces {repo}, {prompt}, and {prompt_file} placeholders.
|
|
17
|
+
* runTask and agentAssessor share this function to ensure consistent agent startup.
|
|
18
|
+
*/
|
|
19
|
+
export function buildAgentInvocation(
|
|
20
|
+
agentName: string,
|
|
21
|
+
repoPath: string,
|
|
22
|
+
prompt: string,
|
|
23
|
+
config: PatchWardenConfig,
|
|
24
|
+
promptFilePath?: string
|
|
25
|
+
): AgentInvocation {
|
|
26
|
+
const agentCmd = guardAgentCommand(agentName, config);
|
|
27
|
+
const sanitizedPrompt = sanitizePromptArg(prompt);
|
|
28
|
+
|
|
29
|
+
const hasPromptFilePlaceholder = agentCmd.args.includes("{prompt_file}");
|
|
30
|
+
const promptMode: "inline" | "file" = hasPromptFilePlaceholder && promptFilePath ? "file" : "inline";
|
|
31
|
+
|
|
32
|
+
const resolvedArgs = agentCmd.args.map((arg) => {
|
|
33
|
+
if (arg === "{repo}") return repoPath;
|
|
34
|
+
if (arg === "{prompt}") return sanitizedPrompt;
|
|
35
|
+
if (arg === "{prompt_file}" && promptMode === "file" && promptFilePath) return promptFilePath;
|
|
36
|
+
return arg;
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
command: agentCmd.command,
|
|
41
|
+
args: resolvedArgs,
|
|
42
|
+
cwd: repoPath,
|
|
43
|
+
commandLabel: `${basename(agentCmd.command)} (configured agent command)`,
|
|
44
|
+
promptMode,
|
|
45
|
+
...(promptMode === "file" && promptFilePath ? { promptFilePath } : {}),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Build task execution prompt. Mechanically extracted from runTask.ts.
|
|
51
|
+
*/
|
|
52
|
+
export function buildExecutionPrompt(plan: string, repoPath: string, testCommand: string): string {
|
|
53
|
+
let prompt = `You are executing a pre-written plan in a local repository.
|
|
54
|
+
|
|
55
|
+
## Repository
|
|
56
|
+
${repoPath}
|
|
57
|
+
|
|
58
|
+
## Plan
|
|
59
|
+
${plan}
|
|
60
|
+
|
|
61
|
+
## Instructions
|
|
62
|
+
1. Read the plan carefully.
|
|
63
|
+
2. Implement the changes in this repository only.
|
|
64
|
+
3. Do NOT modify files outside this repository.
|
|
65
|
+
4. Leave repository changes uncommitted for review; remote operations are outside this task.
|
|
66
|
+
5. After implementing, describe what you changed.
|
|
67
|
+
6. Output a summary with what was done, files modified, and issues encountered.
|
|
68
|
+
`;
|
|
69
|
+
if (testCommand) {
|
|
70
|
+
prompt += `\n7. You may run ${testCommand}; PatchWarden will independently run it again for verification.`;
|
|
71
|
+
}
|
|
72
|
+
return prompt;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Build agentAssessor inspect-only prompt.
|
|
77
|
+
*/
|
|
78
|
+
export function buildAssessmentPrompt(goal: string, planContent: string, repoPath: string): string {
|
|
79
|
+
return `You are performing a READ-ONLY risk assessment of a planned task. Do NOT modify any files.
|
|
80
|
+
|
|
81
|
+
## Repository
|
|
82
|
+
${repoPath}
|
|
83
|
+
|
|
84
|
+
## Goal
|
|
85
|
+
${goal}
|
|
86
|
+
|
|
87
|
+
## Plan
|
|
88
|
+
${planContent}
|
|
89
|
+
|
|
90
|
+
## Instructions
|
|
91
|
+
1. Read the plan and goal carefully.
|
|
92
|
+
2. Inspect the repository to understand the scope of changes.
|
|
93
|
+
3. Do NOT create, edit, delete, rename, or generate any files.
|
|
94
|
+
4. Assess the risk level of executing this plan.
|
|
95
|
+
5. Identify affected file paths within the repository.
|
|
96
|
+
6. Check for destructive actions, sensitive file access, or out-of-scope changes.
|
|
97
|
+
7. Output your assessment as JSON after the marker ===ASSESSMENT_JSON=== on a new line.
|
|
98
|
+
|
|
99
|
+
## Required JSON output format
|
|
100
|
+
After your analysis, output exactly this format:
|
|
101
|
+
|
|
102
|
+
===ASSESSMENT_JSON===
|
|
103
|
+
{
|
|
104
|
+
"risk_level": "low" | "medium" | "high",
|
|
105
|
+
"reason_codes": ["short", "descriptive", "codes"],
|
|
106
|
+
"affected_paths": ["relative/path/to/file"],
|
|
107
|
+
"destructive_actions": ["description of any destructive actions"],
|
|
108
|
+
"requires_user_confirm": false,
|
|
109
|
+
"confidence": 0.0,
|
|
110
|
+
"notes": "Brief summary of findings"
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
## Risk level guidelines
|
|
114
|
+
- low: Small source change, no sensitive files, no destructive actions
|
|
115
|
+
- medium: Multiple files, build artifacts, dependency changes, or uncertain scope
|
|
116
|
+
- high: Destructive actions, sensitive file access, out-of-scope changes, or large-scale deletion
|
|
117
|
+
|
|
118
|
+
## Constraints
|
|
119
|
+
- affected_paths must be relative paths within the repository (no absolute paths)
|
|
120
|
+
- reason_codes: max 50 entries, each max 100 chars
|
|
121
|
+
- destructive_actions: max 20 entries, each max 200 chars
|
|
122
|
+
- notes: max 2000 chars
|
|
123
|
+
- confidence: number from 0.0 to 1.0
|
|
124
|
+
`;
|
|
125
|
+
}
|
|
@@ -19,6 +19,8 @@ const SKIP_DIRECTORIES = new Set([".git", ".patchwarden", "node_modules"]);
|
|
|
19
19
|
export interface FileFingerprint {
|
|
20
20
|
size: number;
|
|
21
21
|
sha256: string;
|
|
22
|
+
tracked: boolean;
|
|
23
|
+
ignored: boolean;
|
|
22
24
|
}
|
|
23
25
|
|
|
24
26
|
export interface RepoSnapshot {
|
|
@@ -37,6 +39,33 @@ export interface ChangedFile {
|
|
|
37
39
|
old_path?: string;
|
|
38
40
|
before_sha256: string | null;
|
|
39
41
|
after_sha256: string | null;
|
|
42
|
+
tracked: boolean;
|
|
43
|
+
ignored: boolean;
|
|
44
|
+
kind: "source" | "build_artifact" | "runtime_generated";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface ClassifiedChange {
|
|
48
|
+
path: string;
|
|
49
|
+
change: ChangedFile["change"];
|
|
50
|
+
tracked: boolean;
|
|
51
|
+
ignored: boolean;
|
|
52
|
+
kind: ChangedFile["kind"];
|
|
53
|
+
reason: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface ArtifactHygiene {
|
|
57
|
+
counts: {
|
|
58
|
+
source_changes: number;
|
|
59
|
+
tracked_build_artifacts: number;
|
|
60
|
+
ignored_untracked_artifacts: number;
|
|
61
|
+
runtime_generated_files: number;
|
|
62
|
+
suspicious_changes: number;
|
|
63
|
+
};
|
|
64
|
+
source_changes: ClassifiedChange[];
|
|
65
|
+
tracked_build_artifacts: ClassifiedChange[];
|
|
66
|
+
ignored_untracked_artifacts: ClassifiedChange[];
|
|
67
|
+
runtime_generated_files: ClassifiedChange[];
|
|
68
|
+
suspicious_changes: ClassifiedChange[];
|
|
40
69
|
}
|
|
41
70
|
|
|
42
71
|
export interface ChangeArtifacts {
|
|
@@ -57,6 +86,7 @@ export interface ChangeArtifacts {
|
|
|
57
86
|
workspace_dirty_after: boolean;
|
|
58
87
|
patch_mode: "textual" | "no_changes" | "hash_only";
|
|
59
88
|
unavailable_reason: string | null;
|
|
89
|
+
artifact_hygiene: ArtifactHygiene;
|
|
60
90
|
}
|
|
61
91
|
|
|
62
92
|
export function captureRepoSnapshot(repoPath: string): RepoSnapshot {
|
|
@@ -65,11 +95,23 @@ export function captureRepoSnapshot(repoPath: string): RepoSnapshot {
|
|
|
65
95
|
let head: string | null = null;
|
|
66
96
|
let status = "";
|
|
67
97
|
let paths: string[] = [];
|
|
98
|
+
const trackedPaths = new Set<string>();
|
|
99
|
+
const ignoredPaths = new Set<string>();
|
|
68
100
|
|
|
69
101
|
if (isGit) {
|
|
70
102
|
const headResult = runGit(repoPath, ["rev-parse", "HEAD"]);
|
|
71
103
|
if (headResult.status === 0) head = headResult.stdout.trim() || null;
|
|
72
104
|
status = runGit(repoPath, ["status", "--porcelain=v1", "-uall"]).stdout.trimEnd();
|
|
105
|
+
const tracked = runGit(repoPath, ["ls-files", "-z"]);
|
|
106
|
+
if (tracked.status === 0) {
|
|
107
|
+
for (const path of tracked.stdout.split("\0").filter(Boolean)) trackedPaths.add(normalizePath(path));
|
|
108
|
+
}
|
|
109
|
+
const ignored = runGit(repoPath, ["ls-files", "-o", "-i", "--exclude-standard", "-z"]);
|
|
110
|
+
if (ignored.status === 0) {
|
|
111
|
+
for (const path of ignored.stdout.split("\0").filter(Boolean)) ignoredPaths.add(normalizePath(path));
|
|
112
|
+
} else {
|
|
113
|
+
warnings.push("git ignored-file discovery failed; ignored classification may be incomplete");
|
|
114
|
+
}
|
|
73
115
|
const listed = runGit(repoPath, ["ls-files", "-co", "--exclude-standard", "-z"]);
|
|
74
116
|
if (listed.status === 0) {
|
|
75
117
|
paths = [...new Set([
|
|
@@ -92,7 +134,7 @@ export function captureRepoSnapshot(repoPath: string): RepoSnapshot {
|
|
|
92
134
|
|
|
93
135
|
const files: Record<string, FileFingerprint> = {};
|
|
94
136
|
for (const inputPath of paths.sort()) {
|
|
95
|
-
const normalized = inputPath
|
|
137
|
+
const normalized = normalizePath(inputPath);
|
|
96
138
|
if (!normalized || normalized.startsWith(".patchwarden/") || isSensitivePath(normalized)) continue;
|
|
97
139
|
const absolutePath = resolve(repoPath, inputPath);
|
|
98
140
|
try {
|
|
@@ -101,7 +143,12 @@ export function captureRepoSnapshot(repoPath: string): RepoSnapshot {
|
|
|
101
143
|
const sha256 = stat.size <= MAX_HASH_BYTES
|
|
102
144
|
? createHash("sha256").update(readFileSync(absolutePath)).digest("hex")
|
|
103
145
|
: `large-file:${stat.size}:${Math.trunc(stat.mtimeMs)}`;
|
|
104
|
-
files[normalized] = {
|
|
146
|
+
files[normalized] = {
|
|
147
|
+
size: stat.size,
|
|
148
|
+
sha256,
|
|
149
|
+
tracked: trackedPaths.has(normalized),
|
|
150
|
+
ignored: !trackedPaths.has(normalized) && ignoredPaths.has(normalized),
|
|
151
|
+
};
|
|
105
152
|
} catch {
|
|
106
153
|
warnings.push(`could not fingerprint: ${normalized}`);
|
|
107
154
|
}
|
|
@@ -128,6 +175,7 @@ export function buildChangeArtifacts(
|
|
|
128
175
|
after: RepoSnapshot
|
|
129
176
|
): ChangeArtifacts {
|
|
130
177
|
const changedFiles = compareSnapshots(before, after);
|
|
178
|
+
const artifactHygiene = classifyArtifactHygiene(changedFiles);
|
|
131
179
|
const sections: string[] = [];
|
|
132
180
|
const scopedPaths = [...new Set(changedFiles.flatMap((file) => file.old_path ? [file.old_path, file.path] : [file.path]))];
|
|
133
181
|
|
|
@@ -179,6 +227,7 @@ export function buildChangeArtifacts(
|
|
|
179
227
|
? "Git could not produce a textual patch for the changed files; hash evidence remains available."
|
|
180
228
|
: "Repository is not a Git worktree; only bounded hash evidence is available.")
|
|
181
229
|
: null,
|
|
230
|
+
artifact_hygiene: artifactHygiene,
|
|
182
231
|
};
|
|
183
232
|
}
|
|
184
233
|
|
|
@@ -232,11 +281,11 @@ export function compareSnapshots(before: RepoSnapshot, after: RepoSnapshot): Cha
|
|
|
232
281
|
const left = before.files[path];
|
|
233
282
|
const right = after.files[path];
|
|
234
283
|
if (!left && right) {
|
|
235
|
-
changed.push(
|
|
284
|
+
changed.push(classifyChangedFile(path, "added", null, right));
|
|
236
285
|
} else if (left && !right) {
|
|
237
|
-
changed.push(
|
|
286
|
+
changed.push(classifyChangedFile(path, "deleted", left, null));
|
|
238
287
|
} else if (left.sha256 !== right.sha256) {
|
|
239
|
-
changed.push(
|
|
288
|
+
changed.push(classifyChangedFile(path, "modified", left, right));
|
|
240
289
|
}
|
|
241
290
|
}
|
|
242
291
|
const deletedByHash = new Map<string, ChangedFile[]>();
|
|
@@ -260,6 +309,9 @@ export function compareSnapshots(before: RepoSnapshot, after: RepoSnapshot): Cha
|
|
|
260
309
|
change: "renamed",
|
|
261
310
|
before_sha256: source.before_sha256,
|
|
262
311
|
after_sha256: file.after_sha256,
|
|
312
|
+
tracked: file.tracked || source.tracked,
|
|
313
|
+
ignored: file.ignored,
|
|
314
|
+
kind: classifyPathKind(file.path),
|
|
263
315
|
});
|
|
264
316
|
}
|
|
265
317
|
|
|
@@ -267,6 +319,89 @@ export function compareSnapshots(before: RepoSnapshot, after: RepoSnapshot): Cha
|
|
|
267
319
|
.sort((left, right) => left.path.localeCompare(right.path));
|
|
268
320
|
}
|
|
269
321
|
|
|
322
|
+
export function emptyArtifactHygiene(): ArtifactHygiene {
|
|
323
|
+
return {
|
|
324
|
+
counts: {
|
|
325
|
+
source_changes: 0,
|
|
326
|
+
tracked_build_artifacts: 0,
|
|
327
|
+
ignored_untracked_artifacts: 0,
|
|
328
|
+
runtime_generated_files: 0,
|
|
329
|
+
suspicious_changes: 0,
|
|
330
|
+
},
|
|
331
|
+
source_changes: [],
|
|
332
|
+
tracked_build_artifacts: [],
|
|
333
|
+
ignored_untracked_artifacts: [],
|
|
334
|
+
runtime_generated_files: [],
|
|
335
|
+
suspicious_changes: [],
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function classifyChangedFile(
|
|
340
|
+
path: string,
|
|
341
|
+
change: ChangedFile["change"],
|
|
342
|
+
before: FileFingerprint | null,
|
|
343
|
+
after: FileFingerprint | null
|
|
344
|
+
): ChangedFile {
|
|
345
|
+
return {
|
|
346
|
+
path,
|
|
347
|
+
change,
|
|
348
|
+
before_sha256: before?.sha256 || null,
|
|
349
|
+
after_sha256: after?.sha256 || null,
|
|
350
|
+
tracked: Boolean(after?.tracked || before?.tracked),
|
|
351
|
+
ignored: Boolean(after?.ignored ?? before?.ignored),
|
|
352
|
+
kind: classifyPathKind(path),
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function classifyArtifactHygiene(changes: ChangedFile[]): ArtifactHygiene {
|
|
357
|
+
const hygiene = emptyArtifactHygiene();
|
|
358
|
+
const entries = changes.map((change): ClassifiedChange => ({
|
|
359
|
+
path: change.path,
|
|
360
|
+
change: change.change,
|
|
361
|
+
tracked: change.tracked,
|
|
362
|
+
ignored: change.ignored,
|
|
363
|
+
kind: change.kind,
|
|
364
|
+
reason: classificationReason(change),
|
|
365
|
+
}));
|
|
366
|
+
hygiene.source_changes = entries.filter((entry) => entry.kind === "source" && !entry.ignored);
|
|
367
|
+
hygiene.tracked_build_artifacts = entries.filter((entry) => entry.kind === "build_artifact" && entry.tracked);
|
|
368
|
+
hygiene.ignored_untracked_artifacts = entries.filter((entry) => entry.ignored && !entry.tracked);
|
|
369
|
+
hygiene.runtime_generated_files = entries.filter((entry) => entry.kind === "runtime_generated");
|
|
370
|
+
hygiene.suspicious_changes = entries.filter((entry) =>
|
|
371
|
+
(entry.kind === "build_artifact" || entry.kind === "runtime_generated") && !entry.ignored
|
|
372
|
+
);
|
|
373
|
+
hygiene.counts = {
|
|
374
|
+
source_changes: hygiene.source_changes.length,
|
|
375
|
+
tracked_build_artifacts: hygiene.tracked_build_artifacts.length,
|
|
376
|
+
ignored_untracked_artifacts: hygiene.ignored_untracked_artifacts.length,
|
|
377
|
+
runtime_generated_files: hygiene.runtime_generated_files.length,
|
|
378
|
+
suspicious_changes: hygiene.suspicious_changes.length,
|
|
379
|
+
};
|
|
380
|
+
return hygiene;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function classifyPathKind(path: string): ChangedFile["kind"] {
|
|
384
|
+
const normalized = normalizePath(path).toLowerCase();
|
|
385
|
+
const parts = normalized.split("/");
|
|
386
|
+
const basename = parts[parts.length - 1] || "";
|
|
387
|
+
if (basename === "sync-store.json" || /\.(log|tmp|temp|pid)$/.test(basename)) return "runtime_generated";
|
|
388
|
+
if (parts.some((part) => ["dist", "release", "build", "out", "coverage", ".next"].includes(part))) return "build_artifact";
|
|
389
|
+
if (/\.(exe|dll|pak|bin|zip|tgz|tar\.gz)$/.test(basename)) return "build_artifact";
|
|
390
|
+
return "source";
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function classificationReason(change: ChangedFile): string {
|
|
394
|
+
if (change.ignored) return "untracked path is ignored by repository Git rules";
|
|
395
|
+
if (change.kind === "build_artifact" && change.tracked) return "artifact-like path is tracked by Git and requires review";
|
|
396
|
+
if (change.kind === "build_artifact") return "artifact-like path is not ignored and requires review";
|
|
397
|
+
if (change.kind === "runtime_generated") return "runtime-generated path is not ignored and requires review";
|
|
398
|
+
return change.tracked ? "tracked source change" : "untracked source change";
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function normalizePath(value: string): string {
|
|
402
|
+
return value.replace(/\\/g, "/");
|
|
403
|
+
}
|
|
404
|
+
|
|
270
405
|
function walkWorkspace(root: string): string[] {
|
|
271
406
|
const result: string[] = [];
|
|
272
407
|
const visit = (directory: string) => {
|
package/src/runner/runTask.ts
CHANGED
|
@@ -6,21 +6,22 @@ import {
|
|
|
6
6
|
writeFileSync,
|
|
7
7
|
type WriteStream,
|
|
8
8
|
} from "node:fs";
|
|
9
|
-
import {
|
|
9
|
+
import { isAbsolute, join, relative, resolve } from "node:path";
|
|
10
10
|
import { getTasksDir, getPlansDir, getConfig, resolveWorkspaceRoot } from "../config.js";
|
|
11
11
|
import { guardPath, guardWorkspacePath } from "../security/pathGuard.js";
|
|
12
12
|
import {
|
|
13
|
-
guardAgentCommand,
|
|
14
13
|
guardTestCommand,
|
|
15
|
-
sanitizePromptArg,
|
|
16
14
|
} from "../security/commandGuard.js";
|
|
17
15
|
import { writeTaskProgress } from "../taskProgress.js";
|
|
18
16
|
import { writeTaskRuntime } from "../taskRuntime.js";
|
|
17
|
+
import { validateAssessmentFreshness } from "../assessments/assessmentStore.js";
|
|
18
|
+
import { buildAgentInvocation, buildExecutionPrompt } from "./agentInvocation.js";
|
|
19
19
|
import type { TaskPhase, TaskStatus } from "../tools/createTask.js";
|
|
20
20
|
import {
|
|
21
21
|
buildChangeArtifacts,
|
|
22
22
|
captureRepoSnapshot,
|
|
23
23
|
compareSnapshots,
|
|
24
|
+
emptyArtifactHygiene,
|
|
24
25
|
writeSnapshot,
|
|
25
26
|
type ChangedFile,
|
|
26
27
|
type ChangeArtifacts,
|
|
@@ -94,7 +95,6 @@ export async function runTask(taskId: string): Promise<TaskRunResult> {
|
|
|
94
95
|
let verifyCommands: string[];
|
|
95
96
|
let timeoutSeconds: number;
|
|
96
97
|
try {
|
|
97
|
-
verifyCommands = normalizeVerifyCommands(initialStatus.verify_commands, testCommand, config);
|
|
98
98
|
timeoutSeconds = normalizeTimeout(initialStatus.timeout_seconds, config);
|
|
99
99
|
} catch (error) {
|
|
100
100
|
return failBeforeExecution(taskId, taskDir, errorMessage(error));
|
|
@@ -109,6 +109,28 @@ export async function runTask(taskId: string): Promise<TaskRunResult> {
|
|
|
109
109
|
const message = `repo_path validation failed: ${errorMessage(error)}`;
|
|
110
110
|
return failBeforeExecution(taskId, taskDir, message);
|
|
111
111
|
}
|
|
112
|
+
try {
|
|
113
|
+
verifyCommands = normalizeVerifyCommands(initialStatus.verify_commands, testCommand, config, repoPath);
|
|
114
|
+
} catch (error) {
|
|
115
|
+
const message = `verification metadata validation failed: ${errorMessage(error)}`;
|
|
116
|
+
return failBeforeExecution(taskId, taskDir, message);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ── Assessment freshness revalidation before execution ──
|
|
120
|
+
const assessmentId = String(initialStatus.assessment_id || "");
|
|
121
|
+
if (assessmentId) {
|
|
122
|
+
try {
|
|
123
|
+
const preExecSnapshot = captureRepoSnapshot(repoPath);
|
|
124
|
+
const validation = validateAssessmentFreshness(assessmentId, preExecSnapshot);
|
|
125
|
+
if (!validation.valid) {
|
|
126
|
+
const message = `assessment validation failed: ${validation.failure_reason}. Re-run create_task with execution_mode=assess_only to get a fresh assessment_id.`;
|
|
127
|
+
return failBeforeExecution(taskId, taskDir, message);
|
|
128
|
+
}
|
|
129
|
+
} catch (error) {
|
|
130
|
+
const message = `assessment validation error: ${errorMessage(error)}`;
|
|
131
|
+
return failBeforeExecution(taskId, taskDir, message);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
112
134
|
|
|
113
135
|
updateStatus(taskDir, {
|
|
114
136
|
status: "running",
|
|
@@ -143,19 +165,14 @@ export async function runTask(taskId: string): Promise<TaskRunResult> {
|
|
|
143
165
|
const planFile = resolve(plansDir, planId, "plan.md");
|
|
144
166
|
if (!existsSync(planFile)) throw new Error(`Plan not found: "${planId}". Save the plan first.`);
|
|
145
167
|
const planContent = readFileSync(planFile, "utf-8");
|
|
146
|
-
const
|
|
147
|
-
const
|
|
148
|
-
const
|
|
149
|
-
if (arg === "{repo}") return repoPath;
|
|
150
|
-
if (arg === "{prompt}") return prompt;
|
|
151
|
-
return arg;
|
|
152
|
-
});
|
|
153
|
-
const agentCommandLabel = `${basename(agentCmd.command)} (configured agent command)`;
|
|
168
|
+
const prompt = buildExecutionPrompt(planContent, repoPath, testCommand);
|
|
169
|
+
const invocation = buildAgentInvocation(agentName, repoPath, prompt, config);
|
|
170
|
+
const agentCommandLabel = invocation.commandLabel;
|
|
154
171
|
|
|
155
172
|
setTaskPhase(taskDir, "executing_agent", agentCommandLabel);
|
|
156
173
|
agentResult = await runManagedProcess({
|
|
157
|
-
command:
|
|
158
|
-
args:
|
|
174
|
+
command: invocation.command,
|
|
175
|
+
args: invocation.args,
|
|
159
176
|
cwd: repoPath,
|
|
160
177
|
taskDir,
|
|
161
178
|
statusFile,
|
|
@@ -232,6 +249,7 @@ export async function runTask(taskId: string): Promise<TaskRunResult> {
|
|
|
232
249
|
workspace_dirty_after: beforeSnapshot.workspace_dirty,
|
|
233
250
|
patch_mode: "hash_only",
|
|
234
251
|
unavailable_reason: `Change capture failed: ${errorMessage(error)}`,
|
|
252
|
+
artifact_hygiene: emptyArtifactHygiene(),
|
|
235
253
|
};
|
|
236
254
|
finalError ||= `Change capture failed: ${errorMessage(error)}`;
|
|
237
255
|
finalStatus = "failed";
|
|
@@ -326,6 +344,7 @@ export async function runTask(taskId: string): Promise<TaskRunResult> {
|
|
|
326
344
|
change_policy: changePolicy,
|
|
327
345
|
summary: finalError || "Agent execution and configured verification completed successfully.",
|
|
328
346
|
changed_files: changes.changed_files,
|
|
347
|
+
artifact_hygiene: changes.artifact_hygiene,
|
|
329
348
|
out_of_scope_changes: outOfScopeChanges,
|
|
330
349
|
verify_status: verifyJson.status,
|
|
331
350
|
verify_commands: verifyJson.commands,
|
|
@@ -365,6 +384,7 @@ export async function runTask(taskId: string): Promise<TaskRunResult> {
|
|
|
365
384
|
finished_at: finishedAt,
|
|
366
385
|
error: finalError,
|
|
367
386
|
changed_files: changes.changed_files.map(({ path, change }) => ({ path, change })),
|
|
387
|
+
artifact_hygiene_counts: changes.artifact_hygiene.counts,
|
|
368
388
|
out_of_scope_changes: outOfScopeChanges,
|
|
369
389
|
verify_status: verifyJson.status,
|
|
370
390
|
verify_commands: verifyCommands,
|
|
@@ -483,6 +503,8 @@ async function runManagedProcess(options: {
|
|
|
483
503
|
let spawnError: string | null = null;
|
|
484
504
|
let terminationReason: TerminationReason = null;
|
|
485
505
|
let forceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
506
|
+
let fallbackTimer: ReturnType<typeof setTimeout> | null = null;
|
|
507
|
+
let childExitFinish: ((code: number | null) => void) | null = null;
|
|
486
508
|
let terminationStarted = false;
|
|
487
509
|
|
|
488
510
|
const heartbeat = () => {
|
|
@@ -520,6 +542,15 @@ async function runManagedProcess(options: {
|
|
|
520
542
|
gracefulKill(child);
|
|
521
543
|
forceTimer = setTimeout(() => forceKill(child), GRACEFUL_KILL_MS);
|
|
522
544
|
}
|
|
545
|
+
// Fallback: if neither close nor exit fires within 10s after taskkill,
|
|
546
|
+
// force-resolve to prevent the runner from hanging indefinitely.
|
|
547
|
+
// Only starts AFTER termination is requested — normal long tasks are unaffected.
|
|
548
|
+
fallbackTimer = setTimeout(() => {
|
|
549
|
+
try { forceKill(child); } catch {}
|
|
550
|
+
try { child.kill("SIGKILL"); } catch {}
|
|
551
|
+
// Resolve the exit promise via the shared finish handle
|
|
552
|
+
if (childExitFinish) childExitFinish(null);
|
|
553
|
+
}, 10000);
|
|
523
554
|
};
|
|
524
555
|
|
|
525
556
|
child.stdout?.on("data", (chunk: Buffer) => {
|
|
@@ -540,9 +571,18 @@ async function runManagedProcess(options: {
|
|
|
540
571
|
const finish = (code: number | null) => {
|
|
541
572
|
if (settled) return;
|
|
542
573
|
settled = true;
|
|
574
|
+
if (fallbackTimer) clearTimeout(fallbackTimer);
|
|
575
|
+
childExitFinish = null;
|
|
543
576
|
resolveExit(code);
|
|
544
577
|
};
|
|
578
|
+
childExitFinish = finish;
|
|
545
579
|
child.once("close", (code) => finish(code));
|
|
580
|
+
child.once("exit", (code) => {
|
|
581
|
+
// On Windows, "close" can lag behind "exit" when stdio pipes drain
|
|
582
|
+
// after taskkill. Resolve on exit to avoid hanging the runner, but
|
|
583
|
+
// still let "close" fire if it arrives first.
|
|
584
|
+
finish(code);
|
|
585
|
+
});
|
|
546
586
|
child.once("error", (error) => {
|
|
547
587
|
spawnError = error.message;
|
|
548
588
|
finish(null);
|
|
@@ -551,8 +591,8 @@ async function runManagedProcess(options: {
|
|
|
551
591
|
|
|
552
592
|
clearInterval(heartbeatTimer);
|
|
553
593
|
if (forceTimer) clearTimeout(forceTimer);
|
|
554
|
-
stdoutStream?.
|
|
555
|
-
stderrStream?.
|
|
594
|
+
stdoutStream?.destroy();
|
|
595
|
+
stderrStream?.destroy();
|
|
556
596
|
writeTaskRuntime(options.taskDir, {
|
|
557
597
|
phase: options.phase,
|
|
558
598
|
last_heartbeat_at: new Date().toISOString(),
|
|
@@ -572,7 +612,7 @@ async function runTrustedTestCommand(
|
|
|
572
612
|
deadlineMs: number
|
|
573
613
|
): Promise<TestExecutionResult> {
|
|
574
614
|
const config = getConfig();
|
|
575
|
-
const trusted = guardTestCommand(testCommand, config);
|
|
615
|
+
const trusted = guardTestCommand(testCommand, config, repoPath);
|
|
576
616
|
const startedAtMs = Date.now();
|
|
577
617
|
const startedAt = new Date(startedAtMs).toISOString();
|
|
578
618
|
const parts = trusted.split(/\s+/).filter(Boolean);
|
|
@@ -606,29 +646,6 @@ async function runTrustedTestCommand(
|
|
|
606
646
|
};
|
|
607
647
|
}
|
|
608
648
|
|
|
609
|
-
function buildExecutionPrompt(plan: string, repoPath: string, testCommand: string): string {
|
|
610
|
-
let prompt = `You are executing a pre-written plan in a local repository.
|
|
611
|
-
|
|
612
|
-
## Repository
|
|
613
|
-
${repoPath}
|
|
614
|
-
|
|
615
|
-
## Plan
|
|
616
|
-
${plan}
|
|
617
|
-
|
|
618
|
-
## Instructions
|
|
619
|
-
1. Read the plan carefully.
|
|
620
|
-
2. Implement the changes in this repository only.
|
|
621
|
-
3. Do NOT modify files outside this repository.
|
|
622
|
-
4. Do NOT commit or push changes.
|
|
623
|
-
5. After implementing, describe what you changed.
|
|
624
|
-
6. Output a summary with what was done, files modified, and issues encountered.
|
|
625
|
-
`;
|
|
626
|
-
if (testCommand) {
|
|
627
|
-
prompt += `\n7. You may run ${testCommand}; PatchWarden will independently run it again for verification.`;
|
|
628
|
-
}
|
|
629
|
-
return prompt;
|
|
630
|
-
}
|
|
631
|
-
|
|
632
649
|
function buildTestLog(result: TestExecutionResult): string {
|
|
633
650
|
if (result.skipped) return `${result.command || "(no test command)"}\nExit code: not run\n${result.stderr}\n`;
|
|
634
651
|
return [
|
|
@@ -777,14 +794,15 @@ function buildVerifyLog(commands: VerifyCommandRecord[]): string {
|
|
|
777
794
|
function normalizeVerifyCommands(
|
|
778
795
|
value: unknown,
|
|
779
796
|
legacyTestCommand: string,
|
|
780
|
-
config: ReturnType<typeof getConfig
|
|
797
|
+
config: ReturnType<typeof getConfig>,
|
|
798
|
+
repoPath: string
|
|
781
799
|
): string[] {
|
|
782
800
|
if (value !== undefined && !Array.isArray(value)) {
|
|
783
801
|
throw new Error("Invalid task verify_commands metadata; expected an array.");
|
|
784
802
|
}
|
|
785
803
|
return [...new Set([
|
|
786
|
-
...((value as unknown[] | undefined) || []).map((command) => guardTestCommand(String(command), config)),
|
|
787
|
-
...(legacyTestCommand ? [guardTestCommand(legacyTestCommand, config)] : []),
|
|
804
|
+
...((value as unknown[] | undefined) || []).map((command) => guardTestCommand(String(command), config, repoPath)),
|
|
805
|
+
...(legacyTestCommand ? [guardTestCommand(legacyTestCommand, config, repoPath)] : []),
|
|
788
806
|
])];
|
|
789
807
|
}
|
|
790
808
|
|