wogiflow 2.32.0 → 2.34.1

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 (39) hide show
  1. package/.claude/docs/claude-code-compatibility.md +51 -0
  2. package/.claude/docs/scheduled-mode.md +213 -0
  3. package/.claude/docs/skill-portability.md +190 -0
  4. package/.claude/rules/alternative-hook-args-exec-form.md +6 -0
  5. package/.claude/settings.json +2 -1
  6. package/.claude/skills/_template/skill.md +1 -0
  7. package/.claude/skills/conventional-commit/knowledge/examples.md +65 -0
  8. package/.claude/skills/conventional-commit/skill.md +76 -0
  9. package/bin/flow +16 -0
  10. package/lib/scheduled-mode.js +374 -0
  11. package/lib/skill-export-agentskills.js +211 -0
  12. package/lib/skill-export-claude-plugin.js +183 -0
  13. package/lib/skill-portability.js +342 -0
  14. package/lib/skill-registry.js +32 -2
  15. package/lib/workspace-channel-server.js +106 -3
  16. package/lib/workspace-channel-tracking.js +102 -1
  17. package/lib/workspace-dispatch-tracking.js +28 -0
  18. package/lib/workspace-messages.js +32 -4
  19. package/lib/workspace-subtask-state.js +215 -0
  20. package/lib/workspace.js +81 -0
  21. package/package.json +2 -2
  22. package/scripts/flow +25 -0
  23. package/scripts/flow-config-defaults.js +20 -0
  24. package/scripts/flow-constants.js +3 -1
  25. package/scripts/flow-schedule.js +486 -0
  26. package/scripts/flow-scheduled-runner.js +659 -0
  27. package/scripts/flow-skill-export.js +334 -0
  28. package/scripts/flow-standards-checker.js +37 -0
  29. package/scripts/hooks/adapters/claude-code.js +18 -3
  30. package/scripts/hooks/core/git-safety-gate.js +118 -27
  31. package/scripts/hooks/core/long-input-enforcement.js +139 -4
  32. package/scripts/hooks/core/overdue-dispatches.js +28 -6
  33. package/scripts/hooks/core/session-start-worker.js +52 -0
  34. package/scripts/hooks/core/stop-orchestrator.js +17 -2
  35. package/scripts/hooks/core/validation.js +8 -0
  36. package/scripts/hooks/core/worker-continuation-gate.js +326 -0
  37. package/scripts/hooks/core/workspace-stop-gates.js +21 -0
  38. package/scripts/hooks/core/workspace-stop-notify.js +174 -59
  39. package/scripts/hooks/entry/claude-code/post-tool-use.js +26 -0
@@ -0,0 +1,659 @@
1
+ #!/usr/bin/env node
2
+
3
+ 'use strict';
4
+
5
+ /**
6
+ * Wogi Flow — Scheduled / Background Headless Runner (Phase 1A — wf-b211a076).
7
+ *
8
+ * Entry point invoked by `.github/workflows/wogi-scheduled.yml` (or by a
9
+ * launchd/cron/systemd unit installed via `flow schedule install`).
10
+ *
11
+ * Usage:
12
+ * node scripts/flow-scheduled-runner.js <job-name> [--dry-run] [--repo=owner/name]
13
+ *
14
+ * Jobs:
15
+ * nightly-regression Wraps scripts/flow-step-regression.js; skipped on empty diff.
16
+ * weekly-audit Headless `claude -p` running /wogi-audit.
17
+ * weekly-digest Headless `claude -p` running /wogi-debt + /wogi-gate-stats.
18
+ * per-pr-review Headless `claude -p` running ultrareview on a PR.
19
+ *
20
+ * Read-only-by-default invariants (HARD GATES — also enforced by tests):
21
+ * - Runs only on the default branch (origin/HEAD).
22
+ * - Operates in a temp worktree created via scripts/flow-worktree.js
23
+ * (real `runInWorktree` wrap as of R-379 fix; failure to create worktree
24
+ * is a hard error — no silent fallback to the user's working dir).
25
+ * - Never `git push origin master` and never `gh pr merge`.
26
+ * - Never writes to .workflow/state/decisions.md.
27
+ *
28
+ * Safeguards:
29
+ * - --dry-run prints $/month projection and exits 0 without invoking claude.
30
+ * - scheduledMode.dailyTokenBudget cap — same-day jobs no-op once hit.
31
+ * - One persistent labelled issue per job (`wogi/scheduled-${job}`).
32
+ * - Silence-on-green default — nightly posts nothing when all tests pass.
33
+ * - Skip-on-empty-diff — nightly checks `git diff @{yesterday}..HEAD`.
34
+ * - 10-minute hard timeout per job via AbortController; on timeout opens a
35
+ * `wogi/scheduled-failure` issue with captured stderr.
36
+ * - Retry-1×-then-alert with 30s backoff on transient failures.
37
+ * - Clears routing-pending.json + pending-question.json before each invocation.
38
+ */
39
+
40
+ const fs = require('node:fs');
41
+ const path = require('node:path');
42
+ const { execFileSync } = require('node:child_process');
43
+
44
+ const {
45
+ JOB_NAMES,
46
+ DEFAULT_JOB_TIMEOUT_MS,
47
+ TRANSIENT_RETRY_DELAY_MS,
48
+ clearStaleMarkers,
49
+ loadHeadlessProfile,
50
+ projectMonthlyCost,
51
+ withTimeout,
52
+ enforceTokenBudget,
53
+ updateDedupIssue,
54
+ validateModelName,
55
+ isTransientError,
56
+ DEFAULT_TOKENS_PER_INVOCATION,
57
+ } = require('../lib/scheduled-mode');
58
+
59
+ const { runInWorktree } = require('./flow-worktree');
60
+
61
+ // F16 (R-379): named constant for stderr-truncation cap so the meaning is
62
+ // inspectable and tunable in one place (decisions.md Code Quality §1).
63
+ const MAX_STDERR_BYTES = 4096;
64
+
65
+ // F10 (R-379): redact secret-shaped strings from stderr BEFORE we post it
66
+ // to a public GH Issue. Conservative — only strips the well-known token
67
+ // shapes (Anthropic API keys, GitHub PATs/fine-grained). Real users may
68
+ // have other tokens in their environment; this is best-effort, not a
69
+ // guarantee. The right defense remains "don't echo secrets in error
70
+ // messages in the first place"; this is the defense-in-depth pass.
71
+ const SECRET_REDACTION_PATTERNS = [
72
+ // Anthropic API keys — sk-ant-…
73
+ { re: /sk-ant-[a-zA-Z0-9_\-]{20,}/g, replacement: '[REDACTED:anthropic-key]' },
74
+ // GitHub PATs — ghp_… (classic), github_pat_… (fine-grained)
75
+ { re: /ghp_[a-zA-Z0-9]{36}/g, replacement: '[REDACTED:github-pat-classic]' },
76
+ { re: /github_pat_[a-zA-Z0-9_]{22,}/g, replacement: '[REDACTED:github-pat-fg]' },
77
+ // OAuth-shaped bearer tokens — only when they appear next to the literal
78
+ // "Authorization: Bearer" header (avoid stripping every JWT in stderr).
79
+ { re: /(Authorization:\s*Bearer\s+)[A-Za-z0-9_\-.]{20,}/gi, replacement: '$1[REDACTED:bearer]' },
80
+ ];
81
+
82
+ function redactSecrets(text) {
83
+ if (typeof text !== 'string') return '';
84
+ let out = text;
85
+ for (const { re, replacement } of SECRET_REDACTION_PATTERNS) {
86
+ out = out.replace(re, replacement);
87
+ }
88
+ return out;
89
+ }
90
+
91
+ const { PATHS, getConfig, safeJsonParse } = require('./flow-utils');
92
+
93
+ // ============================================================
94
+ // Constants
95
+ // ============================================================
96
+
97
+ const USAGE_LOG_PATH = path.join(PATHS.state, 'scheduled-usage-log.json');
98
+ const FAILURE_LABEL = 'wogi/scheduled-failure';
99
+
100
+ // ============================================================
101
+ // CLI arg parsing
102
+ // ============================================================
103
+
104
+ function parseArgs(argv) {
105
+ const args = { job: null, dryRun: false, repo: null, _raw: argv.slice() };
106
+ for (let i = 0; i < argv.length; i++) {
107
+ const a = argv[i];
108
+ if (a === '--dry-run') args.dryRun = true;
109
+ else if (a.startsWith('--repo=')) args.repo = a.slice('--repo='.length);
110
+ else if (!a.startsWith('--') && !args.job) args.job = a;
111
+ }
112
+ return args;
113
+ }
114
+
115
+ // ============================================================
116
+ // Subprocess helpers (execFile only — no shell interpolation)
117
+ // ============================================================
118
+
119
+ function execSafe(cmd, args, opts = {}) {
120
+ try {
121
+ const out = execFileSync(cmd, args, {
122
+ encoding: 'utf-8',
123
+ stdio: ['pipe', 'pipe', 'pipe'],
124
+ ...opts,
125
+ });
126
+ return { ok: true, stdout: out.trim(), stderr: '' };
127
+ } catch (err) {
128
+ return {
129
+ ok: false,
130
+ stdout: (err.stdout && err.stdout.toString()) || '',
131
+ stderr: (err.stderr && err.stderr.toString()) || err.message,
132
+ error: err,
133
+ };
134
+ }
135
+ }
136
+
137
+ // ============================================================
138
+ // Default-branch detection
139
+ // ============================================================
140
+
141
+ /**
142
+ * Detect the repo's default branch via origin/HEAD.
143
+ * Falls back to 'master' then 'main' if origin/HEAD is unconfigured.
144
+ */
145
+ function detectDefaultBranch(cwd) {
146
+ const r = execSafe('git', ['symbolic-ref', 'refs/remotes/origin/HEAD'], { cwd });
147
+ if (r.ok && r.stdout) {
148
+ // "refs/remotes/origin/master" → "master"
149
+ return r.stdout.replace(/^refs\/remotes\/origin\//, '').trim();
150
+ }
151
+ // Fallback probes
152
+ for (const candidate of ['master', 'main']) {
153
+ const probe = execSafe('git', ['rev-parse', '--verify', `origin/${candidate}`], { cwd });
154
+ if (probe.ok) return candidate;
155
+ }
156
+ return null;
157
+ }
158
+
159
+ // ============================================================
160
+ // Usage log (token budget tracking)
161
+ // ============================================================
162
+
163
+ function readUsageLog() {
164
+ const parsed = safeJsonParse(USAGE_LOG_PATH, null);
165
+ return parsed && typeof parsed === 'object' ? parsed : {};
166
+ }
167
+
168
+ function writeUsageLog(log) {
169
+ try {
170
+ fs.mkdirSync(path.dirname(USAGE_LOG_PATH), { recursive: true });
171
+ fs.writeFileSync(USAGE_LOG_PATH, JSON.stringify(log, null, 2));
172
+ } catch (err) {
173
+ console.warn(`[scheduled-runner] failed to persist usage log: ${err.message}`);
174
+ }
175
+ }
176
+
177
+ function recordUsage(jobName, tokens, now = Date.now()) {
178
+ const key = new Date(now).toISOString().slice(0, 10);
179
+ const log = readUsageLog();
180
+ if (!log[key]) log[key] = {};
181
+ log[key][jobName] = (log[key][jobName] ?? 0) + tokens;
182
+ writeUsageLog(log);
183
+ return log;
184
+ }
185
+
186
+ // ============================================================
187
+ // Empty-diff check for nightly-regression
188
+ // ============================================================
189
+
190
+ function hasDiffSinceYesterday(cwd) {
191
+ // Use a SHA range — @{yesterday} can fail in CI where reflog is empty.
192
+ // We anchor against the merge-base with origin/<default-branch> from ~24h ago.
193
+ // Simpler + portable: compare HEAD vs HEAD~ at >=24h old commit if available.
194
+ const r = execSafe('git', ['log', '--since=24 hours ago', '--oneline', '-1'], { cwd });
195
+ if (!r.ok) {
196
+ // Be conservative: if we can't tell, treat as "has diff" so we don't silently skip.
197
+ return true;
198
+ }
199
+ return r.stdout.trim().length > 0;
200
+ }
201
+
202
+ // ============================================================
203
+ // Dedup-issue execution
204
+ // ============================================================
205
+
206
+ function listDedupIssues(jobName, repo) {
207
+ const label = `wogi/scheduled-${jobName}`;
208
+ const args = ['issue', 'list', '--label', label, '--state', 'open', '--json', 'number'];
209
+ if (repo) args.push('--repo', repo);
210
+ const r = execSafe('gh', args);
211
+ if (!r.ok) return [];
212
+ try {
213
+ // F19 (R-379): use safeJsonParse for prototype-pollution guard, even
214
+ // though gh's output is trusted today — consistent with the project
215
+ // convention (security-patterns.md §2).
216
+ const parsed = safeJsonParse(r.stdout || '[]', []);
217
+ if (!Array.isArray(parsed)) return [];
218
+ return parsed.map((x) => x && x.number).filter((n) => Number.isFinite(n));
219
+ } catch (_err) {
220
+ return [];
221
+ }
222
+ }
223
+
224
+ function postDedupIssue(jobName, body, repo) {
225
+ const existing = listDedupIssues(jobName, repo);
226
+ const { mode, argv } = updateDedupIssue(jobName, body, { existingIssueNumbers: existing });
227
+ const fullArgv = repo ? [...argv, '--repo', repo] : argv;
228
+ const r = execSafe('gh', fullArgv);
229
+ return { mode, ok: r.ok, error: r.ok ? null : r.stderr };
230
+ }
231
+
232
+ function openFailureIssue(jobName, summary, stderr, repo) {
233
+ const title = `[scheduled-failure] ${jobName} — ${new Date().toISOString().slice(0, 10)}`;
234
+ const body = [
235
+ `### Scheduled job failure`,
236
+ `- **Job**: \`${jobName}\``,
237
+ `- **Time**: ${new Date().toISOString()}`,
238
+ ``,
239
+ `### Summary`,
240
+ summary,
241
+ ``,
242
+ `### stderr (last ${MAX_STDERR_BYTES} bytes, secrets redacted)`,
243
+ '```',
244
+ redactSecrets((stderr || '').slice(-MAX_STDERR_BYTES)),
245
+ '```',
246
+ ].join('\n');
247
+ const argv = ['issue', 'create', '--title', title, '--body', body, '--label', FAILURE_LABEL];
248
+ const fullArgv = repo ? [...argv, '--repo', repo] : argv;
249
+ return execSafe('gh', fullArgv);
250
+ }
251
+
252
+ // ============================================================
253
+ // Read-only invariant guard (defense-in-depth — runner self-check)
254
+ // ============================================================
255
+
256
+ function assertReadOnlyEnv() {
257
+ // Defense-in-depth: refuse to start if env hints at write-mode operation.
258
+ // This is belt-and-braces — the workflow YAML uses contents:read where
259
+ // possible, but a misconfigured local cron unit could grant more.
260
+ if (process.env.WOGI_SCHEDULED_ALLOW_WRITE === '1') {
261
+ throw new Error(
262
+ 'scheduled-runner: refusing to run with WOGI_SCHEDULED_ALLOW_WRITE=1 — read-only invariant.'
263
+ );
264
+ }
265
+ }
266
+
267
+ // ============================================================
268
+ // Job implementations
269
+ // ============================================================
270
+
271
+ /**
272
+ * Run the nightly regression job. Wraps flow-step-regression.js.
273
+ * Silence-on-green: posts nothing if all pass.
274
+ */
275
+ async function runNightlyRegression(ctx) {
276
+ const { profile, cwd, repo, signal } = ctx;
277
+ validateModelName(profile.model);
278
+
279
+ if (signal && signal.aborted) {
280
+ return { passed: false, message: 'aborted', skipped: false };
281
+ }
282
+
283
+ if (!hasDiffSinceYesterday(cwd)) {
284
+ return { passed: true, skipped: true, message: 'no diff in last 24h — skipped' };
285
+ }
286
+
287
+ // Delegate to the existing step runner — do NOT rewrite it.
288
+ // The step runner shells out via execSync internally; we capture its result.
289
+ const scriptPath = path.join(__dirname, 'flow-step-regression.js');
290
+ if (!fs.existsSync(scriptPath)) {
291
+ return { passed: false, message: 'flow-step-regression.js not found', skipped: false };
292
+ }
293
+
294
+ const stepRunner = require(scriptPath);
295
+ let result;
296
+ try {
297
+ result = await stepRunner.run({ stepConfig: { sampleSize: 3 }, mode: 'auto' });
298
+ } catch (err) {
299
+ return { passed: false, message: `regression runner threw: ${err.message}`, skipped: false };
300
+ }
301
+
302
+ if (result && result.passed) {
303
+ // Silence-on-green: do NOT touch the dedup issue.
304
+ return { passed: true, skipped: false, message: result.message, silent: true };
305
+ }
306
+
307
+ // Failure: build a markdown body and post to the dedup issue.
308
+ const body = [
309
+ `### Nightly regression — FAILED`,
310
+ `- **Time**: ${new Date().toISOString()}`,
311
+ `- **Branch**: ${ctx.defaultBranch}`,
312
+ ``,
313
+ `### Message`,
314
+ result?.message || '(no message)',
315
+ ``,
316
+ `### Details`,
317
+ '```json',
318
+ JSON.stringify(result?.details || {}, null, 2),
319
+ '```',
320
+ ].join('\n');
321
+ if (!profile.dryRun) {
322
+ postDedupIssue('nightly-regression', body, repo);
323
+ }
324
+ return { passed: false, skipped: false, message: result?.message || 'regression failed' };
325
+ }
326
+
327
+ /**
328
+ * Generic claude-headless invoker for audit / digest / per-pr-review jobs.
329
+ *
330
+ * NOTE: We do not actually shell out to `claude -p` unless the binary exists.
331
+ * In CI we expect the GH Actions workflow to provide it; locally on a dev
332
+ * machine, the headless runner is typically invoked in --dry-run mode.
333
+ */
334
+ async function runClaudeHeadless(jobName, prompt, ctx) {
335
+ const { profile, signal } = ctx;
336
+ const model = validateModelName(profile.model);
337
+
338
+ if (profile.dryRun) {
339
+ return {
340
+ passed: true,
341
+ skipped: true,
342
+ message: `dry-run: would invoke claude -p --model=${model} on ${jobName}`,
343
+ silent: true,
344
+ };
345
+ }
346
+
347
+ if (signal && signal.aborted) {
348
+ return { passed: false, message: 'aborted', skipped: false };
349
+ }
350
+
351
+ // Probe for the `claude` binary. In environments without it, we exit
352
+ // cleanly rather than crash — the failure-issue path covers reporting.
353
+ const which = execSafe('which', ['claude']);
354
+ if (!which.ok) {
355
+ return {
356
+ passed: false,
357
+ skipped: false,
358
+ message: 'claude CLI not on PATH — headless invocation not possible',
359
+ };
360
+ }
361
+
362
+ const r = execSafe('claude', ['-p', '--model', model, prompt], {
363
+ cwd: ctx.cwd,
364
+ env: { ...process.env },
365
+ timeout: ctx.timeoutMs ?? DEFAULT_JOB_TIMEOUT_MS,
366
+ });
367
+
368
+ // F4 / R-379: record the actual pre-flight estimate (not 0). Without this,
369
+ // enforceTokenBudget always sees 0 spent and the daily cap is a no-op.
370
+ recordUsage(jobName, ctx.estimatedTokens ?? 0);
371
+
372
+ return {
373
+ passed: r.ok,
374
+ skipped: false,
375
+ message: r.ok ? `${jobName} ran cleanly` : `claude exited non-zero`,
376
+ stdout: r.stdout,
377
+ stderr: r.stderr,
378
+ };
379
+ }
380
+
381
+ async function runWeeklyAudit(ctx) {
382
+ const result = await runClaudeHeadless('weekly-audit', '/wogi-audit', ctx);
383
+ if (result.passed && !result.skipped && !ctx.profile.dryRun) {
384
+ const body = [
385
+ `### Weekly Audit Report`,
386
+ `- **Time**: ${new Date().toISOString()}`,
387
+ ``,
388
+ '```',
389
+ (result.stdout || '').slice(0, 8000),
390
+ '```',
391
+ ].join('\n');
392
+ postDedupIssue('weekly-audit', body, ctx.repo);
393
+ }
394
+ return result;
395
+ }
396
+
397
+ async function runWeeklyDigest(ctx) {
398
+ const prompt = '/wogi-debt and then /wogi-gate-stats --since=7d. Produce a single digest.';
399
+ const result = await runClaudeHeadless('weekly-digest', prompt, ctx);
400
+ if (result.passed && !result.skipped && !ctx.profile.dryRun) {
401
+ const body = [
402
+ `### Weekly Debt + Gate-stats Digest`,
403
+ `- **Time**: ${new Date().toISOString()}`,
404
+ ``,
405
+ '```',
406
+ (result.stdout || '').slice(0, 8000),
407
+ '```',
408
+ ].join('\n');
409
+ postDedupIssue('weekly-digest', body, ctx.repo);
410
+ }
411
+ return result;
412
+ }
413
+
414
+ async function runPerPrReview(ctx) {
415
+ const prNumber = process.env.PR_NUMBER || '0';
416
+ // F3 / R-379: must be the slash-command form (/ultrareview …) so Claude Code
417
+ // routes it to the ultrareview skill. The bare-string form ("ultrareview 42")
418
+ // sends a literal text prompt that goes nowhere useful. Mirrors how
419
+ // runWeeklyAudit invokes "/wogi-audit" above.
420
+ const prompt = `/ultrareview ${prNumber}`;
421
+ const result = await runClaudeHeadless('per-pr-review', prompt, ctx);
422
+ // Per-PR review posts on the PR (gh pr comment) — handled by the GH workflow
423
+ // step using the returned stdout. Runner doesn't auto-merge.
424
+ return result;
425
+ }
426
+
427
+ const JOB_HANDLERS = {
428
+ 'nightly-regression': runNightlyRegression,
429
+ 'weekly-audit': runWeeklyAudit,
430
+ 'weekly-digest': runWeeklyDigest,
431
+ 'per-pr-review': runPerPrReview,
432
+ };
433
+
434
+ // ============================================================
435
+ // Orchestrator — single job invocation with timeout + retry
436
+ // ============================================================
437
+
438
+ async function runOnce(jobName, ctx) {
439
+ const handler = JOB_HANDLERS[jobName];
440
+ if (!handler) {
441
+ throw new Error(`scheduled-runner: unknown job "${jobName}"`);
442
+ }
443
+ return withTimeout(
444
+ ({ signal }) => handler({ ...ctx, signal }),
445
+ ctx.timeoutMs ?? DEFAULT_JOB_TIMEOUT_MS,
446
+ );
447
+ }
448
+
449
+ async function runJobWithRetry(jobName, ctx) {
450
+ const first = await runOnce(jobName, ctx);
451
+
452
+ // F5 (R-379): handlers catch internally (via execSafe / try-catch) and
453
+ // return `{passed:false, ...}` rather than throwing, so `withTimeout`
454
+ // wraps them as `{ok:true, result:{...}}`. Without this branch, the
455
+ // transient-retry path is unreachable because `first.ok` is almost
456
+ // always true. Look at the INNER result for transient signals too.
457
+ const transientInOuter = !first.ok && first.error && isTransientError(first.error);
458
+ const transientInInner =
459
+ first.ok &&
460
+ first.result &&
461
+ first.result.passed === false &&
462
+ typeof first.result.message === 'string' &&
463
+ isTransientError({ message: first.result.message });
464
+
465
+ if (transientInOuter || transientInInner) {
466
+ await new Promise((r) => setTimeout(r, TRANSIENT_RETRY_DELAY_MS));
467
+ return runOnce(jobName, ctx);
468
+ }
469
+
470
+ return first;
471
+ }
472
+
473
+ // ============================================================
474
+ // Main entry
475
+ // ============================================================
476
+
477
+ async function main(argv = process.argv.slice(2), deps = {}) {
478
+ const args = parseArgs(argv);
479
+ if (!args.job) {
480
+ console.error(
481
+ `Usage: node scripts/flow-scheduled-runner.js <job-name> [--dry-run] [--repo=owner/name]\n` +
482
+ `Jobs: ${JOB_NAMES.join(', ')}`
483
+ );
484
+ return 2;
485
+ }
486
+ if (!JOB_NAMES.includes(args.job)) {
487
+ console.error(`scheduled-runner: unknown job "${args.job}". Allowed: ${JOB_NAMES.join(', ')}`);
488
+ return 2;
489
+ }
490
+
491
+ assertReadOnlyEnv();
492
+
493
+ const config = (deps.config || getConfig());
494
+ const sm = (config && config.scheduledMode) || {};
495
+ if (!sm.enabled && !args.dryRun) {
496
+ console.log(`scheduled-runner: scheduledMode.enabled is false — exiting with 0`);
497
+ return 0;
498
+ }
499
+
500
+ const profile = loadHeadlessProfile(config, args.job);
501
+ profile.dryRun = profile.dryRun || args.dryRun;
502
+
503
+ // --- Dry-run path: print projection and exit, no claude invocation. ---
504
+ if (profile.dryRun) {
505
+ const projection = projectMonthlyCost(config || {});
506
+ const lines = [
507
+ `scheduled-runner: DRY-RUN (job=${args.job}, model=${profile.model})`,
508
+ `Projected monthly cost across all configured jobs: $${projection.total}`,
509
+ ``,
510
+ `Per-job:`,
511
+ ];
512
+ for (const [name, info] of Object.entries(projection.byJob)) {
513
+ lines.push(` ${name.padEnd(22)} ${info.model.padEnd(8)} ` +
514
+ `${info.invocations}× ${info.tokens.toLocaleString()} tok → $${info.cost}`);
515
+ }
516
+ console.log(lines.join('\n'));
517
+ return 0;
518
+ }
519
+
520
+ // --- Token-budget check ---
521
+ const budgetVerdict = enforceTokenBudget(
522
+ readUsageLog(),
523
+ profile.dailyTokenBudget,
524
+ Date.now(),
525
+ args.job,
526
+ );
527
+ if (!budgetVerdict.allowed) {
528
+ console.warn(`scheduled-runner: ${args.job} skipped — ${budgetVerdict.reason}`);
529
+ return 0; // no-op, exit clean
530
+ }
531
+
532
+ // --- Default-branch + worktree enforcement ---
533
+ const cwd = process.cwd();
534
+ const defaultBranch = detectDefaultBranch(cwd) || 'master';
535
+ const currentBranch = execSafe('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd }).stdout;
536
+ // We do NOT crash if the current branch isn't default — the runner itself
537
+ // creates a worktree on the default branch. But we record it so the audit trail
538
+ // is complete.
539
+
540
+ // --- Clear stale markers ---
541
+ const cleared = clearStaleMarkers(PATHS.state);
542
+ if (cleared.cleared.length > 0) {
543
+ console.log(`scheduled-runner: cleared ${cleared.cleared.join(', ')}`);
544
+ }
545
+
546
+ // --- Execute the job (with timeout + retry, in a temp worktree) ---
547
+ // F2 / R-379: the runner MUST operate in a temp worktree on the default
548
+ // branch — never touch the user's working dir. Failure to create the worktree
549
+ // is a hard error (no silent fallback); the invariant matters even if Claude
550
+ // never writes to disk, because regression scripts and tests do.
551
+ // F4 / R-379: ctx.estimatedTokens now carries the real per-job estimate so
552
+ // enforceTokenBudget can actually do its job.
553
+ const buildCtx = (worktreeCwd) => ({
554
+ profile,
555
+ cwd: worktreeCwd ?? cwd,
556
+ repo: args.repo,
557
+ defaultBranch,
558
+ currentBranch,
559
+ estimatedTokens: DEFAULT_TOKENS_PER_INVOCATION[args.job] ?? 0,
560
+ timeoutMs: DEFAULT_JOB_TIMEOUT_MS,
561
+ });
562
+
563
+ const worktreeOpts = {
564
+ taskId: `scheduled-${args.job}-${Date.now()}`,
565
+ baseBranch: defaultBranch,
566
+ cwd,
567
+ };
568
+
569
+ let worktreeWrap;
570
+ try {
571
+ worktreeWrap = await (deps.runInWorktree || runInWorktree)(
572
+ worktreeOpts,
573
+ async (worktreePath) => runJobWithRetry(args.job, buildCtx(worktreePath)),
574
+ );
575
+ } catch (err) {
576
+ // Hard-error: refuse to silently fall back to the user's working directory.
577
+ openFailureIssue(
578
+ args.job,
579
+ `Worktree creation failed; refusing to run on the user's working tree (read-only invariant).`,
580
+ String(err && err.message ? err.message : err),
581
+ args.repo,
582
+ );
583
+ return 1;
584
+ }
585
+
586
+ if (!worktreeWrap.success) {
587
+ // Worktree was created but the wrapped job threw; report and exit.
588
+ openFailureIssue(
589
+ args.job,
590
+ `Job threw inside the temp worktree.`,
591
+ String(worktreeWrap.error || '(no error message)'),
592
+ args.repo,
593
+ );
594
+ return 1;
595
+ }
596
+
597
+ const verdict = worktreeWrap.result;
598
+ if (!verdict.ok) {
599
+ const stderr =
600
+ (verdict.error && verdict.error.stderr) ||
601
+ (verdict.error && verdict.error.message) ||
602
+ '(no stderr)';
603
+ if (verdict.timedOut) {
604
+ openFailureIssue(
605
+ args.job,
606
+ `Job exceeded ${DEFAULT_JOB_TIMEOUT_MS}ms hard timeout.`,
607
+ stderr,
608
+ args.repo,
609
+ );
610
+ } else {
611
+ openFailureIssue(args.job, `Job failed permanently after retry.`, stderr, args.repo);
612
+ }
613
+ return 1;
614
+ }
615
+
616
+ const result = verdict.result || {};
617
+ if (result.skipped) {
618
+ console.log(`scheduled-runner: ${args.job} skipped — ${result.message}`);
619
+ return 0;
620
+ }
621
+ if (result.silent) {
622
+ console.log(`scheduled-runner: ${args.job} green — silence-on-green`);
623
+ return 0;
624
+ }
625
+ console.log(`scheduled-runner: ${args.job} → passed=${result.passed}`);
626
+ return result.passed ? 0 : 1;
627
+ }
628
+
629
+ // ============================================================
630
+ // Exports (for tests) + CLI
631
+ // ============================================================
632
+
633
+ module.exports = {
634
+ main,
635
+ parseArgs,
636
+ detectDefaultBranch,
637
+ hasDiffSinceYesterday,
638
+ listDedupIssues,
639
+ postDedupIssue,
640
+ openFailureIssue,
641
+ readUsageLog,
642
+ writeUsageLog,
643
+ recordUsage,
644
+ runOnce,
645
+ runJobWithRetry,
646
+ assertReadOnlyEnv,
647
+ // Exported for R-379 fix tests:
648
+ runPerPrReview,
649
+ JOB_HANDLERS,
650
+ };
651
+
652
+ if (require.main === module) {
653
+ main(process.argv.slice(2))
654
+ .then((code) => process.exit(code))
655
+ .catch((err) => {
656
+ console.error(`scheduled-runner: fatal: ${err.stack || err.message}`);
657
+ process.exit(1);
658
+ });
659
+ }