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
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(/</g, '<')
|
|
52
|
+
.replace(/>/g, '>')
|
|
53
|
+
.replace(/'/g, "'")
|
|
54
|
+
.replace(/"/g, '"')
|
|
55
|
+
.replace(/ /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
|
+
}
|