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,195 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
3
|
+
import {
|
|
4
|
+
cpSync,
|
|
5
|
+
existsSync,
|
|
6
|
+
mkdirSync,
|
|
7
|
+
mkdtempSync,
|
|
8
|
+
readFileSync,
|
|
9
|
+
rmSync,
|
|
10
|
+
writeFileSync,
|
|
11
|
+
} from "node:fs";
|
|
12
|
+
import { tmpdir } from "node:os";
|
|
13
|
+
import { dirname, join, resolve } from "node:path";
|
|
14
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
15
|
+
import { fileURLToPath } from "node:url";
|
|
16
|
+
|
|
17
|
+
if (process.platform !== "win32") {
|
|
18
|
+
console.log("ok - watcher supervisor smoke skipped outside Windows");
|
|
19
|
+
process.exit(0);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const root = resolve(fileURLToPath(new URL("..", import.meta.url)));
|
|
23
|
+
const temp = mkdtempSync(join(tmpdir(), "patchwarden-watcher-supervisor-"));
|
|
24
|
+
const children = [];
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
await runScenario("owned watcher exit triggers a controlled restart", "exit_once", 1, 9_000, (state) => {
|
|
28
|
+
if (state.attempts < 2 || state.status.restart_attempts !== 1) {
|
|
29
|
+
throw new Error(`Expected one owned restart: ${JSON.stringify(state)}`);
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
await runScenario("frozen owned heartbeat reaches the restart limit", "freeze", 1, 22_000, (state) => {
|
|
34
|
+
if (state.status.status !== "restart_limit_reached" || state.status.restart_attempts !== 2) {
|
|
35
|
+
throw new Error(`Expected restart_limit_reached: ${JSON.stringify(state)}`);
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
await runExternalScenario();
|
|
40
|
+
console.log("ok - watcher supervisor handles exit, stale heartbeat, retry limit, and external ownership");
|
|
41
|
+
} finally {
|
|
42
|
+
for (const child of children) {
|
|
43
|
+
if (child.exitCode === null) child.kill("SIGKILL");
|
|
44
|
+
}
|
|
45
|
+
rmSync(temp, { recursive: true, force: true });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function runScenario(label, mode, maxRestarts, lifetimeMs, assertState) {
|
|
49
|
+
const fixture = createFixture(label.replace(/[^a-z0-9]+/gi, "-"));
|
|
50
|
+
const result = runLauncher(fixture, mode, maxRestarts, lifetimeMs);
|
|
51
|
+
if (result.error && result.error.code === "ETIMEDOUT") throw result.error;
|
|
52
|
+
if (!existsSync(fixture.watcherStatusPath)) {
|
|
53
|
+
throw new Error(`Watcher status was not created. exit=${result.status} stdout=${result.stdout} stderr=${result.stderr}`);
|
|
54
|
+
}
|
|
55
|
+
const status = readJson(fixture.watcherStatusPath);
|
|
56
|
+
const attempts = Number(readFileSync(fixture.watcherAttemptPath, "utf-8")) || 0;
|
|
57
|
+
assertState({ status, attempts, stdout: result.stdout, stderr: result.stderr });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function runExternalScenario() {
|
|
61
|
+
const fixture = createFixture("external");
|
|
62
|
+
const env = fixtureEnv(fixture, "healthy", 7_000);
|
|
63
|
+
const external = spawn(process.execPath, [fixture.watcherPath], {
|
|
64
|
+
cwd: fixture.project,
|
|
65
|
+
env: {
|
|
66
|
+
...env,
|
|
67
|
+
PATCHWARDEN_CONFIG: fixture.configPath,
|
|
68
|
+
PATCHWARDEN_WATCHER_INSTANCE_ID: "external-fixture",
|
|
69
|
+
PATCHWARDEN_WATCHER_LAUNCHER_PID: "999999",
|
|
70
|
+
},
|
|
71
|
+
stdio: "ignore",
|
|
72
|
+
});
|
|
73
|
+
children.push(external);
|
|
74
|
+
for (let attempt = 0; attempt < 40 && !existsSync(fixture.heartbeatPath); attempt++) await sleep(100);
|
|
75
|
+
if (!existsSync(fixture.heartbeatPath)) throw new Error("External watcher heartbeat was not created");
|
|
76
|
+
|
|
77
|
+
const result = runLauncher(fixture, "healthy", 1, 7_000);
|
|
78
|
+
if (result.error && result.error.code === "ETIMEDOUT") throw result.error;
|
|
79
|
+
if (external.exitCode !== null) throw new Error("Launcher stopped an external watcher");
|
|
80
|
+
const status = readJson(fixture.watcherStatusPath);
|
|
81
|
+
if (status.managed !== false || status.status !== "external_healthy") {
|
|
82
|
+
throw new Error(`External watcher ownership mismatch: ${JSON.stringify(status)}`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function createFixture(name) {
|
|
87
|
+
const project = join(temp, name, "patchwarden-fixture");
|
|
88
|
+
const scripts = join(project, "scripts");
|
|
89
|
+
const runner = join(project, "dist", "runner");
|
|
90
|
+
const workspace = join(project, "workspace");
|
|
91
|
+
mkdirSync(scripts, { recursive: true });
|
|
92
|
+
mkdirSync(runner, { recursive: true });
|
|
93
|
+
mkdirSync(workspace, { recursive: true });
|
|
94
|
+
cpSync(join(root, "scripts", "start-patchwarden-tunnel.ps1"), join(scripts, "start-patchwarden-tunnel.ps1"));
|
|
95
|
+
writeFileSync(join(scripts, "patchwarden-mcp-stdio.cmd"), "@echo off\r\nexit /b 0\r\n", "utf-8");
|
|
96
|
+
const manifestFixture = JSON.stringify({
|
|
97
|
+
ok: true,
|
|
98
|
+
server_version: "0.4.0",
|
|
99
|
+
schema_epoch: "2026-06-20-v2",
|
|
100
|
+
tool_profile: "chatgpt_core",
|
|
101
|
+
tool_count: 16,
|
|
102
|
+
tool_names: [],
|
|
103
|
+
tool_manifest_sha256: "a".repeat(64),
|
|
104
|
+
});
|
|
105
|
+
writeFileSync(join(scripts, "mcp-manifest-check.js"), `process.stdout.write(${JSON.stringify(manifestFixture)});\n`, "utf-8");
|
|
106
|
+
writeFileSync(join(project, "dist", "index.js"), "", "utf-8");
|
|
107
|
+
const watcherAttemptPath = join(project, "watcher-attempt.txt");
|
|
108
|
+
const watcherPath = join(runner, "watch.js");
|
|
109
|
+
writeFileSync(watcherPath, watcherFixtureSource(watcherAttemptPath), "utf-8");
|
|
110
|
+
const configPath = join(project, "patchwarden.config.json");
|
|
111
|
+
writeFileSync(configPath, JSON.stringify({
|
|
112
|
+
workspaceRoot: workspace,
|
|
113
|
+
plansDir: ".patchwarden/plans",
|
|
114
|
+
tasksDir: ".patchwarden/tasks",
|
|
115
|
+
watcherStaleSeconds: 5,
|
|
116
|
+
agents: {},
|
|
117
|
+
allowedTestCommands: [],
|
|
118
|
+
maxReadFileBytes: 200000,
|
|
119
|
+
defaultTaskTimeoutSeconds: 30,
|
|
120
|
+
maxTaskTimeoutSeconds: 60,
|
|
121
|
+
}, null, 2), "utf-8");
|
|
122
|
+
const mockJs = join(project, "mock-tunnel-client.js");
|
|
123
|
+
const mockCmd = join(project, "mock-tunnel-client.cmd");
|
|
124
|
+
writeFileSync(mockCmd, `@echo off\r\nnode "${mockJs}" %*\r\n`, "utf-8");
|
|
125
|
+
writeFileSync(mockJs, tunnelFixtureSource(), "utf-8");
|
|
126
|
+
const localAppData = join(project, "localappdata");
|
|
127
|
+
return {
|
|
128
|
+
project,
|
|
129
|
+
configPath,
|
|
130
|
+
watcherPath,
|
|
131
|
+
watcherAttemptPath,
|
|
132
|
+
heartbeatPath: join(workspace, ".patchwarden", "watcher-heartbeat.json"),
|
|
133
|
+
watcherStatusPath: join(localAppData, "patchwarden", "runtime", "watcher-status.json"),
|
|
134
|
+
mockCmd,
|
|
135
|
+
localAppData,
|
|
136
|
+
appData: join(project, "appdata"),
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function runLauncher(fixture, mode, maxRestarts, lifetimeMs) {
|
|
141
|
+
return spawnSync("powershell.exe", [
|
|
142
|
+
"-NoProfile", "-ExecutionPolicy", "Bypass", "-File", join(fixture.project, "scripts", "start-patchwarden-tunnel.ps1"),
|
|
143
|
+
"-TunnelId", "tunnel_watcher_fixture",
|
|
144
|
+
"-TunnelClientExe", fixture.mockCmd,
|
|
145
|
+
"-ProxyUrl", "http://127.0.0.1:1",
|
|
146
|
+
"-MaxReconnectAttempts", "1",
|
|
147
|
+
"-WatcherMaxRestartAttempts", String(maxRestarts),
|
|
148
|
+
"-WatcherHealthyResetSeconds", "60",
|
|
149
|
+
], {
|
|
150
|
+
cwd: fixture.project,
|
|
151
|
+
env: fixtureEnv(fixture, mode, lifetimeMs),
|
|
152
|
+
encoding: "utf-8",
|
|
153
|
+
timeout: lifetimeMs + 20_000,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function fixtureEnv(fixture, mode, lifetimeMs) {
|
|
158
|
+
return {
|
|
159
|
+
...process.env,
|
|
160
|
+
APPDATA: fixture.appData,
|
|
161
|
+
LOCALAPPDATA: fixture.localAppData,
|
|
162
|
+
CONTROL_PLANE_API_KEY: "watcher-supervisor-smoke-secret",
|
|
163
|
+
WATCHER_FIXTURE_MODE: mode,
|
|
164
|
+
TUNNEL_FIXTURE_LIFETIME_MS: String(lifetimeMs),
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function watcherFixtureSource(attemptPath) {
|
|
169
|
+
return `
|
|
170
|
+
const fs=require('fs');const path=require('path');
|
|
171
|
+
let attempt=0;try{attempt=Number(fs.readFileSync(${JSON.stringify(attemptPath)},'utf8'))||0}catch{}
|
|
172
|
+
attempt++;fs.writeFileSync(${JSON.stringify(attemptPath)},String(attempt));
|
|
173
|
+
const cfg=JSON.parse(fs.readFileSync(process.env.PATCHWARDEN_CONFIG,'utf8'));
|
|
174
|
+
const heartbeat=path.join(cfg.workspaceRoot,'.patchwarden','watcher-heartbeat.json');fs.mkdirSync(path.dirname(heartbeat),{recursive:true});
|
|
175
|
+
const write=()=>{const temp=heartbeat+'.'+process.pid+'.tmp';fs.writeFileSync(temp,JSON.stringify({status:'running',pid:process.pid,instance_id:process.env.PATCHWARDEN_WATCHER_INSTANCE_ID,launcher_pid:Number(process.env.PATCHWARDEN_WATCHER_LAUNCHER_PID),started_at:new Date().toISOString(),last_heartbeat_at:new Date().toISOString()}));fs.renameSync(temp,heartbeat)};
|
|
176
|
+
write();const mode=process.env.WATCHER_FIXTURE_MODE;
|
|
177
|
+
if(mode==='exit_once'&&attempt===1)setTimeout(()=>process.exit(7),100);
|
|
178
|
+
else if(mode==='freeze')setInterval(()=>{},1000);
|
|
179
|
+
else setInterval(write,250);
|
|
180
|
+
`;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function tunnelFixtureSource() {
|
|
184
|
+
return `
|
|
185
|
+
const fs=require('fs');const args=process.argv.slice(2);const command=args[0]||'';
|
|
186
|
+
const flag=(name)=>{const i=args.indexOf(name);return i>=0?args[i+1]:''};
|
|
187
|
+
if(command==='init'||command==='doctor'){console.log('{}');process.exit(0)}
|
|
188
|
+
if(command==='health'){console.log(JSON.stringify({healthz:{ok:true},readyz:{ok:true}}));process.exit(0)}
|
|
189
|
+
if(command==='run'){const url=flag('--health.url-file'),pid=flag('--pid.file');fs.mkdirSync(require('path').dirname(url),{recursive:true});fs.writeFileSync(url,'http://127.0.0.1:18889');fs.writeFileSync(pid,String(process.pid));setTimeout(()=>process.exit(9),Number(process.env.TUNNEL_FIXTURE_LIFETIME_MS)||9000)}
|
|
190
|
+
`;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function readJson(path) {
|
|
194
|
+
return JSON.parse(readFileSync(path, "utf-8").replace(/^\uFEFF/, ""));
|
|
195
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
|
|
4
|
+
// ── Type definitions ──────────────────────────────────────────────
|
|
5
|
+
|
|
6
|
+
export interface AgentConfig {
|
|
7
|
+
command: string;
|
|
8
|
+
args: string[];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface PatchWardenConfig {
|
|
12
|
+
workspaceRoot: string;
|
|
13
|
+
plansDir: string;
|
|
14
|
+
tasksDir: string;
|
|
15
|
+
agents: Record<string, AgentConfig>;
|
|
16
|
+
allowedTestCommands: string[];
|
|
17
|
+
maxReadFileBytes: number;
|
|
18
|
+
defaultTaskTimeoutSeconds: number;
|
|
19
|
+
maxTaskTimeoutSeconds: number;
|
|
20
|
+
watcherStaleSeconds: number;
|
|
21
|
+
toolProfile?: "full" | "chatgpt_core";
|
|
22
|
+
repoAliases?: Record<string, string>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// ── Defaults ──────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
const DEFAULT_CONFIG: PatchWardenConfig = {
|
|
28
|
+
workspaceRoot: process.cwd(),
|
|
29
|
+
plansDir: ".patchwarden/plans",
|
|
30
|
+
tasksDir: ".patchwarden/tasks",
|
|
31
|
+
agents: {
|
|
32
|
+
codex: {
|
|
33
|
+
command: "codex",
|
|
34
|
+
args: ["exec", "--cd", "{repo}", "{prompt}"],
|
|
35
|
+
},
|
|
36
|
+
opencode: {
|
|
37
|
+
command: "opencode",
|
|
38
|
+
args: ["run", "{prompt}"],
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
allowedTestCommands: [
|
|
42
|
+
"npm test",
|
|
43
|
+
"npm run test",
|
|
44
|
+
"npm run lint",
|
|
45
|
+
"npm run format:check",
|
|
46
|
+
"npm run build",
|
|
47
|
+
"npm run dist",
|
|
48
|
+
"npm run doctor",
|
|
49
|
+
"pnpm test",
|
|
50
|
+
"pnpm run test",
|
|
51
|
+
"pnpm run lint",
|
|
52
|
+
"pnpm run format:check",
|
|
53
|
+
"pnpm run build",
|
|
54
|
+
"pnpm run dist",
|
|
55
|
+
"pnpm run doctor",
|
|
56
|
+
"pytest",
|
|
57
|
+
"cargo test",
|
|
58
|
+
],
|
|
59
|
+
maxReadFileBytes: 200_000,
|
|
60
|
+
defaultTaskTimeoutSeconds: 900,
|
|
61
|
+
maxTaskTimeoutSeconds: 3600,
|
|
62
|
+
watcherStaleSeconds: 30,
|
|
63
|
+
toolProfile: "full",
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
// ── Load config ───────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
let _config: PatchWardenConfig | null = null;
|
|
69
|
+
|
|
70
|
+
export function loadConfig(configPath?: string): PatchWardenConfig {
|
|
71
|
+
if (_config) return _config;
|
|
72
|
+
|
|
73
|
+
const explicitPath = configPath || process.env.PATCHWARDEN_CONFIG;
|
|
74
|
+
const candidatePaths = explicitPath
|
|
75
|
+
? [explicitPath]
|
|
76
|
+
: [
|
|
77
|
+
resolve(process.cwd(), "patchwarden.config.json"),
|
|
78
|
+
resolve(process.cwd(), ".patchwarden.json"),
|
|
79
|
+
];
|
|
80
|
+
|
|
81
|
+
for (const p of candidatePaths) {
|
|
82
|
+
if (existsSync(p)) {
|
|
83
|
+
try {
|
|
84
|
+
const rawText = stripBom(readFileSync(p, "utf-8"));
|
|
85
|
+
const raw = JSON.parse(rawText);
|
|
86
|
+
_config = normalizeConfig({ ...DEFAULT_CONFIG, ...raw } as PatchWardenConfig);
|
|
87
|
+
return _config;
|
|
88
|
+
} catch (err) {
|
|
89
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
90
|
+
throw new Error(`Failed to load PatchWarden config "${p}": ${message}`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (explicitPath) {
|
|
96
|
+
throw new Error(`PatchWarden config not found: "${explicitPath}"`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
_config = normalizeConfig({ ...DEFAULT_CONFIG });
|
|
100
|
+
return _config;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function getConfig(): PatchWardenConfig {
|
|
104
|
+
if (!_config) return loadConfig();
|
|
105
|
+
return _config;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Resolve workspaceRoot: expand relative paths */
|
|
109
|
+
export function resolveWorkspaceRoot(config: PatchWardenConfig): string {
|
|
110
|
+
return resolve(config.workspaceRoot);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Resolve plans/tasks dirs relative to workspaceRoot */
|
|
114
|
+
export function getPlansDir(config: PatchWardenConfig): string {
|
|
115
|
+
return resolve(config.workspaceRoot, config.plansDir);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function getTasksDir(config: PatchWardenConfig): string {
|
|
119
|
+
return resolve(config.workspaceRoot, config.tasksDir);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function stripBom(value: string): string {
|
|
123
|
+
return value.charCodeAt(0) === 0xfeff ? value.slice(1) : value;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function normalizeConfig(config: PatchWardenConfig): PatchWardenConfig {
|
|
127
|
+
if (!config.workspaceRoot || typeof config.workspaceRoot !== "string") {
|
|
128
|
+
throw new Error("workspaceRoot must be a non-empty string");
|
|
129
|
+
}
|
|
130
|
+
if (!config.plansDir || typeof config.plansDir !== "string") {
|
|
131
|
+
throw new Error("plansDir must be a non-empty string");
|
|
132
|
+
}
|
|
133
|
+
if (!config.tasksDir || typeof config.tasksDir !== "string") {
|
|
134
|
+
throw new Error("tasksDir must be a non-empty string");
|
|
135
|
+
}
|
|
136
|
+
if (!config.agents || typeof config.agents !== "object") {
|
|
137
|
+
throw new Error("agents must be an object");
|
|
138
|
+
}
|
|
139
|
+
if (!Array.isArray(config.allowedTestCommands)) {
|
|
140
|
+
throw new Error("allowedTestCommands must be an array");
|
|
141
|
+
}
|
|
142
|
+
if (!Number.isFinite(config.maxReadFileBytes) || config.maxReadFileBytes <= 0) {
|
|
143
|
+
throw new Error("maxReadFileBytes must be a positive number");
|
|
144
|
+
}
|
|
145
|
+
if (!Number.isInteger(config.defaultTaskTimeoutSeconds) || config.defaultTaskTimeoutSeconds <= 0) {
|
|
146
|
+
throw new Error("defaultTaskTimeoutSeconds must be a positive integer");
|
|
147
|
+
}
|
|
148
|
+
if (!Number.isInteger(config.maxTaskTimeoutSeconds) || config.maxTaskTimeoutSeconds <= 0) {
|
|
149
|
+
throw new Error("maxTaskTimeoutSeconds must be a positive integer");
|
|
150
|
+
}
|
|
151
|
+
if (config.defaultTaskTimeoutSeconds > config.maxTaskTimeoutSeconds) {
|
|
152
|
+
throw new Error("defaultTaskTimeoutSeconds cannot exceed maxTaskTimeoutSeconds");
|
|
153
|
+
}
|
|
154
|
+
if (!Number.isInteger(config.watcherStaleSeconds) || config.watcherStaleSeconds < 5 || config.watcherStaleSeconds > 3600) {
|
|
155
|
+
throw new Error("watcherStaleSeconds must be an integer from 5 to 3600");
|
|
156
|
+
}
|
|
157
|
+
if (config.toolProfile !== undefined && config.toolProfile !== "full" && config.toolProfile !== "chatgpt_core") {
|
|
158
|
+
throw new Error('toolProfile must be "full" or "chatgpt_core"');
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
...config,
|
|
163
|
+
workspaceRoot: resolve(config.workspaceRoot),
|
|
164
|
+
};
|
|
165
|
+
}
|