ralph-codex 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,695 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import yaml from "js-yaml";
4
+ import { colors } from "../ui/terminal.js";
5
+
6
+ const root = process.cwd();
7
+ const argv = process.argv.slice(2);
8
+
9
+ const sections = new Set(["tasks", "criteria", "config"]);
10
+ let section = null;
11
+ let argIndex = 0;
12
+
13
+ if (argv[0] && !argv[0].startsWith("-") && sections.has(argv[0])) {
14
+ section = argv[0];
15
+ argIndex = 1;
16
+ } else if (argv[0] && !argv[0].startsWith("-") && argv[0] !== "help") {
17
+ process.stderr.write(`Unknown section: ${argv[0]}\n`);
18
+ process.stderr.write('Run "ralph-codex view --help" for usage.\n');
19
+ process.exit(1);
20
+ }
21
+
22
+ let tasksPath = "tasks.md";
23
+ let configPath = null;
24
+ let format = "table";
25
+ let limit = 0;
26
+ let only = null;
27
+ let watch = false;
28
+ let showHelp = false;
29
+
30
+ for (let i = argIndex; i < argv.length; i += 1) {
31
+ const arg = argv[i];
32
+ if (arg === "--help" || arg === "-h" || arg === "help") {
33
+ showHelp = true;
34
+ continue;
35
+ }
36
+ if (arg === "--tasks") {
37
+ tasksPath = argv[i + 1];
38
+ i += 1;
39
+ continue;
40
+ }
41
+ if (arg === "--config") {
42
+ configPath = argv[i + 1];
43
+ i += 1;
44
+ continue;
45
+ }
46
+ if (arg === "--format") {
47
+ format = (argv[i + 1] || format).toLowerCase();
48
+ i += 1;
49
+ continue;
50
+ }
51
+ if (arg === "--limit") {
52
+ const value = Number(argv[i + 1] || 0);
53
+ limit = Number.isFinite(value) ? Math.max(0, value) : limit;
54
+ i += 1;
55
+ continue;
56
+ }
57
+ if (arg === "--only") {
58
+ only = (argv[i + 1] || "").toLowerCase() || null;
59
+ i += 1;
60
+ continue;
61
+ }
62
+ if (arg === "--watch" || arg === "-w") {
63
+ watch = true;
64
+ continue;
65
+ }
66
+ }
67
+
68
+ function printHelp() {
69
+ process.stdout.write(
70
+ `\n${colors.cyan("ralph-codex view [section] [options]")}\n\n` +
71
+ `${colors.yellow("Sections:")}\n` +
72
+ ` ${colors.green("tasks")} Task list status\n` +
73
+ ` ${colors.green("criteria")} Success criteria from tasks.md\n` +
74
+ ` ${colors.green("config")} Effective config values\n\n` +
75
+ `${colors.yellow("Options:")}\n` +
76
+ ` ${colors.green("--tasks <path>")} Tasks file (default: tasks.md)\n` +
77
+ ` ${colors.green("--config <path>")} Config path (default: ralph.config.yml)\n` +
78
+ ` ${colors.green("--format <format>")} table | list | json (default: table)\n` +
79
+ ` ${colors.green("--limit <n>")} Limit task rows (0 = no limit)\n` +
80
+ ` ${colors.green("--only <filter>")} pending | blocked | done (tasks only)\n` +
81
+ ` ${colors.green("--watch, -w")} Watch for changes and refresh\n` +
82
+ ` ${colors.green("-h, --help")} Show help\n\n` +
83
+ `${colors.yellow("Examples:")}\n` +
84
+ ` ralph-codex view\n` +
85
+ ` ralph-codex view tasks --only pending\n` +
86
+ ` ralph-codex view criteria --format list\n` +
87
+ ` ralph-codex view config --format json\n\n`
88
+ );
89
+ }
90
+
91
+ if (showHelp) {
92
+ printHelp();
93
+ process.exit(0);
94
+ }
95
+
96
+ const allowedFormats = new Set(["table", "list", "json"]);
97
+ if (!allowedFormats.has(format)) {
98
+ console.error(`Invalid format: ${format}`);
99
+ console.error("Use --format table|list|json.");
100
+ process.exit(1);
101
+ }
102
+
103
+ const allowedFilters = new Set(["pending", "blocked", "done"]);
104
+ if (only && !allowedFilters.has(only)) {
105
+ console.error(`Invalid --only filter: ${only}`);
106
+ console.error("Use --only pending|blocked|done.");
107
+ process.exit(1);
108
+ }
109
+
110
+ if (watch && format === "json") {
111
+ console.error("Watch mode does not support --format json.");
112
+ process.exit(1);
113
+ }
114
+
115
+ const resolvedConfigPath = configPath || path.join(root, "ralph.config.yml");
116
+ const tasksPathArg = tasksPath;
117
+
118
+ function loadConfig(filePath) {
119
+ if (!fs.existsSync(filePath)) return { config: {}, missing: true };
120
+ try {
121
+ const content = fs.readFileSync(filePath, "utf8");
122
+ return { config: yaml.load(content) || {}, missing: false };
123
+ } catch (error) {
124
+ console.error(`Failed to read config at ${filePath}: ${error?.message || error}`);
125
+ process.exit(1);
126
+ }
127
+ }
128
+
129
+ function resolveTasksPath(config, currentPath) {
130
+ if (currentPath !== "tasks.md") return currentPath;
131
+ if (config?.plan?.tasks_path) return config.plan.tasks_path;
132
+ if (config?.run?.tasks_path) return config.run.tasks_path;
133
+ return currentPath;
134
+ }
135
+
136
+ function formatValue(value) {
137
+ if (value === null) return "null";
138
+ if (value === undefined) return "undefined";
139
+ if (typeof value === "boolean") return value ? "true" : "false";
140
+ if (typeof value === "number") return String(value);
141
+ if (Array.isArray(value)) return value.length ? value.join(", ") : "[]";
142
+ const trimmed = String(value);
143
+ return trimmed === "" ? "(empty)" : trimmed;
144
+ }
145
+
146
+ function pad(value, width) {
147
+ const text = String(value);
148
+ return text + " ".repeat(Math.max(0, width - text.length));
149
+ }
150
+
151
+ function formatStatus(status) {
152
+ const raw =
153
+ status === "done" ? "[x]" : status === "blocked" ? "[~]" : "[ ]";
154
+ if (status === "done") return { raw, display: colors.green(raw) };
155
+ if (status === "blocked") return { raw, display: colors.yellow(raw) };
156
+ return { raw, display: colors.gray(raw) };
157
+ }
158
+
159
+ function parseTasks(content) {
160
+ const tasks = [];
161
+ const lines = content.split(/\r?\n/);
162
+ let index = 0;
163
+ for (const line of lines) {
164
+ const match = line.match(/^\s*[-*]\s+\[([ x~])\]\s+(.*)$/);
165
+ if (!match) continue;
166
+ index += 1;
167
+ const statusToken = match[1].toLowerCase();
168
+ const status =
169
+ statusToken === "x" ? "done" : statusToken === "~" ? "blocked" : "pending";
170
+ tasks.push({ index, status, text: match[2].trim() });
171
+ }
172
+ return tasks;
173
+ }
174
+
175
+ function summarizeTasks(tasks) {
176
+ const total = tasks.length;
177
+ const done = tasks.filter((task) => task.status === "done").length;
178
+ const blocked = tasks.filter((task) => task.status === "blocked").length;
179
+ const pending = total - done - blocked;
180
+ const percent = total === 0 ? 0 : Math.round((done / total) * 100);
181
+ return { total, done, blocked, pending, percent };
182
+ }
183
+
184
+ function filterTasks(tasks, filter) {
185
+ if (!filter) return tasks;
186
+ return tasks.filter((task) => task.status === filter);
187
+ }
188
+
189
+ function extractSuccessCriteria(content) {
190
+ const lines = content.split(/\r?\n/);
191
+ let start = -1;
192
+ for (let i = 0; i < lines.length; i += 1) {
193
+ if (/^(#+\s*)?success criteria\b/i.test(lines[i].trim())) {
194
+ start = i;
195
+ break;
196
+ }
197
+ }
198
+ if (start === -1) return [];
199
+ const items = [];
200
+ for (let i = start + 1; i < lines.length; i += 1) {
201
+ const line = lines[i].trim();
202
+ if (!line) continue;
203
+ if (/^#+\s+/.test(line)) break;
204
+ if (line.startsWith("- ")) items.push(line.slice(2).trim());
205
+ if (line.startsWith("* ")) items.push(line.slice(2).trim());
206
+ }
207
+ return items;
208
+ }
209
+
210
+ function getLastBlocker(logPath) {
211
+ if (!fs.existsSync(logPath)) return "";
212
+ const content = fs.readFileSync(logPath, "utf8");
213
+ const lines = content.split(/\r?\n/);
214
+ for (let i = lines.length - 1; i >= 0; i -= 1) {
215
+ const line = lines[i];
216
+ const match = line.match(/^- Blockers \+ next step:\s*(.*)$/);
217
+ if (match) {
218
+ const text = match[1].trim();
219
+ if (text) return text;
220
+ for (let j = i + 1; j < lines.length; j += 1) {
221
+ const next = lines[j].trim();
222
+ if (next) return next;
223
+ }
224
+ }
225
+ }
226
+ return "";
227
+ }
228
+
229
+ function renderTasksHeader(tasksFilePath) {
230
+ if (format === "json") return;
231
+ process.stdout.write(`${colors.cyan("Tasks")}\n`);
232
+ process.stdout.write(`${colors.gray(`File: ${tasksFilePath}`)}\n`);
233
+ process.stdout.write("\n");
234
+ }
235
+
236
+ function renderTasksDivider() {
237
+ if (format === "json") return;
238
+ process.stdout.write(`${colors.gray("--------")}\n`);
239
+ }
240
+
241
+ function renderTasksSummary(summary, logPath) {
242
+ if (format === "json") return;
243
+ process.stdout.write(
244
+ `Total ${summary.total} | ` +
245
+ `${colors.green(`Done ${summary.done}`)} | ` +
246
+ `${colors.yellow(`Blocked ${summary.blocked}`)} | ` +
247
+ `Remaining ${summary.pending} (${summary.percent}%)\n`
248
+ );
249
+
250
+ const lastBlocker = getLastBlocker(logPath);
251
+ if (lastBlocker) {
252
+ process.stdout.write(`${colors.yellow(`Last blocker: ${lastBlocker}`)}\n`);
253
+ }
254
+ process.stdout.write("\n");
255
+ }
256
+
257
+ function renderTasksList(filtered) {
258
+ if (format === "json") return;
259
+ if (format === "list") {
260
+ const list = filtered.map((task) => {
261
+ const status = formatStatus(task.status).display;
262
+ return `${task.index}. ${status} ${task.text}`;
263
+ });
264
+ process.stdout.write(list.join("\n"));
265
+ process.stdout.write(list.length ? "\n" : "");
266
+ return;
267
+ }
268
+
269
+ const rows = filtered.map((task) => {
270
+ const status = formatStatus(task.status);
271
+ return {
272
+ idxRaw: String(task.index),
273
+ statusRaw: status.raw,
274
+ statusDisplay: status.display,
275
+ text: task.text,
276
+ };
277
+ });
278
+
279
+ const idxWidth = Math.max(3, ...rows.map((row) => row.idxRaw.length));
280
+ const statusWidth = Math.max(6, ...rows.map((row) => row.statusRaw.length));
281
+
282
+ process.stdout.write(
283
+ `${pad("Idx", idxWidth)} ${pad("Status", statusWidth)} Task\n`
284
+ );
285
+ process.stdout.write(
286
+ `${"-".repeat(idxWidth)} ${"-".repeat(statusWidth)} ----\n`
287
+ );
288
+ rows.forEach((row) => {
289
+ const statusPadding = " ".repeat(
290
+ Math.max(0, statusWidth - row.statusRaw.length)
291
+ );
292
+ process.stdout.write(
293
+ `${pad(row.idxRaw, idxWidth)} ${row.statusDisplay}${statusPadding} ${row.text}\n`
294
+ );
295
+ });
296
+ }
297
+
298
+ function renderCriteriaSection(tasksFilePath, criteria) {
299
+ if (format === "json") return;
300
+ process.stdout.write(`${colors.cyan("Success criteria")}\n`);
301
+ process.stdout.write(`${colors.gray(`File: ${tasksFilePath}`)}\n`);
302
+ process.stdout.write(`Total ${criteria.length}\n\n`);
303
+ }
304
+
305
+ function renderCriteriaList(criteria) {
306
+ if (format === "json") return;
307
+ if (format === "list") {
308
+ const list = criteria.map((item) => `- ${item}`);
309
+ process.stdout.write(list.join("\n"));
310
+ process.stdout.write(list.length ? "\n" : "");
311
+ return;
312
+ }
313
+
314
+ const rows = criteria.map((item, index) => ({
315
+ idxRaw: String(index + 1),
316
+ text: item,
317
+ }));
318
+ const idxWidth = Math.max(3, ...rows.map((row) => row.idxRaw.length));
319
+ process.stdout.write(`${pad("Idx", idxWidth)} Criterion\n`);
320
+ process.stdout.write(`${"-".repeat(idxWidth)} ---------\n`);
321
+ rows.forEach((row) => {
322
+ process.stdout.write(`${pad(row.idxRaw, idxWidth)} ${row.text}\n`);
323
+ });
324
+ }
325
+
326
+ function getConfigRows(config) {
327
+ const rows = [];
328
+ const defaults = {
329
+ codex: {
330
+ model: null,
331
+ profile: null,
332
+ sandbox: null,
333
+ ask_for_approval: null,
334
+ full_auto: false,
335
+ model_reasoning_effort: null,
336
+ },
337
+ docker: {
338
+ enabled: false,
339
+ use_for_plan: false,
340
+ base_image: "node:20-bullseye",
341
+ codex_install: "",
342
+ },
343
+ plan: {
344
+ tasks_path: "tasks.md",
345
+ auto_detect_success_criteria: false,
346
+ },
347
+ run: {
348
+ tasks_path: "tasks.md",
349
+ max_iterations: 15,
350
+ max_iteration_seconds: null,
351
+ max_total_seconds: null,
352
+ tail_log: true,
353
+ tail_scratchpad: false,
354
+ },
355
+ };
356
+
357
+ const addRow = (key, value, source) => {
358
+ rows.push({
359
+ key,
360
+ value: formatValue(value),
361
+ source,
362
+ });
363
+ };
364
+
365
+ const addSection = (sectionKey, fields) => {
366
+ const sectionData = config?.[sectionKey] || {};
367
+ Object.keys(fields).forEach((field) => {
368
+ const source = Object.prototype.hasOwnProperty.call(sectionData, field)
369
+ ? "config"
370
+ : "default";
371
+ const value =
372
+ source === "config" ? sectionData[field] : defaults[sectionKey][field];
373
+ addRow(`${sectionKey}.${field}`, value, source);
374
+ });
375
+ };
376
+
377
+ addSection("codex", defaults.codex);
378
+ addSection("docker", defaults.docker);
379
+ addSection("plan", defaults.plan);
380
+ addSection("run", defaults.run);
381
+ return rows;
382
+ }
383
+
384
+ function renderConfigSection(configPathValue, missing, rows, warnings) {
385
+ if (format === "json") return;
386
+ process.stdout.write(`${colors.cyan("Config (effective)")}\n`);
387
+ const note = missing ? " (missing, using defaults)" : "";
388
+ process.stdout.write(`${colors.gray(`File: ${configPathValue}${note}`)}\n\n`);
389
+
390
+ if (format === "list") {
391
+ rows.forEach((row) => {
392
+ process.stdout.write(`- ${row.key}: ${row.value} (${row.source})\n`);
393
+ });
394
+ } else {
395
+ const keyWidth = Math.max(3, ...rows.map((row) => row.key.length));
396
+ const valueWidth = Math.max(5, ...rows.map((row) => row.value.length));
397
+ process.stdout.write(
398
+ `${pad("Key", keyWidth)} ${pad("Value", valueWidth)} Source\n`
399
+ );
400
+ process.stdout.write(
401
+ `${"-".repeat(keyWidth)} ${"-".repeat(valueWidth)} ------\n`
402
+ );
403
+ rows.forEach((row) => {
404
+ const source =
405
+ row.source === "config" ? colors.green(row.source) : colors.gray(row.source);
406
+ process.stdout.write(
407
+ `${pad(row.key, keyWidth)} ${pad(row.value, valueWidth)} ${source}\n`
408
+ );
409
+ });
410
+ }
411
+
412
+ if (warnings.length > 0) {
413
+ process.stdout.write(`\n${colors.yellow("Warnings:")}\n`);
414
+ warnings.forEach((warning) =>
415
+ process.stdout.write(`${colors.yellow(`- ${warning}`)}\n`)
416
+ );
417
+ }
418
+ }
419
+
420
+ function buildConfigWarnings(config) {
421
+ const warnings = [];
422
+ if (config?.docker?.enabled && !config?.docker?.codex_install) {
423
+ warnings.push("docker.codex_install is required when docker.enabled is true.");
424
+ }
425
+ return warnings;
426
+ }
427
+
428
+ function renderOnce({ allowMissingTasks }) {
429
+ const { config, missing } = loadConfig(resolvedConfigPath);
430
+ const resolvedTasksPath = resolveTasksPath(config, tasksPathArg);
431
+ const tasksFilePath = path.join(root, resolvedTasksPath);
432
+ const logPath = path.join(root, ".ralph", "loop-log.md");
433
+
434
+ const needsTasks =
435
+ section === "tasks" || section === "criteria" || section === null;
436
+ const tasksFileExists = fs.existsSync(tasksFilePath);
437
+
438
+ const output = {};
439
+ let tasksData = null;
440
+ let criteriaData = null;
441
+
442
+ if (needsTasks && !tasksFileExists) {
443
+ if (!allowMissingTasks && (section === "tasks" || section === "criteria")) {
444
+ console.error(`Missing ${resolvedTasksPath}. Run ralph-codex plan first.`);
445
+ process.exit(1);
446
+ }
447
+
448
+ const emptySummary = { total: 0, done: 0, blocked: 0, pending: 0, percent: 0 };
449
+ tasksData = {
450
+ path: resolvedTasksPath,
451
+ summary: emptySummary,
452
+ items: [],
453
+ missing: true,
454
+ filtered: {
455
+ only: only || "all",
456
+ limit: limit || 0,
457
+ count: 0,
458
+ },
459
+ };
460
+ criteriaData = {
461
+ path: resolvedTasksPath,
462
+ items: [],
463
+ missing: true,
464
+ };
465
+ } else if (needsTasks) {
466
+ const tasksContent = fs.readFileSync(tasksFilePath, "utf8");
467
+ const allTasks = parseTasks(tasksContent);
468
+ const summary = summarizeTasks(allTasks);
469
+ const filtered = filterTasks(allTasks, only);
470
+ const sliced = limit > 0 ? filtered.slice(0, limit) : filtered;
471
+ const criteria = extractSuccessCriteria(tasksContent);
472
+
473
+ tasksData = {
474
+ path: resolvedTasksPath,
475
+ summary,
476
+ items: sliced,
477
+ missing: false,
478
+ filtered: {
479
+ only: only || "all",
480
+ limit: limit || 0,
481
+ count: filtered.length,
482
+ },
483
+ };
484
+ criteriaData = {
485
+ path: resolvedTasksPath,
486
+ items: criteria,
487
+ missing: false,
488
+ };
489
+ }
490
+
491
+ if (section === "config" || section === null) {
492
+ const rows = getConfigRows(config);
493
+ const warnings = buildConfigWarnings(config);
494
+ if (format !== "json" && section !== null) {
495
+ renderConfigSection(
496
+ path.relative(root, resolvedConfigPath),
497
+ missing,
498
+ rows,
499
+ warnings
500
+ );
501
+ }
502
+ }
503
+
504
+ if (format === "json") {
505
+ if (tasksData && (section === "tasks" || section === null)) {
506
+ output.tasks = tasksData;
507
+ }
508
+ if (criteriaData && (section === "criteria" || section === null)) {
509
+ output.criteria = criteriaData;
510
+ }
511
+ if (section === "config" || section === null) {
512
+ output.config = {
513
+ path: path.relative(root, resolvedConfigPath),
514
+ missing,
515
+ values: getConfigRows(config),
516
+ warnings: buildConfigWarnings(config),
517
+ };
518
+ }
519
+ } else if (section === null) {
520
+ if (criteriaData?.missing) {
521
+ process.stdout.write(
522
+ `${colors.yellow(`Missing ${criteriaData.path}. Task data unavailable.`)}\n\n`
523
+ );
524
+ } else if (criteriaData) {
525
+ renderCriteriaSection(criteriaData.path, criteriaData.items);
526
+ renderCriteriaList(criteriaData.items);
527
+ process.stdout.write("\n");
528
+ }
529
+
530
+ const rows = getConfigRows(config);
531
+ const warnings = buildConfigWarnings(config);
532
+ renderConfigSection(path.relative(root, resolvedConfigPath), missing, rows, warnings);
533
+ process.stdout.write("\n");
534
+
535
+ if (tasksData?.missing) {
536
+ // Already reported above for criteria.
537
+ } else if (tasksData) {
538
+ renderTasksHeader(tasksData.path);
539
+ renderTasksList(tasksData.items);
540
+ renderTasksDivider();
541
+ renderTasksSummary(tasksData.summary, logPath);
542
+ process.stdout.write("\n");
543
+ }
544
+ } else if (section === "criteria" && criteriaData) {
545
+ if (criteriaData.missing) {
546
+ process.stdout.write(
547
+ `${colors.yellow(`Missing ${criteriaData.path}. Task data unavailable.`)}\n\n`
548
+ );
549
+ } else {
550
+ renderCriteriaSection(criteriaData.path, criteriaData.items);
551
+ renderCriteriaList(criteriaData.items);
552
+ }
553
+ } else if (section === "tasks" && tasksData) {
554
+ if (tasksData.missing) {
555
+ process.stdout.write(
556
+ `${colors.yellow(`Missing ${tasksData.path}. Task data unavailable.`)}\n\n`
557
+ );
558
+ } else {
559
+ renderTasksHeader(tasksData.path);
560
+ renderTasksList(tasksData.items);
561
+ renderTasksDivider();
562
+ renderTasksSummary(tasksData.summary, logPath);
563
+ }
564
+ }
565
+
566
+ return {
567
+ output,
568
+ tasksFilePath,
569
+ logPath,
570
+ };
571
+ }
572
+
573
+ function clearScreen() {
574
+ if (process.stdout.isTTY) {
575
+ process.stdout.write("\x1Bc");
576
+ }
577
+ }
578
+
579
+ const watchState = {
580
+ watchers: new Map(),
581
+ pollTimer: null,
582
+ };
583
+
584
+ const debounceMs = 150;
585
+ let refreshTimer = null;
586
+ let refreshing = false;
587
+ let pendingRefresh = false;
588
+
589
+ function scheduleRefresh() {
590
+ if (refreshTimer) clearTimeout(refreshTimer);
591
+ refreshTimer = setTimeout(() => {
592
+ refreshTimer = null;
593
+ triggerRefresh();
594
+ }, debounceMs);
595
+ }
596
+
597
+ function buildWatchMap(targets) {
598
+ const map = new Map();
599
+ targets.forEach((filePath) => {
600
+ const dir = path.dirname(filePath);
601
+ const name = path.basename(filePath);
602
+ if (!map.has(dir)) map.set(dir, new Set());
603
+ map.get(dir).add(name);
604
+ });
605
+ return map;
606
+ }
607
+
608
+ function updateWatchers(targetMap) {
609
+ let needsPolling = false;
610
+
611
+ for (const [dir, info] of watchState.watchers) {
612
+ if (!targetMap.has(dir)) {
613
+ info.watcher.close();
614
+ watchState.watchers.delete(dir);
615
+ }
616
+ }
617
+
618
+ for (const [dir, names] of targetMap) {
619
+ if (watchState.watchers.has(dir)) {
620
+ watchState.watchers.get(dir).names = names;
621
+ continue;
622
+ }
623
+
624
+ try {
625
+ const watcher = fs.watch(dir, { persistent: true }, (event, filename) => {
626
+ if (!filename) {
627
+ scheduleRefresh();
628
+ return;
629
+ }
630
+ const name = filename.toString();
631
+ const watched = watchState.watchers.get(dir)?.names;
632
+ if (!watched || watched.has(name)) {
633
+ scheduleRefresh();
634
+ }
635
+ });
636
+ watchState.watchers.set(dir, { watcher, names });
637
+ } catch (_) {
638
+ needsPolling = true;
639
+ }
640
+ }
641
+
642
+ if (needsPolling && !watchState.pollTimer) {
643
+ watchState.pollTimer = setInterval(scheduleRefresh, 1000);
644
+ }
645
+
646
+ if (!needsPolling && watchState.pollTimer) {
647
+ clearInterval(watchState.pollTimer);
648
+ watchState.pollTimer = null;
649
+ }
650
+ }
651
+
652
+ function renderAndUpdate({ allowMissingTasks }) {
653
+ if (watch) clearScreen();
654
+
655
+ const result = renderOnce({ allowMissingTasks });
656
+
657
+ if (format === "json") {
658
+ const finalOutput = section ? result.output[section] || {} : result.output;
659
+ process.stdout.write(`${JSON.stringify(finalOutput, null, 2)}\n`);
660
+ }
661
+
662
+ if (watch) {
663
+ const watchTargets = [resolvedConfigPath];
664
+ if (section === null || section === "tasks" || section === "criteria") {
665
+ watchTargets.push(result.tasksFilePath);
666
+ }
667
+ if (section === null || section === "tasks") {
668
+ watchTargets.push(result.logPath);
669
+ }
670
+ updateWatchers(buildWatchMap(watchTargets));
671
+ process.stdout.write(
672
+ colors.gray("\nWatching for changes... (Ctrl+C to exit)\n")
673
+ );
674
+ }
675
+ }
676
+
677
+ function triggerRefresh() {
678
+ if (refreshing) {
679
+ pendingRefresh = true;
680
+ return;
681
+ }
682
+ refreshing = true;
683
+ renderAndUpdate({ allowMissingTasks: true });
684
+ refreshing = false;
685
+ if (pendingRefresh) {
686
+ pendingRefresh = false;
687
+ scheduleRefresh();
688
+ }
689
+ }
690
+
691
+ if (watch) {
692
+ renderAndUpdate({ allowMissingTasks: true });
693
+ } else {
694
+ renderAndUpdate({ allowMissingTasks: false });
695
+ }