patchwork-os 0.2.0-beta.2 → 0.2.0-beta.3
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.bridge.md +5 -5
- package/README.md +156 -12
- package/dist/activityLog.d.ts +6 -0
- package/dist/activityLog.js +8 -0
- package/dist/activityLog.js.map +1 -1
- package/dist/analyticsPrefs.d.ts +35 -2
- package/dist/analyticsPrefs.js +120 -21
- package/dist/analyticsPrefs.js.map +1 -1
- package/dist/analyticsSend.js +5 -1
- package/dist/analyticsSend.js.map +1 -1
- package/dist/bridge.d.ts +2 -0
- package/dist/bridge.js +111 -7
- package/dist/bridge.js.map +1 -1
- package/dist/bridgeLockDiscovery.d.ts +27 -1
- package/dist/bridgeLockDiscovery.js +37 -11
- package/dist/bridgeLockDiscovery.js.map +1 -1
- package/dist/commands/patchworkInit.d.ts +5 -0
- package/dist/commands/patchworkInit.js +86 -7
- package/dist/commands/patchworkInit.js.map +1 -1
- package/dist/commands/recipe.d.ts +51 -0
- package/dist/commands/recipe.js +353 -2
- package/dist/commands/recipe.js.map +1 -1
- package/dist/commands/recipeInstall.js +6 -3
- package/dist/commands/recipeInstall.js.map +1 -1
- package/dist/commands/task.js +2 -2
- package/dist/commands/task.js.map +1 -1
- package/dist/config.d.ts +9 -2
- package/dist/config.js +35 -17
- package/dist/config.js.map +1 -1
- package/dist/connectors/tokenStorage.js +46 -10
- package/dist/connectors/tokenStorage.js.map +1 -1
- package/dist/featureFlags.d.ts +76 -0
- package/dist/featureFlags.js +166 -2
- package/dist/featureFlags.js.map +1 -1
- package/dist/index.js +765 -69
- package/dist/index.js.map +1 -1
- package/dist/lockfile.js +4 -1
- package/dist/lockfile.js.map +1 -1
- package/dist/patchworkConfig.js +5 -0
- package/dist/patchworkConfig.js.map +1 -1
- package/dist/recipeOrchestration.js +35 -1
- package/dist/recipeOrchestration.js.map +1 -1
- package/dist/recipeRoutes.d.ts +36 -0
- package/dist/recipeRoutes.js +231 -32
- package/dist/recipeRoutes.js.map +1 -1
- package/dist/recipes/agentExecutor.d.ts +25 -5
- package/dist/recipes/agentExecutor.js.map +1 -1
- package/dist/recipes/chainedRunner.js +16 -2
- package/dist/recipes/chainedRunner.js.map +1 -1
- package/dist/recipes/connectorPreflight.d.ts +53 -0
- package/dist/recipes/connectorPreflight.js +79 -0
- package/dist/recipes/connectorPreflight.js.map +1 -0
- package/dist/recipes/githubInstallSource.d.ts +62 -0
- package/dist/recipes/githubInstallSource.js +125 -0
- package/dist/recipes/githubInstallSource.js.map +1 -0
- package/dist/recipes/haltCategory.d.ts +80 -0
- package/dist/recipes/haltCategory.js +125 -0
- package/dist/recipes/haltCategory.js.map +1 -0
- package/dist/recipes/idempotencyKey.d.ts +126 -0
- package/dist/recipes/idempotencyKey.js +298 -0
- package/dist/recipes/idempotencyKey.js.map +1 -0
- package/dist/recipes/judgeSummary.d.ts +50 -0
- package/dist/recipes/judgeSummary.js +47 -0
- package/dist/recipes/judgeSummary.js.map +1 -0
- package/dist/recipes/judgeVerdict.d.ts +48 -0
- package/dist/recipes/judgeVerdict.js +174 -0
- package/dist/recipes/judgeVerdict.js.map +1 -0
- package/dist/recipes/migrations/index.d.ts +9 -0
- package/dist/recipes/migrations/index.js +133 -0
- package/dist/recipes/migrations/index.js.map +1 -1
- package/dist/recipes/runBudget.d.ts +70 -0
- package/dist/recipes/runBudget.js +109 -0
- package/dist/recipes/runBudget.js.map +1 -0
- package/dist/recipes/scheduler.js +1 -1
- package/dist/recipes/scheduler.js.map +1 -1
- package/dist/recipes/schema.d.ts +30 -0
- package/dist/recipes/toolRegistry.js +19 -0
- package/dist/recipes/toolRegistry.js.map +1 -1
- package/dist/recipes/tools/http.d.ts +10 -0
- package/dist/recipes/tools/http.js +176 -0
- package/dist/recipes/tools/http.js.map +1 -0
- package/dist/recipes/tools/index.d.ts +1 -0
- package/dist/recipes/tools/index.js +1 -0
- package/dist/recipes/tools/index.js.map +1 -1
- package/dist/recipes/validation.js +1 -1
- package/dist/recipes/validation.js.map +1 -1
- package/dist/recipes/yamlRunner.d.ts +71 -7
- package/dist/recipes/yamlRunner.js +156 -22
- package/dist/recipes/yamlRunner.js.map +1 -1
- package/dist/runLog.d.ts +28 -0
- package/dist/runLog.js +5 -0
- package/dist/runLog.js.map +1 -1
- package/dist/server.d.ts +65 -0
- package/dist/server.js +302 -3
- package/dist/server.js.map +1 -1
- package/dist/streamableHttp.js +17 -6
- package/dist/streamableHttp.js.map +1 -1
- package/dist/tools/bridgeDoctor.js +6 -2
- package/dist/tools/bridgeDoctor.js.map +1 -1
- package/dist/tools/ccRoutines.d.ts +221 -0
- package/dist/tools/ccRoutines.js +264 -0
- package/dist/tools/ccRoutines.js.map +1 -0
- package/dist/tools/getCodeCoverage.js +7 -3
- package/dist/tools/getCodeCoverage.js.map +1 -1
- package/dist/tools/index.js +6 -0
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/recentTracesDigest.js +56 -11
- package/dist/tools/recentTracesDigest.js.map +1 -1
- package/dist/tools/testRunners/vitestJest.js +3 -1
- package/dist/tools/testRunners/vitestJest.js.map +1 -1
- package/dist/tools/utils.js +6 -3
- package/dist/tools/utils.js.map +1 -1
- package/package.json +17 -6
- package/scripts/postinstall.mjs +27 -0
- package/scripts/smoke/run-all.mjs +162 -0
- package/scripts/start-all.mjs +513 -0
- package/scripts/start-all.ps1 +209 -0
- package/scripts/start-all.sh +73 -17
- package/scripts/start-orchestrator.ps1 +158 -0
- package/scripts/start-remote.mjs +122 -0
- package/templates/automation-policies/recipe-authoring.json +1 -1
- package/templates/automation-policies/security-first.json +1 -1
- package/templates/automation-policies/strict-lint.json +1 -1
- package/templates/automation-policies/test-driven.json +1 -1
- package/templates/automation-policy.example.json +1 -1
- package/templates/co.patchwork-os.bridge.plist +1 -1
- package/templates/recipes/approval-queue-ui-test.yaml +1 -1
- package/templates/recipes/ctx-loop-test.yaml +1 -1
- package/templates/recipes/webhook/apple-watch-health-log.yaml +145 -0
- package/dist/commands/marketplace.d.ts +0 -16
- package/dist/commands/marketplace.js +0 -32
- package/dist/commands/marketplace.js.map +0 -1
- package/dist/recipes/legacyRecipeCompat.d.ts +0 -10
- package/dist/recipes/legacyRecipeCompat.js +0 -131
- package/dist/recipes/legacyRecipeCompat.js.map +0 -1
package/dist/index.js
CHANGED
|
@@ -119,7 +119,6 @@ const KNOWN_SUBCOMMANDS = [
|
|
|
119
119
|
"gen-plugin-stub",
|
|
120
120
|
"notify",
|
|
121
121
|
"install",
|
|
122
|
-
"marketplace",
|
|
123
122
|
"status",
|
|
124
123
|
"shim",
|
|
125
124
|
"recipe",
|
|
@@ -128,6 +127,10 @@ const KNOWN_SUBCOMMANDS = [
|
|
|
128
127
|
"dashboard",
|
|
129
128
|
"launchd",
|
|
130
129
|
"start",
|
|
130
|
+
"kill-switch",
|
|
131
|
+
"panic",
|
|
132
|
+
"halts",
|
|
133
|
+
"judgments",
|
|
131
134
|
];
|
|
132
135
|
const __invokedSubcommand = (() => {
|
|
133
136
|
const sub = process.argv[2];
|
|
@@ -139,10 +142,20 @@ const __invokedSubcommand = (() => {
|
|
|
139
142
|
? sub
|
|
140
143
|
: null;
|
|
141
144
|
})();
|
|
145
|
+
// bash/zsh set process.env._ to the actual invoked binary path (e.g. /usr/local/bin/patchwork-os).
|
|
146
|
+
// More reliable than argv[1] which resolves to the .js entrypoint via npm global shim.
|
|
147
|
+
function invokedBinaryName() {
|
|
148
|
+
const fromEnv = process.env._
|
|
149
|
+
? path.basename(process.env._).replace(/\.(cmd|js)$/i, "")
|
|
150
|
+
: "";
|
|
151
|
+
if (fromEnv && fromEnv !== "node" && fromEnv !== "npm")
|
|
152
|
+
return fromEnv;
|
|
153
|
+
return path.basename(process.argv[1] ?? "").replace(/\.js$/, "");
|
|
154
|
+
}
|
|
142
155
|
const __invokedBareBinaryDashboard = (() => {
|
|
143
156
|
if (process.argv[2] && process.argv[2] !== "dashboard")
|
|
144
157
|
return false;
|
|
145
|
-
const binName =
|
|
158
|
+
const binName = invokedBinaryName();
|
|
146
159
|
return (binName === "patchwork-os" ||
|
|
147
160
|
binName === "patchwork" ||
|
|
148
161
|
binName === "patchwork.js");
|
|
@@ -153,6 +166,44 @@ if (process.argv[2] === "--version" || process.argv[2] === "-v") {
|
|
|
153
166
|
console.log(`claude-ide-bridge ${PACKAGE_VERSION}`);
|
|
154
167
|
process.exit(0);
|
|
155
168
|
}
|
|
169
|
+
// Handle top-level --help / -h / help — print a grouped command index so a
|
|
170
|
+
// first-time user has a discoverable entry point. Without this, bare
|
|
171
|
+
// `patchwork --help` falls through to bridge-daemon arg parsing and errors.
|
|
172
|
+
if (process.argv[2] === "--help" ||
|
|
173
|
+
process.argv[2] === "-h" ||
|
|
174
|
+
process.argv[2] === "help") {
|
|
175
|
+
const binName = path.basename(process.argv[1] ?? "patchwork");
|
|
176
|
+
process.stdout.write(`${binName} ${PACKAGE_VERSION}\n\n` +
|
|
177
|
+
`First time? Run:\n` +
|
|
178
|
+
` ${binName} init # set up ~/.patchwork + Claude Code hooks\n` +
|
|
179
|
+
` ${binName} start-all # bridge + Claude + dashboard\n\n` +
|
|
180
|
+
`Get started\n` +
|
|
181
|
+
` init [--workspace <dir>] Scaffold ~/.patchwork; register CC hooks\n` +
|
|
182
|
+
` install-extension Install the VS Code / Cursor / Windsurf extension\n` +
|
|
183
|
+
` start-all [--no-dashboard] Launch bridge + Claude --ide + dashboard\n` +
|
|
184
|
+
` start-orchestrator Multi-IDE-window meta-bridge\n\n` +
|
|
185
|
+
`Recipes\n` +
|
|
186
|
+
` recipe new <name> [-i] Scaffold a recipe\n` +
|
|
187
|
+
` recipe list List installed recipes\n` +
|
|
188
|
+
` recipe run <name> [--vars k=v] Run a recipe by name\n` +
|
|
189
|
+
` recipe install <source> Install from a path or GitHub source\n` +
|
|
190
|
+
` recipe --help Full recipe subcommand index\n\n` +
|
|
191
|
+
`Diagnose\n` +
|
|
192
|
+
` halts [--window 1h|24h|overnight|7d] Morning summary of recent recipe halts\n` +
|
|
193
|
+
` traces export Bundle approval / recipe / decision traces\n` +
|
|
194
|
+
` print-token [--port N] Print the active bridge auth token\n\n` +
|
|
195
|
+
`Daemon (no subcommand)\n` +
|
|
196
|
+
` --workspace <dir> Start the bridge in foreground\n` +
|
|
197
|
+
` --watch Auto-restart supervisor\n` +
|
|
198
|
+
` --slim 27 IDE-only tools (default: full)\n\n` +
|
|
199
|
+
`Other\n` +
|
|
200
|
+
` --version, -v Print package version\n` +
|
|
201
|
+
` shim stdio↔WebSocket shim (used by MCP clients)\n` +
|
|
202
|
+
` notify <event> Notify a running bridge of a CC hook event\n\n` +
|
|
203
|
+
`Bridge-daemon flags: run \`${binName} --workspace . --help-flags\` for the full list,\n` +
|
|
204
|
+
`or see https://github.com/Oolab-labs/patchwork-os#readme.\n`);
|
|
205
|
+
process.exit(0);
|
|
206
|
+
}
|
|
156
207
|
// Handle patchwork-init subcommand — T2 from docs/install-ux-plan.md.
|
|
157
208
|
// Separate from the bridge-only `init` to preserve back-compat. See ADR-0008.
|
|
158
209
|
if (process.argv[2] === "patchwork-init") {
|
|
@@ -160,6 +211,13 @@ if (process.argv[2] === "patchwork-init") {
|
|
|
160
211
|
await runPatchworkInit(process.argv.slice(3));
|
|
161
212
|
process.exit(0);
|
|
162
213
|
}
|
|
214
|
+
// `patchwork-os init` → dashboard setup, not IDE bridge installer.
|
|
215
|
+
// patchwork init / claude-ide-bridge init still go to the bridge path below.
|
|
216
|
+
if (process.argv[2] === "init" && invokedBinaryName() === "patchwork-os") {
|
|
217
|
+
const { runPatchworkInit } = await import("./commands/patchworkInit.js");
|
|
218
|
+
await runPatchworkInit(process.argv.slice(3));
|
|
219
|
+
process.exit(0);
|
|
220
|
+
}
|
|
163
221
|
// Handle start-all subcommand — launches the full 3-pane tmux orchestrator.
|
|
164
222
|
// Also triggered when invoked as `claude-ide-bridge-start` directly.
|
|
165
223
|
const isStartAll = process.argv[2] === "start-all" ||
|
|
@@ -168,8 +226,11 @@ if (isStartAll) {
|
|
|
168
226
|
const startAllArgs = process.argv[2] === "start-all"
|
|
169
227
|
? process.argv.slice(3)
|
|
170
228
|
: process.argv.slice(2);
|
|
171
|
-
|
|
172
|
-
|
|
229
|
+
// Dispatch the cross-platform Node orchestrator (start-all.mjs). The
|
|
230
|
+
// bash entry-point is kept as a developer shortcut but Windows has no
|
|
231
|
+
// `bash` on PATH by default, and the .mjs is functionally equivalent.
|
|
232
|
+
const scriptPath = path.resolve(__dirnameTop, "..", "scripts", "start-all.mjs");
|
|
233
|
+
const result = spawnSync(process.execPath, [scriptPath, ...startAllArgs], {
|
|
173
234
|
stdio: "inherit",
|
|
174
235
|
});
|
|
175
236
|
process.exit(result.status ?? 1);
|
|
@@ -177,14 +238,16 @@ if (isStartAll) {
|
|
|
177
238
|
// `patchwork start` — opinionated front door over start-all.
|
|
178
239
|
// Defaults to full mode (all tools registered) and the web dashboard, so the
|
|
179
240
|
// doc-promised "patchwork start → everything works" path actually works.
|
|
180
|
-
// Pass-through args still go to start-all.
|
|
241
|
+
// Pass-through args still go to start-all.mjs; --help short-circuits.
|
|
181
242
|
if (process.argv[2] === "start") {
|
|
182
243
|
const passthrough = process.argv.slice(3);
|
|
183
244
|
if (passthrough.includes("--help") || passthrough.includes("-h")) {
|
|
184
245
|
process.stdout.write(`patchwork start — Launch the full Patchwork stack
|
|
185
246
|
|
|
186
|
-
Starts bridge + Claude
|
|
247
|
+
Starts bridge + Claude + dashboard via the cross-platform Node orchestrator.
|
|
187
248
|
Defaults to full mode so all bridge tools are registered.
|
|
249
|
+
On macOS/Linux: uses tmux when available, falls back to background mode.
|
|
250
|
+
On Windows: runs natively via the Node orchestrator (no WSL required).
|
|
188
251
|
|
|
189
252
|
Usage: patchwork start [options]
|
|
190
253
|
|
|
@@ -206,13 +269,20 @@ This is a thin wrapper over \`start-all\`. For advanced flags see:
|
|
|
206
269
|
const args = [...passthrough];
|
|
207
270
|
const slimIdx = args.indexOf("--slim");
|
|
208
271
|
if (slimIdx >= 0) {
|
|
209
|
-
args.splice(slimIdx, 1); //
|
|
272
|
+
args.splice(slimIdx, 1); // slim is the .mjs default; strip so --full isn't re-added below
|
|
210
273
|
}
|
|
211
274
|
else if (!args.includes("--full")) {
|
|
212
275
|
args.push("--full");
|
|
213
276
|
}
|
|
214
|
-
|
|
215
|
-
|
|
277
|
+
// On non-Windows: auto-detect tmux; fall back to --no-tmux background mode if absent.
|
|
278
|
+
if (process.platform !== "win32" && !args.includes("--no-tmux")) {
|
|
279
|
+
const tmuxCheck = spawnSync("which", ["tmux"], { stdio: "ignore" });
|
|
280
|
+
if (tmuxCheck.status !== 0)
|
|
281
|
+
args.push("--no-tmux");
|
|
282
|
+
}
|
|
283
|
+
// Dispatch to the cross-platform Node orchestrator (see above).
|
|
284
|
+
const scriptPath = path.resolve(__dirnameTop, "..", "scripts", "start-all.mjs");
|
|
285
|
+
const result = spawnSync(process.execPath, [scriptPath, ...args], {
|
|
216
286
|
stdio: "inherit",
|
|
217
287
|
});
|
|
218
288
|
process.exit(result.status ?? 1);
|
|
@@ -368,13 +438,6 @@ if (process.argv[2] === "install") {
|
|
|
368
438
|
await runInstall(process.argv.slice(3));
|
|
369
439
|
process.exit(0);
|
|
370
440
|
}
|
|
371
|
-
// Handle marketplace subcommand — DEPRECATED, prints migration message.
|
|
372
|
-
// See issue #279 and src/commands/marketplace.ts for the rationale.
|
|
373
|
-
if (process.argv[2] === "marketplace") {
|
|
374
|
-
const { runMarketplace } = await import("./commands/marketplace.js");
|
|
375
|
-
await runMarketplace(process.argv.slice(3));
|
|
376
|
-
process.exit(0);
|
|
377
|
-
}
|
|
378
441
|
// Handle tools subcommand — search/list tools without a bridge connection
|
|
379
442
|
if (process.argv[2] === "tools") {
|
|
380
443
|
const { runToolsCommand } = await import("./commands/tools.js");
|
|
@@ -885,6 +948,31 @@ Edit, save, hot-reload — Claude's next turn sees the new tool. See [documents/
|
|
|
885
948
|
}
|
|
886
949
|
process.exit(0);
|
|
887
950
|
}
|
|
951
|
+
// Patchwork: `patchwork recipe` (no subcommand) / `recipe --help` — print
|
|
952
|
+
// the subcommand index. Without this branch, `patchwork recipe` falls through
|
|
953
|
+
// to the bridge daemon, leaving subcommands completely undiscoverable from
|
|
954
|
+
// the CLI (the only way to find them today is to read CLAUDE.md or source).
|
|
955
|
+
if (process.argv[2] === "recipe" &&
|
|
956
|
+
(process.argv[3] === undefined ||
|
|
957
|
+
process.argv[3] === "--help" ||
|
|
958
|
+
process.argv[3] === "-h" ||
|
|
959
|
+
process.argv[3] === "help")) {
|
|
960
|
+
process.stdout.write(`Usage: patchwork recipe <subcommand> [args...]\n\n` +
|
|
961
|
+
`Subcommands:\n` +
|
|
962
|
+
` new <name> Scaffold a recipe (interactive with -i)\n` +
|
|
963
|
+
` list List installed recipes (workspace + user)\n` +
|
|
964
|
+
` run <name> Run a recipe by name\n` +
|
|
965
|
+
` install <src> Install a recipe from a path or GitHub source\n` +
|
|
966
|
+
` uninstall <name> Remove an installed recipe\n` +
|
|
967
|
+
` enable <name> Re-enable a disabled recipe\n` +
|
|
968
|
+
` disable <name> Pause a recipe (scheduled triggers stop firing)\n` +
|
|
969
|
+
` preflight <file> Static-validate a recipe YAML before running\n` +
|
|
970
|
+
` lint <file> Run all lint checks on a recipe YAML\n` +
|
|
971
|
+
` fmt <file> Format a recipe YAML in place\n` +
|
|
972
|
+
` schema Print the recipe JSON Schema\n\n` +
|
|
973
|
+
`Run \`patchwork recipe <subcommand> --help\` for subcommand-specific options.\n`);
|
|
974
|
+
process.exit(0);
|
|
975
|
+
}
|
|
888
976
|
// Patchwork: `patchwork recipe list` — enumerate installed recipes.
|
|
889
977
|
if (process.argv[2] === "recipe" && process.argv[3] === "list") {
|
|
890
978
|
(async () => {
|
|
@@ -960,11 +1048,13 @@ if (process.argv[2] === "recipe" && process.argv[3] === "uninstall") {
|
|
|
960
1048
|
// a running bridge's /recipes/run endpoint if one is available.
|
|
961
1049
|
if (process.argv[2] === "recipe" && process.argv[3] === "run") {
|
|
962
1050
|
const args = process.argv.slice(4);
|
|
963
|
-
const usage = "Usage: patchwork recipe run <name-or-file> [--local] [--dry-run] [--step <id>] [--var KEY=VALUE]\n";
|
|
1051
|
+
const usage = "Usage: patchwork recipe run <name-or-file> [--local] [--dry-run] [--step <id>] [--var KEY=VALUE] [--attempt <id>] [--ledger-dir <path>]\n";
|
|
964
1052
|
let localFlag = false;
|
|
965
1053
|
let dryRun = false;
|
|
966
1054
|
let recipeRef;
|
|
967
1055
|
let step;
|
|
1056
|
+
let attemptId;
|
|
1057
|
+
let ledgerDir;
|
|
968
1058
|
const vars = {};
|
|
969
1059
|
for (let i = 0; i < args.length; i++) {
|
|
970
1060
|
const arg = args[i];
|
|
@@ -991,6 +1081,29 @@ if (process.argv[2] === "recipe" && process.argv[3] === "run") {
|
|
|
991
1081
|
step = value;
|
|
992
1082
|
continue;
|
|
993
1083
|
}
|
|
1084
|
+
if (currentArg === "--attempt" || currentArg.startsWith("--attempt=")) {
|
|
1085
|
+
const value = currentArg === "--attempt"
|
|
1086
|
+
? args[++i]
|
|
1087
|
+
: currentArg.slice("--attempt=".length);
|
|
1088
|
+
if (!value) {
|
|
1089
|
+
process.stderr.write(`Error: --attempt requires a value\n${usage}`);
|
|
1090
|
+
process.exit(1);
|
|
1091
|
+
}
|
|
1092
|
+
attemptId = value;
|
|
1093
|
+
continue;
|
|
1094
|
+
}
|
|
1095
|
+
if (currentArg === "--ledger-dir" ||
|
|
1096
|
+
currentArg.startsWith("--ledger-dir=")) {
|
|
1097
|
+
const value = currentArg === "--ledger-dir"
|
|
1098
|
+
? args[++i]
|
|
1099
|
+
: currentArg.slice("--ledger-dir=".length);
|
|
1100
|
+
if (!value) {
|
|
1101
|
+
process.stderr.write(`Error: --ledger-dir requires a value\n${usage}`);
|
|
1102
|
+
process.exit(1);
|
|
1103
|
+
}
|
|
1104
|
+
ledgerDir = value;
|
|
1105
|
+
continue;
|
|
1106
|
+
}
|
|
994
1107
|
if (currentArg === "--var" || currentArg.startsWith("--var=")) {
|
|
995
1108
|
const assignment = currentArg === "--var" ? args[++i] : currentArg.slice("--var=".length);
|
|
996
1109
|
if (!assignment) {
|
|
@@ -1037,7 +1150,7 @@ if (process.argv[2] === "recipe" && process.argv[3] === "run") {
|
|
|
1037
1150
|
})();
|
|
1038
1151
|
const { findBridgeLock } = await import("./bridgeLockDiscovery.js");
|
|
1039
1152
|
const lock = localFlag ? null : findBridgeLock();
|
|
1040
|
-
if (lock && !dryRun && !step && !explicitFile) {
|
|
1153
|
+
if (lock && !dryRun && !step && !explicitFile && !attemptId) {
|
|
1041
1154
|
const res = await fetch(`http://127.0.0.1:${lock.port}/recipes/run`, {
|
|
1042
1155
|
method: "POST",
|
|
1043
1156
|
headers: {
|
|
@@ -1080,9 +1193,37 @@ if (process.argv[2] === "recipe" && process.argv[3] === "run") {
|
|
|
1080
1193
|
? ` Running step "${step}" from recipe "${recipeArg}" locally…\n`
|
|
1081
1194
|
: ` Running recipe "${recipeArg}" locally…\n`);
|
|
1082
1195
|
const workdir = lock?.workspace || process.cwd();
|
|
1196
|
+
// PR5c — resume support: when --attempt is given, mint or reuse a
|
|
1197
|
+
// stable id and point the runner at a disk-backed effect ledger.
|
|
1198
|
+
// `--attempt new` always mints a fresh id; any other value is
|
|
1199
|
+
// taken verbatim (so the user can re-run the same attempt and
|
|
1200
|
+
// skip already-completed write tools).
|
|
1201
|
+
let resolvedAttempt;
|
|
1202
|
+
let resolvedLedgerDir;
|
|
1203
|
+
if (attemptId !== undefined) {
|
|
1204
|
+
resolvedAttempt =
|
|
1205
|
+
attemptId === "new"
|
|
1206
|
+
? `mr_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
|
|
1207
|
+
: attemptId;
|
|
1208
|
+
// Validate at the CLI boundary so an invalid id fails loudly
|
|
1209
|
+
// before any side effects run (and before it lands in the run
|
|
1210
|
+
// log or hashed into a ledger scope key).
|
|
1211
|
+
try {
|
|
1212
|
+
const { assertValidManualRunId } = await import("./recipes/idempotencyKey.js");
|
|
1213
|
+
assertValidManualRunId(resolvedAttempt);
|
|
1214
|
+
}
|
|
1215
|
+
catch (err) {
|
|
1216
|
+
process.stderr.write(`Error: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
1217
|
+
process.exit(1);
|
|
1218
|
+
}
|
|
1219
|
+
resolvedLedgerDir = ledgerDir ?? path.join(os.homedir(), ".patchwork");
|
|
1220
|
+
process.stdout.write(` Attempt id: ${resolvedAttempt} (ledger: ${resolvedLedgerDir})\n`);
|
|
1221
|
+
}
|
|
1083
1222
|
const run = await runRecipe(recipeArg, {
|
|
1084
1223
|
...(step ? { step } : {}),
|
|
1085
1224
|
...(seedVars ? { vars: seedVars } : {}),
|
|
1225
|
+
...(resolvedAttempt && { manualRunId: resolvedAttempt }),
|
|
1226
|
+
...(resolvedLedgerDir && { ledgerDir: resolvedLedgerDir }),
|
|
1086
1227
|
workdir,
|
|
1087
1228
|
});
|
|
1088
1229
|
if (run.stepSelection) {
|
|
@@ -1435,6 +1576,478 @@ if (process.argv[2] === "traces" && process.argv[3] === "import") {
|
|
|
1435
1576
|
}
|
|
1436
1577
|
})();
|
|
1437
1578
|
}
|
|
1579
|
+
// `patchwork kill-switch engage|release|status` — issue #422 step 3.
|
|
1580
|
+
//
|
|
1581
|
+
// Discovers the running bridge via lock file, POSTs /kill-switch with
|
|
1582
|
+
// Bearer auth, and surfaces structured errors (env-locked, no-bridge,
|
|
1583
|
+
// wedged-bridge). Multi-bridge fan-out: iterates ALL live `isBridge:true`
|
|
1584
|
+
// locks and engages/releases each (v2-B2 from #422).
|
|
1585
|
+
//
|
|
1586
|
+
// v2-I4: mandatory 10s deadline per request. No silent fallback on
|
|
1587
|
+
// timeout/ECONNREFUSED/non-2xx — error message + exit non-zero.
|
|
1588
|
+
if (process.argv[2] === "kill-switch") {
|
|
1589
|
+
const sub = process.argv[3];
|
|
1590
|
+
if (!sub || (sub !== "engage" && sub !== "release" && sub !== "status")) {
|
|
1591
|
+
process.stderr.write('Usage: patchwork kill-switch <engage|release|status> [--reason "..."]\n' +
|
|
1592
|
+
"\n" +
|
|
1593
|
+
" engage Block all write-tier tool calls across every running bridge.\n" +
|
|
1594
|
+
" release Resume writes.\n" +
|
|
1595
|
+
" status Print engaged/locked state per running bridge.\n" +
|
|
1596
|
+
"\n" +
|
|
1597
|
+
"Exits non-zero if any bridge is unreachable or env-locked.\n");
|
|
1598
|
+
process.exit(1);
|
|
1599
|
+
}
|
|
1600
|
+
(async () => {
|
|
1601
|
+
try {
|
|
1602
|
+
// Parse optional flags early so --force-local can be used without a bridge.
|
|
1603
|
+
const args = process.argv.slice(4);
|
|
1604
|
+
const reasonIdx = args.findIndex((a) => a === "--reason" || a === "-m");
|
|
1605
|
+
const reason = reasonIdx >= 0 && reasonIdx + 1 < args.length
|
|
1606
|
+
? args[reasonIdx + 1]
|
|
1607
|
+
: undefined;
|
|
1608
|
+
// v2-I4: --force-local writes flags.json directly when no live bridge
|
|
1609
|
+
// is reachable. The running bridge's fs.watch (v2-S1) picks up the
|
|
1610
|
+
// change within ~100ms; without a running bridge this is "effective
|
|
1611
|
+
// next boot" — which is still better than a silent noop.
|
|
1612
|
+
const forceLocal = args.includes("--force-local");
|
|
1613
|
+
// v2-B2: enumerate ALL live bridge locks (not just the first).
|
|
1614
|
+
const { findAllLiveBridges } = await import("./bridgeLockDiscovery.js");
|
|
1615
|
+
const liveLocks = findAllLiveBridges();
|
|
1616
|
+
if (liveLocks.length === 0) {
|
|
1617
|
+
if (forceLocal && (sub === "engage" || sub === "release")) {
|
|
1618
|
+
// --force-local: write flags.json directly. The running bridge's
|
|
1619
|
+
// fs.watch picks this up within ~100ms; if the bridge is wedged
|
|
1620
|
+
// or not started, this is effective on next start.
|
|
1621
|
+
const { setFlag, KILL_SWITCH_WRITES } = await import("./featureFlags.js");
|
|
1622
|
+
const engage = sub === "engage";
|
|
1623
|
+
setFlag(KILL_SWITCH_WRITES, engage, true);
|
|
1624
|
+
// Audit in a sibling CLI-only JSONL (v2-I10: bridge-only writes
|
|
1625
|
+
// go to decision_traces.jsonl; CLI fallback is distinct).
|
|
1626
|
+
const os = await import("node:os");
|
|
1627
|
+
const path = await import("node:path");
|
|
1628
|
+
const fs = await import("node:fs");
|
|
1629
|
+
const cliTraceFile = path.join(process.env.PATCHWORK_HOME ??
|
|
1630
|
+
path.join(os.default.homedir(), ".patchwork"), "decision_traces.cli.jsonl");
|
|
1631
|
+
const dir = path.dirname(cliTraceFile);
|
|
1632
|
+
if (!fs.existsSync(dir))
|
|
1633
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
1634
|
+
const entry = JSON.stringify({
|
|
1635
|
+
ts: new Date().toISOString(),
|
|
1636
|
+
event: engage ? "engage" : "release",
|
|
1637
|
+
actor: "cli-force-local",
|
|
1638
|
+
...(reason ? { reason } : {}),
|
|
1639
|
+
});
|
|
1640
|
+
fs.appendFileSync(cliTraceFile, `${entry}\n`);
|
|
1641
|
+
process.stdout.write(` ✓ kill-switch ${engage ? "ENGAGED" : "released"} via --force-local (flags.json written directly).\n` +
|
|
1642
|
+
" Running bridges will pick this up via fs.watch within ~100ms.\n");
|
|
1643
|
+
process.exit(0);
|
|
1644
|
+
}
|
|
1645
|
+
process.stderr.write("No running bridge found.\n" +
|
|
1646
|
+
" - For `engage`/`release`, kill-switch has no live target to update.\n" +
|
|
1647
|
+
" - Use --force-local to write flags.json directly (bridge fs.watch picks it up).\n" +
|
|
1648
|
+
" - Or restart the bridge and re-run this command.\n");
|
|
1649
|
+
process.exit(2);
|
|
1650
|
+
}
|
|
1651
|
+
// v2-I4: 10s per-request deadline. AbortController per call.
|
|
1652
|
+
async function callBridge(lock, method, body) {
|
|
1653
|
+
const controller = new AbortController();
|
|
1654
|
+
const timer = setTimeout(() => controller.abort(), 10_000);
|
|
1655
|
+
try {
|
|
1656
|
+
const res = await fetch(`http://127.0.0.1:${lock.port}/kill-switch`, {
|
|
1657
|
+
method,
|
|
1658
|
+
headers: {
|
|
1659
|
+
Authorization: `Bearer ${lock.authToken}`,
|
|
1660
|
+
"Content-Type": "application/json",
|
|
1661
|
+
},
|
|
1662
|
+
...(body ? { body: JSON.stringify(body) } : {}),
|
|
1663
|
+
signal: controller.signal,
|
|
1664
|
+
});
|
|
1665
|
+
let json;
|
|
1666
|
+
try {
|
|
1667
|
+
json = (await res.json());
|
|
1668
|
+
}
|
|
1669
|
+
catch {
|
|
1670
|
+
json = undefined;
|
|
1671
|
+
}
|
|
1672
|
+
return {
|
|
1673
|
+
ok: res.status >= 200 && res.status < 300,
|
|
1674
|
+
status: res.status,
|
|
1675
|
+
...(json ? { json } : {}),
|
|
1676
|
+
};
|
|
1677
|
+
}
|
|
1678
|
+
catch (err) {
|
|
1679
|
+
return {
|
|
1680
|
+
ok: false,
|
|
1681
|
+
status: 0,
|
|
1682
|
+
error: err instanceof Error ? err.message : String(err),
|
|
1683
|
+
};
|
|
1684
|
+
}
|
|
1685
|
+
finally {
|
|
1686
|
+
clearTimeout(timer);
|
|
1687
|
+
}
|
|
1688
|
+
}
|
|
1689
|
+
if (sub === "status") {
|
|
1690
|
+
let anyFailed = false;
|
|
1691
|
+
for (const lock of liveLocks) {
|
|
1692
|
+
const result = await callBridge(lock, "GET");
|
|
1693
|
+
if (!result.ok) {
|
|
1694
|
+
anyFailed = true;
|
|
1695
|
+
process.stderr.write(` ✗ bridge pid=${lock.pid} port=${lock.port} unreachable (${result.error ?? `status ${result.status}`})\n`);
|
|
1696
|
+
continue;
|
|
1697
|
+
}
|
|
1698
|
+
const j = result.json ?? {};
|
|
1699
|
+
const engaged = j.engaged === true ? "ENGAGED" : "released";
|
|
1700
|
+
const lockedSuffix = j.locked
|
|
1701
|
+
? ` [env-locked: ${j.lockedReason ?? "yes"}]`
|
|
1702
|
+
: "";
|
|
1703
|
+
const wsLabel = lock.workspace
|
|
1704
|
+
? lock.workspace.split("/").slice(-2).join("/")
|
|
1705
|
+
: `pid=${lock.pid}`;
|
|
1706
|
+
process.stdout.write(` ${engaged} port=${lock.port} ${wsLabel}${lockedSuffix}\n`);
|
|
1707
|
+
}
|
|
1708
|
+
process.exit(anyFailed ? 2 : 0);
|
|
1709
|
+
}
|
|
1710
|
+
// engage / release: POST to every live bridge, surface aggregate result.
|
|
1711
|
+
const engage = sub === "engage";
|
|
1712
|
+
let anyFailed = false;
|
|
1713
|
+
let anyChanged = false;
|
|
1714
|
+
for (const lock of liveLocks) {
|
|
1715
|
+
const result = await callBridge(lock, "POST", {
|
|
1716
|
+
engage,
|
|
1717
|
+
...(reason ? { reason } : {}),
|
|
1718
|
+
});
|
|
1719
|
+
const wsLabel = lock.workspace
|
|
1720
|
+
? lock.workspace.split("/").slice(-2).join("/")
|
|
1721
|
+
: `pid=${lock.pid}`;
|
|
1722
|
+
if (result.status === 409) {
|
|
1723
|
+
anyFailed = true;
|
|
1724
|
+
const lr = result.json?.lockedReason ??
|
|
1725
|
+
"env-locked at boot";
|
|
1726
|
+
process.stderr.write(` ✗ port=${lock.port} ${wsLabel}: cannot ${sub} — ${lr}\n`);
|
|
1727
|
+
continue;
|
|
1728
|
+
}
|
|
1729
|
+
if (!result.ok) {
|
|
1730
|
+
anyFailed = true;
|
|
1731
|
+
process.stderr.write(` ✗ port=${lock.port} ${wsLabel}: ${result.error ?? `status ${result.status}`}\n`);
|
|
1732
|
+
continue;
|
|
1733
|
+
}
|
|
1734
|
+
const j = result.json ?? {};
|
|
1735
|
+
const changedTag = j.changed === true ? "" : " (no-op, already in state)";
|
|
1736
|
+
if (j.changed === true)
|
|
1737
|
+
anyChanged = true;
|
|
1738
|
+
process.stdout.write(` ✓ port=${lock.port} ${wsLabel}: ${engage ? "ENGAGED" : "released"}${changedTag}\n`);
|
|
1739
|
+
}
|
|
1740
|
+
if (anyFailed) {
|
|
1741
|
+
process.exit(2);
|
|
1742
|
+
}
|
|
1743
|
+
if (!anyChanged) {
|
|
1744
|
+
process.stdout.write(`\n All ${liveLocks.length} bridge${liveLocks.length === 1 ? "" : "s"} already in target state — no audit emit.\n`);
|
|
1745
|
+
}
|
|
1746
|
+
else {
|
|
1747
|
+
process.stdout.write(`\n Kill-switch ${engage ? "engaged" : "released"} on ${liveLocks.length} bridge${liveLocks.length === 1 ? "" : "s"}.\n`);
|
|
1748
|
+
}
|
|
1749
|
+
process.exit(0);
|
|
1750
|
+
}
|
|
1751
|
+
catch (err) {
|
|
1752
|
+
process.stderr.write(`Error: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
1753
|
+
process.exit(1);
|
|
1754
|
+
}
|
|
1755
|
+
})();
|
|
1756
|
+
}
|
|
1757
|
+
// `patchwork panic` — alias for `patchwork kill-switch engage` (v2-Strong-2).
|
|
1758
|
+
//
|
|
1759
|
+
// Discoverable under stress (short command, obvious intent). Canonical noun
|
|
1760
|
+
// form is `kill-switch engage`; this alias matches it so shell history six
|
|
1761
|
+
// months later still makes sense. Does not accept sub-verbs — just runs engage.
|
|
1762
|
+
if (process.argv[2] === "panic") {
|
|
1763
|
+
// Spawn self with kill-switch engage to reuse the full handler without
|
|
1764
|
+
// duplicating 200+ LOC. Passes through any flags (--reason, --force-local).
|
|
1765
|
+
import("node:child_process").then(({ spawnSync }) => {
|
|
1766
|
+
const self = process.argv[1] ?? process.execPath;
|
|
1767
|
+
const extra = process.argv.slice(3); // e.g. --reason "..." --force-local
|
|
1768
|
+
const result = spawnSync(process.execPath, [self, "kill-switch", "engage", ...extra], { stdio: "inherit" });
|
|
1769
|
+
process.exit(result.status ?? 1);
|
|
1770
|
+
});
|
|
1771
|
+
}
|
|
1772
|
+
// `patchwork halts` — one-screen morning summary of recent recipe halts.
|
|
1773
|
+
//
|
|
1774
|
+
// Composes the haltReason field (#441), category aggregator + endpoint
|
|
1775
|
+
// (#444), and dashboard pill conventions: queries the live bridge's
|
|
1776
|
+
// /runs/halt-summary endpoint over the chosen window and prints a
|
|
1777
|
+
// per-category breakdown plus the 5 most-recent halt reasons. Default
|
|
1778
|
+
// window is "overnight" (since 6pm yesterday local) so it lines up with
|
|
1779
|
+
// "what halted while I was asleep?".
|
|
1780
|
+
if (process.argv[2] === "halts") {
|
|
1781
|
+
const args = process.argv.slice(3);
|
|
1782
|
+
const wantHelp = args.includes("--help") || args.includes("-h");
|
|
1783
|
+
if (wantHelp) {
|
|
1784
|
+
process.stdout.write("Usage: patchwork halts [--window <name>] [--recipe <name>] [--json]\n" +
|
|
1785
|
+
"\n" +
|
|
1786
|
+
" --window 1h | 24h | overnight | 7d | any (default: overnight)\n" +
|
|
1787
|
+
" --recipe <name> filter to one recipe by name\n" +
|
|
1788
|
+
" --json emit raw JSON (for scripting)\n" +
|
|
1789
|
+
"\n" +
|
|
1790
|
+
'"overnight" = since 6pm yesterday local time.\n');
|
|
1791
|
+
process.exit(0);
|
|
1792
|
+
}
|
|
1793
|
+
function parseWindow() {
|
|
1794
|
+
const idx = args.findIndex((a) => a === "--window" || a === "-w");
|
|
1795
|
+
const raw = idx >= 0 && idx + 1 < args.length ? args[idx + 1] : "overnight";
|
|
1796
|
+
if (raw === "1h" ||
|
|
1797
|
+
raw === "24h" ||
|
|
1798
|
+
raw === "overnight" ||
|
|
1799
|
+
raw === "7d" ||
|
|
1800
|
+
raw === "any")
|
|
1801
|
+
return raw;
|
|
1802
|
+
process.stderr.write(`Unknown --window value: "${raw}"\n`);
|
|
1803
|
+
process.exit(1);
|
|
1804
|
+
}
|
|
1805
|
+
function windowSinceMs(w) {
|
|
1806
|
+
if (w === "any")
|
|
1807
|
+
return null;
|
|
1808
|
+
if (w === "1h")
|
|
1809
|
+
return 60 * 60 * 1000;
|
|
1810
|
+
if (w === "24h")
|
|
1811
|
+
return 24 * 60 * 60 * 1000;
|
|
1812
|
+
if (w === "7d")
|
|
1813
|
+
return 7 * 24 * 60 * 60 * 1000;
|
|
1814
|
+
const d = new Date();
|
|
1815
|
+
d.setHours(18, 0, 0, 0);
|
|
1816
|
+
if (d.getTime() > Date.now())
|
|
1817
|
+
d.setDate(d.getDate() - 1);
|
|
1818
|
+
return Date.now() - d.getTime();
|
|
1819
|
+
}
|
|
1820
|
+
const window = parseWindow();
|
|
1821
|
+
const wantJson = args.includes("--json");
|
|
1822
|
+
const recipeIdx = args.findIndex((a) => a === "--recipe" || a === "-r");
|
|
1823
|
+
const recipeFilter = recipeIdx >= 0 && recipeIdx + 1 < args.length
|
|
1824
|
+
? args[recipeIdx + 1]
|
|
1825
|
+
: undefined;
|
|
1826
|
+
(async () => {
|
|
1827
|
+
try {
|
|
1828
|
+
const { findAllLiveBridges } = await import("./bridgeLockDiscovery.js");
|
|
1829
|
+
const liveLocks = findAllLiveBridges();
|
|
1830
|
+
if (liveLocks.length === 0) {
|
|
1831
|
+
process.stderr.write("No running bridge found. Start one with `patchwork start` (or `--driver subprocess`).\n");
|
|
1832
|
+
process.exit(2);
|
|
1833
|
+
}
|
|
1834
|
+
// Single-bridge default: query the first. Multi-bridge users will
|
|
1835
|
+
// typically have one orchestrator anyway; expanding to fan-out is a
|
|
1836
|
+
// follow-up if needed.
|
|
1837
|
+
const lock = liveLocks[0];
|
|
1838
|
+
if (!lock) {
|
|
1839
|
+
process.stderr.write("No running bridge found.\n");
|
|
1840
|
+
process.exit(2);
|
|
1841
|
+
}
|
|
1842
|
+
const sinceMs = windowSinceMs(window);
|
|
1843
|
+
const params = [];
|
|
1844
|
+
if (sinceMs != null)
|
|
1845
|
+
params.push(`sinceMs=${sinceMs}`);
|
|
1846
|
+
if (recipeFilter)
|
|
1847
|
+
params.push(`recipe=${encodeURIComponent(recipeFilter)}`);
|
|
1848
|
+
const qs = params.length > 0 ? `?${params.join("&")}` : "";
|
|
1849
|
+
const controller = new AbortController();
|
|
1850
|
+
const timer = setTimeout(() => controller.abort(), 10_000);
|
|
1851
|
+
let res;
|
|
1852
|
+
try {
|
|
1853
|
+
res = await fetch(`http://127.0.0.1:${lock.port}/runs/halt-summary${qs}`, {
|
|
1854
|
+
headers: { Authorization: `Bearer ${lock.authToken}` },
|
|
1855
|
+
signal: controller.signal,
|
|
1856
|
+
});
|
|
1857
|
+
}
|
|
1858
|
+
finally {
|
|
1859
|
+
clearTimeout(timer);
|
|
1860
|
+
}
|
|
1861
|
+
if (!res.ok) {
|
|
1862
|
+
process.stderr.write(`Bridge returned ${res.status} for /runs/halt-summary\n`);
|
|
1863
|
+
process.exit(1);
|
|
1864
|
+
}
|
|
1865
|
+
const summary = (await res.json());
|
|
1866
|
+
if (wantJson) {
|
|
1867
|
+
process.stdout.write(`${JSON.stringify(summary, null, 2)}\n`);
|
|
1868
|
+
process.exit(0);
|
|
1869
|
+
}
|
|
1870
|
+
const labels = {
|
|
1871
|
+
agent_silent_fail: "agent silent-fail",
|
|
1872
|
+
agent_narration_only: "agent narration-only",
|
|
1873
|
+
agent_threw: "agent threw",
|
|
1874
|
+
tool_threw: "tool threw",
|
|
1875
|
+
tool_error: "tool error",
|
|
1876
|
+
kill_switch: "kill-switch blocked",
|
|
1877
|
+
run_level: "run-level halt",
|
|
1878
|
+
unknown: "uncategorised",
|
|
1879
|
+
};
|
|
1880
|
+
const windowLabel = {
|
|
1881
|
+
"1h": "last hour",
|
|
1882
|
+
"24h": "last 24h",
|
|
1883
|
+
overnight: "since 6pm yesterday",
|
|
1884
|
+
"7d": "last 7 days",
|
|
1885
|
+
any: "all time",
|
|
1886
|
+
};
|
|
1887
|
+
const recipeSuffix = recipeFilter ? ` · recipe="${recipeFilter}"` : "";
|
|
1888
|
+
process.stdout.write(`Halts — ${windowLabel[window]}${recipeSuffix}\n`);
|
|
1889
|
+
process.stdout.write(`Total: ${summary.total}\n`);
|
|
1890
|
+
if (summary.total === 0) {
|
|
1891
|
+
process.stdout.write("\n (nothing halted in this window)\n");
|
|
1892
|
+
process.exit(0);
|
|
1893
|
+
}
|
|
1894
|
+
const entries = Object.entries(summary.byCategory).sort(([, a], [, b]) => b - a);
|
|
1895
|
+
process.stdout.write("\nBy category:\n");
|
|
1896
|
+
for (const [cat, count] of entries) {
|
|
1897
|
+
const label = labels[cat] ?? cat;
|
|
1898
|
+
process.stdout.write(` ${String(count).padStart(3)} ${label}\n`);
|
|
1899
|
+
}
|
|
1900
|
+
if (summary.recent.length > 0) {
|
|
1901
|
+
process.stdout.write("\nMost recent:\n");
|
|
1902
|
+
for (const r of summary.recent) {
|
|
1903
|
+
// Truncate the reason to ~120 chars so a wide stack trace
|
|
1904
|
+
// can't blow up the terminal width on phones / narrow panes.
|
|
1905
|
+
const reason = r.reason.length > 120 ? `${r.reason.slice(0, 117)}…` : r.reason;
|
|
1906
|
+
process.stdout.write(` #${r.runSeq} [${r.category}] ${reason}\n`);
|
|
1907
|
+
}
|
|
1908
|
+
}
|
|
1909
|
+
process.exit(0);
|
|
1910
|
+
}
|
|
1911
|
+
catch (err) {
|
|
1912
|
+
process.stderr.write(`Error: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
1913
|
+
process.exit(1);
|
|
1914
|
+
}
|
|
1915
|
+
})();
|
|
1916
|
+
}
|
|
1917
|
+
// `patchwork judgments` — PR3b sibling of `patchwork halts`. Same window
|
|
1918
|
+
// + recipe filter shape; queries /runs/judge-summary and prints a
|
|
1919
|
+
// per-verdict breakdown plus the 5 most-recent verdicts.
|
|
1920
|
+
if (process.argv[2] === "judgments") {
|
|
1921
|
+
const args = process.argv.slice(3);
|
|
1922
|
+
const wantHelp = args.includes("--help") || args.includes("-h");
|
|
1923
|
+
if (wantHelp) {
|
|
1924
|
+
process.stdout.write("Usage: patchwork judgments [--window <name>] [--recipe <name>] [--json]\n" +
|
|
1925
|
+
"\n" +
|
|
1926
|
+
" --window 1h | 24h | overnight | 7d | any (default: overnight)\n" +
|
|
1927
|
+
" --recipe <name> filter to one recipe by name\n" +
|
|
1928
|
+
" --json emit raw JSON (for scripting)\n" +
|
|
1929
|
+
"\n" +
|
|
1930
|
+
'"overnight" = since 6pm yesterday local time.\n');
|
|
1931
|
+
process.exit(0);
|
|
1932
|
+
}
|
|
1933
|
+
function parseWindow() {
|
|
1934
|
+
const idx = args.findIndex((a) => a === "--window" || a === "-w");
|
|
1935
|
+
const raw = idx >= 0 && idx + 1 < args.length ? args[idx + 1] : "overnight";
|
|
1936
|
+
if (raw === "1h" ||
|
|
1937
|
+
raw === "24h" ||
|
|
1938
|
+
raw === "overnight" ||
|
|
1939
|
+
raw === "7d" ||
|
|
1940
|
+
raw === "any")
|
|
1941
|
+
return raw;
|
|
1942
|
+
process.stderr.write(`Unknown --window value: "${raw}"\n`);
|
|
1943
|
+
process.exit(1);
|
|
1944
|
+
}
|
|
1945
|
+
function windowSinceMs(w) {
|
|
1946
|
+
if (w === "any")
|
|
1947
|
+
return null;
|
|
1948
|
+
if (w === "1h")
|
|
1949
|
+
return 60 * 60 * 1000;
|
|
1950
|
+
if (w === "24h")
|
|
1951
|
+
return 24 * 60 * 60 * 1000;
|
|
1952
|
+
if (w === "7d")
|
|
1953
|
+
return 7 * 24 * 60 * 60 * 1000;
|
|
1954
|
+
const d = new Date();
|
|
1955
|
+
d.setHours(18, 0, 0, 0);
|
|
1956
|
+
if (d.getTime() > Date.now())
|
|
1957
|
+
d.setDate(d.getDate() - 1);
|
|
1958
|
+
return Date.now() - d.getTime();
|
|
1959
|
+
}
|
|
1960
|
+
const window = parseWindow();
|
|
1961
|
+
const wantJson = args.includes("--json");
|
|
1962
|
+
const recipeIdx = args.findIndex((a) => a === "--recipe" || a === "-r");
|
|
1963
|
+
const recipeFilter = recipeIdx >= 0 && recipeIdx + 1 < args.length
|
|
1964
|
+
? args[recipeIdx + 1]
|
|
1965
|
+
: undefined;
|
|
1966
|
+
(async () => {
|
|
1967
|
+
try {
|
|
1968
|
+
const { findAllLiveBridges } = await import("./bridgeLockDiscovery.js");
|
|
1969
|
+
const liveLocks = findAllLiveBridges();
|
|
1970
|
+
if (liveLocks.length === 0) {
|
|
1971
|
+
process.stderr.write("No running bridge found. Start one with `patchwork start` (or `--driver subprocess`).\n");
|
|
1972
|
+
process.exit(2);
|
|
1973
|
+
}
|
|
1974
|
+
const lock = liveLocks[0];
|
|
1975
|
+
if (!lock) {
|
|
1976
|
+
process.stderr.write("No running bridge found.\n");
|
|
1977
|
+
process.exit(2);
|
|
1978
|
+
}
|
|
1979
|
+
const sinceMs = windowSinceMs(window);
|
|
1980
|
+
const params = [];
|
|
1981
|
+
if (sinceMs != null)
|
|
1982
|
+
params.push(`sinceMs=${sinceMs}`);
|
|
1983
|
+
if (recipeFilter)
|
|
1984
|
+
params.push(`recipe=${encodeURIComponent(recipeFilter)}`);
|
|
1985
|
+
const qs = params.length > 0 ? `?${params.join("&")}` : "";
|
|
1986
|
+
const controller = new AbortController();
|
|
1987
|
+
const timer = setTimeout(() => controller.abort(), 10_000);
|
|
1988
|
+
let res;
|
|
1989
|
+
try {
|
|
1990
|
+
res = await fetch(`http://127.0.0.1:${lock.port}/runs/judge-summary${qs}`, {
|
|
1991
|
+
headers: { Authorization: `Bearer ${lock.authToken}` },
|
|
1992
|
+
signal: controller.signal,
|
|
1993
|
+
});
|
|
1994
|
+
}
|
|
1995
|
+
finally {
|
|
1996
|
+
clearTimeout(timer);
|
|
1997
|
+
}
|
|
1998
|
+
if (!res.ok) {
|
|
1999
|
+
process.stderr.write(`Bridge returned ${res.status} for /runs/judge-summary\n`);
|
|
2000
|
+
process.exit(1);
|
|
2001
|
+
}
|
|
2002
|
+
const summary = (await res.json());
|
|
2003
|
+
if (wantJson) {
|
|
2004
|
+
process.stdout.write(`${JSON.stringify(summary, null, 2)}\n`);
|
|
2005
|
+
process.exit(0);
|
|
2006
|
+
}
|
|
2007
|
+
const labels = {
|
|
2008
|
+
approve: "approve",
|
|
2009
|
+
request_changes: "request changes",
|
|
2010
|
+
unparseable: "unparseable",
|
|
2011
|
+
};
|
|
2012
|
+
const windowLabel = {
|
|
2013
|
+
"1h": "last hour",
|
|
2014
|
+
"24h": "last 24h",
|
|
2015
|
+
overnight: "since 6pm yesterday",
|
|
2016
|
+
"7d": "last 7 days",
|
|
2017
|
+
any: "all time",
|
|
2018
|
+
};
|
|
2019
|
+
const recipeSuffix = recipeFilter ? ` · recipe="${recipeFilter}"` : "";
|
|
2020
|
+
process.stdout.write(`Judgments — ${windowLabel[window]}${recipeSuffix}\n`);
|
|
2021
|
+
process.stdout.write(`Total: ${summary.total}\n`);
|
|
2022
|
+
if (summary.total === 0) {
|
|
2023
|
+
process.stdout.write("\n (no judge steps fired in this window)\n");
|
|
2024
|
+
process.exit(0);
|
|
2025
|
+
}
|
|
2026
|
+
const entries = Object.entries(summary.byVerdict).sort(([, a], [, b]) => b - a);
|
|
2027
|
+
process.stdout.write("\nBy verdict:\n");
|
|
2028
|
+
for (const [verdict, count] of entries) {
|
|
2029
|
+
const label = labels[verdict] ?? verdict;
|
|
2030
|
+
process.stdout.write(` ${String(count).padStart(3)} ${label}\n`);
|
|
2031
|
+
}
|
|
2032
|
+
if (summary.recent.length > 0) {
|
|
2033
|
+
process.stdout.write("\nMost recent:\n");
|
|
2034
|
+
for (const r of summary.recent) {
|
|
2035
|
+
const reason = r.firstReason
|
|
2036
|
+
? r.firstReason.length > 120
|
|
2037
|
+
? `${r.firstReason.slice(0, 117)}…`
|
|
2038
|
+
: r.firstReason
|
|
2039
|
+
: "(no reason)";
|
|
2040
|
+
process.stdout.write(` #${r.runSeq} [${r.verdict}] ${r.stepId}: ${reason}\n`);
|
|
2041
|
+
}
|
|
2042
|
+
}
|
|
2043
|
+
process.exit(0);
|
|
2044
|
+
}
|
|
2045
|
+
catch (err) {
|
|
2046
|
+
process.stderr.write(`Error: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
2047
|
+
process.exit(1);
|
|
2048
|
+
}
|
|
2049
|
+
})();
|
|
2050
|
+
}
|
|
1438
2051
|
if (process.argv[2] === "recipe" && process.argv[3] === "schema") {
|
|
1439
2052
|
const outputDir = process.argv[4] ?? path.join(process.cwd(), "schemas");
|
|
1440
2053
|
(async () => {
|
|
@@ -1454,44 +2067,62 @@ if (process.argv[2] === "recipe" && process.argv[3] === "schema") {
|
|
|
1454
2067
|
})();
|
|
1455
2068
|
}
|
|
1456
2069
|
// Patchwork: `patchwork recipe new <name>` — scaffold a new recipe from template.
|
|
2070
|
+
// With `--interactive`, drops into a connector-aware prompt tree instead.
|
|
1457
2071
|
if (process.argv[2] === "recipe" && process.argv[3] === "new") {
|
|
1458
2072
|
const args = process.argv.slice(4);
|
|
1459
|
-
const
|
|
1460
|
-
if (
|
|
1461
|
-
process.stderr.write("Usage: patchwork recipe new <name> [--template <name>] [--desc <description>] [--out <dir>]\n" +
|
|
1462
|
-
" --out <dir> Write the recipe to <dir>/<name>.yaml.\n" +
|
|
1463
|
-
" Defaults to ~/.patchwork/recipes/ — pass `--out .` to\n" +
|
|
1464
|
-
" write into the current directory instead.\n");
|
|
1465
|
-
process.stderr.write("\nTemplates:\n");
|
|
1466
|
-
(async () => {
|
|
1467
|
-
const { listTemplates } = await import("./commands/recipe.js");
|
|
1468
|
-
for (const t of listTemplates()) {
|
|
1469
|
-
process.stderr.write(` ${t}\n`);
|
|
1470
|
-
}
|
|
1471
|
-
process.exit(1);
|
|
1472
|
-
})();
|
|
1473
|
-
}
|
|
1474
|
-
else {
|
|
2073
|
+
const isInteractive = args.includes("--interactive") || args.includes("-i");
|
|
2074
|
+
if (isInteractive) {
|
|
1475
2075
|
(async () => {
|
|
1476
2076
|
try {
|
|
1477
|
-
const {
|
|
1478
|
-
const
|
|
1479
|
-
const
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
2077
|
+
const { runNewInteractive } = await import("./commands/recipe.js");
|
|
2078
|
+
const { createInterface } = await import("node:readline/promises");
|
|
2079
|
+
const rl = createInterface({
|
|
2080
|
+
input: process.stdin,
|
|
2081
|
+
output: process.stdout,
|
|
2082
|
+
});
|
|
1483
2083
|
const outIdx = args.indexOf("--out");
|
|
1484
2084
|
const outRaw = outIdx >= 0 ? args[outIdx + 1] : undefined;
|
|
1485
|
-
// `--out .` is the common case for "scaffold in cwd" — resolve so
|
|
1486
|
-
// the success message shows the absolute path the user can open.
|
|
1487
2085
|
const outputDir = outRaw ? path.resolve(outRaw) : undefined;
|
|
1488
|
-
const
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
2086
|
+
const deps = {
|
|
2087
|
+
ask: async (q) => (await rl.question(`${q}: `)).trim(),
|
|
2088
|
+
pickFromList: async (q, options) => {
|
|
2089
|
+
process.stdout.write(`\n${q}\n`);
|
|
2090
|
+
options.forEach((opt, i) => {
|
|
2091
|
+
process.stdout.write(` ${i + 1}. ${opt}\n`);
|
|
2092
|
+
});
|
|
2093
|
+
for (let attempt = 0; attempt < 5; attempt++) {
|
|
2094
|
+
const raw = (await rl.question(`Choose 1-${options.length}: `)).trim();
|
|
2095
|
+
const idx = Number.parseInt(raw, 10);
|
|
2096
|
+
if (Number.isFinite(idx) && idx >= 1 && idx <= options.length) {
|
|
2097
|
+
return idx;
|
|
2098
|
+
}
|
|
2099
|
+
process.stdout.write(`Invalid choice. Enter a number 1-${options.length}.\n`);
|
|
2100
|
+
}
|
|
2101
|
+
throw new Error("Too many invalid choices");
|
|
2102
|
+
},
|
|
2103
|
+
confirm: async (q) => {
|
|
2104
|
+
const a = (await rl.question(`${q} [y/N]: `)).trim().toLowerCase();
|
|
2105
|
+
return a === "y" || a === "yes";
|
|
2106
|
+
},
|
|
2107
|
+
preview: (yaml) => {
|
|
2108
|
+
process.stdout.write("\n--- Preview ---\n");
|
|
2109
|
+
process.stdout.write(yaml);
|
|
2110
|
+
process.stdout.write("---\n\n");
|
|
2111
|
+
},
|
|
2112
|
+
};
|
|
2113
|
+
const result = await runNewInteractive({
|
|
2114
|
+
deps,
|
|
1492
2115
|
...(outputDir ? { outputDir } : {}),
|
|
1493
2116
|
});
|
|
2117
|
+
rl.close();
|
|
1494
2118
|
process.stdout.write(` ✓ Created ${result.path}\n`);
|
|
2119
|
+
if (result.warnings.length > 0) {
|
|
2120
|
+
process.stdout.write(`\n ⚠ Lint warnings (recipe still written):\n`);
|
|
2121
|
+
for (const w of result.warnings) {
|
|
2122
|
+
process.stdout.write(` [${w.level}] ${w.message}\n`);
|
|
2123
|
+
}
|
|
2124
|
+
}
|
|
2125
|
+
process.stdout.write(`\n Run with: patchwork recipe run ${result.path}\n`);
|
|
1495
2126
|
process.exit(0);
|
|
1496
2127
|
}
|
|
1497
2128
|
catch (err) {
|
|
@@ -1500,6 +2131,53 @@ if (process.argv[2] === "recipe" && process.argv[3] === "new") {
|
|
|
1500
2131
|
}
|
|
1501
2132
|
})();
|
|
1502
2133
|
}
|
|
2134
|
+
else {
|
|
2135
|
+
const recipeName = args[0];
|
|
2136
|
+
if (!recipeName) {
|
|
2137
|
+
process.stderr.write("Usage: patchwork recipe new <name> [--template <name>] [--desc <description>] [--out <dir>]\n" +
|
|
2138
|
+
" --interactive (-i) Run the connector-aware prompt tree instead of using a template.\n" +
|
|
2139
|
+
" --out <dir> Write the recipe to <dir>/<name>.yaml.\n" +
|
|
2140
|
+
" Defaults to ~/.patchwork/recipes/ — pass `--out .` to\n" +
|
|
2141
|
+
" write into the current directory instead.\n");
|
|
2142
|
+
process.stderr.write("\nTemplates:\n");
|
|
2143
|
+
(async () => {
|
|
2144
|
+
const { listTemplates } = await import("./commands/recipe.js");
|
|
2145
|
+
for (const t of listTemplates()) {
|
|
2146
|
+
process.stderr.write(` ${t}\n`);
|
|
2147
|
+
}
|
|
2148
|
+
process.exit(1);
|
|
2149
|
+
})();
|
|
2150
|
+
}
|
|
2151
|
+
else {
|
|
2152
|
+
(async () => {
|
|
2153
|
+
try {
|
|
2154
|
+
const { runNew } = await import("./commands/recipe.js");
|
|
2155
|
+
const templateIdx = args.indexOf("--template");
|
|
2156
|
+
const template = templateIdx >= 0 ? args[templateIdx + 1] : undefined;
|
|
2157
|
+
const descIdx = args.indexOf("--desc");
|
|
2158
|
+
const description = (descIdx >= 0 ? args[descIdx + 1] : undefined) ??
|
|
2159
|
+
`Recipe: ${recipeName}`;
|
|
2160
|
+
const outIdx = args.indexOf("--out");
|
|
2161
|
+
const outRaw = outIdx >= 0 ? args[outIdx + 1] : undefined;
|
|
2162
|
+
// `--out .` is the common case for "scaffold in cwd" — resolve so
|
|
2163
|
+
// the success message shows the absolute path the user can open.
|
|
2164
|
+
const outputDir = outRaw ? path.resolve(outRaw) : undefined;
|
|
2165
|
+
const result = runNew({
|
|
2166
|
+
name: recipeName,
|
|
2167
|
+
description,
|
|
2168
|
+
...(template ? { template } : {}),
|
|
2169
|
+
...(outputDir ? { outputDir } : {}),
|
|
2170
|
+
});
|
|
2171
|
+
process.stdout.write(` ✓ Created ${result.path}\n`);
|
|
2172
|
+
process.exit(0);
|
|
2173
|
+
}
|
|
2174
|
+
catch (err) {
|
|
2175
|
+
process.stderr.write(`Error: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
2176
|
+
process.exit(1);
|
|
2177
|
+
}
|
|
2178
|
+
})();
|
|
2179
|
+
}
|
|
2180
|
+
}
|
|
1503
2181
|
}
|
|
1504
2182
|
// Patchwork: `patchwork recipe lint <file.yaml>` — validate recipe against schema.
|
|
1505
2183
|
if (process.argv[2] === "recipe" && process.argv[3] === "lint") {
|
|
@@ -2240,13 +2918,8 @@ Steps performed:
|
|
|
2240
2918
|
let hooksWired = false;
|
|
2241
2919
|
try {
|
|
2242
2920
|
const settingsPath = path.join(os.homedir(), ".claude", "settings.json");
|
|
2243
|
-
const
|
|
2244
|
-
|
|
2245
|
-
if (hooksObj && typeof hooksObj === "object") {
|
|
2246
|
-
hooksWired = Object.values(hooksObj).flat().some((e) => typeof e?.command ===
|
|
2247
|
-
"string" &&
|
|
2248
|
-
(e.command ?? "").includes("claude-ide-bridge"));
|
|
2249
|
-
}
|
|
2921
|
+
const { isPreToolUseHookRegistered } = await import("./preToolUseHook.js");
|
|
2922
|
+
hooksWired = isPreToolUseHookRegistered(settingsPath);
|
|
2250
2923
|
}
|
|
2251
2924
|
catch {
|
|
2252
2925
|
/* file may not exist yet — non-fatal */
|
|
@@ -2581,11 +3254,24 @@ if (process.argv[2] === "launchd") {
|
|
|
2581
3254
|
// F6: "Did you mean?" for unknown CLI subcommands
|
|
2582
3255
|
// Patchwork: no-args → terminal dashboard (when invoked as patchwork-os or patchwork).
|
|
2583
3256
|
{
|
|
2584
|
-
const binName =
|
|
3257
|
+
const binName = invokedBinaryName();
|
|
2585
3258
|
const isPatchworkBin = binName === "patchwork-os" ||
|
|
2586
3259
|
binName === "patchwork" ||
|
|
2587
3260
|
binName === "patchwork.js";
|
|
2588
3261
|
if (isPatchworkBin && (!process.argv[2] || process.argv[2] === "dashboard")) {
|
|
3262
|
+
// First-run guard: if the user hasn't run `patchwork init` yet, launching
|
|
3263
|
+
// the dashboard renders an empty panel with no signpost. Print an
|
|
3264
|
+
// actionable pointer instead and exit cleanly.
|
|
3265
|
+
const cfgPath = path.join(os.homedir(), ".patchwork", "config.json");
|
|
3266
|
+
if (!existsSync(cfgPath) && !process.argv[2]) {
|
|
3267
|
+
process.stdout.write(`No Patchwork config found at ${cfgPath}.\n\n` +
|
|
3268
|
+
`Run \`${binName} init\` to scaffold ~/.patchwork and wire up\n` +
|
|
3269
|
+
`Claude Code hooks, then \`${binName}\` again to open the dashboard.\n\n` +
|
|
3270
|
+
`For just the IDE bridge (no recipes / approval queue), run:\n` +
|
|
3271
|
+
` ${binName} install-extension\n` +
|
|
3272
|
+
` ${binName} --workspace .\n`);
|
|
3273
|
+
process.exit(0);
|
|
3274
|
+
}
|
|
2589
3275
|
(async () => {
|
|
2590
3276
|
const { runDashboard } = await import("./commands/dashboard.js");
|
|
2591
3277
|
await runDashboard();
|
|
@@ -2663,8 +3349,10 @@ else {
|
|
|
2663
3349
|
.digest("hex")
|
|
2664
3350
|
.slice(0, 6);
|
|
2665
3351
|
const sessionName = `claude-bridge-${ws}${hash}`;
|
|
2666
|
-
// Check if tmux is available
|
|
2667
|
-
const tmuxCheck =
|
|
3352
|
+
// Check if tmux is available (skip on Windows — tmux doesn't exist there)
|
|
3353
|
+
const tmuxCheck = process.platform !== "win32"
|
|
3354
|
+
? spawnSync("which", ["tmux"], { stdio: "ignore" })
|
|
3355
|
+
: { status: 1 };
|
|
2668
3356
|
if (tmuxCheck.status !== 0) {
|
|
2669
3357
|
process.stderr.write("WARNING: --auto-tmux requested but tmux is not installed. Running without tmux.\n");
|
|
2670
3358
|
}
|
|
@@ -2741,19 +3429,27 @@ else {
|
|
|
2741
3429
|
process.stderr.write(`Error: ${message}\n`);
|
|
2742
3430
|
process.exit(1);
|
|
2743
3431
|
});
|
|
2744
|
-
// F5: Silent self-update nudge (fire-and-forget)
|
|
2745
|
-
|
|
2746
|
-
|
|
2747
|
-
|
|
2748
|
-
|
|
2749
|
-
|
|
2750
|
-
|
|
2751
|
-
|
|
2752
|
-
|
|
2753
|
-
}
|
|
2754
|
-
|
|
2755
|
-
|
|
2756
|
-
|
|
3432
|
+
// F5: Silent self-update nudge (fire-and-forget).
|
|
3433
|
+
// Skip when running from a source tree (any of: a `.git` sibling of the
|
|
3434
|
+
// package, or __dirnameTop not under a node_modules/). Otherwise a dev
|
|
3435
|
+
// who built locally sees "Bridge v<X> available" pointing at an npm
|
|
3436
|
+
// install path they're not using.
|
|
3437
|
+
const isSourceBuild = existsSync(path.join(__dirnameTop, "..", ".git")) ||
|
|
3438
|
+
!__dirnameTop.includes(`${path.sep}node_modules${path.sep}`);
|
|
3439
|
+
if (!isSourceBuild) {
|
|
3440
|
+
import("node:child_process")
|
|
3441
|
+
.then(({ exec }) => {
|
|
3442
|
+
exec("npm view claude-ide-bridge version", { timeout: 5000 }, (err, stdout) => {
|
|
3443
|
+
if (err || !stdout)
|
|
3444
|
+
return;
|
|
3445
|
+
const latest = stdout.trim();
|
|
3446
|
+
if (latest && semverGt(latest, PACKAGE_VERSION)) {
|
|
3447
|
+
console.log(`\n Bridge v${latest} available — run: npm update -g claude-ide-bridge\n`);
|
|
3448
|
+
}
|
|
3449
|
+
});
|
|
3450
|
+
})
|
|
3451
|
+
.catch(() => { });
|
|
3452
|
+
}
|
|
2757
3453
|
}
|
|
2758
3454
|
} // end of `else` for `if (__subcommandWillRun)` (bridge-mode block)
|
|
2759
3455
|
//# sourceMappingURL=index.js.map
|