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.
- package/LICENSE +21 -0
- package/README.md +314 -0
- package/bin/nightytidy.js +3 -0
- package/package.json +55 -0
- package/src/checks.js +367 -0
- package/src/claude.js +655 -0
- package/src/cli.js +1012 -0
- package/src/consolidation.js +81 -0
- package/src/dashboard-html.js +496 -0
- package/src/dashboard-standalone.js +167 -0
- package/src/dashboard-tui.js +208 -0
- package/src/dashboard.js +427 -0
- package/src/env.js +100 -0
- package/src/executor.js +550 -0
- package/src/git.js +348 -0
- package/src/lock.js +186 -0
- package/src/logger.js +111 -0
- package/src/notifications.js +33 -0
- package/src/orchestrator.js +919 -0
- package/src/prompts/loader.js +55 -0
- package/src/prompts/manifest.json +138 -0
- package/src/prompts/specials/changelog.md +28 -0
- package/src/prompts/specials/consolidation.md +61 -0
- package/src/prompts/specials/doc-update.md +1 -0
- package/src/prompts/specials/report.md +95 -0
- package/src/prompts/steps/01-documentation.md +173 -0
- package/src/prompts/steps/02-test-coverage.md +181 -0
- package/src/prompts/steps/03-test-hardening.md +181 -0
- package/src/prompts/steps/04-test-architecture.md +130 -0
- package/src/prompts/steps/05-test-consolidation.md +165 -0
- package/src/prompts/steps/06-test-quality.md +211 -0
- package/src/prompts/steps/07-api-design.md +165 -0
- package/src/prompts/steps/08-security-sweep.md +207 -0
- package/src/prompts/steps/09-dependency-health.md +217 -0
- package/src/prompts/steps/10-codebase-cleanup.md +189 -0
- package/src/prompts/steps/11-crosscutting-concerns.md +196 -0
- package/src/prompts/steps/12-file-decomposition.md +263 -0
- package/src/prompts/steps/13-code-elegance.md +329 -0
- package/src/prompts/steps/14-architectural-complexity.md +297 -0
- package/src/prompts/steps/15-type-safety.md +192 -0
- package/src/prompts/steps/16-logging-error-message.md +173 -0
- package/src/prompts/steps/17-data-integrity.md +139 -0
- package/src/prompts/steps/18-performance.md +183 -0
- package/src/prompts/steps/19-cost-resource-optimization.md +136 -0
- package/src/prompts/steps/20-error-recovery.md +145 -0
- package/src/prompts/steps/21-race-condition-audit.md +178 -0
- package/src/prompts/steps/22-bug-hunt.md +229 -0
- package/src/prompts/steps/23-frontend-quality.md +210 -0
- package/src/prompts/steps/24-uiux-audit.md +284 -0
- package/src/prompts/steps/25-state-management.md +170 -0
- package/src/prompts/steps/26-perceived-performance.md +190 -0
- package/src/prompts/steps/27-devops.md +165 -0
- package/src/prompts/steps/28-scheduled-job-chron-jobs.md +141 -0
- package/src/prompts/steps/29-observability.md +152 -0
- package/src/prompts/steps/30-backup-check.md +155 -0
- package/src/prompts/steps/31-product-polish-ux-friction.md +122 -0
- package/src/prompts/steps/32-feature-discovery-opportunity.md +128 -0
- package/src/prompts/steps/33-strategic-opportunities.md +217 -0
- package/src/report.js +540 -0
- package/src/setup.js +133 -0
- 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
|
+
}
|