@bridge_gpt/mcp-server 0.2.49 → 0.2.50

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.
Files changed (47) hide show
  1. package/README.md +24 -7
  2. package/build/base-ref.js +28 -3
  3. package/build/claude-review-workflow-drift-probe.js +130 -0
  4. package/build/claude-review-workflow-drift.js +173 -0
  5. package/build/claude-review-workflow.js +81 -16
  6. package/build/commands.generated.js +5 -5
  7. package/build/conductor/done-gate.js +25 -3
  8. package/build/conductor/install-doctor.js +65 -5
  9. package/build/conductor/latest-check-selector.js +170 -0
  10. package/build/conductor/local-merge.js +8 -6
  11. package/build/conductor-bin.js +1 -1
  12. package/build/{brainstorm-files.js → council-files.js} +15 -15
  13. package/build/decision-page-schema.js +1 -1
  14. package/build/docs.generated.js +1 -1
  15. package/build/doctor.js +162 -4
  16. package/build/executor/worktree.js +46 -1
  17. package/build/index.js +92 -51
  18. package/build/init.js +9 -2
  19. package/build/install-bridge.js +60 -2
  20. package/build/install-reexec.js +47 -9
  21. package/build/pipelines.generated.js +1 -1
  22. package/build/plane/cli.js +12 -2
  23. package/build/plane/manifest.js +25 -1
  24. package/build/plane/member-roster.js +61 -7
  25. package/build/plane/preflight.js +24 -9
  26. package/build/plane/supervisor.js +77 -5
  27. package/build/plane/types.js +23 -3
  28. package/build/readme.generated.js +1 -1
  29. package/build/run-unit-tests-launcher.js +2 -1
  30. package/build/stale-worktree-doctor.js +120 -0
  31. package/build/start-tickets-prereqs.js +70 -0
  32. package/build/start-tickets.js +91 -3
  33. package/build/version.generated.js +3 -2
  34. package/package.json +4 -2
  35. package/build/chain-orchestrator.js +0 -1457
  36. package/build/chain-utils.js +0 -68
  37. package/build/command-catalog.js +0 -376
  38. package/build/schedule-run.js +0 -1300
  39. package/build/schedule-store.js +0 -172
  40. package/build/scheduled-prompt.js +0 -115
  41. package/build/scheduler-backends/at-fallback.js +0 -139
  42. package/build/scheduler-backends/escaping.js +0 -143
  43. package/build/scheduler-backends/index.js +0 -72
  44. package/build/scheduler-backends/launchd.js +0 -225
  45. package/build/scheduler-backends/systemd-user.js +0 -250
  46. package/build/scheduler-backends/task-scheduler.js +0 -214
  47. package/build/scheduler-backends/types.js +0 -23
@@ -1,1300 +0,0 @@
1
- /**
2
- * `schedule-run` — local-only cross-platform scheduler subcommand for the
3
- * packaged `@bridge_gpt/mcp-server` CLI (BAPI-327, Phase B of the Full Automation
4
- * v1 epic BAPI-325).
5
- *
6
- * npx -y @bridge_gpt/mcp-server schedule-run <create|list|cancel|doctor> [flags]
7
- *
8
- * At a chosen time T it fires, on the user's machine:
9
- * <claude> -p '/full-automation --scheduled-at <T> --idea-file <abs> [--auto]'
10
- * via an OS-native one-shot unit (launchd / Task Scheduler / systemd-user / at).
11
- *
12
- * Strictly local: NO Bridge API HTTP calls, NO database/server-side state. All
13
- * schedule state lives under `~/.bridge-gpt/schedules/`. The structure mirrors
14
- * `start-tickets.ts`: a hand-rolled parser, an injectable command/runtime deps
15
- * boundary, and discriminated parse/result types — no new runtime npm deps.
16
- */
17
- import { execFile } from "node:child_process";
18
- import { promises as fs } from "node:fs";
19
- import { randomUUID, createHash } from "node:crypto";
20
- import { pathApiForPlatform, selectSchedulerBackend, getSchedulerBackendByName, getSchedulerBackendsForPlatform, unsupportedSchedulerPlatformMessage, } from "./scheduler-backends/index.js";
21
- import { ensureScheduleDirectories, getSchedulePaths, writeScheduleMetadata, readScheduleMetadata, listScheduleMetadata, deleteScheduleMetadata, appendScheduleRunEvent, } from "./schedule-store.js";
22
- import { getAgentLauncher, formatValidAgentLauncherNames } from "./agent-launchers/index.js";
23
- import { resolveCommandOnPath } from "./agent-launchers/claude.js";
24
- import { discoverCommandCatalog, resolveSchedulableCommand, validateCommandArgv, } from "./command-catalog.js";
25
- import { buildTargetCommandLine } from "./scheduled-prompt.js";
26
- import { MCP_PACKAGE_NAME } from "./mcp-identity.js";
27
- /**
28
- * Default deps backed by real subprocesses / process state. Subprocess execution
29
- * uses `execFile` (list-based, never `shell: true`) and supports
30
- * `RunCommandOptions.input` by writing to the child's stdin — required by the
31
- * `at` backend, which pipes its heredoc script.
32
- */
33
- export function createDefaultScheduleRunDeps() {
34
- const runCommand = (file, args, options) => new Promise((resolve) => {
35
- const child = execFile(file, args, {
36
- cwd: options?.cwd,
37
- env: options?.env ?? process.env,
38
- maxBuffer: 64 * 1024 * 1024,
39
- encoding: "utf-8",
40
- }, (error, stdout, stderr) => {
41
- const exitCode = error && typeof error.code === "number"
42
- ? error.code
43
- : error
44
- ? 1
45
- : 0;
46
- resolve({ stdout: stdout ?? "", stderr: stderr ?? "", exitCode });
47
- });
48
- if (options?.input !== undefined && child.stdin) {
49
- child.stdin.write(options.input);
50
- child.stdin.end();
51
- }
52
- });
53
- return {
54
- runCommand,
55
- platform: process.platform,
56
- env: process.env,
57
- cwd: process.cwd(),
58
- execPath: process.execPath,
59
- homeDir: process.env.HOME ?? process.env.USERPROFILE ?? "",
60
- now: () => Date.now(),
61
- // The packaged CLI entry is argv[1]; the trigger invocation re-enters it as
62
- // `node <cliEntryPath> schedule-run _execute <id>`.
63
- cliEntryPath: process.argv[1],
64
- // Best-effort: a Bridge credential is considered resolvable when BAPI_API_KEY
65
- // is present in the environment. Never reads or returns the value itself.
66
- bridgeCredentialResolved: () => Boolean(process.env.BAPI_API_KEY),
67
- };
68
- }
69
- /** Return true only for a zero exit code. */
70
- export function commandSucceeded(result) {
71
- return result.exitCode === 0;
72
- }
73
- /**
74
- * Detect a CLI entry path that lives inside a transient npx cache directory
75
- * (`~/.npm/_npx/<hash>/…` on POSIX, `…\npm-cache\_npx\<hash>\…` on Windows).
76
- * Such an entry can be garbage-collected before a future schedule fires, which
77
- * would make the trigger shim `node <gone-path>` fail with ENOENT. Matched on the
78
- * `_npx` path segment so it is independent of the surrounding cache root.
79
- */
80
- export function isTransientNpxEntryPath(entryPath) {
81
- if (!entryPath)
82
- return false;
83
- return /[\\/]_npx[\\/]/.test(entryPath);
84
- }
85
- const VALID_BACKEND_NAMES = [
86
- "launchd",
87
- "task-scheduler",
88
- "systemd-user",
89
- "at-fallback",
90
- ];
91
- /** Safe schedule id / unit-name pattern. */
92
- const SCHEDULE_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
93
- // ---------------------------------------------------------------------------
94
- // Usage
95
- // ---------------------------------------------------------------------------
96
- /** User-facing usage text for `schedule-run`. */
97
- export function getScheduleRunUsage() {
98
- return [
99
- "Usage:",
100
- ` npx -y ${MCP_PACKAGE_NAME} schedule-run <create|list|cancel|doctor> [flags]`,
101
- "",
102
- "Schedules a local, one-shot run of ANY Bridge slash-command automation (from",
103
- "the repo's .claude/commands/*.md catalog) at a chosen time using an OS-native",
104
- "scheduler (launchd / Task Scheduler / systemd-user / at). Schedules are stored",
105
- "locally under ~/.bridge-gpt/schedules/ and are never sent to a server.",
106
- "",
107
- "create:",
108
- " schedule-run create --in 2h --command review-ticket -- BAPI-999",
109
- ' schedule-run create --at "<datetime>" --command start-tickets -- BAPI-1 BAPI-2',
110
- " legacy: schedule-run create --in 2h --idea-file <path>",
111
- "",
112
- "All tokens after a bare -- are the structured argv passed to the command.",
113
- "",
114
- "create flags:",
115
- ' --at "<datetime>" ISO-8601 time to run (mutually exclusive with --in)',
116
- " --in <duration> Run after a duration, e.g. 30m, 4h, 1d (mutually exclusive with --at)",
117
- " --command <name> Discovered .claude/commands command to schedule",
118
- " --idea-file <path> Legacy: schedule /full-automation with this idea file",
119
- " --agent <name> Agent to launch (claude [default] or cursor-agent)",
120
- " --repo-path <path> Working directory + command catalog root (default: cwd)",
121
- " --auto Run hands-off; gate proceeds even when firing late",
122
- " --no-auto Late runs halt without executing (headless-safe default)",
123
- " --dry-run Print the generated unit + prompt + invocation without creating anything",
124
- " --id <id> Override the auto-generated schedule id (mainly for tests)",
125
- "",
126
- "list flags:",
127
- " --id <id> Filter to a single schedule id",
128
- " --agent <name> Filter by agent",
129
- " --backend <name> Filter by backend (launchd|task-scheduler|systemd-user|at-fallback)",
130
- " --json Emit JSON instead of a table",
131
- "",
132
- "cancel:",
133
- " cancel <id> Cancel by positional id",
134
- " cancel --id <id> Cancel by --id flag",
135
- " --agent <name> Only cancel when the recorded agent matches",
136
- " --backend <name> Only cancel when the recorded backend matches",
137
- "",
138
- "doctor:",
139
- " Read-only diagnostics: platform support, backend candidate order, scheduler",
140
- " availability, and whether claude/npx resolve on PATH. Creates nothing.",
141
- "",
142
- " -h, --help Show this help",
143
- ].join("\n");
144
- }
145
- // ---------------------------------------------------------------------------
146
- // Argument parsing
147
- // ---------------------------------------------------------------------------
148
- /** Internal: a flag that expects a following value. */
149
- function takeValue(argv, index, arg, flag) {
150
- if (arg.startsWith(`${flag}=`)) {
151
- return { value: arg.slice(`${flag}=`.length), nextIndex: index };
152
- }
153
- if (index + 1 >= argv.length) {
154
- return { error: `${flag} requires a value.` };
155
- }
156
- return { value: argv[index + 1], nextIndex: index + 1 };
157
- }
158
- function matchesFlag(arg, flag) {
159
- return arg === flag || arg.startsWith(`${flag}=`);
160
- }
161
- /**
162
- * Parse `create` args. Everything after the FIRST bare `--` is the structured
163
- * command argv (passed through verbatim, never re-tokenized); scheduler flags are
164
- * parsed only before that delimiter. `--idea-file` without `--command` implies
165
- * the legacy `full-automation` command.
166
- */
167
- export function parseScheduleCreateArgs(argv) {
168
- // Split on the first bare "--". Help is honored only from the scheduler-flag
169
- // side; tokens after "--" belong to the delegated command verbatim.
170
- const delimIndex = argv.indexOf("--");
171
- const schedulerArgs = delimIndex === -1 ? argv : argv.slice(0, delimIndex);
172
- const commandArgs = delimIndex === -1 ? [] : argv.slice(delimIndex + 1);
173
- if (schedulerArgs.includes("-h") || schedulerArgs.includes("--help")) {
174
- return { status: "help", usage: getScheduleRunUsage() };
175
- }
176
- let at;
177
- let inDuration;
178
- let commandName;
179
- let ideaFile;
180
- let agent = "claude";
181
- let repoPath;
182
- let id;
183
- let sawAutoApprove = false;
184
- let sawNoAutoApprove = false;
185
- let dryRun = false;
186
- for (let i = 0; i < schedulerArgs.length; i++) {
187
- const arg = schedulerArgs[i];
188
- if (matchesFlag(arg, "--at")) {
189
- const r = takeValue(schedulerArgs, i, arg, "--at");
190
- if ("error" in r)
191
- return { status: "error", message: r.error };
192
- at = r.value;
193
- i = r.nextIndex;
194
- continue;
195
- }
196
- if (matchesFlag(arg, "--in")) {
197
- const r = takeValue(schedulerArgs, i, arg, "--in");
198
- if ("error" in r)
199
- return { status: "error", message: r.error };
200
- inDuration = r.value;
201
- i = r.nextIndex;
202
- continue;
203
- }
204
- if (matchesFlag(arg, "--command")) {
205
- const r = takeValue(schedulerArgs, i, arg, "--command");
206
- if ("error" in r)
207
- return { status: "error", message: r.error };
208
- commandName = r.value;
209
- i = r.nextIndex;
210
- continue;
211
- }
212
- if (matchesFlag(arg, "--idea-file")) {
213
- const r = takeValue(schedulerArgs, i, arg, "--idea-file");
214
- if ("error" in r)
215
- return { status: "error", message: r.error };
216
- ideaFile = r.value;
217
- i = r.nextIndex;
218
- continue;
219
- }
220
- if (matchesFlag(arg, "--agent")) {
221
- const r = takeValue(schedulerArgs, i, arg, "--agent");
222
- if ("error" in r)
223
- return { status: "error", message: r.error };
224
- if (!getAgentLauncher(r.value)) {
225
- return {
226
- status: "error",
227
- message: `Invalid --agent value: '${r.value}'. Valid: ${formatValidAgentLauncherNames()}.`,
228
- };
229
- }
230
- agent = r.value;
231
- i = r.nextIndex;
232
- continue;
233
- }
234
- if (matchesFlag(arg, "--repo-path")) {
235
- const r = takeValue(schedulerArgs, i, arg, "--repo-path");
236
- if ("error" in r)
237
- return { status: "error", message: r.error };
238
- repoPath = r.value;
239
- i = r.nextIndex;
240
- continue;
241
- }
242
- if (matchesFlag(arg, "--id")) {
243
- const r = takeValue(schedulerArgs, i, arg, "--id");
244
- if ("error" in r)
245
- return { status: "error", message: r.error };
246
- id = r.value;
247
- i = r.nextIndex;
248
- continue;
249
- }
250
- if (arg === "--auto") {
251
- sawAutoApprove = true;
252
- continue;
253
- }
254
- if (arg === "--no-auto") {
255
- sawNoAutoApprove = true;
256
- continue;
257
- }
258
- if (arg === "--dry-run") {
259
- dryRun = true;
260
- continue;
261
- }
262
- if (arg.startsWith("-")) {
263
- return { status: "error", message: `Unknown flag: ${arg}` };
264
- }
265
- return {
266
- status: "error",
267
- message: `Unexpected positional argument: '${arg}'. create takes flags before '--'; ` +
268
- "put command arguments after '--'.",
269
- };
270
- }
271
- if (Boolean(at) === Boolean(inDuration)) {
272
- return {
273
- status: "error",
274
- message: "create requires exactly one of --at or --in.",
275
- };
276
- }
277
- if (sawAutoApprove && sawNoAutoApprove) {
278
- return {
279
- status: "error",
280
- message: "--auto and --no-auto are mutually exclusive.",
281
- };
282
- }
283
- if (id !== undefined && !SCHEDULE_ID_PATTERN.test(id)) {
284
- return {
285
- status: "error",
286
- message: `Invalid --id value: '${id}'. Ids must match ${SCHEDULE_ID_PATTERN.source} ` +
287
- "(start alphanumeric; letters, digits, '_' and '-'; max 64 chars).",
288
- };
289
- }
290
- // Legacy compatibility: --idea-file without --command implies full-automation.
291
- // --idea-file with any OTHER command is rejected (it only applies to that path).
292
- if (ideaFile !== undefined) {
293
- if (commandName === undefined) {
294
- commandName = "full-automation";
295
- }
296
- else if (commandName !== "full-automation") {
297
- return {
298
- status: "error",
299
- message: `--idea-file is only valid for legacy full-automation scheduling, not --command '${commandName}'. ` +
300
- "Pass command arguments after '--' instead.",
301
- };
302
- }
303
- }
304
- if (commandName === undefined) {
305
- return {
306
- status: "error",
307
- message: "create requires --command <name> (or legacy --idea-file <path>).",
308
- };
309
- }
310
- return {
311
- status: "ok",
312
- subcommand: "create",
313
- options: {
314
- at,
315
- in: inDuration,
316
- commandName,
317
- commandArgs,
318
- ideaFile,
319
- agent,
320
- repoPath,
321
- autoApprove: !sawNoAutoApprove,
322
- autoApproveExplicit: sawAutoApprove || sawNoAutoApprove,
323
- dryRun,
324
- id,
325
- },
326
- };
327
- }
328
- /** Parse the hidden `_execute <id>` shim args (exactly one valid schedule id). */
329
- export function parseScheduleExecuteArgs(argv) {
330
- const positionals = argv.filter((a) => !a.startsWith("-"));
331
- const flags = argv.filter((a) => a.startsWith("-"));
332
- if (flags.length > 0) {
333
- return { status: "error", message: `_execute takes no flags (got ${flags.join(", ")}).` };
334
- }
335
- if (positionals.length !== 1) {
336
- return { status: "error", message: "_execute requires exactly one schedule id." };
337
- }
338
- const id = positionals[0];
339
- if (!SCHEDULE_ID_PATTERN.test(id)) {
340
- return { status: "error", message: `Invalid schedule id: '${id}'.` };
341
- }
342
- return { status: "ok", subcommand: "_execute", options: { id } };
343
- }
344
- /** Parse `list` args. */
345
- export function parseScheduleListArgs(argv) {
346
- if (argv.includes("-h") || argv.includes("--help")) {
347
- return { status: "help", usage: getScheduleRunUsage() };
348
- }
349
- let id;
350
- let agent;
351
- let backend;
352
- let json = false;
353
- for (let i = 0; i < argv.length; i++) {
354
- const arg = argv[i];
355
- if (matchesFlag(arg, "--id")) {
356
- const r = takeValue(argv, i, arg, "--id");
357
- if ("error" in r)
358
- return { status: "error", message: r.error };
359
- id = r.value;
360
- i = r.nextIndex;
361
- continue;
362
- }
363
- if (matchesFlag(arg, "--agent")) {
364
- const r = takeValue(argv, i, arg, "--agent");
365
- if ("error" in r)
366
- return { status: "error", message: r.error };
367
- if (!getAgentLauncher(r.value)) {
368
- return {
369
- status: "error",
370
- message: `Invalid --agent filter: '${r.value}'. Valid: ${formatValidAgentLauncherNames()}.`,
371
- };
372
- }
373
- agent = r.value;
374
- i = r.nextIndex;
375
- continue;
376
- }
377
- if (matchesFlag(arg, "--backend")) {
378
- const r = takeValue(argv, i, arg, "--backend");
379
- if ("error" in r)
380
- return { status: "error", message: r.error };
381
- if (!VALID_BACKEND_NAMES.includes(r.value)) {
382
- return {
383
- status: "error",
384
- message: `Invalid --backend filter: '${r.value}'. Valid: ${VALID_BACKEND_NAMES.join(", ")}.`,
385
- };
386
- }
387
- backend = r.value;
388
- i = r.nextIndex;
389
- continue;
390
- }
391
- if (arg === "--json") {
392
- json = true;
393
- continue;
394
- }
395
- if (arg.startsWith("-")) {
396
- return { status: "error", message: `Unknown flag: ${arg}` };
397
- }
398
- return {
399
- status: "error",
400
- message: `Unexpected positional argument: '${arg}'. list takes only flags.`,
401
- };
402
- }
403
- return { status: "ok", subcommand: "list", options: { id, agent, backend, json } };
404
- }
405
- /** Parse `cancel` args (one id via positional or --id, never both/multiple). */
406
- export function parseScheduleCancelArgs(argv) {
407
- if (argv.includes("-h") || argv.includes("--help")) {
408
- return { status: "help", usage: getScheduleRunUsage() };
409
- }
410
- let positionalId;
411
- let flagId;
412
- let agent;
413
- let backend;
414
- for (let i = 0; i < argv.length; i++) {
415
- const arg = argv[i];
416
- if (matchesFlag(arg, "--id")) {
417
- const r = takeValue(argv, i, arg, "--id");
418
- if ("error" in r)
419
- return { status: "error", message: r.error };
420
- if (flagId !== undefined) {
421
- return { status: "error", message: "cancel accepts only one --id." };
422
- }
423
- flagId = r.value;
424
- i = r.nextIndex;
425
- continue;
426
- }
427
- if (matchesFlag(arg, "--agent")) {
428
- const r = takeValue(argv, i, arg, "--agent");
429
- if ("error" in r)
430
- return { status: "error", message: r.error };
431
- agent = r.value;
432
- i = r.nextIndex;
433
- continue;
434
- }
435
- if (matchesFlag(arg, "--backend")) {
436
- const r = takeValue(argv, i, arg, "--backend");
437
- if ("error" in r)
438
- return { status: "error", message: r.error };
439
- if (!VALID_BACKEND_NAMES.includes(r.value)) {
440
- return {
441
- status: "error",
442
- message: `Invalid --backend filter: '${r.value}'. Valid: ${VALID_BACKEND_NAMES.join(", ")}.`,
443
- };
444
- }
445
- backend = r.value;
446
- i = r.nextIndex;
447
- continue;
448
- }
449
- if (arg.startsWith("-")) {
450
- return { status: "error", message: `Unknown flag: ${arg}` };
451
- }
452
- if (positionalId !== undefined) {
453
- return { status: "error", message: "cancel accepts only one schedule id." };
454
- }
455
- positionalId = arg;
456
- }
457
- if (positionalId !== undefined && flagId !== undefined) {
458
- return {
459
- status: "error",
460
- message: "cancel accepts a positional id OR --id, not both.",
461
- };
462
- }
463
- const id = positionalId ?? flagId;
464
- if (!id) {
465
- return { status: "error", message: "cancel requires a schedule id (positional or --id)." };
466
- }
467
- return { status: "ok", subcommand: "cancel", options: { id, agent, backend } };
468
- }
469
- /** Parse `doctor` args (read-only; only --json / help). */
470
- export function parseScheduleDoctorArgs(argv) {
471
- if (argv.includes("-h") || argv.includes("--help")) {
472
- return { status: "help", usage: getScheduleRunUsage() };
473
- }
474
- let json = false;
475
- for (const arg of argv) {
476
- if (arg === "--json") {
477
- json = true;
478
- continue;
479
- }
480
- if (arg.startsWith("-")) {
481
- return { status: "error", message: `Unknown flag: ${arg}` };
482
- }
483
- return {
484
- status: "error",
485
- message: `Unexpected positional argument: '${arg}'. doctor takes no positional arguments.`,
486
- };
487
- }
488
- return { status: "ok", subcommand: "doctor", options: { json } };
489
- }
490
- /** Top-level parser: honor help first, require a valid subcommand, dispatch. */
491
- export function parseScheduleRunArgs(argv) {
492
- // Help before any other validation, even with later unknown flags.
493
- if (argv.length === 0) {
494
- return {
495
- status: "error",
496
- message: "Missing subcommand. Expected one of: create, list, cancel, doctor.",
497
- };
498
- }
499
- const [subcommand, ...rest] = argv;
500
- if (subcommand === "-h" || subcommand === "--help") {
501
- return { status: "help", usage: getScheduleRunUsage() };
502
- }
503
- switch (subcommand) {
504
- case "create":
505
- return parseScheduleCreateArgs(rest);
506
- case "list":
507
- return parseScheduleListArgs(rest);
508
- case "cancel":
509
- return parseScheduleCancelArgs(rest);
510
- case "doctor":
511
- return parseScheduleDoctorArgs(rest);
512
- // Hidden internal shim the OS unit invokes; intentionally absent from usage.
513
- case "_execute":
514
- return parseScheduleExecuteArgs(rest);
515
- default:
516
- return {
517
- status: "error",
518
- message: `Unknown subcommand '${subcommand}'. Expected one of: create, list, cancel, doctor.`,
519
- };
520
- }
521
- }
522
- /** Parse `--at` as ISO-8601 and normalize to `Date.toISOString()`. */
523
- export function parseRunAtIso(value) {
524
- const date = new Date(value);
525
- if (Number.isNaN(date.getTime())) {
526
- return { ok: false, error: `Invalid --at datetime: '${value}'. Expected an ISO-8601 timestamp.` };
527
- }
528
- return { ok: true, iso: date.toISOString() };
529
- }
530
- /** Parse a positive `<n><m|h|d>` duration relative to the injected clock. */
531
- export function parseDurationFromNow(value, nowMs) {
532
- const match = value.match(/^(\d+)([mhd])$/);
533
- if (!match) {
534
- return {
535
- ok: false,
536
- error: `Invalid --in duration: '${value}'. Use a positive number followed by m, h, or d (e.g. 30m, 4h, 1d).`,
537
- };
538
- }
539
- const amount = Number(match[1]);
540
- if (amount <= 0) {
541
- return { ok: false, error: `Invalid --in duration: '${value}'. Duration must be positive.` };
542
- }
543
- const unitMs = match[2] === "m" ? 60_000 : match[2] === "h" ? 3_600_000 : 86_400_000;
544
- return { ok: true, iso: new Date(nowMs + amount * unitMs).toISOString() };
545
- }
546
- /** Resolve an absolute path against the platform path API and deps.cwd. */
547
- function resolveAbsolute(p, deps) {
548
- const pathApi = pathApiForPlatform(deps.platform);
549
- return pathApi.isAbsolute(p) ? pathApi.normalize(p) : pathApi.resolve(deps.cwd, p);
550
- }
551
- /**
552
- * Collect read-only prerequisite/auth readiness for the selected create input.
553
- * Returns presence/readiness booleans only — it never reads or returns secret
554
- * values (CURSOR_API_KEY / Bridge credential material).
555
- */
556
- export function collectScheduleRunPrereqStatus(input) {
557
- return {
558
- agentBinaryResolved: input.agentBinaryResolved,
559
- repoPathExists: input.repoPathExists,
560
- cursorApiKeyPresent: Boolean(input.deps.env.CURSOR_API_KEY),
561
- bridgeCredentialResolved: input.deps.bridgeCredentialResolved?.() ?? Boolean(input.deps.env.BAPI_API_KEY),
562
- };
563
- }
564
- /**
565
- * Resolve and validate generic create inputs. Resolves the repo path first and
566
- * validates it is a directory; discovers the `.claude/commands` catalog under it;
567
- * resolves and validates the requested command (rejecting unknown /
568
- * `schedulable: false` / malformed); validates structured argv against the
569
- * command's schema; resolves the agent binary on the baked PATH; builds the agent
570
- * invocation via the launcher and the trigger invocation for the `_execute` shim.
571
- * The legacy `--idea-file` path is converted to structured `["--idea-file", abs]`
572
- * args for `full-automation`. Installs nothing — orchestration owns side effects.
573
- */
574
- export async function resolveScheduleCreateInput(options, runAtIso, deps) {
575
- const launcher = getAgentLauncher(options.agent);
576
- if (!launcher) {
577
- return {
578
- ok: false,
579
- error: `Unsupported agent: '${options.agent}'. Valid: ${formatValidAgentLauncherNames()}.`,
580
- };
581
- }
582
- const commandName = options.commandName;
583
- if (!commandName) {
584
- return { ok: false, error: "create requires a command (--command or legacy --idea-file)." };
585
- }
586
- // Resolve + validate the repo path (command-catalog root) BEFORE discovery.
587
- const repoPath = resolveAbsolute(options.repoPath ?? deps.cwd, deps);
588
- try {
589
- const repoStat = await fs.stat(repoPath);
590
- if (!repoStat.isDirectory()) {
591
- return { ok: false, error: `--repo-path is not a directory: ${repoPath}` };
592
- }
593
- }
594
- catch {
595
- return { ok: false, error: `--repo-path does not exist: ${repoPath}` };
596
- }
597
- // Build the structured command argv. Legacy --idea-file is validated and turned
598
- // into ["--idea-file", <abs>] (full-automation only; enforced by the parser).
599
- let commandArgs = options.commandArgs;
600
- let legacyIdeaFile;
601
- if (options.ideaFile !== undefined) {
602
- const ideaFile = resolveAbsolute(options.ideaFile, deps);
603
- try {
604
- const ideaStat = await fs.stat(ideaFile);
605
- if (!ideaStat.isFile())
606
- return { ok: false, error: `--idea-file is not a file: ${ideaFile}` };
607
- }
608
- catch {
609
- return { ok: false, error: `--idea-file does not exist: ${ideaFile}` };
610
- }
611
- legacyIdeaFile = ideaFile;
612
- commandArgs = ["--idea-file", ideaFile];
613
- }
614
- // Discover the command catalog under the resolved repo and resolve the command.
615
- const catalog = await discoverCommandCatalog(repoPath, deps.platform, deps.commandCatalogFsDeps);
616
- const resolvedCommand = resolveSchedulableCommand(catalog, commandName);
617
- if (!resolvedCommand.ok)
618
- return { ok: false, error: resolvedCommand.error };
619
- const command = resolvedCommand.command;
620
- // Validate the structured argv against the command's schema (or passthrough).
621
- const validation = validateCommandArgv(commandArgs, command.argumentSchema);
622
- if (!validation.ok) {
623
- return { ok: false, error: `Invalid arguments for '${commandName}': ${validation.error}` };
624
- }
625
- const normalizedArgv = validation.normalizedArgv;
626
- const envPath = deps.env.PATH ?? deps.env.Path ?? "";
627
- const nodePath = deps.execPath;
628
- const agentPath = await launcher.resolveBinary(envPath, deps);
629
- if (!agentPath) {
630
- return {
631
- ok: false,
632
- error: `Could not resolve the '${launcher.capability.command}' binary on the baked PATH.`,
633
- };
634
- }
635
- // npx is resolved best-effort; empty when absent.
636
- const npxPath = (await resolveCommandOnPath("npx", envPath, deps)) ?? "";
637
- const id = options.id ?? randomUUID();
638
- const agentInvocation = launcher.buildInvocation(agentPath, {
639
- scheduleId: id,
640
- runAtIso,
641
- commandName,
642
- args: normalizedArgv,
643
- autoApprove: options.autoApprove,
644
- repoPath,
645
- commandFilePath: command.filePath,
646
- commandBody: command.body,
647
- schema: command.argumentSchema,
648
- });
649
- const cliEntryPath = deps.cliEntryPath ?? process.argv[1] ?? "";
650
- const triggerInvocation = {
651
- exe: nodePath,
652
- args: [cliEntryPath, "schedule-run", "_execute", id],
653
- };
654
- const commandBodyHash = createHash("sha256").update(command.body, "utf-8").digest("hex");
655
- const warnings = [];
656
- if (command.interactive && !options.autoApproveExplicit) {
657
- warnings.push(`Command '${commandName}' has an interactive step but --auto/--no-auto was not specified; ` +
658
- "a headless scheduled run may hang waiting for confirmation. Pass --auto to run hands-off.");
659
- }
660
- if (isTransientNpxEntryPath(cliEntryPath)) {
661
- warnings.push(`The scheduled run re-enters this CLI at '${cliEntryPath}', which is inside an npx cache ` +
662
- "directory. npx caches can be pruned before a future schedule fires, which would make the " +
663
- `run fail with ENOENT. Install the CLI persistently (e.g. \`npm i -g ${MCP_PACKAGE_NAME}\`) ` +
664
- "and schedule with the installed binary so the trigger path stays stable.");
665
- }
666
- const paths = getSchedulePaths(id, deps.homeDir, deps.platform);
667
- return {
668
- ok: true,
669
- resolved: {
670
- id,
671
- runAtIso,
672
- agent: options.agent,
673
- command: commandName,
674
- args: normalizedArgv,
675
- commandFilePath: command.filePath,
676
- commandBodyHash,
677
- autoApprove: options.autoApprove,
678
- repoPath,
679
- legacyIdeaFile,
680
- envPath,
681
- nodePath,
682
- npxPath,
683
- agentPath,
684
- agentInvocation,
685
- triggerInvocation,
686
- renderedPrompt: agentInvocation.prompt,
687
- schema: command.argumentSchema,
688
- paths: {
689
- schedulesDir: paths.schedulesDir,
690
- logsDir: paths.logsDir,
691
- stdoutPath: paths.stdoutPath,
692
- stderrPath: paths.stderrPath,
693
- },
694
- warnings,
695
- prereq: collectScheduleRunPrereqStatus({
696
- agent: options.agent,
697
- agentBinaryResolved: Boolean(agentPath),
698
- repoPathExists: true,
699
- deps,
700
- }),
701
- },
702
- };
703
- }
704
- /**
705
- * Build the local-only schedule metadata from resolved inputs + backend result.
706
- * Writes the generalized fields plus legacy compatibility aliases (`invocation`,
707
- * `claude_path`, `idea_file`) so old readers and full-automation records keep
708
- * working. Run history is initialized with a `created` event.
709
- */
710
- export function buildScheduleMetadata(resolved, createResult, createdAtIso) {
711
- return {
712
- id: resolved.id,
713
- run_at_iso: resolved.runAtIso,
714
- scheduled_at: resolved.runAtIso,
715
- backend: createResult.backend,
716
- unit_path: createResult.unitPath,
717
- unit_paths: createResult.unitPaths,
718
- backend_job_id: createResult.backendJobId,
719
- agent: resolved.agent,
720
- agent_path: resolved.agentPath,
721
- command: resolved.command,
722
- args: resolved.args,
723
- command_file: resolved.commandFilePath,
724
- command_body_hash: resolved.commandBodyHash,
725
- auto_approve: resolved.autoApprove,
726
- repo_path: resolved.repoPath,
727
- created_at: createdAtIso,
728
- env_path: resolved.envPath,
729
- node_path: resolved.nodePath,
730
- npx_path: resolved.npxPath,
731
- // Legacy alias retained for full-automation/claude records.
732
- claude_path: resolved.agent === "claude" ? resolved.agentPath : undefined,
733
- idea_file: resolved.legacyIdeaFile,
734
- invocation: resolved.agentInvocation,
735
- agent_invocation: resolved.agentInvocation,
736
- trigger_invocation: resolved.triggerInvocation,
737
- run_history: [{ status: "created", at: createdAtIso }],
738
- logs: { stdout: resolved.paths.stdoutPath, stderr: resolved.paths.stderrPath },
739
- };
740
- }
741
- /** Compute the run time (exactly one of --at / --in already validated). */
742
- function resolveRunAtIso(options, deps) {
743
- if (options.at !== undefined) {
744
- return parseRunAtIso(options.at);
745
- }
746
- const nowMs = deps.now ? deps.now() : Date.now();
747
- return parseDurationFromNow(options.in, nowMs);
748
- }
749
- /**
750
- * Orchestrate a create. Dry-run selects the primary backend and returns
751
- * artifacts WITHOUT creating directories or writing metadata. Real create:
752
- * resolve → ensure directories → backend.create → write metadata only after
753
- * backend success; if metadata persistence fails after a native unit/job was
754
- * created, attempt a best-effort backend cancel before returning a failure.
755
- */
756
- export async function orchestrateScheduleCreate(options, deps) {
757
- const runAt = resolveRunAtIso(options, deps);
758
- if (!runAt.ok)
759
- return { ok: false, error: runAt.error };
760
- const resolvedResult = await resolveScheduleCreateInput(options, runAt.iso, deps);
761
- if (!resolvedResult.ok)
762
- return { ok: false, error: resolvedResult.error };
763
- const resolved = resolvedResult.resolved;
764
- const createdAtIso = new Date(deps.now ? deps.now() : Date.now()).toISOString();
765
- const metadataPath = getSchedulePaths(resolved.id, deps.homeDir, deps.platform).metadataPath;
766
- // For a real create, reject a colliding/unreadable existing schedule BEFORE
767
- // probing scheduler availability or creating anything (fail fast, no side
768
- // effects). Without this, `schtasks /Create /F` would replace the prior task
769
- // and writeScheduleMetadata would overwrite the prior <id>.json, losing it.
770
- // A missing file reads as null (proceed); a present-but-unreadable file
771
- // (malformed JSON, EACCES, …) is surfaced as a controlled error rather than
772
- // being swallowed and silently overwritten — mirroring how
773
- // orchestrateScheduleList surfaces malformed metadata instead of ignoring it.
774
- if (!options.dryRun) {
775
- let existing;
776
- try {
777
- existing = await readScheduleMetadata(resolved.id, deps.homeDir, deps.platform);
778
- }
779
- catch (error) {
780
- const msg = error instanceof Error ? error.message : String(error);
781
- return {
782
- ok: false,
783
- error: `Could not read existing schedule metadata for id '${resolved.id}' at ${metadataPath}: ${msg}. ` +
784
- "Resolve or remove it before creating.",
785
- };
786
- }
787
- if (existing) {
788
- return {
789
- ok: false,
790
- error: `A schedule with id '${resolved.id}' already exists (${metadataPath}). ` +
791
- "Cancel it first (schedule-run cancel <id>) or choose a different --id.",
792
- };
793
- }
794
- }
795
- const selection = await selectSchedulerBackend(deps, options.dryRun);
796
- if (!selection.ok)
797
- return { ok: false, error: selection.error };
798
- const backend = selection.backend;
799
- const createInput = {
800
- deps,
801
- id: resolved.id,
802
- runAtIso: resolved.runAtIso,
803
- triggerInvocation: resolved.triggerInvocation,
804
- agentInvocation: resolved.agentInvocation,
805
- command: resolved.command,
806
- args: resolved.args,
807
- agent: resolved.agent,
808
- repoPath: resolved.repoPath,
809
- envPath: resolved.envPath,
810
- nodePath: resolved.nodePath,
811
- npxPath: resolved.npxPath,
812
- agentPath: resolved.agentPath,
813
- legacyIdeaFile: resolved.legacyIdeaFile,
814
- paths: resolved.paths,
815
- dryRun: options.dryRun,
816
- };
817
- if (options.dryRun) {
818
- const createResult = await backend.create(createInput);
819
- if (!createResult.ok) {
820
- return { ok: false, error: createResult.error ?? "Backend dry-run failed." };
821
- }
822
- const metadata = buildScheduleMetadata(resolved, createResult, createdAtIso);
823
- return { ok: true, dryRun: true, metadata, metadataPath, createResult, resolved };
824
- }
825
- await ensureScheduleDirectories(deps.homeDir, deps.platform);
826
- const createResult = await backend.create(createInput);
827
- if (!createResult.ok) {
828
- return { ok: false, error: createResult.error ?? "Scheduler backend create failed." };
829
- }
830
- const metadata = buildScheduleMetadata(resolved, createResult, createdAtIso);
831
- // Append the `scheduled` event only after the backend created a real OS job.
832
- const scheduledAtIso = new Date(deps.now ? deps.now() : Date.now()).toISOString();
833
- metadata.run_history = [...(metadata.run_history ?? []), { status: "scheduled", at: scheduledAtIso }];
834
- try {
835
- await writeScheduleMetadata(metadata, deps.homeDir, deps.platform);
836
- }
837
- catch (error) {
838
- // Native unit/job exists but we can't persist metadata — roll back the unit.
839
- await backend.cancel({ deps, metadata }).catch(() => undefined);
840
- return {
841
- ok: false,
842
- error: `Created the native ${createResult.backend} unit but failed to persist schedule metadata ` +
843
- `(${error instanceof Error ? error.message : String(error)}); attempted to cancel the native unit.`,
844
- };
845
- }
846
- return { ok: true, dryRun: false, metadata, metadataPath, createResult, resolved };
847
- }
848
- /** Format a prereq status block (presence/readiness only, never secret values). */
849
- function formatPrereqStatus(agent, prereq) {
850
- const lines = [
851
- ` agent binary: ${prereq.agentBinaryResolved ? "resolved" : "MISSING"}`,
852
- ` repo path: ${prereq.repoPathExists ? "exists" : "MISSING"}`,
853
- ];
854
- if (agent === "cursor-agent") {
855
- lines.push(` CURSOR_API_KEY set: ${prereq.cursorApiKeyPresent ? "yes" : "no"}`);
856
- }
857
- else {
858
- lines.push(` Bridge credential: ${prereq.bridgeCredentialResolved ? "resolved" : "not resolved"}`);
859
- }
860
- return lines;
861
- }
862
- /**
863
- * Format a create result for display. Real create and dry-run both show the
864
- * delegated command, normalized args, schedule time, agent, repo path, metadata
865
- * path, agent binary, trigger invocation, agent invocation argv, and the rendered
866
- * target command line. Dry-run additionally prints the full concatenated prompt,
867
- * the prerequisite/auth status (secret-free), and the backend artifacts.
868
- */
869
- export function formatScheduleCreateResult(result) {
870
- if (!result.ok)
871
- return `Error: ${result.error}`;
872
- const m = result.metadata;
873
- const r = result.resolved;
874
- const targetCommandLine = buildTargetCommandLine({
875
- commandName: r.command,
876
- args: r.args,
877
- scheduledAt: r.runAtIso,
878
- autoApprove: r.autoApprove,
879
- schema: r.schema,
880
- });
881
- const lines = [];
882
- lines.push(result.dryRun ? "[dry-run] schedule-run create preview" : "Schedule created.");
883
- lines.push(` id: ${m.id}`);
884
- lines.push(` backend: ${m.backend}`);
885
- lines.push(` command: ${r.command}`);
886
- lines.push(` args: ${r.args.length > 0 ? r.args.join(" ") : "(none)"}`);
887
- lines.push(` run at: ${m.run_at_iso}`);
888
- lines.push(` agent: ${m.agent}`);
889
- lines.push(` auto-approve: ${m.auto_approve ? "yes" : "no"}`);
890
- lines.push(` metadata: ${result.metadataPath}${result.dryRun ? " (not written in dry-run)" : ""}`);
891
- lines.push(` repo path: ${m.repo_path}`);
892
- if (m.idea_file)
893
- lines.push(` idea file: ${m.idea_file}`);
894
- lines.push(` stdout log: ${m.logs.stdout}`);
895
- lines.push(` stderr log: ${m.logs.stderr}`);
896
- lines.push(` agent binary: ${r.agentPath}`);
897
- lines.push(` target command: ${targetCommandLine}`);
898
- lines.push(` trigger invocation: ${[r.triggerInvocation.exe, ...r.triggerInvocation.args].join(" ")}`);
899
- lines.push(` agent invocation: ${[r.agentInvocation.exe, ...r.agentInvocation.args].join(" ")}`);
900
- if (m.unit_paths.length > 0) {
901
- lines.push(` unit paths: ${m.unit_paths.join(", ")}`);
902
- }
903
- for (const warning of r.warnings) {
904
- lines.push(` warning: ${warning}`);
905
- }
906
- if (result.dryRun) {
907
- lines.push("");
908
- lines.push("prerequisites (secret-free):");
909
- lines.push(...formatPrereqStatus(r.agent, r.prereq));
910
- lines.push("");
911
- lines.push("----- rendered prompt -----");
912
- lines.push(r.renderedPrompt);
913
- for (const artifact of result.createResult.artifacts) {
914
- lines.push("");
915
- lines.push(`----- ${artifact.kind}: ${artifact.path} -----`);
916
- lines.push(artifact.content);
917
- }
918
- }
919
- return lines.join("\n");
920
- }
921
- /** Apply id/agent/backend filters consistently. */
922
- export function filterScheduleMetadata(rows, filters) {
923
- return rows.filter((row) => {
924
- if (filters.id !== undefined && row.id !== filters.id)
925
- return false;
926
- if (filters.agent !== undefined && row.agent !== filters.agent)
927
- return false;
928
- if (filters.backend !== undefined && row.backend !== filters.backend)
929
- return false;
930
- return true;
931
- });
932
- }
933
- /** Read local metadata, reconcile each backend group with native state. */
934
- export async function orchestrateScheduleList(options, deps) {
935
- const rows = await listScheduleMetadata(deps.homeDir, deps.platform);
936
- const warnings = [];
937
- const valid = [];
938
- for (const row of rows) {
939
- if (row.ok) {
940
- valid.push(row.metadata);
941
- }
942
- else {
943
- warnings.push(`Skipping malformed schedule metadata ${row.path}: ${row.error}`);
944
- }
945
- }
946
- const filtered = filterScheduleMetadata(valid, {
947
- id: options.id,
948
- agent: options.agent,
949
- backend: options.backend,
950
- });
951
- // Group by backend so each backend's list() is called once with its rows.
952
- const byBackend = new Map();
953
- for (const row of filtered) {
954
- const group = byBackend.get(row.backend) ?? [];
955
- group.push(row);
956
- byBackend.set(row.backend, group);
957
- }
958
- const entries = [];
959
- for (const [backendName, recorded] of byBackend) {
960
- const backend = getSchedulerBackendByName(backendName);
961
- if (!backend) {
962
- for (const metadata of recorded) {
963
- entries.push({ metadata, status: "backend-unavailable", detail: "unknown backend" });
964
- }
965
- continue;
966
- }
967
- const groupEntries = await backend.list({ deps, recorded });
968
- entries.push(...groupEntries);
969
- }
970
- // Stable order by id for deterministic output.
971
- entries.sort((a, b) => a.metadata.id.localeCompare(b.metadata.id));
972
- return { entries, warnings };
973
- }
974
- /** Derive the displayed command (legacy rows show `full-automation`). */
975
- export function scheduleCommandLabel(m) {
976
- if (m.command)
977
- return m.command;
978
- if (m.idea_file)
979
- return "full-automation";
980
- return "(unknown)";
981
- }
982
- /** Latest run-history status, or empty string when no history is recorded. */
983
- export function latestRunStatus(m) {
984
- const h = m.run_history;
985
- return h && h.length > 0 ? h[h.length - 1].status : "";
986
- }
987
- /** Format a list report as a table (or JSON when requested). */
988
- export function formatScheduleListResult(report, json) {
989
- if (json) {
990
- // Wrap as { entries, warnings } so malformed-metadata warnings are not lost
991
- // for consumers piping `schedule-run list --json` into tooling.
992
- return JSON.stringify({
993
- entries: report.entries.map((e) => ({
994
- id: e.metadata.id,
995
- command: scheduleCommandLabel(e.metadata),
996
- args: e.metadata.args ?? [],
997
- scheduled_at: e.metadata.scheduled_at ?? e.metadata.run_at_iso,
998
- run_at: e.metadata.run_at_iso,
999
- backend: e.metadata.backend,
1000
- agent: e.metadata.agent,
1001
- native_status: e.status,
1002
- latest_run_status: latestRunStatus(e.metadata),
1003
- run_history: e.metadata.run_history ?? [],
1004
- status: e.status,
1005
- unit_path: e.metadata.unit_path,
1006
- })),
1007
- warnings: report.warnings,
1008
- }, null, 2);
1009
- }
1010
- const lines = [];
1011
- for (const w of report.warnings)
1012
- lines.push(`Warning: ${w}`);
1013
- if (report.entries.length === 0) {
1014
- lines.push("No schedules found.");
1015
- return lines.join("\n");
1016
- }
1017
- lines.push(["ID", "COMMAND", "RUN_AT", "BACKEND", "AGENT", "NATIVE", "LATEST", "UNIT_PATH"].join(" "));
1018
- for (const e of report.entries) {
1019
- lines.push([
1020
- e.metadata.id,
1021
- scheduleCommandLabel(e.metadata),
1022
- e.metadata.run_at_iso,
1023
- e.metadata.backend,
1024
- e.metadata.agent,
1025
- e.status,
1026
- latestRunStatus(e.metadata) || "-",
1027
- e.metadata.unit_path ?? "-",
1028
- ].join(" "));
1029
- }
1030
- return lines.join("\n");
1031
- }
1032
- /** Cancel a schedule by id (with optional agent/backend filters). */
1033
- export async function orchestrateScheduleCancel(options, deps) {
1034
- const metadata = await readScheduleMetadata(options.id, deps.homeDir, deps.platform);
1035
- if (!metadata) {
1036
- return { ok: false, notFound: true, error: `No schedule found with id '${options.id}'.` };
1037
- }
1038
- if (options.agent !== undefined && metadata.agent !== options.agent) {
1039
- return {
1040
- ok: false,
1041
- notFound: true,
1042
- error: `Schedule '${options.id}' does not match agent filter '${options.agent}'.`,
1043
- };
1044
- }
1045
- if (options.backend !== undefined && metadata.backend !== options.backend) {
1046
- return {
1047
- ok: false,
1048
- notFound: true,
1049
- error: `Schedule '${options.id}' does not match backend filter '${options.backend}'.`,
1050
- };
1051
- }
1052
- const backend = getSchedulerBackendByName(metadata.backend);
1053
- if (!backend) {
1054
- return { ok: false, error: `Unknown backend '${metadata.backend}' recorded for '${options.id}'.` };
1055
- }
1056
- const cancelResult = await backend.cancel({ deps, metadata });
1057
- if (!cancelResult.ok) {
1058
- return { ok: false, error: cancelResult.error ?? "Backend cancel failed." };
1059
- }
1060
- // Best-effort: record a `canceled` event before deleting metadata. This is
1061
- // primarily useful if deletion later fails, or if cancellation is refactored
1062
- // to retain records; a failure here must not mask a successful native cancel.
1063
- const canceledAtIso = new Date(deps.now ? deps.now() : Date.now()).toISOString();
1064
- await appendScheduleRunEvent(options.id, { status: "canceled", at: canceledAtIso }, deps.homeDir, deps.platform).catch(() => undefined);
1065
- // Stale or removed both delete local metadata; logs are preserved by the store.
1066
- await deleteScheduleMetadata(options.id, deps.homeDir, deps.platform);
1067
- return {
1068
- ok: true,
1069
- id: options.id,
1070
- backend: metadata.backend,
1071
- nativeRemoved: cancelResult.nativeRemoved,
1072
- stale: cancelResult.stale,
1073
- metadataRemoved: true,
1074
- };
1075
- }
1076
- /** Format a cancel result. */
1077
- export function formatScheduleCancelResult(result) {
1078
- if (!result.ok)
1079
- return `Error: ${result.error}`;
1080
- return [
1081
- `Schedule '${result.id}' canceled.`,
1082
- ` backend: ${result.backend}`,
1083
- ` native removed: ${result.nativeRemoved ? "yes" : `no${result.stale ? " (stale)" : ""}`}`,
1084
- ` metadata removed: ${result.metadataRemoved ? "yes" : "no"}`,
1085
- " logs: preserved",
1086
- ].join("\n");
1087
- }
1088
- /**
1089
- * Read-only diagnostics: platform support, backend order/availability, both agent
1090
- * binaries, and secret-free auth readiness for each agent. Stays zero-argument
1091
- * and environment-oriented — command-specific prompt rendering belongs to
1092
- * `create --dry-run`, never here.
1093
- */
1094
- export async function orchestrateScheduleDoctor(deps) {
1095
- const platformResult = getSchedulerBackendsForPlatform(deps.platform);
1096
- const envPath = deps.env.PATH ?? deps.env.Path ?? "";
1097
- const claudeResolved = Boolean(await resolveCommandOnPath("claude", envPath, deps));
1098
- const cursorResolved = Boolean(await resolveCommandOnPath("cursor-agent", envPath, deps));
1099
- const npxResolved = Boolean(await resolveCommandOnPath("npx", envPath, deps));
1100
- const cursorApiKeyPresent = Boolean(deps.env.CURSOR_API_KEY);
1101
- const bridgeCredentialResolved = deps.bridgeCredentialResolved?.() ?? Boolean(deps.env.BAPI_API_KEY);
1102
- if (!platformResult.ok) {
1103
- return {
1104
- platform: deps.platform,
1105
- platformSupported: false,
1106
- candidateBackends: [],
1107
- backendAvailability: [],
1108
- claudeResolved,
1109
- cursorResolved,
1110
- npxResolved,
1111
- cursorApiKeyPresent,
1112
- bridgeCredentialResolved,
1113
- unsupportedMessage: platformResult.error,
1114
- };
1115
- }
1116
- const candidateBackends = platformResult.backends.map((b) => b.name);
1117
- const backendAvailability = [];
1118
- for (const backend of platformResult.backends) {
1119
- backendAvailability.push({ backend: backend.name, available: await backend.isAvailable(deps) });
1120
- }
1121
- return {
1122
- platform: deps.platform,
1123
- platformSupported: true,
1124
- candidateBackends,
1125
- backendAvailability,
1126
- claudeResolved,
1127
- cursorResolved,
1128
- npxResolved,
1129
- cursorApiKeyPresent,
1130
- bridgeCredentialResolved,
1131
- };
1132
- }
1133
- /** Format the doctor report (table or JSON). Never prints secret values. */
1134
- export function formatScheduleDoctorReport(report, json) {
1135
- if (json)
1136
- return JSON.stringify(report, null, 2);
1137
- const lines = [
1138
- "schedule-run doctor (read-only diagnostics)",
1139
- `Platform: ${report.platform}`,
1140
- ];
1141
- if (!report.platformSupported) {
1142
- lines.push(report.unsupportedMessage ?? unsupportedSchedulerPlatformMessage(report.platform));
1143
- }
1144
- else {
1145
- lines.push(`Candidate backends (in order): ${report.candidateBackends.join(", ")}`);
1146
- for (const a of report.backendAvailability) {
1147
- lines.push(` ${a.available ? "AVAILABLE " : "UNAVAILABLE"} ${a.backend}`);
1148
- }
1149
- }
1150
- lines.push(`claude on PATH: ${report.claudeResolved ? "yes" : "no"}`);
1151
- lines.push(`cursor-agent on PATH: ${report.cursorResolved ? "yes" : "no"}`);
1152
- lines.push(`npx on PATH: ${report.npxResolved ? "yes" : "no"}`);
1153
- lines.push(`CURSOR_API_KEY set: ${report.cursorApiKeyPresent ? "yes" : "no"}`);
1154
- lines.push(`Bridge credential: ${report.bridgeCredentialResolved ? "resolved" : "not resolved"}`);
1155
- return lines.join("\n");
1156
- }
1157
- /** Current time as ISO from the injectable clock. */
1158
- function nowIso(deps) {
1159
- return new Date(deps.now ? deps.now() : Date.now()).toISOString();
1160
- }
1161
- /**
1162
- * The hidden `_execute <id>` shim the OS unit runs at fire time (BAPI-351). It
1163
- * reads the local schedule metadata, validates the stored agent invocation,
1164
- * records a `started` event, spawns the agent via the list-based `runCommand`
1165
- * boundary (NEVER a shell string) with the schedule's cwd + baked env, forwards
1166
- * the agent's stdout/stderr to its own streams (so existing OS log redirection
1167
- * captures them), then records `completed`/`failed` and returns the agent's exit
1168
- * code. Run-history writes are best-effort and never mask the agent's exit code;
1169
- * env values (including any secrets) are never printed.
1170
- */
1171
- export async function orchestrateScheduleExecute(options, deps, io) {
1172
- const metadata = await readScheduleMetadata(options.id, deps.homeDir, deps.platform);
1173
- if (!metadata) {
1174
- return { ok: false, exitCode: 1, error: `No schedule found with id '${options.id}'.` };
1175
- }
1176
- const agentInvocation = metadata.agent_invocation ?? metadata.invocation;
1177
- if (!agentInvocation || !agentInvocation.exe) {
1178
- await appendScheduleRunEvent(options.id, { status: "failed", at: nowIso(deps), message: "missing agent_invocation" }, deps.homeDir, deps.platform).catch(() => undefined);
1179
- return { ok: false, exitCode: 1, error: `Schedule '${options.id}' has no agent invocation to run.` };
1180
- }
1181
- await appendScheduleRunEvent(options.id, { status: "started", at: nowIso(deps) }, deps.homeDir, deps.platform).catch(() => undefined);
1182
- // Child env: base env + baked PATH + schedule-context variables. No secrets are
1183
- // synthesized here; only values already present in metadata/env are forwarded.
1184
- const env = { ...deps.env };
1185
- if (metadata.env_path) {
1186
- env.PATH = metadata.env_path;
1187
- if (deps.platform === "win32")
1188
- env.Path = metadata.env_path;
1189
- }
1190
- env.BRIDGE_GPT_SCHEDULE_ID = metadata.id;
1191
- if (metadata.command)
1192
- env.BRIDGE_GPT_COMMAND = metadata.command;
1193
- if (metadata.args)
1194
- env.BRIDGE_GPT_COMMAND_ARGS_JSON = JSON.stringify(metadata.args);
1195
- if (metadata.repo_path)
1196
- env.BRIDGE_GPT_REPO_PATH = metadata.repo_path;
1197
- if (metadata.agent)
1198
- env.BRIDGE_GPT_AGENT = metadata.agent;
1199
- if (metadata.agent_path)
1200
- env.BRIDGE_GPT_AGENT_PATH = metadata.agent_path;
1201
- if (metadata.idea_file)
1202
- env.BRIDGE_GPT_IDEA_FILE = metadata.idea_file;
1203
- let result;
1204
- try {
1205
- result = await deps.runCommand(agentInvocation.exe, agentInvocation.args, {
1206
- cwd: metadata.repo_path,
1207
- env,
1208
- });
1209
- }
1210
- catch (error) {
1211
- const msg = error instanceof Error ? error.message : String(error);
1212
- await appendScheduleRunEvent(options.id, { status: "failed", at: nowIso(deps), message: `agent launch failed: ${msg}` }, deps.homeDir, deps.platform).catch(() => undefined);
1213
- return { ok: false, exitCode: 1, error: `Failed to launch agent: ${msg}` };
1214
- }
1215
- if (result.stdout)
1216
- io.writeStdout(result.stdout);
1217
- if (result.stderr)
1218
- io.writeStderr(result.stderr);
1219
- if (result.exitCode === 0) {
1220
- await appendScheduleRunEvent(options.id, { status: "completed", at: nowIso(deps), exit_code: 0 }, deps.homeDir, deps.platform).catch(() => undefined);
1221
- return { ok: true, exitCode: 0 };
1222
- }
1223
- await appendScheduleRunEvent(options.id, { status: "failed", at: nowIso(deps), exit_code: result.exitCode }, deps.homeDir, deps.platform).catch(() => undefined);
1224
- return { ok: false, exitCode: result.exitCode };
1225
- }
1226
- /**
1227
- * CLI entry for `schedule-run`. Returns a numeric process exit code. Help → 0;
1228
- * parser/validation errors → 1 (printed with usage to stderr); create/list/
1229
- * cancel/doctor dispatch otherwise. Unexpected errors are caught at this
1230
- * boundary, logged internally to stderr, and surfaced to the user sanitized.
1231
- */
1232
- export async function runScheduleRunCli(argv, overrides = {}) {
1233
- const log = overrides.log ?? ((m) => console.log(m));
1234
- const errorLog = overrides.errorLog ?? ((m) => console.error(m));
1235
- const parsed = parseScheduleRunArgs(argv);
1236
- if (parsed.status === "help") {
1237
- log(parsed.usage);
1238
- return 0;
1239
- }
1240
- if (parsed.status === "error") {
1241
- errorLog(`Error: ${parsed.message}`);
1242
- errorLog("");
1243
- errorLog(getScheduleRunUsage());
1244
- return 1;
1245
- }
1246
- const deps = overrides.deps ?? createDefaultScheduleRunDeps();
1247
- try {
1248
- switch (parsed.subcommand) {
1249
- case "create": {
1250
- const result = await orchestrateScheduleCreate(parsed.options, deps);
1251
- if (!result.ok) {
1252
- errorLog(formatScheduleCreateResult(result));
1253
- return 1;
1254
- }
1255
- log(formatScheduleCreateResult(result));
1256
- return 0;
1257
- }
1258
- case "list": {
1259
- const report = await orchestrateScheduleList(parsed.options, deps);
1260
- log(formatScheduleListResult(report, parsed.options.json));
1261
- return 0;
1262
- }
1263
- case "cancel": {
1264
- const result = await orchestrateScheduleCancel(parsed.options, deps);
1265
- if (!result.ok) {
1266
- errorLog(formatScheduleCancelResult(result));
1267
- return 1;
1268
- }
1269
- log(formatScheduleCancelResult(result));
1270
- return 0;
1271
- }
1272
- case "doctor": {
1273
- const report = await orchestrateScheduleDoctor(deps);
1274
- log(formatScheduleDoctorReport(report, parsed.options.json));
1275
- return report.platformSupported ? 0 : 1;
1276
- }
1277
- case "_execute": {
1278
- const io = {
1279
- writeStdout: overrides.writeStdout ?? ((chunk) => process.stdout.write(chunk)),
1280
- writeStderr: overrides.writeStderr ?? ((chunk) => process.stderr.write(chunk)),
1281
- };
1282
- const result = await orchestrateScheduleExecute(parsed.options, deps, io);
1283
- if (!result.ok && result.error) {
1284
- errorLog(`Error: ${result.error}`);
1285
- }
1286
- return result.exitCode;
1287
- }
1288
- }
1289
- }
1290
- catch (error) {
1291
- // Log the internal detail to stderr for local diagnostics, but surface a
1292
- // sanitized message as the primary user-facing error.
1293
- const detail = error instanceof Error ? error.message : String(error);
1294
- errorLog(`Internal error: ${detail}`);
1295
- errorLog("Error: schedule-run failed unexpectedly. See the message above for local diagnostics.");
1296
- return 1;
1297
- }
1298
- // Unreachable: the switch is exhaustive over the parsed subcommands.
1299
- return 1;
1300
- }