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.
- package/README.en.md +41 -7
- package/README.md +36 -9
- package/dist/controlCenter.js +197 -1
- package/dist/direct/directSessionStore.d.ts +2 -0
- package/dist/direct/directVerification.js +7 -0
- package/dist/doctor.js +1 -1
- package/dist/policy/projectPolicy.d.ts +55 -0
- package/dist/policy/projectPolicy.js +286 -0
- package/dist/smoke-test.js +8 -8
- package/dist/test/unit/evidence-pack.test.d.ts +1 -0
- package/dist/test/unit/evidence-pack.test.js +130 -0
- package/dist/test/unit/project-policy-release-mode.test.d.ts +1 -0
- package/dist/test/unit/project-policy-release-mode.test.js +125 -0
- package/dist/test/unit/run-task-loop.test.d.ts +1 -0
- package/dist/test/unit/run-task-loop.test.js +380 -0
- package/dist/test/unit/schema-drift-check.test.js +10 -9
- package/dist/tools/evidencePack.d.ts +39 -0
- package/dist/tools/evidencePack.js +168 -0
- package/dist/tools/recommendAgentForTask.d.ts +19 -0
- package/dist/tools/recommendAgentForTask.js +56 -0
- package/dist/tools/registry.js +376 -2
- package/dist/tools/releaseMode.d.ts +50 -0
- package/dist/tools/releaseMode.js +370 -0
- package/dist/tools/runDirectVerificationBundle.d.ts +26 -0
- package/dist/tools/runDirectVerificationBundle.js +64 -0
- package/dist/tools/runTaskLoop.d.ts +57 -0
- package/dist/tools/runTaskLoop.js +417 -0
- package/dist/tools/runVerification.d.ts +4 -0
- package/dist/tools/runVerification.js +4 -0
- package/dist/tools/safeViews.d.ts +6 -0
- package/dist/tools/safeViews.js +2 -0
- package/dist/tools/taskLineage.d.ts +91 -0
- package/dist/tools/taskLineage.js +175 -0
- package/dist/tools/toolCatalog.d.ts +2 -2
- package/dist/tools/toolCatalog.js +6 -0
- package/dist/tools/toolRegistry.js +110 -0
- package/dist/version.d.ts +2 -2
- package/dist/version.js +2 -2
- package/docs/chatgpt-usage.md +31 -0
- package/docs/control-center/README.md +9 -0
- package/package.json +2 -2
- package/scripts/checks/control-center-smoke.js +87 -0
- package/scripts/checks/control-smoke.js +2 -2
- package/scripts/checks/mcp-manifest-check.js +12 -0
- package/scripts/checks/mcp-smoke.js +31 -7
- package/scripts/checks/watcher-supervisor-smoke.js +1 -1
- package/src/controlCenter.ts +198 -1
- package/src/direct/directSessionStore.ts +2 -0
- package/src/direct/directVerification.ts +7 -0
- package/src/doctor.ts +1 -1
- package/src/policy/projectPolicy.ts +344 -0
- package/src/smoke-test.ts +5 -5
- package/src/test/unit/evidence-pack.test.ts +142 -0
- package/src/test/unit/project-policy-release-mode.test.ts +156 -0
- package/src/test/unit/run-task-loop.test.ts +425 -0
- package/src/test/unit/schema-drift-check.test.ts +11 -9
- package/src/tools/evidencePack.ts +205 -0
- package/src/tools/listWorkspace.ts +71 -71
- package/src/tools/recommendAgentForTask.ts +79 -0
- package/src/tools/registry.ts +405 -2
- package/src/tools/releaseMode.ts +450 -0
- package/src/tools/runDirectVerificationBundle.ts +98 -0
- package/src/tools/runTaskLoop.ts +526 -0
- package/src/tools/runVerification.ts +8 -0
- package/src/tools/safeViews.ts +2 -0
- package/src/tools/taskLineage.ts +300 -0
- package/src/tools/toolCatalog.ts +6 -0
- package/src/tools/toolRegistry.ts +110 -0
- package/src/version.ts +2 -2
- package/ui/pages/dashboard.html +143 -2
|
@@ -0,0 +1,425 @@
|
|
|
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, normalize } from "node:path";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { reloadConfig } from "../../config.js";
|
|
7
|
+
import { runTaskLoopWithDeps } from "../../tools/runTaskLoop.js";
|
|
8
|
+
import { createLineageId, getTaskLineage, writeTaskLineage } from "../../tools/taskLineage.js";
|
|
9
|
+
|
|
10
|
+
let tempDir: string;
|
|
11
|
+
let prevConfigEnv: string | undefined;
|
|
12
|
+
|
|
13
|
+
function writeConfig() {
|
|
14
|
+
const configPath = join(tempDir, "patchwarden.config.json");
|
|
15
|
+
writeFileSync(
|
|
16
|
+
configPath,
|
|
17
|
+
JSON.stringify({
|
|
18
|
+
workspaceRoot: tempDir,
|
|
19
|
+
tasksDir: ".patchwarden/tasks",
|
|
20
|
+
plansDir: ".patchwarden/plans",
|
|
21
|
+
assessmentsDir: ".patchwarden/assessments",
|
|
22
|
+
agents: {
|
|
23
|
+
fake: { command: "fake-agent", args: [] },
|
|
24
|
+
},
|
|
25
|
+
allowedTestCommands: ["npm test", "npm run build"],
|
|
26
|
+
directAllowedCommands: ["npm test", "npm run build"],
|
|
27
|
+
defaultTaskTimeoutSeconds: 30,
|
|
28
|
+
maxTaskTimeoutSeconds: 120,
|
|
29
|
+
enableDirectProfile: true,
|
|
30
|
+
}),
|
|
31
|
+
"utf-8"
|
|
32
|
+
);
|
|
33
|
+
prevConfigEnv = process.env.PATCHWARDEN_CONFIG;
|
|
34
|
+
process.env.PATCHWARDEN_CONFIG = configPath;
|
|
35
|
+
reloadConfig();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function depsFor(options: {
|
|
39
|
+
decisions?: string[];
|
|
40
|
+
tasks?: string[];
|
|
41
|
+
statuses?: Record<string, string>;
|
|
42
|
+
verifications?: Record<string, string>;
|
|
43
|
+
audits?: Record<string, { verdict: string; fail?: string[]; warn?: string[] }>;
|
|
44
|
+
directEnabled?: boolean;
|
|
45
|
+
directBundleStatus?: "passed" | "failed";
|
|
46
|
+
directAuditDecision?: "pass" | "warn" | "fail";
|
|
47
|
+
}) {
|
|
48
|
+
const decisions = [...(options.decisions || ["allow"])];
|
|
49
|
+
const tasks = [...(options.tasks || ["task-main"])];
|
|
50
|
+
const calls: string[] = [];
|
|
51
|
+
const deps = {
|
|
52
|
+
createTask: ((input: any) => {
|
|
53
|
+
calls.push(input.execution_mode === "assess_only" ? "assess" : "execute");
|
|
54
|
+
if (input.execution_mode === "assess_only") {
|
|
55
|
+
const decision = decisions.shift() || "allow";
|
|
56
|
+
return {
|
|
57
|
+
assessment_id: `assessment-${calls.length}`,
|
|
58
|
+
decision,
|
|
59
|
+
reason_codes: decision === "allow" ? ["repo_scoped"] : ["release_template_needs_confirm"],
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
return { task_id: tasks.shift() || `task-${calls.length}`, status: "pending" };
|
|
63
|
+
}) as any,
|
|
64
|
+
waitForTask: (async (taskId: string) => ({
|
|
65
|
+
task_id: taskId,
|
|
66
|
+
status: options.statuses?.[taskId] || "done_by_agent",
|
|
67
|
+
phase: options.statuses?.[taskId] || "done_by_agent",
|
|
68
|
+
terminal: true,
|
|
69
|
+
continuation_required: false,
|
|
70
|
+
next_action: "safe_audit",
|
|
71
|
+
})) as any,
|
|
72
|
+
safeResult: ((taskId: string) => ({
|
|
73
|
+
task_id: taskId,
|
|
74
|
+
status: options.statuses?.[taskId] || "done_by_agent",
|
|
75
|
+
terminal: true,
|
|
76
|
+
verification: { status: options.verifications?.[taskId] || "passed" },
|
|
77
|
+
next_action: "audit_or_accept",
|
|
78
|
+
})) as any,
|
|
79
|
+
safeTestSummary: ((taskId: string) => ({
|
|
80
|
+
task_id: taskId,
|
|
81
|
+
status: options.verifications?.[taskId] || "passed",
|
|
82
|
+
commands: [{ command: "npm test", status: options.verifications?.[taskId] || "passed", exit_code: 0 }],
|
|
83
|
+
})) as any,
|
|
84
|
+
safeAudit: ((taskId: string) => {
|
|
85
|
+
const audit = options.audits?.[taskId] || { verdict: "pass" };
|
|
86
|
+
return {
|
|
87
|
+
task_id: taskId,
|
|
88
|
+
verdict: audit.verdict,
|
|
89
|
+
fail_checks: (audit.fail || []).map((name) => ({ name, result: "fail" })),
|
|
90
|
+
warn_checks: (audit.warn || []).map((name) => ({ name, result: "warn" })),
|
|
91
|
+
recommended_next_actions: ["accept"],
|
|
92
|
+
};
|
|
93
|
+
}) as any,
|
|
94
|
+
createDirectSession: ((input: any) => ({
|
|
95
|
+
session_id: "direct-test",
|
|
96
|
+
repo_path: input.repo_path,
|
|
97
|
+
resolved_repo_path: tempDir,
|
|
98
|
+
workspace_clean: true,
|
|
99
|
+
allowed_commands: ["npm test", "npm run build"],
|
|
100
|
+
expires_at: "2026-07-04T13:00:00.000Z",
|
|
101
|
+
next_action: "run_verification",
|
|
102
|
+
})) as any,
|
|
103
|
+
runDirectVerificationBundle: (async () => ({
|
|
104
|
+
session_id: "direct-test",
|
|
105
|
+
status: options.directBundleStatus || "passed",
|
|
106
|
+
command_count: 1,
|
|
107
|
+
passed_commands: (options.directBundleStatus || "passed") === "passed" ? 1 : 0,
|
|
108
|
+
failed_commands: (options.directBundleStatus || "passed") === "passed" ? 0 : 1,
|
|
109
|
+
timed_out_commands: 0,
|
|
110
|
+
commands: [{
|
|
111
|
+
command: "npm test",
|
|
112
|
+
passed: (options.directBundleStatus || "passed") === "passed",
|
|
113
|
+
exit_code: (options.directBundleStatus || "passed") === "passed" ? 0 : 1,
|
|
114
|
+
timed_out: false,
|
|
115
|
+
redacted: false,
|
|
116
|
+
redaction_categories: [],
|
|
117
|
+
started_at: "2026-07-04T12:00:00.000Z",
|
|
118
|
+
finished_at: "2026-07-04T12:00:01.000Z",
|
|
119
|
+
}],
|
|
120
|
+
large_logs_omitted: true,
|
|
121
|
+
next_action: "safe_finalize_direct_session",
|
|
122
|
+
})) as any,
|
|
123
|
+
safeFinalizeDirectSession: (() => ({
|
|
124
|
+
session_id: "direct-test",
|
|
125
|
+
finalized: true,
|
|
126
|
+
changed_files_total: 0,
|
|
127
|
+
next_action: "safe_audit_direct_session",
|
|
128
|
+
})) as any,
|
|
129
|
+
safeAuditDirectSession: (() => ({
|
|
130
|
+
session_id: "direct-test",
|
|
131
|
+
decision: options.directAuditDecision || "pass",
|
|
132
|
+
reason_codes: [],
|
|
133
|
+
blocking_findings: [],
|
|
134
|
+
warnings: [],
|
|
135
|
+
evidence: { changed_files_total: 0, verification_runs: [] },
|
|
136
|
+
next_action: "accept",
|
|
137
|
+
})) as any,
|
|
138
|
+
writeTaskLineage,
|
|
139
|
+
createLineageId: (() => "lineage_20260704_test") as typeof createLineageId,
|
|
140
|
+
recommendAgentForTask: ((input: any) => ({
|
|
141
|
+
repo_path: input.repo_path,
|
|
142
|
+
resolved_repo_path: tempDir,
|
|
143
|
+
recommended_agent: "fake",
|
|
144
|
+
fallback_agent: null,
|
|
145
|
+
fallback: false,
|
|
146
|
+
reason: "test route",
|
|
147
|
+
risk_notes: [],
|
|
148
|
+
suggested_verify_commands: ["npm test"],
|
|
149
|
+
bounded: true,
|
|
150
|
+
})) as any,
|
|
151
|
+
createWorktree: ((goalId: string, subgoalId: string, workspaceRoot: string) => {
|
|
152
|
+
calls.push(`worktree:${workspaceRoot}`);
|
|
153
|
+
return {
|
|
154
|
+
worktreeId: "wt-test",
|
|
155
|
+
worktreePath: tempDir,
|
|
156
|
+
branch: "pw-test",
|
|
157
|
+
};
|
|
158
|
+
}) as any,
|
|
159
|
+
now: () => new Date("2026-07-04T12:00:00.000Z"),
|
|
160
|
+
sleep: async () => {},
|
|
161
|
+
};
|
|
162
|
+
return { deps, calls };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
describe("runTaskLoop", () => {
|
|
166
|
+
beforeEach(() => {
|
|
167
|
+
tempDir = mkdtempSync(join(tmpdir(), "pw-loop-"));
|
|
168
|
+
writeConfig();
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
afterEach(() => {
|
|
172
|
+
if (prevConfigEnv === undefined) delete process.env.PATCHWARDEN_CONFIG;
|
|
173
|
+
else process.env.PATCHWARDEN_CONFIG = prevConfigEnv;
|
|
174
|
+
reloadConfig();
|
|
175
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("returns success with a bounded lineage summary", async () => {
|
|
179
|
+
const { deps } = depsFor({});
|
|
180
|
+
const result = await runTaskLoopWithDeps({
|
|
181
|
+
repo_path: ".",
|
|
182
|
+
goal: "Make a safe change",
|
|
183
|
+
agent: "fake",
|
|
184
|
+
verify_commands: ["npm test"],
|
|
185
|
+
}, deps);
|
|
186
|
+
|
|
187
|
+
assert.equal(result.stop_reason, "success");
|
|
188
|
+
assert.equal(result.final_status, "accepted");
|
|
189
|
+
assert.equal(result.tasks.main, "task-main");
|
|
190
|
+
const payload = JSON.stringify(result);
|
|
191
|
+
assert.ok(!payload.includes("stdout"));
|
|
192
|
+
assert.ok(!payload.includes("stderr"));
|
|
193
|
+
assert.ok(!payload.includes("diff.patch"));
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("keeps v1.3 behavior when direct_verify is false", async () => {
|
|
197
|
+
const { deps } = depsFor({});
|
|
198
|
+
const result = await runTaskLoopWithDeps({
|
|
199
|
+
repo_path: ".",
|
|
200
|
+
goal: "Run without Direct",
|
|
201
|
+
agent: "fake",
|
|
202
|
+
verify_commands: ["npm test"],
|
|
203
|
+
direct_verify: false,
|
|
204
|
+
}, deps);
|
|
205
|
+
|
|
206
|
+
assert.equal(result.stop_reason, "success");
|
|
207
|
+
assert.equal(result.direct_verify, false);
|
|
208
|
+
assert.deepEqual(result.tasks.direct_sessions, []);
|
|
209
|
+
assert.equal(result.isolation_mode, "current_repo");
|
|
210
|
+
assert.equal(result.worktree.status, "not_used");
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("records bounded agent routing when agent is auto", async () => {
|
|
214
|
+
const { deps } = depsFor({});
|
|
215
|
+
const result = await runTaskLoopWithDeps({
|
|
216
|
+
repo_path: ".",
|
|
217
|
+
goal: "Pick a safe agent",
|
|
218
|
+
agent: "auto",
|
|
219
|
+
verify_commands: ["npm test"],
|
|
220
|
+
scope_files: ["src/index.ts"],
|
|
221
|
+
}, deps);
|
|
222
|
+
|
|
223
|
+
assert.equal(result.stop_reason, "success");
|
|
224
|
+
assert.equal(result.agent_routing?.requested_agent, "auto");
|
|
225
|
+
assert.equal(result.agent_routing?.selected_agent, "fake");
|
|
226
|
+
assert.equal(result.agent_routing?.reason, "test route");
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("uses worktree isolation for task execution and records worktree evidence", async () => {
|
|
230
|
+
const { deps, calls } = depsFor({});
|
|
231
|
+
const result = await runTaskLoopWithDeps({
|
|
232
|
+
repo_path: "child-repo",
|
|
233
|
+
goal: "Run in a worktree",
|
|
234
|
+
agent: "fake",
|
|
235
|
+
verify_commands: ["npm test"],
|
|
236
|
+
isolation_mode: "worktree",
|
|
237
|
+
worktree_cleanup: "keep",
|
|
238
|
+
}, deps);
|
|
239
|
+
|
|
240
|
+
assert.equal(result.stop_reason, "success");
|
|
241
|
+
assert.equal(result.isolation_mode, "worktree");
|
|
242
|
+
assert.equal(result.worktree.worktree_id, "wt-test");
|
|
243
|
+
assert.equal(result.worktree.branch, "pw-test");
|
|
244
|
+
assert.equal(result.worktree.status, "active");
|
|
245
|
+
assert.ok(calls.includes(`worktree:${normalize(join(tempDir, "child-repo"))}`));
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it("records Direct verification evidence when direct_verify succeeds", async () => {
|
|
249
|
+
const { deps } = depsFor({});
|
|
250
|
+
const result = await runTaskLoopWithDeps({
|
|
251
|
+
repo_path: ".",
|
|
252
|
+
goal: "Run with Direct verification",
|
|
253
|
+
agent: "fake",
|
|
254
|
+
verify_commands: ["npm test"],
|
|
255
|
+
direct_verify: true,
|
|
256
|
+
}, deps);
|
|
257
|
+
|
|
258
|
+
assert.equal(result.stop_reason, "success");
|
|
259
|
+
assert.equal(result.direct_verify, true);
|
|
260
|
+
assert.equal(result.tasks.direct_sessions.length, 1);
|
|
261
|
+
assert.equal(result.tasks.direct_sessions[0].session_id, "direct-test");
|
|
262
|
+
assert.equal(result.tasks.direct_sessions[0].status, "passed");
|
|
263
|
+
assert.equal(result.tasks.direct_sessions[0].audit_decision, "pass");
|
|
264
|
+
const payload = JSON.stringify(result);
|
|
265
|
+
assert.ok(!payload.includes("stdout_tail"));
|
|
266
|
+
assert.ok(!payload.includes("stderr_tail"));
|
|
267
|
+
assert.ok(!payload.includes("diff.patch"));
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it("stops clearly when direct_verify is requested but Direct profile is disabled", async () => {
|
|
271
|
+
const configPath = join(tempDir, "patchwarden.config.json");
|
|
272
|
+
const raw = JSON.parse(readFileSync(configPath, "utf-8"));
|
|
273
|
+
raw.enableDirectProfile = false;
|
|
274
|
+
writeFileSync(configPath, JSON.stringify(raw), "utf-8");
|
|
275
|
+
reloadConfig();
|
|
276
|
+
const { deps } = depsFor({});
|
|
277
|
+
|
|
278
|
+
const result = await runTaskLoopWithDeps({
|
|
279
|
+
repo_path: ".",
|
|
280
|
+
goal: "Run with unavailable Direct",
|
|
281
|
+
agent: "fake",
|
|
282
|
+
verify_commands: ["npm test"],
|
|
283
|
+
direct_verify: true,
|
|
284
|
+
}, deps);
|
|
285
|
+
|
|
286
|
+
assert.equal(result.stop_reason, "direct_profile_disabled");
|
|
287
|
+
assert.equal(result.final_status, "blocked");
|
|
288
|
+
assert.equal(result.tasks.main, "task-main");
|
|
289
|
+
assert.equal(result.tasks.direct_sessions[0].session_id, "not_created");
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it("returns direct_verification_failed without leaking Direct logs", async () => {
|
|
293
|
+
const { deps } = depsFor({ directBundleStatus: "failed" });
|
|
294
|
+
const result = await runTaskLoopWithDeps({
|
|
295
|
+
repo_path: ".",
|
|
296
|
+
goal: "Run with failing Direct verification",
|
|
297
|
+
agent: "fake",
|
|
298
|
+
verify_commands: ["npm test"],
|
|
299
|
+
direct_verify: true,
|
|
300
|
+
}, deps);
|
|
301
|
+
|
|
302
|
+
assert.equal(result.stop_reason, "direct_verification_failed");
|
|
303
|
+
assert.equal(result.final_status, "needs_fix");
|
|
304
|
+
assert.equal(result.tasks.direct_sessions[0].failed_commands, 1);
|
|
305
|
+
const payload = JSON.stringify(result);
|
|
306
|
+
assert.ok(!payload.includes("stdout_tail"));
|
|
307
|
+
assert.ok(!payload.includes("stderr_tail"));
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it("returns direct_audit_failed when Direct audit fails", async () => {
|
|
311
|
+
const { deps } = depsFor({ directAuditDecision: "fail" });
|
|
312
|
+
const result = await runTaskLoopWithDeps({
|
|
313
|
+
repo_path: ".",
|
|
314
|
+
goal: "Run with failing Direct audit",
|
|
315
|
+
agent: "fake",
|
|
316
|
+
verify_commands: ["npm test"],
|
|
317
|
+
direct_verify: true,
|
|
318
|
+
}, deps);
|
|
319
|
+
|
|
320
|
+
assert.equal(result.stop_reason, "direct_audit_failed");
|
|
321
|
+
assert.equal(result.final_status, "blocked");
|
|
322
|
+
assert.equal(result.tasks.direct_sessions[0].audit_decision, "fail");
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it("creates a fix_tests follow-up after failed verification", async () => {
|
|
326
|
+
const { deps } = depsFor({
|
|
327
|
+
tasks: ["task-main", "task-fix"],
|
|
328
|
+
statuses: { "task-main": "failed_verification", "task-fix": "done_by_agent" },
|
|
329
|
+
verifications: { "task-main": "failed", "task-fix": "passed" },
|
|
330
|
+
audits: {
|
|
331
|
+
"task-main": { verdict: "warn", warn: ["test_exit_code"] },
|
|
332
|
+
"task-fix": { verdict: "pass" },
|
|
333
|
+
},
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
const result = await runTaskLoopWithDeps({
|
|
337
|
+
repo_path: ".",
|
|
338
|
+
goal: "Repair tests",
|
|
339
|
+
agent: "fake",
|
|
340
|
+
verify_commands: ["npm test"],
|
|
341
|
+
max_iterations: 2,
|
|
342
|
+
}, deps);
|
|
343
|
+
|
|
344
|
+
assert.equal(result.stop_reason, "success");
|
|
345
|
+
assert.equal(result.tasks.main, "task-main");
|
|
346
|
+
assert.deepEqual(result.tasks.fix, ["task-fix"]);
|
|
347
|
+
assert.equal(result.rounds.length, 2);
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
it("stops before execution when assessment needs confirmation", async () => {
|
|
351
|
+
const { deps, calls } = depsFor({ decisions: ["needs_confirm"] });
|
|
352
|
+
const result = await runTaskLoopWithDeps({
|
|
353
|
+
repo_path: ".",
|
|
354
|
+
goal: "Release-like work",
|
|
355
|
+
agent: "fake",
|
|
356
|
+
verify_commands: ["npm test"],
|
|
357
|
+
}, deps);
|
|
358
|
+
|
|
359
|
+
assert.equal(result.stop_reason, "user_confirmation_required");
|
|
360
|
+
assert.equal(result.stopped_before_execution, true);
|
|
361
|
+
assert.deepEqual(calls, ["assess"]);
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
it("returns max_iterations_reached when verification keeps failing", async () => {
|
|
365
|
+
const { deps } = depsFor({
|
|
366
|
+
statuses: { "task-main": "failed_verification" },
|
|
367
|
+
verifications: { "task-main": "failed" },
|
|
368
|
+
audits: { "task-main": { verdict: "warn", warn: ["test_exit_code"] } },
|
|
369
|
+
});
|
|
370
|
+
const result = await runTaskLoopWithDeps({
|
|
371
|
+
repo_path: ".",
|
|
372
|
+
goal: "Fix tests",
|
|
373
|
+
agent: "fake",
|
|
374
|
+
verify_commands: ["npm test"],
|
|
375
|
+
max_iterations: 1,
|
|
376
|
+
}, deps);
|
|
377
|
+
|
|
378
|
+
assert.equal(result.stop_reason, "max_iterations_reached");
|
|
379
|
+
assert.equal(result.final_status, "needs_fix");
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
it("writes BOM-free lineage JSON readable through get_task_lineage", async () => {
|
|
383
|
+
const { deps } = depsFor({});
|
|
384
|
+
const result = await runTaskLoopWithDeps({
|
|
385
|
+
repo_path: ".",
|
|
386
|
+
goal: "Persist lineage",
|
|
387
|
+
agent: "fake",
|
|
388
|
+
verify_commands: ["npm test"],
|
|
389
|
+
}, deps);
|
|
390
|
+
|
|
391
|
+
const lineagePath = join(tempDir, ".patchwarden", "lineages", result.lineage_id, "lineage.json");
|
|
392
|
+
const raw = readFileSync(lineagePath);
|
|
393
|
+
assert.notEqual(raw[0], 0xef);
|
|
394
|
+
const parsed = JSON.parse(raw.toString("utf-8"));
|
|
395
|
+
assert.equal(parsed.lineage_id, result.lineage_id);
|
|
396
|
+
|
|
397
|
+
const safe = getTaskLineage(result.lineage_id);
|
|
398
|
+
assert.equal(safe.lineage_id, result.lineage_id);
|
|
399
|
+
assert.equal(safe.rounds.length, 1);
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
it("reads legacy string direct_sessions as bounded evidence", () => {
|
|
403
|
+
const safe = writeTaskLineage({
|
|
404
|
+
lineage_id: "lineage_legacy_direct",
|
|
405
|
+
goal: "Legacy lineage",
|
|
406
|
+
repo_path: ".",
|
|
407
|
+
created_at: "2026-07-04T12:00:00.000Z",
|
|
408
|
+
updated_at: "2026-07-04T12:00:00.000Z",
|
|
409
|
+
final_status: "accepted",
|
|
410
|
+
stop_reason: "success",
|
|
411
|
+
next_action: "accept",
|
|
412
|
+
main_task: "task-main",
|
|
413
|
+
fix_tasks: [],
|
|
414
|
+
cleanup_tasks: [],
|
|
415
|
+
direct_sessions: ["direct-old"],
|
|
416
|
+
rounds: [],
|
|
417
|
+
warnings: [],
|
|
418
|
+
errors: [],
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
assert.equal(safe.tasks.direct_sessions[0].session_id, "direct-old");
|
|
422
|
+
const readBack = getTaskLineage("lineage_legacy_direct");
|
|
423
|
+
assert.equal(readBack.tasks.direct_sessions[0].session_id, "direct-old");
|
|
424
|
+
});
|
|
425
|
+
});
|
|
@@ -34,6 +34,8 @@ const OTHER_SCHEMA = {
|
|
|
34
34
|
required: ["plan_id"],
|
|
35
35
|
};
|
|
36
36
|
|
|
37
|
+
const CORE_COUNT = CHATGPT_CORE_TOOL_NAMES.length;
|
|
38
|
+
|
|
37
39
|
interface MakeMetaOpts {
|
|
38
40
|
name: string;
|
|
39
41
|
inputSchema?: unknown;
|
|
@@ -151,7 +153,7 @@ describe("checkChatgptCoreManifestStable", () => {
|
|
|
151
153
|
|
|
152
154
|
it("缺少工具时 ok=false", () => {
|
|
153
155
|
// 移除最后一个工具,使 chatgpt_core 工具数为 20
|
|
154
|
-
const names = [...CHATGPT_CORE_TOOL_NAMES].slice(0,
|
|
156
|
+
const names = [...CHATGPT_CORE_TOOL_NAMES].slice(0, CORE_COUNT - 1);
|
|
155
157
|
const toolDefs = makeToolDefs(names.map((name) => ({ name })));
|
|
156
158
|
|
|
157
159
|
const result = checkChatgptCoreManifestStable(toolDefs);
|
|
@@ -163,7 +165,7 @@ describe("checkChatgptCoreManifestStable", () => {
|
|
|
163
165
|
"warning 应包含 missing tools",
|
|
164
166
|
);
|
|
165
167
|
assert.ok(
|
|
166
|
-
result.warnings[0].includes(CHATGPT_CORE_TOOL_NAMES[
|
|
168
|
+
result.warnings[0].includes(CHATGPT_CORE_TOOL_NAMES[CORE_COUNT - 1]),
|
|
167
169
|
"warning 应包含缺失的工具名",
|
|
168
170
|
);
|
|
169
171
|
});
|
|
@@ -173,7 +175,7 @@ describe("checkChatgptCoreManifestStable", () => {
|
|
|
173
175
|
|
|
174
176
|
describe("checkNewToolsProfileAppend", () => {
|
|
175
177
|
it("chatgpt_core 21 工具时 ok=true", () => {
|
|
176
|
-
const registry = makeCoreRegistry(
|
|
178
|
+
const registry = makeCoreRegistry(CORE_COUNT);
|
|
177
179
|
|
|
178
180
|
const result = checkNewToolsProfileAppend(registry);
|
|
179
181
|
|
|
@@ -183,7 +185,7 @@ describe("checkNewToolsProfileAppend", () => {
|
|
|
183
185
|
|
|
184
186
|
it("chatgpt_core 工具数不为 21 时 ok=false", () => {
|
|
185
187
|
// 22 个 chatgpt_core 工具:21 个标准工具 + 1 个重复名(模拟新工具追加)
|
|
186
|
-
const registry = makeCoreRegistry(
|
|
188
|
+
const registry = makeCoreRegistry(CORE_COUNT);
|
|
187
189
|
registry.push(makeMeta({ name: "extra_tool", profiles: ["chatgpt_core"] }));
|
|
188
190
|
|
|
189
191
|
const result = checkNewToolsProfileAppend(registry);
|
|
@@ -191,11 +193,11 @@ describe("checkNewToolsProfileAppend", () => {
|
|
|
191
193
|
assert.equal(result.ok, false);
|
|
192
194
|
assert.equal(result.warnings.length, 1);
|
|
193
195
|
assert.ok(
|
|
194
|
-
result.warnings[0].includes(
|
|
196
|
+
result.warnings[0].includes(`expected ${CORE_COUNT}`),
|
|
195
197
|
"warning 应包含预期数量 21",
|
|
196
198
|
);
|
|
197
199
|
assert.ok(
|
|
198
|
-
result.warnings[0].includes(
|
|
200
|
+
result.warnings[0].includes(`got ${CORE_COUNT + 1}`),
|
|
199
201
|
"warning 应包含实际数量 22",
|
|
200
202
|
);
|
|
201
203
|
});
|
|
@@ -206,7 +208,7 @@ describe("checkNewToolsProfileAppend", () => {
|
|
|
206
208
|
describe("runAllSchemaDriftChecks", () => {
|
|
207
209
|
it("无 drift 时 ok=true", () => {
|
|
208
210
|
// registry 与 toolDefs 完全一致,且 chatgpt_core 工具数为 21
|
|
209
|
-
const registry = makeCoreRegistry(
|
|
211
|
+
const registry = makeCoreRegistry(CORE_COUNT);
|
|
210
212
|
const toolDefs = makeCoreToolDefs();
|
|
211
213
|
|
|
212
214
|
const result = runAllSchemaDriftChecks(registry, toolDefs);
|
|
@@ -221,7 +223,7 @@ describe("runAllSchemaDriftChecks", () => {
|
|
|
221
223
|
// - check 2: toolDefs 缺少一个 chatgpt_core 工具(数量 16)
|
|
222
224
|
// - check 3: registry 有 22 个 chatgpt_core 工具
|
|
223
225
|
const driftedSchema = OTHER_SCHEMA;
|
|
224
|
-
const registry = makeCoreRegistry(
|
|
226
|
+
const registry = makeCoreRegistry(CORE_COUNT);
|
|
225
227
|
// 第一个工具的 digest 指向 SAMPLE_SCHEMA,但 toolDefs 中该工具用 driftedSchema
|
|
226
228
|
registry[0] = makeMeta({
|
|
227
229
|
name: CHATGPT_CORE_TOOL_NAMES[0],
|
|
@@ -233,7 +235,7 @@ describe("runAllSchemaDriftChecks", () => {
|
|
|
233
235
|
|
|
234
236
|
// toolDefs:第一个工具用 driftedSchema(触发 check 1),且移除最后一个核心工具(触发 check 2)
|
|
235
237
|
const coreNames = [...CHATGPT_CORE_TOOL_NAMES];
|
|
236
|
-
const toolDefEntries = coreNames.slice(0,
|
|
238
|
+
const toolDefEntries = coreNames.slice(0, CORE_COUNT - 1).map((name) => ({
|
|
237
239
|
name,
|
|
238
240
|
inputSchema: name === CHATGPT_CORE_TOOL_NAMES[0] ? driftedSchema : SAMPLE_SCHEMA,
|
|
239
241
|
}));
|