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
package/src/sync.js ADDED
@@ -0,0 +1,536 @@
1
+ /**
2
+ * Google Doc prompt sync — fetches published Google Doc, parses prompt
3
+ * sections, and updates local markdown files + manifest.
4
+ *
5
+ * Error contract: warns but never throws. Returns { success, summary, error }.
6
+ */
7
+
8
+ import { readFileSync, writeFileSync, unlinkSync } from 'fs';
9
+ import { createHash } from 'crypto';
10
+ import { fileURLToPath } from 'url';
11
+ import path from 'path';
12
+ import { info, warn, debug } from './logger.js';
13
+
14
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
15
+ const PROMPTS_DIR = path.join(__dirname, 'prompts');
16
+ const STEPS_DIR = path.join(PROMPTS_DIR, 'steps');
17
+ const MANIFEST_PATH = path.join(PROMPTS_DIR, 'manifest.json');
18
+ const EXECUTOR_PATH = path.join(__dirname, 'executor.js');
19
+
20
+ const FETCH_TIMEOUT_MS = 30_000;
21
+
22
+ /** Headings in the Google Doc that are NOT prompts (overview/explanation tabs). */
23
+ const NON_PROMPT_HEADINGS = new Set([
24
+ 'overnight ai refactoring/codebase improvement prompts',
25
+ 'overview',
26
+ 'author/date',
27
+ 'author & date',
28
+ 'the core idea',
29
+ 'why overnight works',
30
+ 'how to use',
31
+ 'suggested order',
32
+ 'running these multiple times is profitable',
33
+ 'safety rails built into every prompt',
34
+ 'conclusion',
35
+ 'meta prompts',
36
+ 'for creating a new prompt',
37
+ ]);
38
+
39
+ /**
40
+ * Safety: if sync would remove more than this fraction of existing prompts,
41
+ * abort — something went wrong with parsing.
42
+ */
43
+ const MAX_REMOVAL_FRACTION = 0.5;
44
+
45
+ // ── HTML parsing helpers ────────────────────────────────────────────
46
+
47
+ /** Decode common HTML entities to plain text. */
48
+ export function decodeEntities(text) {
49
+ return text
50
+ .replace(/&/g, '&')
51
+ .replace(/&lt;/g, '<')
52
+ .replace(/&gt;/g, '>')
53
+ .replace(/&#39;/g, "'")
54
+ .replace(/&quot;/g, '"')
55
+ .replace(/&nbsp;/g, ' ')
56
+ .replace(/\u00a0/g, ' ');
57
+ }
58
+
59
+ /** Strip all HTML tags from a string. */
60
+ export function stripTags(html) {
61
+ return html.replace(/<[^>]+>/g, '');
62
+ }
63
+
64
+ /**
65
+ * Convert a Google Docs HTML section body into clean markdown text.
66
+ *
67
+ * Google Docs published HTML stores markdown as plain text inside
68
+ * <p><span> wrappers — no semantic HTML formatting. We extract each
69
+ * <p> as a line, strip tags, decode entities, and collapse blank lines.
70
+ */
71
+ export function htmlToMarkdown(sectionHtml) {
72
+ const lines = [];
73
+ const pRegex = /<p[^>]*>([\s\S]*?)<\/p>/gi;
74
+ let pMatch;
75
+ while ((pMatch = pRegex.exec(sectionHtml)) !== null) {
76
+ const text = decodeEntities(stripTags(pMatch[1])).trim();
77
+ lines.push(text);
78
+ }
79
+
80
+ // Collapse consecutive empty lines to a single blank line
81
+ const collapsed = [];
82
+ let prevEmpty = false;
83
+ for (const line of lines) {
84
+ const isEmpty = line === '';
85
+ if (isEmpty && prevEmpty) continue;
86
+ collapsed.push(line);
87
+ prevEmpty = isEmpty;
88
+ }
89
+
90
+ // Trim leading/trailing blank lines and ensure single trailing newline
91
+ const result = collapsed.join('\n').trim();
92
+ return result ? result + '\n' : '';
93
+ }
94
+
95
+ // ── Section parsing ─────────────────────────────────────────────────
96
+
97
+ /**
98
+ * Parse Google Doc HTML into sections split by title paragraphs.
99
+ * Google Docs uses <p class="... title"> for tab headings.
100
+ *
101
+ * Returns [{ heading, htmlContent, index }] where index is the
102
+ * sequential position in the document (0-based).
103
+ */
104
+ export function parseDocSections(html) {
105
+ // Match <p ... title ...> elements — Google Docs tab separators.
106
+ // The "title" appears as a CSS class in the class attribute.
107
+ const titleRegex = /<p[^>]*\btitle\b[^>]*>([\s\S]*?)<\/p>/gi;
108
+ const titleMatches = [];
109
+ let match;
110
+ while ((match = titleRegex.exec(html)) !== null) {
111
+ const heading = decodeEntities(stripTags(match[1])).trim();
112
+ titleMatches.push({
113
+ heading,
114
+ pos: match.index,
115
+ endPos: match.index + match[0].length,
116
+ });
117
+ }
118
+
119
+ // Extract content between consecutive title paragraphs
120
+ const sections = [];
121
+ for (let i = 0; i < titleMatches.length; i++) {
122
+ const start = titleMatches[i].endPos;
123
+ const end = i + 1 < titleMatches.length
124
+ ? titleMatches[i + 1].pos
125
+ : html.length;
126
+ sections.push({
127
+ heading: titleMatches[i].heading,
128
+ htmlContent: html.substring(start, end),
129
+ index: i,
130
+ });
131
+ }
132
+
133
+ return sections;
134
+ }
135
+
136
+ /**
137
+ * Filter sections to only those that are improvement prompts
138
+ * (not explanation/overview tabs).
139
+ *
140
+ * Two-layer filter:
141
+ * 1. Blocklist: skip known non-prompt headings
142
+ * 2. Content check: section body contains prompt-like content
143
+ */
144
+ export function filterPromptSections(sections) {
145
+ return sections.filter(section => {
146
+ const normalizedHeading = section.heading.toLowerCase().trim();
147
+
148
+ // Layer 1: skip known non-prompt headings
149
+ if (NON_PROMPT_HEADINGS.has(normalizedHeading)) {
150
+ debug(`Skipping non-prompt tab: "${section.heading}"`);
151
+ return false;
152
+ }
153
+
154
+ // Layer 2: check for prompt-like content (relaxed — just verify
155
+ // it has substantial text, not just an empty section)
156
+ const plainText = decodeEntities(stripTags(section.htmlContent)).trim();
157
+ if (plainText.length < 100) {
158
+ debug(`Skipping short section: "${section.heading}" (${plainText.length} chars)`);
159
+ return false;
160
+ }
161
+
162
+ return true;
163
+ });
164
+ }
165
+
166
+ // ── Name normalization & matching ───────────────────────────────────
167
+
168
+ /** Normalize a heading or name for fuzzy comparison. */
169
+ export function normalizeName(name) {
170
+ return name
171
+ .toLowerCase()
172
+ .replace(/&/g, '')
173
+ .replace(/[^a-z0-9\s]/g, '')
174
+ .replace(/^\d+\s+/, '') // strip leading number prefix (e.g. "01 " from "01. Documentation")
175
+ .replace(/\s+/g, ' ')
176
+ .trim();
177
+ }
178
+
179
+ /** Convert a heading to a kebab-case ID suitable for filenames. */
180
+ export function headingToId(number, heading) {
181
+ const padded = String(number).padStart(2, '0');
182
+ const kebab = heading
183
+ .toLowerCase()
184
+ .replace(/&/g, '')
185
+ .replace(/[^a-z0-9\s]/g, '')
186
+ .replace(/\s+/g, '-')
187
+ .replace(/-+/g, '-')
188
+ .replace(/^-|-$/g, '');
189
+ return `${padded}-${kebab}`;
190
+ }
191
+
192
+ /**
193
+ * Match parsed prompt sections to existing manifest entries.
194
+ *
195
+ * Returns { matched, added, removed } where:
196
+ * - matched: [{ entry, section, changed }]
197
+ * - added: [{ heading, markdown, suggestedId, suggestedNumber }]
198
+ * - removed: [{ entry }] — exist locally but not in doc
199
+ */
200
+ export function matchToManifest(promptSections, manifest) {
201
+ const matched = [];
202
+ const added = [];
203
+
204
+ // Build a map of normalized manifest names → entries
205
+ const manifestMap = new Map();
206
+ for (const entry of manifest.steps) {
207
+ manifestMap.set(normalizeName(entry.name), entry);
208
+ }
209
+
210
+ // Track which manifest entries were matched
211
+ const matchedManifestIds = new Set();
212
+
213
+ for (let i = 0; i < promptSections.length; i++) {
214
+ const section = promptSections[i];
215
+ const normalizedHeading = normalizeName(section.heading);
216
+
217
+ // Find matching manifest entry
218
+ let matchedEntry = null;
219
+ for (const [normalizedName, entry] of manifestMap) {
220
+ if (normalizedName === normalizedHeading) {
221
+ matchedEntry = entry;
222
+ break;
223
+ }
224
+ }
225
+
226
+ if (matchedEntry) {
227
+ matchedManifestIds.add(matchedEntry.id);
228
+ const markdown = htmlToMarkdown(section.htmlContent);
229
+ const existingPath = path.join(STEPS_DIR, `${matchedEntry.id}.md`);
230
+ let changed = false;
231
+ try {
232
+ const existing = readFileSync(existingPath, 'utf8');
233
+ changed = existing.trim() !== markdown.trim();
234
+ } catch {
235
+ changed = true; // file doesn't exist yet
236
+ }
237
+ matched.push({ entry: matchedEntry, section, markdown, changed });
238
+ } else {
239
+ const markdown = htmlToMarkdown(section.htmlContent);
240
+ const suggestedNumber = i + 1;
241
+ const suggestedId = headingToId(suggestedNumber, section.heading);
242
+ added.push({
243
+ heading: section.heading,
244
+ markdown,
245
+ suggestedId,
246
+ suggestedNumber,
247
+ });
248
+ }
249
+ }
250
+
251
+ // Find removed: manifest entries that weren't matched
252
+ const removed = [];
253
+ for (const entry of manifest.steps) {
254
+ if (!matchedManifestIds.has(entry.id)) {
255
+ removed.push({ entry });
256
+ }
257
+ }
258
+
259
+ return { matched, added, removed };
260
+ }
261
+
262
+ // ── Hash computation ────────────────────────────────────────────────
263
+
264
+ /**
265
+ * Compute STEPS_HASH using the same algorithm as executor.js:
266
+ * SHA-256 of all prompt contents joined together.
267
+ */
268
+ export function computeStepsHash(promptContents) {
269
+ const content = promptContents.join('');
270
+ return createHash('sha256').update(content).digest('hex');
271
+ }
272
+
273
+ // ── Fetch ───────────────────────────────────────────────────────────
274
+
275
+ /**
276
+ * Fetch the published Google Doc HTML.
277
+ * Returns { success, html, error }.
278
+ */
279
+ export async function fetchDocHtml(url) {
280
+ try {
281
+ const controller = new AbortController();
282
+ const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
283
+ const response = await fetch(url, {
284
+ signal: controller.signal,
285
+ headers: { 'User-Agent': 'NightyTidy-Sync/1.0' },
286
+ });
287
+ clearTimeout(timer);
288
+
289
+ if (!response.ok) {
290
+ return { success: false, html: null, error: `HTTP ${response.status} ${response.statusText}` };
291
+ }
292
+
293
+ const html = await response.text();
294
+ return { success: true, html, error: null };
295
+ } catch (err) {
296
+ const message = err.name === 'AbortError'
297
+ ? `Request timed out after ${FETCH_TIMEOUT_MS / 1000}s`
298
+ : err.message;
299
+ return { success: false, html: null, error: message };
300
+ }
301
+ }
302
+
303
+ // ── Main sync orchestrator ──────────────────────────────────────────
304
+
305
+ /**
306
+ * Build a new manifest with correct ordering and IDs.
307
+ *
308
+ * Uses Google Doc ordering as the canonical order. New prompts are
309
+ * inserted at their natural position. Removed prompts are excluded.
310
+ * IDs are renumbered to maintain sequential NN- prefixes.
311
+ */
312
+ function buildNewManifest(promptSections, matchResult, oldManifest) {
313
+ const { matched, added } = matchResult;
314
+
315
+ // Build ordered entries from doc sections
316
+ const newSteps = [];
317
+ const matchedByHeading = new Map();
318
+ for (const m of matched) {
319
+ matchedByHeading.set(normalizeName(m.section.heading), m);
320
+ }
321
+ const addedByHeading = new Map();
322
+ for (const a of added) {
323
+ addedByHeading.set(normalizeName(a.heading), a);
324
+ }
325
+
326
+ let stepNumber = 1;
327
+ for (const section of promptSections) {
328
+ const normalized = normalizeName(section.heading);
329
+ const m = matchedByHeading.get(normalized);
330
+ const a = addedByHeading.get(normalized);
331
+
332
+ if (m) {
333
+ // Existing entry — keep name, renumber ID
334
+ const newId = headingToId(stepNumber, m.entry.name);
335
+ newSteps.push({ id: newId, name: m.entry.name, _oldId: m.entry.id });
336
+ } else if (a) {
337
+ // New entry
338
+ const newId = headingToId(stepNumber, a.heading);
339
+ newSteps.push({ id: newId, name: a.heading, _oldId: null });
340
+ }
341
+ stepNumber++;
342
+ }
343
+
344
+ return {
345
+ version: oldManifest.version || 1,
346
+ sourceUrl: oldManifest.sourceUrl,
347
+ steps: newSteps.map(({ id, name }) => ({ id, name })),
348
+ _internal: newSteps, // includes _oldId for file rename tracking
349
+ };
350
+ }
351
+
352
+ /**
353
+ * Main sync entry point. Fetches, parses, matches, and optionally writes.
354
+ *
355
+ * options.dryRun — if true, report what would change without writing
356
+ * options.url — override the source URL (defaults to manifest.json sourceUrl)
357
+ */
358
+ export async function syncPrompts(options = {}) {
359
+ try {
360
+ const { dryRun = false, url: urlOverride } = options;
361
+
362
+ // Load current manifest
363
+ let manifest;
364
+ try {
365
+ manifest = JSON.parse(readFileSync(MANIFEST_PATH, 'utf8'));
366
+ } catch (err) {
367
+ return { success: false, summary: null, error: `Failed to read manifest: ${err.message}` };
368
+ }
369
+
370
+ const url = urlOverride || manifest.sourceUrl;
371
+ if (!url) {
372
+ return {
373
+ success: false,
374
+ summary: null,
375
+ error: 'No source URL configured. Add "sourceUrl" to manifest.json or pass --sync-url.',
376
+ };
377
+ }
378
+
379
+ // Fetch
380
+ info(`Fetching prompts from: ${url}`);
381
+ const fetchResult = await fetchDocHtml(url);
382
+ if (!fetchResult.success) {
383
+ return { success: false, summary: null, error: `Fetch failed: ${fetchResult.error}` };
384
+ }
385
+ debug(`Fetched ${fetchResult.html.length} bytes`);
386
+
387
+ // Parse sections
388
+ const allSections = parseDocSections(fetchResult.html);
389
+ debug(`Parsed ${allSections.length} sections from document`);
390
+ if (allSections.length === 0) {
391
+ return { success: false, summary: null, error: 'No sections found in document — HTML structure may have changed' };
392
+ }
393
+
394
+ // Filter to prompts only
395
+ const promptSections = filterPromptSections(allSections);
396
+ info(`Found ${promptSections.length} prompt sections (${allSections.length - promptSections.length} non-prompt tabs filtered)`);
397
+ if (promptSections.length === 0) {
398
+ return { success: false, summary: null, error: 'No prompt sections found — all sections were filtered out' };
399
+ }
400
+
401
+ // Match to manifest
402
+ const matchResult = matchToManifest(promptSections, manifest);
403
+
404
+ // Safety check: if removing more than 50% of prompts, abort
405
+ const existingCount = manifest.steps.length;
406
+ const removeCount = matchResult.removed.length;
407
+ if (existingCount > 0 && removeCount > existingCount * MAX_REMOVAL_FRACTION) {
408
+ return {
409
+ success: false,
410
+ summary: null,
411
+ error: `Safety check failed: sync would remove ${removeCount} of ${existingCount} prompts (>${MAX_REMOVAL_FRACTION * 100}%). ` +
412
+ 'This likely indicates a parsing error. Aborting.',
413
+ };
414
+ }
415
+
416
+ // Build summary
417
+ const summary = {
418
+ updated: matchResult.matched.filter(m => m.changed).map(m => ({ id: m.entry.id, name: m.entry.name })),
419
+ added: matchResult.added.map(a => ({ id: a.suggestedId, name: a.heading })),
420
+ removed: matchResult.removed.map(r => ({ id: r.entry.id, name: r.entry.name })),
421
+ unchanged: matchResult.matched.filter(m => !m.changed).map(m => ({ id: m.entry.id, name: m.entry.name })),
422
+ newStepsHash: null,
423
+ };
424
+
425
+ info(`Sync result: ${summary.updated.length} updated, ${summary.added.length} added, ${summary.removed.length} removed, ${summary.unchanged.length} unchanged`);
426
+
427
+ if (dryRun) {
428
+ return { success: true, summary, error: null };
429
+ }
430
+
431
+ // ── Write phase ──
432
+
433
+ // Build new manifest with correct ordering
434
+ const newManifestData = buildNewManifest(promptSections, matchResult, { ...manifest, sourceUrl: url });
435
+
436
+ // Rename files that need new IDs (to maintain sequential numbering)
437
+ const renames = new Map(); // oldId → newId
438
+ for (const entry of newManifestData._internal) {
439
+ if (entry._oldId && entry._oldId !== entry.id) {
440
+ renames.set(entry._oldId, entry.id);
441
+ }
442
+ }
443
+
444
+ // Write updated prompt files
445
+ for (const m of matchResult.matched) {
446
+ const newId = renames.get(m.entry.id) || m.entry.id;
447
+ const filePath = path.join(STEPS_DIR, `${newId}.md`);
448
+ writeFileSync(filePath, m.markdown);
449
+ if (m.changed) {
450
+ info(`Updated: ${newId}.md (${m.entry.name})`);
451
+ }
452
+ // If renamed, delete old file
453
+ if (renames.has(m.entry.id)) {
454
+ const oldPath = path.join(STEPS_DIR, `${m.entry.id}.md`);
455
+ try { unlinkSync(oldPath); } catch { /* may not exist */ }
456
+ debug(`Renamed: ${m.entry.id}.md → ${newId}.md`);
457
+ }
458
+ }
459
+
460
+ // Write new prompt files
461
+ for (const a of matchResult.added) {
462
+ // Find the actual new ID from the manifest
463
+ const newEntry = newManifestData._internal.find(
464
+ e => normalizeName(e.name) === normalizeName(a.heading)
465
+ );
466
+ const id = newEntry ? newEntry.id : a.suggestedId;
467
+ const filePath = path.join(STEPS_DIR, `${id}.md`);
468
+ writeFileSync(filePath, a.markdown);
469
+ info(`Added: ${id}.md (${a.heading})`);
470
+ }
471
+
472
+ // Delete removed prompt files
473
+ for (const r of matchResult.removed) {
474
+ const filePath = path.join(STEPS_DIR, `${r.entry.id}.md`);
475
+ try {
476
+ unlinkSync(filePath);
477
+ info(`Removed: ${r.entry.id}.md (${r.entry.name})`);
478
+ } catch {
479
+ debug(`Could not delete ${r.entry.id}.md (may already be gone)`);
480
+ }
481
+ }
482
+
483
+ // Write updated manifest
484
+ const cleanManifest = {
485
+ version: newManifestData.version,
486
+ sourceUrl: newManifestData.sourceUrl,
487
+ steps: newManifestData.steps,
488
+ };
489
+ writeFileSync(MANIFEST_PATH, JSON.stringify(cleanManifest, null, 2) + '\n');
490
+ info('Updated manifest.json');
491
+
492
+ // Update summary IDs to reflect new numbering
493
+ summary.updated = matchResult.matched.filter(m => m.changed).map(m => {
494
+ const newId = renames.get(m.entry.id) || m.entry.id;
495
+ return { id: newId, name: m.entry.name };
496
+ });
497
+ summary.unchanged = matchResult.matched.filter(m => !m.changed).map(m => {
498
+ const newId = renames.get(m.entry.id) || m.entry.id;
499
+ return { id: newId, name: m.entry.name };
500
+ });
501
+ summary.added = matchResult.added.map(a => {
502
+ const newEntry = newManifestData._internal.find(
503
+ e => normalizeName(e.name) === normalizeName(a.heading)
504
+ );
505
+ return { id: newEntry ? newEntry.id : a.suggestedId, name: a.heading };
506
+ });
507
+
508
+ // Compute new STEPS_HASH from written files
509
+ const allPromptContents = cleanManifest.steps.map(entry => {
510
+ const filePath = path.join(STEPS_DIR, `${entry.id}.md`);
511
+ return readFileSync(filePath, 'utf8');
512
+ });
513
+ const newHash = computeStepsHash(allPromptContents);
514
+ summary.newStepsHash = newHash;
515
+
516
+ // Update STEPS_HASH in executor.js
517
+ try {
518
+ const executorSource = readFileSync(EXECUTOR_PATH, 'utf8');
519
+ const hashRegex = /(const STEPS_HASH = ')[a-f0-9]{64}(';)/;
520
+ if (hashRegex.test(executorSource)) {
521
+ const updatedSource = executorSource.replace(hashRegex, `$1${newHash}$2`);
522
+ writeFileSync(EXECUTOR_PATH, updatedSource);
523
+ info(`Updated STEPS_HASH in executor.js: ${newHash.slice(0, 16)}...`);
524
+ } else {
525
+ warn('Could not find STEPS_HASH pattern in executor.js — update manually');
526
+ }
527
+ } catch (err) {
528
+ warn(`Failed to update executor.js: ${err.message}`);
529
+ }
530
+
531
+ return { success: true, summary, error: null };
532
+ } catch (err) {
533
+ warn(`Sync failed unexpectedly: ${err.message}`);
534
+ return { success: false, summary: null, error: err.message };
535
+ }
536
+ }