nightytidy 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.
Files changed (61) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +314 -0
  3. package/bin/nightytidy.js +3 -0
  4. package/package.json +55 -0
  5. package/src/checks.js +367 -0
  6. package/src/claude.js +655 -0
  7. package/src/cli.js +1012 -0
  8. package/src/consolidation.js +81 -0
  9. package/src/dashboard-html.js +496 -0
  10. package/src/dashboard-standalone.js +167 -0
  11. package/src/dashboard-tui.js +208 -0
  12. package/src/dashboard.js +427 -0
  13. package/src/env.js +100 -0
  14. package/src/executor.js +550 -0
  15. package/src/git.js +348 -0
  16. package/src/lock.js +186 -0
  17. package/src/logger.js +111 -0
  18. package/src/notifications.js +33 -0
  19. package/src/orchestrator.js +919 -0
  20. package/src/prompts/loader.js +55 -0
  21. package/src/prompts/manifest.json +138 -0
  22. package/src/prompts/specials/changelog.md +28 -0
  23. package/src/prompts/specials/consolidation.md +61 -0
  24. package/src/prompts/specials/doc-update.md +1 -0
  25. package/src/prompts/specials/report.md +95 -0
  26. package/src/prompts/steps/01-documentation.md +173 -0
  27. package/src/prompts/steps/02-test-coverage.md +181 -0
  28. package/src/prompts/steps/03-test-hardening.md +181 -0
  29. package/src/prompts/steps/04-test-architecture.md +130 -0
  30. package/src/prompts/steps/05-test-consolidation.md +165 -0
  31. package/src/prompts/steps/06-test-quality.md +211 -0
  32. package/src/prompts/steps/07-api-design.md +165 -0
  33. package/src/prompts/steps/08-security-sweep.md +207 -0
  34. package/src/prompts/steps/09-dependency-health.md +217 -0
  35. package/src/prompts/steps/10-codebase-cleanup.md +189 -0
  36. package/src/prompts/steps/11-crosscutting-concerns.md +196 -0
  37. package/src/prompts/steps/12-file-decomposition.md +263 -0
  38. package/src/prompts/steps/13-code-elegance.md +329 -0
  39. package/src/prompts/steps/14-architectural-complexity.md +297 -0
  40. package/src/prompts/steps/15-type-safety.md +192 -0
  41. package/src/prompts/steps/16-logging-error-message.md +173 -0
  42. package/src/prompts/steps/17-data-integrity.md +139 -0
  43. package/src/prompts/steps/18-performance.md +183 -0
  44. package/src/prompts/steps/19-cost-resource-optimization.md +136 -0
  45. package/src/prompts/steps/20-error-recovery.md +145 -0
  46. package/src/prompts/steps/21-race-condition-audit.md +178 -0
  47. package/src/prompts/steps/22-bug-hunt.md +229 -0
  48. package/src/prompts/steps/23-frontend-quality.md +210 -0
  49. package/src/prompts/steps/24-uiux-audit.md +284 -0
  50. package/src/prompts/steps/25-state-management.md +170 -0
  51. package/src/prompts/steps/26-perceived-performance.md +190 -0
  52. package/src/prompts/steps/27-devops.md +165 -0
  53. package/src/prompts/steps/28-scheduled-job-chron-jobs.md +141 -0
  54. package/src/prompts/steps/29-observability.md +152 -0
  55. package/src/prompts/steps/30-backup-check.md +155 -0
  56. package/src/prompts/steps/31-product-polish-ux-friction.md +122 -0
  57. package/src/prompts/steps/32-feature-discovery-opportunity.md +128 -0
  58. package/src/prompts/steps/33-strategic-opportunities.md +217 -0
  59. package/src/report.js +540 -0
  60. package/src/setup.js +133 -0
  61. package/src/sync.js +536 -0
@@ -0,0 +1,919 @@
1
+ /**
2
+ * @fileoverview Claude Code orchestrator mode for NightyTidy.
3
+ *
4
+ * Provides a JSON-based API for step-by-step runs where Claude Code
5
+ * (or another orchestrator) controls the workflow conversationally.
6
+ *
7
+ * Error contract: This module NEVER throws. All functions return
8
+ * { success: boolean, ...data } or { success: false, error: string }.
9
+ */
10
+
11
+ import { readFileSync, writeFileSync, renameSync, unlinkSync, existsSync } from 'fs';
12
+ import { spawn } from 'child_process';
13
+ import { fileURLToPath } from 'url';
14
+ import path from 'path';
15
+
16
+ import { initLogger, info, warn, error as logError } from './logger.js';
17
+ import { runPreChecks } from './checks.js';
18
+ import { initGit, excludeEphemeralFiles, getCurrentBranch, createPreRunTag, createRunBranch, mergeRunBranch, getGitInstance, ensureOnBranch } from './git.js';
19
+ import { runPrompt, ERROR_TYPE } from './claude.js';
20
+ import { STEPS, reloadSteps } from './prompts/loader.js';
21
+ import { executeSingleStep, sumCosts, SAFETY_PREAMBLE, PROD_PREAMBLE, copyPromptsToProject } from './executor.js';
22
+ import { notify } from './notifications.js';
23
+ import { generateReport, formatDuration, getVersion, buildReportNames, buildReportPrompt, verifyReportContent, updateClaudeMd } from './report.js';
24
+ import { acquireLock, releaseLock } from './lock.js';
25
+
26
+ /**
27
+ * @typedef {import('./executor.js').CostData} CostData
28
+ * @typedef {import('./executor.js').StepResult} StepResult
29
+ */
30
+
31
+ /**
32
+ * @typedef {Object} OrchestratorState
33
+ * @property {number} version - State format version
34
+ * @property {string} originalBranch - Branch to return to after run
35
+ * @property {string} runBranch - Branch for run changes
36
+ * @property {string} tagName - Safety tag name
37
+ * @property {number[]} selectedSteps - Step numbers selected for this run
38
+ * @property {StepEntry[]} completedSteps - Successfully completed steps
39
+ * @property {StepEntry[]} failedSteps - Failed steps
40
+ * @property {StepEntry[]} [skippedSteps] - Steps skipped (prompt not applicable)
41
+ * @property {number} startTime - Run start timestamp (ms)
42
+ * @property {number|null} timeout - Per-step timeout in ms
43
+ * @property {number|null} dashboardPid - Dashboard server process ID
44
+ * @property {string|null} dashboardUrl - Dashboard server URL
45
+ */
46
+
47
+ /**
48
+ * @typedef {Object} StepEntry
49
+ * @property {number} number - Step number
50
+ * @property {string} name - Step name
51
+ * @property {'completed' | 'failed' | 'skipped'} status - Step status
52
+ * @property {number} duration - Duration in milliseconds
53
+ * @property {number} attempts - Number of attempts
54
+ * @property {string} output - Truncated output (max 6000 chars)
55
+ * @property {string|null} error - Error message if failed
56
+ * @property {CostData|null} cost - Cost data
57
+ * @property {boolean} suspiciousFast - True if flagged as suspicious
58
+ * @property {string|null} errorType - Error type if failed
59
+ * @property {number|null} retryAfterMs - Retry delay for rate limits
60
+ */
61
+
62
+ /**
63
+ * @typedef {Object} OrchestratorResult
64
+ * @property {boolean} success - Whether operation succeeded
65
+ * @property {string} [error] - Error message if failed
66
+ */
67
+
68
+ const PROGRESS_FILENAME = 'nightytidy-progress.json';
69
+ const URL_FILENAME = 'nightytidy-dashboard.url';
70
+
71
+ const STATE_FILENAME = 'nightytidy-run-state.json';
72
+ export const STATE_VERSION = 1;
73
+ const DASHBOARD_STARTUP_TIMEOUT = 5000; // ms — max wait for dashboard server to respond
74
+ const SSE_FLUSH_DELAY = 500; // ms — brief delay to let last SSE event reach clients
75
+
76
+ /**
77
+ * Get the path to the state file for a project.
78
+ *
79
+ * @param {string} projectDir - Project directory
80
+ * @returns {string} Absolute path to state file
81
+ */
82
+ function statePath(projectDir) {
83
+ return path.join(projectDir, STATE_FILENAME);
84
+ }
85
+
86
+ /**
87
+ * Read the orchestrator state file.
88
+ *
89
+ * @param {string} projectDir - Project directory
90
+ * @returns {OrchestratorState|null} State object, or null if not found/invalid
91
+ */
92
+ export function readState(projectDir) {
93
+ const fp = statePath(projectDir);
94
+ if (!existsSync(fp)) return null;
95
+ try {
96
+ const data = JSON.parse(readFileSync(fp, 'utf8'));
97
+ if (data.version !== STATE_VERSION) return null;
98
+ return data;
99
+ } catch {
100
+ return null;
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Write the orchestrator state file atomically.
106
+ * Uses write-to-temp + rename to prevent truncation on crash.
107
+ *
108
+ * @param {string} projectDir - Project directory
109
+ * @param {OrchestratorState} state - State to write
110
+ * @returns {void}
111
+ */
112
+ export function writeState(projectDir, state) {
113
+ // Write to temp file then rename for atomic replacement.
114
+ // Prevents truncated JSON on crash (FINDING-06, audit #21).
115
+ const target = statePath(projectDir);
116
+ const tmp = target + '.tmp';
117
+ writeFileSync(tmp, JSON.stringify(state, null, 2), 'utf8');
118
+ renameSync(tmp, target);
119
+ }
120
+
121
+ /**
122
+ * Delete the orchestrator state file.
123
+ *
124
+ * @param {string} projectDir - Project directory
125
+ * @returns {void}
126
+ */
127
+ export function deleteState(projectDir) {
128
+ try {
129
+ unlinkSync(statePath(projectDir));
130
+ } catch (err) {
131
+ if (err.code !== 'ENOENT') {
132
+ warn(`Failed to delete state file: ${err.message}`);
133
+ }
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Create a success result object.
139
+ *
140
+ * @template T
141
+ * @param {T} data - Data to include in result
142
+ * @returns {{success: true} & T}
143
+ */
144
+ function ok(data) {
145
+ return { success: true, ...data };
146
+ }
147
+
148
+ /**
149
+ * Create a failure result object.
150
+ *
151
+ * @param {string} error - Error message
152
+ * @returns {{success: false, error: string}}
153
+ */
154
+ function fail(error) {
155
+ return { success: false, error };
156
+ }
157
+
158
+ /**
159
+ * Validate that step numbers are within valid range.
160
+ *
161
+ * @param {number[]} numbers - Step numbers to validate
162
+ * @returns {{success: false, error: string}|null} Error result, or null if valid
163
+ */
164
+ function validateStepNumbers(numbers) {
165
+ const valid = STEPS.map(s => s.number);
166
+ const invalid = numbers.filter(n => !valid.includes(n));
167
+ if (invalid.length > 0) {
168
+ return fail(`Invalid step number(s): ${invalid.join(', ')}. Valid range: 1-${STEPS.length}.`);
169
+ }
170
+ return null;
171
+ }
172
+
173
+ /**
174
+ * Validate that a step can be run in the current orchestrator state.
175
+ *
176
+ * @param {number} stepNumber - Step number to validate
177
+ * @param {OrchestratorState} state - Current orchestrator state
178
+ * @returns {string|null} Error string if invalid, null if valid
179
+ */
180
+ function validateStepCanRun(stepNumber, state) {
181
+ if (!state.selectedSteps.includes(stepNumber)) {
182
+ return `Step ${stepNumber} is not in the selected steps for this run. Selected: ${state.selectedSteps.join(', ')}`;
183
+ }
184
+ if (state.completedSteps.some(s => s.number === stepNumber)) {
185
+ return `Step ${stepNumber} has already been completed in this run.`;
186
+ }
187
+ if ((state.skippedSteps || []).some(s => s.number === stepNumber)) {
188
+ return `Step ${stepNumber} was skipped (prompt not applicable to this codebase).`;
189
+ }
190
+ // Failed steps can be retried (e.g., after rate-limit pause/resume).
191
+ // The old entry is removed before recording the new result in runStep().
192
+ return null;
193
+ }
194
+
195
+ /**
196
+ * Build execution results from orchestrator state for report generation.
197
+ *
198
+ * @param {OrchestratorState} state - Orchestrator state
199
+ * @returns {import('./executor.js').ExecutionResults} Execution results
200
+ */
201
+ function buildExecutionResults(state) {
202
+ const skipped = state.skippedSteps || [];
203
+ const allStepResults = [...state.completedSteps, ...state.failedSteps, ...skipped]
204
+ .sort((a, b) => state.selectedSteps.indexOf(a.number) - state.selectedSteps.indexOf(b.number));
205
+
206
+ return {
207
+ results: allStepResults.map(s => ({
208
+ step: { number: s.number, name: s.name },
209
+ status: s.status,
210
+ output: s.output || '',
211
+ duration: s.duration,
212
+ attempts: s.attempts,
213
+ error: s.status === 'failed' ? 'Step failed during orchestrated run' : null,
214
+ cost: s.cost || null,
215
+ })),
216
+ completedCount: state.completedSteps.length,
217
+ failedCount: state.failedSteps.length,
218
+ skippedCount: skipped.length,
219
+ };
220
+ }
221
+
222
+ /**
223
+ * @typedef {Object} ProgressState
224
+ * @property {'running' | 'paused' | 'completed' | 'error'} status
225
+ * @property {number} totalSteps
226
+ * @property {number} currentStepIndex
227
+ * @property {string} currentStepName
228
+ * @property {Array<{number: number, name: string, status: string, duration: number|null}>} steps
229
+ * @property {number} completedCount
230
+ * @property {number} failedCount
231
+ * @property {number} startTime
232
+ * @property {string|null} error
233
+ */
234
+
235
+ /**
236
+ * Build progress state for dashboard display.
237
+ *
238
+ * @param {OrchestratorState} state - Orchestrator state
239
+ * @returns {ProgressState} Progress state for JSON serialization
240
+ */
241
+ function buildProgressState(state) {
242
+ // Pre-index for O(1) lookups instead of O(n) find() calls
243
+ const stepsMap = new Map(STEPS.map(s => [s.number, s]));
244
+ const completedMap = new Map(state.completedSteps.map(s => [s.number, s]));
245
+ const failedMap = new Map(state.failedSteps.map(s => [s.number, s]));
246
+ const skippedMap = new Map((state.skippedSteps || []).map(s => [s.number, s]));
247
+
248
+ return {
249
+ status: 'running',
250
+ totalSteps: state.selectedSteps.length,
251
+ currentStepIndex: -1,
252
+ currentStepName: '',
253
+ steps: state.selectedSteps.map(num => {
254
+ const step = stepsMap.get(num);
255
+ const completed = completedMap.get(num);
256
+ const failed = failedMap.get(num);
257
+ const skipped = skippedMap.get(num);
258
+ return {
259
+ number: num,
260
+ name: step?.name || `Step ${num}`,
261
+ status: completed ? 'completed' : failed ? 'failed' : skipped ? 'skipped' : 'pending',
262
+ duration: completed?.duration || failed?.duration || skipped?.duration || null,
263
+ };
264
+ }),
265
+ completedCount: state.completedSteps.length,
266
+ failedCount: state.failedSteps.length,
267
+ skippedCount: (state.skippedSteps || []).length,
268
+ startTime: state.startTime,
269
+ error: null,
270
+ };
271
+ }
272
+
273
+ /**
274
+ * Write progress state to JSON file for dashboard consumption.
275
+ *
276
+ * @param {string} projectDir - Project directory
277
+ * @param {ProgressState} progressState - Progress state to write
278
+ * @returns {void}
279
+ */
280
+ function writeProgress(projectDir, progressState) {
281
+ try {
282
+ writeFileSync(path.join(projectDir, PROGRESS_FILENAME), JSON.stringify(progressState), 'utf8');
283
+ } catch { /* non-critical */ }
284
+ }
285
+
286
+ const OUTPUT_BUFFER_SIZE = 100 * 1024;
287
+ const OUTPUT_WRITE_INTERVAL = 500;
288
+
289
+ /**
290
+ * Create a throttled output handler for streaming Claude output.
291
+ *
292
+ * @param {ProgressState} progress - Progress state object (mutated)
293
+ * @param {string} projectDir - Project directory for progress file
294
+ * @returns {(chunk: string) => void} Output handler callback
295
+ */
296
+ function createOutputHandler(progress, projectDir) {
297
+ let buffer = '';
298
+ let writePending = false;
299
+
300
+ return (chunk) => {
301
+ buffer += chunk;
302
+ // Forward to stdout so CLI bridge / agent can stream it in real time
303
+ process.stdout.write(chunk);
304
+ if (buffer.length > OUTPUT_BUFFER_SIZE) {
305
+ buffer = buffer.slice(buffer.length - OUTPUT_BUFFER_SIZE);
306
+ }
307
+ if (!writePending) {
308
+ writePending = true;
309
+ setTimeout(() => {
310
+ writePending = false;
311
+ progress.currentStepOutput = buffer;
312
+ writeProgress(projectDir, progress);
313
+ }, OUTPUT_WRITE_INTERVAL);
314
+ }
315
+ };
316
+ }
317
+
318
+ /**
319
+ * Clean up dashboard ephemeral files.
320
+ *
321
+ * @param {string} projectDir - Project directory
322
+ * @returns {void}
323
+ */
324
+ function cleanupDashboard(projectDir) {
325
+ for (const f of [PROGRESS_FILENAME, URL_FILENAME]) {
326
+ try { unlinkSync(path.join(projectDir, f)); } catch { /* already gone */ }
327
+ }
328
+ }
329
+
330
+ /**
331
+ * Spawn a detached dashboard server process.
332
+ *
333
+ * @param {string} projectDir - Project directory
334
+ * @returns {Promise<{url: string, pid: number}|null>} Dashboard info, or null on failure
335
+ */
336
+ function spawnDashboardServer(projectDir) {
337
+ try {
338
+ const serverScript = fileURLToPath(new URL('./dashboard-standalone.js', import.meta.url));
339
+
340
+ const child = spawn(process.execPath, [serverScript, projectDir], {
341
+ detached: true,
342
+ stdio: ['ignore', 'pipe', 'ignore'],
343
+ windowsHide: true,
344
+ });
345
+
346
+ return new Promise((resolve) => {
347
+ let output = '';
348
+ const timer = setTimeout(() => {
349
+ child.stdout.removeAllListeners();
350
+ child.unref();
351
+ info('Dashboard server startup timed out — continuing without live progress display');
352
+ resolve(null);
353
+ }, DASHBOARD_STARTUP_TIMEOUT);
354
+
355
+ child.stdout.on('data', (chunk) => {
356
+ output += chunk.toString();
357
+ if (output.includes('\n')) {
358
+ clearTimeout(timer);
359
+ child.stdout.removeAllListeners();
360
+ child.unref();
361
+ try {
362
+ const parsed = JSON.parse(output.trim());
363
+ return resolve({ url: parsed.url, pid: parsed.pid });
364
+ } catch (parseErr) {
365
+ info(`Dashboard startup response was not valid JSON: ${parseErr.message}`);
366
+ resolve(null);
367
+ }
368
+ }
369
+ });
370
+
371
+ child.on('error', (err) => {
372
+ clearTimeout(timer);
373
+ info(`Dashboard server spawn failed: ${err.message}`);
374
+ resolve(null);
375
+ });
376
+ });
377
+ } catch (err) {
378
+ warn(`Could not start dashboard server: ${err.message}`);
379
+ return Promise.resolve(null);
380
+ }
381
+ }
382
+
383
+ /**
384
+ * Stop the dashboard server process.
385
+ *
386
+ * @param {number|null} pid - Process ID to kill
387
+ * @returns {void}
388
+ */
389
+ function stopDashboardServer(pid) {
390
+ if (!pid) return;
391
+ try {
392
+ process.kill(pid, 'SIGTERM');
393
+ } catch { /* already dead */ }
394
+ }
395
+
396
+ /**
397
+ * @typedef {Object} InitRunResult
398
+ * @property {boolean} success
399
+ * @property {string} [error]
400
+ * @property {string} [runBranch]
401
+ * @property {string} [tagName]
402
+ * @property {string} [originalBranch]
403
+ * @property {number[]} [selectedSteps]
404
+ * @property {string|null} [dashboardUrl]
405
+ */
406
+
407
+ /**
408
+ * Initialize an orchestrated run.
409
+ *
410
+ * Performs pre-checks, git setup, and creates state file. The run can then
411
+ * be executed step-by-step via runStep().
412
+ *
413
+ * @param {string} projectDir - Target project directory
414
+ * @param {Object} [options] - Options
415
+ * @param {string} [options.steps] - Comma-separated step numbers
416
+ * @param {number} [options.timeout] - Per-step timeout in ms
417
+ * @returns {Promise<InitRunResult>} Result object (never throws)
418
+ */
419
+ export async function initRun(projectDir, { steps, timeout, skipDashboard } = {}) {
420
+ try {
421
+ initLogger(projectDir, { quiet: true });
422
+ info(`NightyTidy v${getVersion()} orchestrator starting (Node ${process.version}, ${process.platform} ${process.arch})`);
423
+
424
+ // Check for existing run
425
+ if (readState(projectDir)) {
426
+ return fail('A run is already in progress. Call --finish-run to complete it, or delete nightytidy-run-state.json to force-reset.');
427
+ }
428
+
429
+ writeProgress(projectDir, { status: 'initializing', initPhase: 'lock' });
430
+ await acquireLock(projectDir, { persistent: true });
431
+
432
+ writeProgress(projectDir, { status: 'initializing', initPhase: 'git_init' });
433
+ const git = initGit(projectDir);
434
+ excludeEphemeralFiles();
435
+
436
+ writeProgress(projectDir, { status: 'initializing', initPhase: 'pre_checks' });
437
+ await runPreChecks(projectDir, git);
438
+
439
+ // Auto-sync prompts from Google Doc (non-blocking)
440
+ writeProgress(projectDir, { status: 'initializing', initPhase: 'sync_prompts' });
441
+ try {
442
+ const { syncPrompts } = await import('./sync.js');
443
+ const syncResult = await syncPrompts();
444
+ if (syncResult.success) {
445
+ const changeCount = syncResult.summary.updated.length +
446
+ syncResult.summary.added.length +
447
+ syncResult.summary.removed.length;
448
+ if (changeCount > 0) {
449
+ reloadSteps();
450
+ info(`Prompts synced: ${changeCount} change(s)`);
451
+ } else {
452
+ info('Prompts up to date');
453
+ }
454
+ } else {
455
+ warn(`Prompt sync failed: ${syncResult.error}. Using cached versions.`);
456
+ }
457
+ } catch (err) {
458
+ warn(`Prompt sync error: ${err.message}. Using cached versions.`);
459
+ }
460
+
461
+ // Validate and select steps
462
+ writeProgress(projectDir, { status: 'initializing', initPhase: 'validate_steps' });
463
+ let selectedNums;
464
+ if (steps) {
465
+ const rawTokens = steps.split(',').map(s => s.trim());
466
+ const nums = rawTokens.map(s => parseInt(s, 10));
467
+ const droppedTokens = rawTokens.filter((s, i) => Number.isNaN(nums[i]));
468
+ if (droppedTokens.length > 0) {
469
+ warn(`Ignoring non-numeric step values: ${droppedTokens.join(', ')}`);
470
+ }
471
+ const validNums = nums.filter(n => !Number.isNaN(n));
472
+ if (validNums.length === 0) {
473
+ return fail('No valid step numbers provided. Use --list --json to see available steps.');
474
+ }
475
+ const err = validateStepNumbers(validNums);
476
+ if (err) return err;
477
+ selectedNums = validNums;
478
+ } else {
479
+ selectedNums = STEPS.map(s => s.number);
480
+ }
481
+
482
+ writeProgress(projectDir, { status: 'initializing', initPhase: 'git_branch' });
483
+ const originalBranch = await getCurrentBranch();
484
+ const tagName = await createPreRunTag();
485
+ const runBranch = await createRunBranch(originalBranch);
486
+
487
+ // Sync all prompts into the target project for audit trail
488
+ writeProgress(projectDir, { status: 'initializing', initPhase: 'copy_prompts' });
489
+ copyPromptsToProject(projectDir);
490
+ try {
491
+ const git = getGitInstance();
492
+ await git.add([path.join('audit-reports', 'refactor-prompts')]);
493
+ await git.commit('NightyTidy: Sync refactor prompts');
494
+ } catch (err) {
495
+ warn(`Failed to commit refactor prompts: ${err.message}`);
496
+ }
497
+
498
+ if (!skipDashboard) {
499
+ writeProgress(projectDir, { status: 'initializing', initPhase: 'dashboard' });
500
+ }
501
+ const state = {
502
+ version: STATE_VERSION,
503
+ originalBranch,
504
+ runBranch,
505
+ tagName,
506
+ selectedSteps: selectedNums,
507
+ completedSteps: [],
508
+ failedSteps: [],
509
+ skippedSteps: [],
510
+ startTime: Date.now(),
511
+ timeout: timeout || null,
512
+ dashboardPid: null,
513
+ dashboardUrl: null,
514
+ };
515
+ writeState(projectDir, state);
516
+
517
+ // Write running progress JSON and spawn dashboard server
518
+ writeProgress(projectDir, buildProgressState(state));
519
+ if (!skipDashboard) {
520
+ const dashboard = await spawnDashboardServer(projectDir);
521
+ if (dashboard) {
522
+ state.dashboardPid = dashboard.pid;
523
+ state.dashboardUrl = dashboard.url;
524
+ writeState(projectDir, state);
525
+ info(`Dashboard server at ${dashboard.url} (PID ${dashboard.pid})`);
526
+ }
527
+ }
528
+
529
+ notify('NightyTidy Started', `Orchestrator run initialized with ${selectedNums.length} steps.`);
530
+ info(`Orchestrator init complete: branch=${runBranch}, tag=${tagName}, steps=${selectedNums.join(',')}`);
531
+
532
+ return ok({
533
+ runBranch,
534
+ tagName,
535
+ originalBranch,
536
+ selectedSteps: selectedNums,
537
+ dashboardUrl: state.dashboardUrl,
538
+ });
539
+ } catch (err) {
540
+ return fail(err.message);
541
+ }
542
+ }
543
+
544
+ /**
545
+ * @typedef {Object} RunStepResult
546
+ * @property {boolean} success
547
+ * @property {string} [error]
548
+ * @property {number} [step]
549
+ * @property {string} [name]
550
+ * @property {'completed' | 'failed' | 'skipped'} [status]
551
+ * @property {string} [output]
552
+ * @property {number} [duration]
553
+ * @property {string} [durationFormatted]
554
+ * @property {number} [attempts]
555
+ * @property {number|null} [costUSD]
556
+ * @property {number|null} [inputTokens]
557
+ * @property {number|null} [outputTokens]
558
+ * @property {boolean} [suspiciousFast]
559
+ * @property {string|null} [errorType]
560
+ * @property {number|null} [retryAfterMs]
561
+ * @property {number[]} [remainingSteps]
562
+ */
563
+
564
+ /**
565
+ * Run a single step in an orchestrated run.
566
+ *
567
+ * @param {string} projectDir - Target project directory
568
+ * @param {number} stepNumber - Step number to run
569
+ * @param {Object} [options] - Options
570
+ * @param {number} [options.timeout] - Step timeout in ms (overrides state)
571
+ * @returns {Promise<RunStepResult>} Result object (never throws)
572
+ */
573
+ export async function runStep(projectDir, stepNumber, { timeout } = {}) {
574
+ try {
575
+ if (!Number.isFinite(stepNumber) || stepNumber < 1) {
576
+ return fail(`Invalid step number: ${stepNumber}. Use --list to see available steps.`);
577
+ }
578
+
579
+ initLogger(projectDir, { quiet: true });
580
+
581
+ const state = readState(projectDir);
582
+ if (!state) {
583
+ return fail('No active orchestrator run. Call --init-run first.');
584
+ }
585
+
586
+ const validationError = validateStepCanRun(stepNumber, state);
587
+ if (validationError) return fail(validationError);
588
+
589
+ const step = STEPS.find(s => s.number === stepNumber);
590
+ if (!step) {
591
+ return fail(`Step ${stepNumber} not found in available steps.`);
592
+ }
593
+
594
+ initGit(projectDir);
595
+
596
+ const stepTimeout = timeout || state.timeout || undefined;
597
+
598
+ info(`Orchestrator: running step ${stepNumber} — ${step.name}`);
599
+
600
+ // Branch guard: ensure we're on the run branch before starting
601
+ await ensureOnBranch(state.runBranch);
602
+
603
+ // Update progress: mark step as running
604
+ const stepIdx = state.selectedSteps.indexOf(stepNumber);
605
+ const progress = buildProgressState(state);
606
+ progress.currentStepIndex = stepIdx;
607
+ progress.currentStepName = step.name;
608
+ if (stepIdx >= 0 && progress.steps[stepIdx]) {
609
+ progress.steps[stepIdx].status = 'running';
610
+ }
611
+ writeProgress(projectDir, progress);
612
+
613
+ // Stream Claude output to progress file for dashboard consumption
614
+ const onOutput = createOutputHandler(progress, projectDir);
615
+
616
+ let result = await executeSingleStep(step, projectDir, { timeout: stepTimeout, onOutput });
617
+
618
+ // ── 3-Tier Recovery ──────────────────────────────────────────────
619
+ // Tier 1 already ran above. If it failed (non-rate-limit), try:
620
+ // Tier 2 (prod): --continue to resume the killed session
621
+ // Tier 3 (fresh): clean slate with a new session
622
+ if (result.status === 'failed' && result.errorType !== ERROR_TYPE.RATE_LIMIT) {
623
+ // ── Tier 2: PROD — continue killed session ──
624
+ warn(`Step ${stepNumber} failed (${result.error}) — prodding (resuming previous session)`);
625
+
626
+ progress.prodding = true;
627
+ progress.retrying = false;
628
+ progress.currentStepOutput = '';
629
+ writeProgress(projectDir, progress);
630
+
631
+ const prodOutput = createOutputHandler(progress, projectDir);
632
+ const prodPrompt = SAFETY_PREAMBLE + PROD_PREAMBLE + step.prompt;
633
+ const prodResult = await executeSingleStep(step, projectDir, {
634
+ timeout: stepTimeout, onOutput: prodOutput,
635
+ continueSession: true, promptOverride: prodPrompt,
636
+ });
637
+
638
+ result = {
639
+ ...prodResult,
640
+ attempts: (result.attempts || 0) + (prodResult.attempts || 0),
641
+ cost: sumCosts(result.cost, prodResult.cost),
642
+ };
643
+
644
+ // Branch guard between tiers — recover before deciding next tier
645
+ await ensureOnBranch(state.runBranch);
646
+
647
+ if (result.status === 'completed') {
648
+ info(`Step ${stepNumber} succeeded on prod (session resume)`);
649
+ } else if (result.errorType !== ERROR_TYPE.RATE_LIMIT) {
650
+ // ── Tier 3: FRESH RETRY — clean slate ──
651
+ warn(`Step ${stepNumber} prod failed — fresh retry with new session`);
652
+
653
+ progress.prodding = false;
654
+ progress.retrying = true;
655
+ progress.currentStepOutput = '';
656
+ writeProgress(projectDir, progress);
657
+
658
+ const freshOutput = createOutputHandler(progress, projectDir);
659
+ const freshResult = await executeSingleStep(step, projectDir, {
660
+ timeout: stepTimeout, onOutput: freshOutput,
661
+ });
662
+
663
+ result = {
664
+ ...freshResult,
665
+ attempts: (result.attempts || 0) + (freshResult.attempts || 0),
666
+ cost: sumCosts(result.cost, freshResult.cost),
667
+ };
668
+
669
+ // Branch guard after Tier 3
670
+ await ensureOnBranch(state.runBranch);
671
+
672
+ if (result.status === 'completed') {
673
+ info(`Step ${stepNumber} succeeded on fresh retry`);
674
+ } else {
675
+ warn(`Step ${stepNumber} failed on all 3 tiers — recording as failed`);
676
+ }
677
+ } else {
678
+ warn(`Step ${stepNumber} prod hit rate limit — recording as failed`);
679
+ }
680
+ }
681
+
682
+ // Branch guard: final check before writing state
683
+ await ensureOnBranch(state.runBranch);
684
+
685
+ // Update state — remove any previous failed entry for this step (retry scenario)
686
+ const prevFailIdx = state.failedSteps.findIndex(s => s.number === step.number);
687
+ if (prevFailIdx !== -1) {
688
+ info(`Orchestrator: step ${stepNumber} previously failed — recording retry result`);
689
+ state.failedSteps.splice(prevFailIdx, 1);
690
+ }
691
+
692
+ const output = (result.output || '').slice(0, 6000);
693
+ const stepError = result.status === 'failed' ? (result.error || 'Step failed during orchestrated run') : null;
694
+ const entry = { number: step.number, name: step.name, status: result.status, duration: result.duration, attempts: result.attempts, output, error: stepError, cost: result.cost || null, suspiciousFast: result.suspiciousFast || false, errorType: result.errorType || null, retryAfterMs: result.retryAfterMs || null };
695
+ if (result.status === 'completed') {
696
+ state.completedSteps.push(entry);
697
+ } else if (result.status === 'skipped') {
698
+ if (!state.skippedSteps) state.skippedSteps = [];
699
+ state.skippedSteps.push(entry);
700
+ } else {
701
+ state.failedSteps.push(entry);
702
+ }
703
+ writeState(projectDir, state);
704
+
705
+ // Update progress after step completes (clear output)
706
+ const finalProgress = buildProgressState(state);
707
+ delete finalProgress.currentStepOutput;
708
+ writeProgress(projectDir, finalProgress);
709
+
710
+ // Compute remaining
711
+ const doneNums = new Set([...state.completedSteps.map(s => s.number), ...state.failedSteps.map(s => s.number), ...(state.skippedSteps || []).map(s => s.number)]);
712
+ const remaining = state.selectedSteps.filter(n => !doneNums.has(n));
713
+
714
+ return ok({
715
+ step: stepNumber,
716
+ name: step.name,
717
+ status: result.status,
718
+ output,
719
+ error: stepError,
720
+ duration: result.duration,
721
+ durationFormatted: formatDuration(result.duration),
722
+ attempts: result.attempts,
723
+ costUSD: result.cost?.costUSD ?? null,
724
+ inputTokens: result.cost?.inputTokens ?? null,
725
+ outputTokens: result.cost?.outputTokens ?? null,
726
+ suspiciousFast: result.suspiciousFast || false,
727
+ errorType: result.errorType || null,
728
+ retryAfterMs: result.retryAfterMs || null,
729
+ remainingSteps: remaining,
730
+ });
731
+ } catch (err) {
732
+ return fail(err.message);
733
+ }
734
+ }
735
+
736
+ /**
737
+ * @typedef {Object} FinishRunResult
738
+ * @property {boolean} success
739
+ * @property {string} [error]
740
+ * @property {number} [completed]
741
+ * @property {number} [failed]
742
+ * @property {number} [skipped]
743
+ * @property {string} [totalDurationFormatted]
744
+ * @property {number|null} [totalCostUSD]
745
+ * @property {number|null} [totalInputTokens]
746
+ * @property {number|null} [totalOutputTokens]
747
+ * @property {number|null} [finishCostUSD]
748
+ * @property {number|null} [finishInputTokens]
749
+ * @property {number|null} [finishOutputTokens]
750
+ * @property {number} [finishDuration]
751
+ * @property {boolean} [merged]
752
+ * @property {boolean} [mergeConflict]
753
+ * @property {string} [reportPath]
754
+ * @property {string} [tagName]
755
+ * @property {string} [runBranch]
756
+ */
757
+
758
+ /**
759
+ * Finish an orchestrated run.
760
+ *
761
+ * Generates narrated changelog and action plan (2 AI calls with output
762
+ * streaming to progress JSON), then generates report, commits, merges back
763
+ * to original branch, and cleans up state.
764
+ *
765
+ * @param {string} projectDir - Target project directory
766
+ * @returns {Promise<FinishRunResult>} Result object (never throws)
767
+ */
768
+ export async function finishRun(projectDir) {
769
+ try {
770
+ initLogger(projectDir, { quiet: true });
771
+
772
+ const state = readState(projectDir);
773
+ if (!state) {
774
+ return fail('No active orchestrator run. Nothing to finish.');
775
+ }
776
+
777
+ initGit(projectDir);
778
+ info('Orchestrator: finishing run');
779
+
780
+ const executionResults = buildExecutionResults(state);
781
+
782
+ const totalDuration = Date.now() - state.startTime;
783
+
784
+ // Sum step costs
785
+ const stepsCostUSD = executionResults.results.reduce((sum, r) => sum + (r.cost?.costUSD || 0), 0);
786
+ const stepsInputTokens = executionResults.results.reduce((sum, r) => sum + (r.cost?.inputTokens || 0), 0) || null;
787
+ const stepsOutputTokens = executionResults.results.reduce((sum, r) => sum + (r.cost?.outputTokens || 0), 0) || null;
788
+
789
+ // Build unique report filename (numbered + timestamped)
790
+ const { reportFile } = buildReportNames(projectDir, state.startTime);
791
+
792
+ // ── Single AI call for report generation ──
793
+ const finishStart = Date.now();
794
+
795
+ // Build progress state with virtual "Final Report" step
796
+ const progress = buildProgressState(state);
797
+ progress.steps.push({ number: 0, name: 'Final Report', status: 'running', duration: null });
798
+ progress.currentStepIndex = state.selectedSteps.length;
799
+ progress.currentStepName = 'Final Report';
800
+ writeProgress(projectDir, progress);
801
+
802
+ const onOutput = createOutputHandler(progress, projectDir);
803
+
804
+ // Build metadata for pre-built report sections
805
+ const metadata = {
806
+ projectDir,
807
+ branchName: state.runBranch,
808
+ tagName: state.tagName,
809
+ originalBranch: state.originalBranch,
810
+ startTime: state.startTime,
811
+ endTime: Date.now(),
812
+ totalCostUSD: stepsCostUSD || null,
813
+ totalInputTokens: stepsInputTokens,
814
+ totalOutputTokens: stepsOutputTokens,
815
+ };
816
+
817
+ // Generate report in a single fresh Claude session (like other steps)
818
+ info('Generating report (narration + action plan)...');
819
+ const reportPrompt = buildReportPrompt(executionResults, metadata, { reportFile });
820
+ const reportResult = await runPrompt(SAFETY_PREAMBLE + reportPrompt, projectDir, {
821
+ label: 'Report generation',
822
+ timeout: state.timeout || undefined,
823
+ onOutput,
824
+ });
825
+ let finishCost = reportResult.cost || null;
826
+
827
+ const finishDuration = Date.now() - finishStart;
828
+
829
+ // Verify the report file was created correctly
830
+ const reportPath = path.join(projectDir, reportFile);
831
+ let reportOk = false;
832
+ try {
833
+ if (existsSync(reportPath)) {
834
+ const content = readFileSync(reportPath, 'utf8');
835
+ reportOk = verifyReportContent(content, metadata);
836
+ }
837
+ } catch { /* verification failed */ }
838
+
839
+ // Fallback: generate report via JS template if AI failed
840
+ if (!reportOk) {
841
+ warn('AI report generation failed or produced invalid output — using template fallback');
842
+ generateReport(executionResults, null, metadata, { reportFile, skipClaudeMdUpdate: true });
843
+ }
844
+
845
+ // Update progress: mark finish step as completed
846
+ const lastStep = progress.steps[progress.steps.length - 1];
847
+ lastStep.status = 'completed';
848
+ lastStep.duration = finishDuration;
849
+ delete progress.currentStepOutput;
850
+ writeProgress(projectDir, progress);
851
+
852
+ // Include finish-phase costs in totals
853
+ const totalCostUSD = (stepsCostUSD || 0) + (finishCost?.costUSD || 0) || null;
854
+ const totalInputTokens = (stepsInputTokens || 0) + (finishCost?.inputTokens || 0) || null;
855
+ const totalOutputTokens = (stepsOutputTokens || 0) + (finishCost?.outputTokens || 0) || null;
856
+
857
+ // Always update CLAUDE.md via JS (not AI)
858
+ updateClaudeMd(metadata);
859
+
860
+ // Commit report + CLAUDE.md (if not already committed by Claude)
861
+ const gitInstance = getGitInstance();
862
+ try {
863
+ const filesToCommit = [reportFile, 'CLAUDE.md'];
864
+ await gitInstance.add(filesToCommit);
865
+ await gitInstance.commit('NightyTidy: Add run report and update CLAUDE.md');
866
+ } catch (err) {
867
+ warn(`Failed to commit report: ${err.message}`);
868
+ }
869
+
870
+ // Read report content for embedding in response (avoids fragile file-read API in GUI)
871
+ let reportContent = null;
872
+ try { reportContent = readFileSync(path.join(projectDir, reportFile), 'utf-8'); } catch { /* merge will bring file back */ }
873
+
874
+ // Merge
875
+ const mergeResult = await mergeRunBranch(state.originalBranch, state.runBranch);
876
+
877
+ // Update progress to completed status before cleanup
878
+ const finalProgress = buildProgressState(state);
879
+ finalProgress.status = 'completed';
880
+ writeProgress(projectDir, finalProgress);
881
+
882
+ // Stop dashboard server and clean up
883
+ stopDashboardServer(state.dashboardPid);
884
+ await new Promise(resolve => setTimeout(resolve, SSE_FLUSH_DELAY));
885
+ cleanupDashboard(projectDir);
886
+ releaseLock(projectDir);
887
+ deleteState(projectDir);
888
+
889
+ const skippedStr = (executionResults.skippedCount || 0) > 0 ? `, ${executionResults.skippedCount} skipped` : '';
890
+ const completionMsg = mergeResult.success
891
+ ? `Run complete: ${executionResults.completedCount} completed, ${executionResults.failedCount} failed${skippedStr}.`
892
+ : `Run complete but merge needs attention. Changes on branch: ${state.runBranch}`;
893
+ notify('NightyTidy Complete', completionMsg);
894
+
895
+ info(`Orchestrator finish complete: ${executionResults.completedCount} completed, ${executionResults.failedCount} failed${skippedStr}`);
896
+
897
+ return ok({
898
+ completed: executionResults.completedCount,
899
+ failed: executionResults.failedCount,
900
+ skipped: executionResults.skippedCount || 0,
901
+ totalDurationFormatted: formatDuration(totalDuration),
902
+ totalCostUSD,
903
+ totalInputTokens,
904
+ totalOutputTokens,
905
+ finishCostUSD: finishCost?.costUSD ?? null,
906
+ finishInputTokens: finishCost?.inputTokens ?? null,
907
+ finishOutputTokens: finishCost?.outputTokens ?? null,
908
+ finishDuration,
909
+ merged: mergeResult.success,
910
+ mergeConflict: mergeResult.conflict || false,
911
+ reportPath: reportFile,
912
+ reportContent,
913
+ tagName: state.tagName,
914
+ runBranch: state.runBranch,
915
+ });
916
+ } catch (err) {
917
+ return fail(err.message);
918
+ }
919
+ }