@ulysses-ai/create-workspace 0.14.0-beta.3 → 0.15.0-beta.1

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 (50) hide show
  1. package/lib/init.mjs +12 -25
  2. package/lib/scaffold.mjs +3 -2
  3. package/package.json +1 -1
  4. package/template/.claude/agents/reviewer.md +1 -1
  5. package/template/.claude/hooks/pre-compact.mjs +1 -1
  6. package/template/.claude/hooks/repo-write-detection.mjs +2 -2
  7. package/template/.claude/hooks/session-start.mjs +10 -7
  8. package/template/.claude/hooks/subagent-start.mjs +3 -3
  9. package/template/.claude/recipes/migrate-from-notion.md +6 -6
  10. package/template/.claude/rules/coherent-revisions.md +2 -2
  11. package/template/.claude/rules/local-dev-environment.md.skip +2 -2
  12. package/template/.claude/rules/memory-guidance.md +23 -14
  13. package/template/.claude/rules/token-economics.md.skip +2 -2
  14. package/template/.claude/rules/work-item-tracking.md +1 -1
  15. package/template/.claude/rules/workspace-structure.md +36 -15
  16. package/template/.claude/scripts/build-workspace-context.mjs +712 -0
  17. package/template/.claude/scripts/capture-context.mjs +217 -0
  18. package/template/.claude/scripts/generate-claude-local.mjs +104 -0
  19. package/template/.claude/scripts/migrate-canonical-priority.mjs +108 -0
  20. package/template/.claude/scripts/migrate-open-work.mjs +1 -1
  21. package/template/.claude/scripts/migrate-to-workspace-context.mjs +520 -0
  22. package/template/.claude/scripts/sweep-references.mjs +177 -0
  23. package/template/.claude/skills/aside/SKILL.md +49 -44
  24. package/template/.claude/skills/braindump/SKILL.md +25 -19
  25. package/template/.claude/skills/build-docs-site/SKILL.md +1 -1
  26. package/template/.claude/skills/build-docs-site/checklists/framing.md +1 -1
  27. package/template/.claude/skills/complete-work/SKILL.md +91 -3
  28. package/template/.claude/skills/handoff/SKILL.md +31 -30
  29. package/template/.claude/skills/maintenance/SKILL.md +90 -22
  30. package/template/.claude/skills/pause-work/SKILL.md +1 -1
  31. package/template/.claude/skills/promote/SKILL.md +18 -8
  32. package/template/.claude/skills/release/SKILL.md +20 -13
  33. package/template/.claude/skills/start-work/SKILL.md +1 -1
  34. package/template/.claude/skills/workspace-init/SKILL.md +12 -12
  35. package/template/.claude/skills/workspace-update/SKILL.md +7 -1
  36. package/template/CLAUDE.md.tmpl +4 -3
  37. package/template/_gitignore +1 -0
  38. package/template/workspace.json.tmpl +3 -2
  39. package/template/.claude/hooks/_bash-output-advisory.test.mjs +0 -88
  40. package/template/.claude/hooks/_utils.test.mjs +0 -99
  41. package/template/.claude/lib/freshness.test.mjs +0 -175
  42. package/template/.claude/lib/registry-check.test.mjs +0 -130
  43. package/template/.claude/lib/session-frontmatter.test.mjs +0 -242
  44. package/template/.claude/scripts/build-shared-context-index.mjs +0 -212
  45. package/template/.claude/scripts/build-shared-context-index.test.mjs +0 -318
  46. package/template/.claude/scripts/migrate-claude-md-freshness-include.test.mjs +0 -54
  47. package/template/.claude/scripts/migrate-session-layout.test.mjs +0 -144
  48. package/template/.claude/scripts/sync-tasks.test.mjs +0 -350
  49. package/template/.claude/scripts/trackers/github-issues.test.mjs +0 -190
  50. package/template/.claude/scripts/trackers/interface.test.mjs +0 -40
@@ -0,0 +1,520 @@
1
+ #!/usr/bin/env node
2
+ // Idempotent migrator: shared-context/ → workspace-context/ + new structure.
3
+ //
4
+ // Each step is independently idempotent. Running this on a partially-migrated
5
+ // or fully-migrated workspace is a no-op for already-applied steps.
6
+ //
7
+ // Usage:
8
+ // node migrate-to-workspace-context.mjs [--root <path>] [--dry-run]
9
+ //
10
+ // Steps (each reports applied / skipped / noop):
11
+ // 1. Rename shared-context/ → workspace-context/.
12
+ // 2. Move workspace-context/locked/* into workspace-context/shared/locked/.
13
+ // 3. Move root .md files in workspace-context/ into workspace-context/shared/.
14
+ // 4. Move per-user dirs into workspace-context/team-member/{user}/.
15
+ // Heuristic: if any file in the dir has `author: {dirname}` in
16
+ // frontmatter, it's a user dir. Otherwise it's a project/topic dir
17
+ // and goes into shared/{dirname}/.
18
+ // 5. Apply naming convention: rename files matching frontmatter
19
+ // type braindump/handoff/research to {type}_{stem}.md.
20
+ // 6. Move root-level release-notes/ → workspace-context/release-notes/.
21
+ // 7. Update CLAUDE.md: replace @shared-context/locked/ with the
22
+ // canonical.md + index.md imports.
23
+ // 8. Update workspace.json: rename sharedContextDir → workspaceContextDir
24
+ // and update releaseNotesDir.
25
+ // 9. Update .indexignore: prefix-shift entries that referenced the old
26
+ // layout, add release-notes/ exclusion.
27
+ // 10. Generate CLAUDE.local.md if absent.
28
+ // 11. Delete the old build-shared-context-index.mjs script.
29
+ // 12. Run build-workspace-context.mjs --write to produce auto-files.
30
+
31
+ import {
32
+ existsSync,
33
+ readFileSync,
34
+ writeFileSync,
35
+ readdirSync,
36
+ statSync,
37
+ renameSync,
38
+ rmSync,
39
+ unlinkSync,
40
+ mkdirSync,
41
+ realpathSync,
42
+ } from 'node:fs';
43
+ import { join, basename, resolve, sep, dirname } from 'node:path';
44
+ import { spawnSync } from 'node:child_process';
45
+ import { fileURLToPath } from 'node:url';
46
+ import { parseSessionContent } from '../lib/session-frontmatter.mjs';
47
+ import { generateClaudeLocal } from './generate-claude-local.mjs';
48
+
49
+ function isMainModule(metaUrl) {
50
+ if (!process.argv[1]) return false;
51
+ try {
52
+ return realpathSync(fileURLToPath(metaUrl)) === realpathSync(process.argv[1]);
53
+ } catch { return false; }
54
+ }
55
+
56
+ const OLD = 'shared-context';
57
+ const NEW = 'workspace-context';
58
+ const SHARED = 'shared';
59
+ const LOCKED = 'locked';
60
+ const TEAM_MEMBER = 'team-member';
61
+
62
+ const RESERVED_AT_WC_ROOT = new Set([
63
+ SHARED,
64
+ TEAM_MEMBER,
65
+ 'release-notes',
66
+ 'scaffolder-release-history',
67
+ 'index.md',
68
+ 'canonical.md',
69
+ '.indexignore',
70
+ '.gitignore',
71
+ ]);
72
+
73
+ function parseArgs(argv) {
74
+ const args = { root: process.cwd(), dryRun: false };
75
+ for (let i = 2; i < argv.length; i++) {
76
+ const a = argv[i];
77
+ if (a === '--root') args.root = argv[++i];
78
+ else if (a === '--dry-run') args.dryRun = true;
79
+ else throw new Error(`Unknown arg: ${a}`);
80
+ }
81
+ return args;
82
+ }
83
+
84
+ function isGitRepo(root) {
85
+ const r = spawnSync('git', ['rev-parse', '--is-inside-work-tree'], {
86
+ cwd: root,
87
+ encoding: 'utf-8',
88
+ });
89
+ return r.status === 0 && r.stdout.trim() === 'true';
90
+ }
91
+
92
+ function gitMv(root, src, dst, dryRun) {
93
+ if (dryRun) return { ok: true, dry: true };
94
+ // git mv refuses if dst dir doesn't exist; ensure parent
95
+ mkdirSync(dirname(dst), { recursive: true });
96
+ if (isGitRepo(root)) {
97
+ const r = spawnSync('git', ['mv', src, dst], { cwd: root, encoding: 'utf-8' });
98
+ if (r.status === 0) return { ok: true };
99
+ // fall through to plain rename — happens for untracked files
100
+ }
101
+ renameSync(src, dst);
102
+ return { ok: true };
103
+ }
104
+
105
+ function step(name, fn) {
106
+ return (root, options) => {
107
+ try {
108
+ const result = fn(root, options) ?? {};
109
+ return { name, status: result.status || 'applied', notes: result.notes || [] };
110
+ } catch (e) {
111
+ return { name, status: 'error', error: e.message };
112
+ }
113
+ };
114
+ }
115
+
116
+ // ---------- step 1: rename shared-context → workspace-context ----------
117
+
118
+ const step1 = step('rename-shared-context', (root, { dryRun }) => {
119
+ const oldDir = join(root, OLD);
120
+ const newDir = join(root, NEW);
121
+ if (!existsSync(oldDir) && existsSync(newDir)) {
122
+ return { status: 'skipped', notes: ['already renamed'] };
123
+ }
124
+ if (!existsSync(oldDir) && !existsSync(newDir)) {
125
+ return { status: 'noop', notes: ['no shared-context to migrate'] };
126
+ }
127
+ if (existsSync(oldDir) && existsSync(newDir)) {
128
+ throw new Error(
129
+ 'Both shared-context/ and workspace-context/ exist — manual cleanup needed',
130
+ );
131
+ }
132
+ gitMv(root, OLD, NEW, dryRun);
133
+ return { status: 'applied', notes: [`${OLD}/ → ${NEW}/`] };
134
+ });
135
+
136
+ // ---------- step 2: consolidate locked/ under shared/locked/ ----------
137
+
138
+ const step2 = step('consolidate-locked', (root, { dryRun }) => {
139
+ const wcRoot = join(root, NEW);
140
+ if (!existsSync(wcRoot)) return { status: 'noop' };
141
+
142
+ const oldLocked = join(wcRoot, LOCKED);
143
+ const newLocked = join(wcRoot, SHARED, LOCKED);
144
+ if (!existsSync(oldLocked)) {
145
+ return existsSync(newLocked)
146
+ ? { status: 'skipped', notes: ['locked already under shared/'] }
147
+ : { status: 'noop' };
148
+ }
149
+
150
+ // Both exist: move file-by-file, then drop the empty source dir.
151
+ if (existsSync(newLocked)) {
152
+ const notes = [];
153
+ for (const name of readdirSync(oldLocked)) {
154
+ const srcRel = `${NEW}/${LOCKED}/${name}`;
155
+ const dstRel = `${NEW}/${SHARED}/${LOCKED}/${name}`;
156
+ if (existsSync(join(root, dstRel))) {
157
+ notes.push(`SKIP ${srcRel} (target exists)`);
158
+ continue;
159
+ }
160
+ gitMv(root, srcRel, dstRel, dryRun);
161
+ notes.push(`${srcRel} → ${dstRel}`);
162
+ }
163
+ if (!dryRun) {
164
+ try { rmSync(oldLocked, { recursive: true, force: true }); } catch { /* keep */ }
165
+ }
166
+ return { status: notes.length > 0 ? 'applied' : 'skipped', notes };
167
+ }
168
+
169
+ // Common case: rename the whole locked/ dir into shared/locked/.
170
+ if (!dryRun) mkdirSync(join(wcRoot, SHARED), { recursive: true });
171
+ gitMv(root, `${NEW}/${LOCKED}`, `${NEW}/${SHARED}/${LOCKED}`, dryRun);
172
+ return { status: 'applied', notes: [`${NEW}/${LOCKED}/ → ${NEW}/${SHARED}/${LOCKED}/`] };
173
+ });
174
+
175
+ // ---------- step 3: move root .md files into shared/ ----------
176
+
177
+ const step3 = step('move-root-md-into-shared', (root, { dryRun }) => {
178
+ const wcRoot = join(root, NEW);
179
+ if (!existsSync(wcRoot)) return { status: 'noop' };
180
+ const sharedDir = join(wcRoot, SHARED);
181
+ if (!dryRun) mkdirSync(sharedDir, { recursive: true });
182
+
183
+ const notes = [];
184
+ for (const name of readdirSync(wcRoot)) {
185
+ if (!name.endsWith('.md')) continue;
186
+ if (RESERVED_AT_WC_ROOT.has(name)) continue;
187
+ const srcRel = `${NEW}/${name}`;
188
+ const dstRel = `${NEW}/${SHARED}/${name}`;
189
+ const dstAbs = join(root, dstRel);
190
+ if (existsSync(dstAbs)) {
191
+ notes.push(`SKIP ${srcRel} (destination exists)`);
192
+ continue;
193
+ }
194
+ gitMv(root, srcRel, dstRel, dryRun);
195
+ notes.push(`${srcRel} → ${dstRel}`);
196
+ }
197
+ return { status: notes.length > 0 ? 'applied' : 'skipped', notes };
198
+ });
199
+
200
+ // ---------- step 4: classify and move per-user / project dirs ----------
201
+
202
+ function dirLooksLikeUserScope(dirAbs, dirname) {
203
+ if (!existsSync(dirAbs)) return false;
204
+ const entries = readdirSync(dirAbs);
205
+ for (const name of entries) {
206
+ if (!name.endsWith('.md')) continue;
207
+ const filePath = join(dirAbs, name);
208
+ try {
209
+ const parsed = parseSessionContent(readFileSync(filePath, 'utf-8'));
210
+ if (parsed?.fields?.author === dirname) return true;
211
+ } catch { /* keep scanning */ }
212
+ }
213
+ return false;
214
+ }
215
+
216
+ const step4 = step('move-user-and-project-dirs', (root, { dryRun }) => {
217
+ const wcRoot = join(root, NEW);
218
+ if (!existsSync(wcRoot)) return { status: 'noop' };
219
+
220
+ const tmDir = join(wcRoot, TEAM_MEMBER);
221
+ if (!dryRun) mkdirSync(tmDir, { recursive: true });
222
+ const sharedDir = join(wcRoot, SHARED);
223
+ if (!dryRun) mkdirSync(sharedDir, { recursive: true });
224
+
225
+ const notes = [];
226
+ for (const name of readdirSync(wcRoot)) {
227
+ if (RESERVED_AT_WC_ROOT.has(name)) continue;
228
+ if (name.endsWith('.md')) continue; // handled in step 3
229
+
230
+ const srcAbs = join(wcRoot, name);
231
+ let st;
232
+ try { st = statSync(srcAbs); } catch { continue; }
233
+ if (!st.isDirectory()) continue;
234
+
235
+ if (dirLooksLikeUserScope(srcAbs, name)) {
236
+ const srcRel = `${NEW}/${name}`;
237
+ const dstRel = `${NEW}/${TEAM_MEMBER}/${name}`;
238
+ gitMv(root, srcRel, dstRel, dryRun);
239
+ notes.push(`USER: ${srcRel} → ${dstRel}`);
240
+ } else {
241
+ const srcRel = `${NEW}/${name}`;
242
+ const dstRel = `${NEW}/${SHARED}/${name}`;
243
+ gitMv(root, srcRel, dstRel, dryRun);
244
+ notes.push(`SHARED: ${srcRel} → ${dstRel}`);
245
+ }
246
+ }
247
+ return { status: notes.length > 0 ? 'applied' : 'skipped', notes };
248
+ });
249
+
250
+ // ---------- step 5: apply naming convention ----------
251
+
252
+ function* walkMd(dir) {
253
+ if (!existsSync(dir)) return;
254
+ for (const name of readdirSync(dir)) {
255
+ const full = join(dir, name);
256
+ let st;
257
+ try { st = statSync(full); } catch { continue; }
258
+ if (st.isDirectory()) yield* walkMd(full);
259
+ else if (st.isFile() && name.endsWith('.md')) yield full;
260
+ }
261
+ }
262
+
263
+ const step5 = step('apply-naming-convention', (root, { dryRun }) => {
264
+ const wcRoot = join(root, NEW);
265
+ if (!existsSync(wcRoot)) return { status: 'noop' };
266
+
267
+ const notes = [];
268
+ for (const path of walkMd(wcRoot)) {
269
+ const filename = basename(path);
270
+ // Skip auto-gens and reserved names
271
+ if (['index.md', 'canonical.md'].includes(filename)) continue;
272
+ // Skip files already in locked/ (they use bare names by convention)
273
+ if (path.includes(`${sep}${SHARED}${sep}${LOCKED}${sep}`)) continue;
274
+
275
+ let parsed;
276
+ try {
277
+ parsed = parseSessionContent(readFileSync(path, 'utf-8'));
278
+ } catch { continue; }
279
+
280
+ const fmType = parsed?.fields?.type;
281
+ if (!['braindump', 'handoff', 'research'].includes(fmType)) continue;
282
+
283
+ const expectedPrefix = `${fmType}_`;
284
+ if (filename.startsWith(expectedPrefix) || filename.startsWith(`local-only-${expectedPrefix}`)) {
285
+ continue;
286
+ }
287
+
288
+ const stem = filename.replace(/^local-only-/, '').replace(/\.md$/, '');
289
+ const localPrefix = filename.startsWith('local-only-') ? 'local-only-' : '';
290
+ const newName = `${localPrefix}${expectedPrefix}${stem}.md`;
291
+ const newPath = join(dirname(path), newName);
292
+ if (existsSync(newPath)) {
293
+ notes.push(`SKIP ${filename} (target ${newName} exists)`);
294
+ continue;
295
+ }
296
+ const srcRel = path.replace(root + sep, '').split(sep).join('/');
297
+ const dstRel = newPath.replace(root + sep, '').split(sep).join('/');
298
+ gitMv(root, srcRel, dstRel, dryRun);
299
+ notes.push(`${srcRel} → ${dstRel}`);
300
+ }
301
+ return { status: notes.length > 0 ? 'applied' : 'skipped', notes };
302
+ });
303
+
304
+ // ---------- step 6: move release-notes ----------
305
+
306
+ const step6 = step('move-release-notes', (root, { dryRun }) => {
307
+ const oldRel = join(root, 'release-notes');
308
+ const newRel = join(root, NEW, 'release-notes');
309
+ if (!existsSync(oldRel) && existsSync(newRel)) {
310
+ return { status: 'skipped', notes: ['already moved'] };
311
+ }
312
+ if (!existsSync(oldRel)) return { status: 'noop' };
313
+ if (existsSync(newRel)) {
314
+ return { status: 'skipped', notes: ['both old and new exist — manual merge needed'] };
315
+ }
316
+ // Ensure workspace-context exists
317
+ if (!existsSync(join(root, NEW))) {
318
+ return { status: 'skipped', notes: ['workspace-context/ missing — skipping'] };
319
+ }
320
+ gitMv(root, 'release-notes', `${NEW}/release-notes`, dryRun);
321
+ return { status: 'applied', notes: [`release-notes/ → ${NEW}/release-notes/`] };
322
+ });
323
+
324
+ // ---------- step 7: patch CLAUDE.md ----------
325
+
326
+ const step7 = step('update-claude-md', (root, { dryRun }) => {
327
+ const claudePath = join(root, 'CLAUDE.md');
328
+ if (!existsSync(claudePath)) return { status: 'noop' };
329
+ const before = readFileSync(claudePath, 'utf-8');
330
+
331
+ const hasNewImports =
332
+ before.includes(`@${NEW}/canonical.md`) && before.includes(`@${NEW}/index.md`);
333
+ const hasOldImport = before.includes(`@${OLD}/locked/`) || before.includes(`@${NEW}/locked/`);
334
+
335
+ if (hasNewImports && !hasOldImport) {
336
+ return { status: 'skipped', notes: ['already migrated'] };
337
+ }
338
+
339
+ let after = before;
340
+
341
+ // Replace the broken @shared-context/locked/ (or post-rename @workspace-context/locked/) line.
342
+ // Match the heading + import line together so the heading text gets refreshed too.
343
+ // Use [ \t]* (horizontal whitespace only) so we don't accidentally consume the
344
+ // blank line that separates this block from the next heading.
345
+ const oldImportBlockRe = new RegExp(
346
+ `^## Team Knowledge[^\\n]*\\n@(?:${OLD}|${NEW})/locked/?[ \\t]*$`,
347
+ 'm',
348
+ );
349
+ const replacement = `## Team Knowledge\n@${NEW}/canonical.md\n@${NEW}/index.md`;
350
+ if (oldImportBlockRe.test(after)) {
351
+ after = after.replace(oldImportBlockRe, replacement);
352
+ } else {
353
+ // Fall back to single-line replacement if heading wasn't matched
354
+ const oldImportRe = new RegExp(
355
+ `^@(?:${OLD}|${NEW})/locked/?[ \\t]*$`,
356
+ 'm',
357
+ );
358
+ if (oldImportRe.test(after)) {
359
+ after = after.replace(oldImportRe, `@${NEW}/canonical.md\n@${NEW}/index.md`);
360
+ } else if (!hasNewImports) {
361
+ // Append if no anchor existed
362
+ after = after.trimEnd() + `\n\n${replacement}\n`;
363
+ }
364
+ }
365
+
366
+ // Update copy in the Quick Reference area: "Shared memory lives in `shared-context/`"
367
+ after = after.replace(/`shared-context\/`/g, '`workspace-context/`');
368
+ after = after.replace(/Shared memory lives in/g, 'Team knowledge lives in');
369
+
370
+ if (after === before) return { status: 'skipped' };
371
+ if (!dryRun) writeFileSync(claudePath, after);
372
+ return { status: 'applied', notes: ['CLAUDE.md imports updated'] };
373
+ });
374
+
375
+ // ---------- step 8: patch workspace.json ----------
376
+
377
+ const step8 = step('update-workspace-json', (root, { dryRun }) => {
378
+ const wsPath = join(root, 'workspace.json');
379
+ if (!existsSync(wsPath)) return { status: 'noop' };
380
+ const raw = readFileSync(wsPath, 'utf-8');
381
+ let parsed;
382
+ try { parsed = JSON.parse(raw); } catch (e) {
383
+ throw new Error(`workspace.json is not valid JSON: ${e.message}`);
384
+ }
385
+ const ws = parsed.workspace || {};
386
+ const before = JSON.stringify(parsed);
387
+
388
+ const hadOldKey = 'sharedContextDir' in ws;
389
+ if (hadOldKey) {
390
+ ws.workspaceContextDir = NEW;
391
+ delete ws.sharedContextDir;
392
+ } else if (!ws.workspaceContextDir) {
393
+ ws.workspaceContextDir = NEW;
394
+ }
395
+
396
+ if (ws.releaseNotesDir === 'release-notes' || !ws.releaseNotesDir) {
397
+ ws.releaseNotesDir = `${NEW}/release-notes`;
398
+ }
399
+
400
+ parsed.workspace = ws;
401
+ const after = JSON.stringify(parsed, null, 2) + '\n';
402
+ if (after === raw || JSON.stringify(parsed) === before) {
403
+ return { status: 'skipped', notes: ['already up-to-date'] };
404
+ }
405
+ if (!dryRun) writeFileSync(wsPath, after);
406
+ return { status: 'applied', notes: ['workspace.json updated'] };
407
+ });
408
+
409
+ // ---------- step 9: patch .indexignore ----------
410
+
411
+ const step9 = step('update-indexignore', (root, { dryRun }) => {
412
+ const target = join(root, NEW, '.indexignore');
413
+ if (!existsSync(target)) {
414
+ if (!existsSync(join(root, NEW))) return { status: 'noop' };
415
+ // create with sensible defaults
416
+ const content =
417
+ '# Workspace-context paths excluded from index.md.\n' +
418
+ '# Lines are path prefixes relative to workspace-context/.\n' +
419
+ '# A trailing slash matches a directory; bare paths match exactly.\n\n' +
420
+ 'release-notes/\n' +
421
+ 'shared/scaffolder-release-history/\n';
422
+ if (!dryRun) writeFileSync(target, content);
423
+ return { status: 'applied', notes: ['created .indexignore with defaults'] };
424
+ }
425
+
426
+ const before = readFileSync(target, 'utf-8');
427
+ let after = before
428
+ .replace(/Shared-context/g, 'Workspace-context')
429
+ .replace(/shared-context/g, 'workspace-context');
430
+ // Add release-notes/ if missing (release-notes/ now lives inside workspace-context/)
431
+ const lines = after.split('\n');
432
+ const hasReleaseNotes = lines.some((l) => l.replace(/#.*/, '').trim() === 'release-notes/');
433
+ if (!hasReleaseNotes) {
434
+ after = after.trimEnd() + '\nrelease-notes/\n';
435
+ }
436
+ if (after === before) return { status: 'skipped', notes: ['already up-to-date'] };
437
+ if (!dryRun) writeFileSync(target, after);
438
+ return { status: 'applied', notes: ['.indexignore updated'] };
439
+ });
440
+
441
+ // ---------- step 10: generate CLAUDE.local.md if absent ----------
442
+
443
+ const step10 = step('generate-claude-local', (root, { dryRun }) => {
444
+ const target = join(root, 'CLAUDE.local.md');
445
+ if (existsSync(target)) return { status: 'skipped', notes: ['already exists'] };
446
+ const settings = join(root, '.claude', 'settings.local.json');
447
+ if (!existsSync(settings)) return { status: 'skipped', notes: ['no settings.local.json'] };
448
+ if (dryRun) return { status: 'applied', notes: ['would generate'] };
449
+ try {
450
+ const result = generateClaudeLocal(root);
451
+ return { status: 'applied', notes: [`generated ${result.path}`] };
452
+ } catch (e) {
453
+ return { status: 'skipped', notes: [`could not generate: ${e.message}`] };
454
+ }
455
+ });
456
+
457
+ // ---------- step 11: delete old generator script ----------
458
+
459
+ const step11 = step('delete-legacy-scripts', (root, { dryRun }) => {
460
+ const oldScript = join(root, '.claude', 'scripts', 'build-shared-context-index.mjs');
461
+ const oldTest = join(root, '.claude', 'scripts', 'build-shared-context-index.test.mjs');
462
+ const removed = [];
463
+ for (const f of [oldScript, oldTest]) {
464
+ if (existsSync(f)) {
465
+ if (!dryRun) unlinkSync(f);
466
+ removed.push(f.replace(root + sep, ''));
467
+ }
468
+ }
469
+ return removed.length > 0
470
+ ? { status: 'applied', notes: removed.map((f) => `rm ${f}`) }
471
+ : { status: 'skipped', notes: ['legacy scripts not present'] };
472
+ });
473
+
474
+ // ---------- step 12: regenerate auto-files ----------
475
+
476
+ const step12 = step('build-auto-files', (root, { dryRun }) => {
477
+ if (dryRun) return { status: 'skipped', notes: ['skipped under --dry-run'] };
478
+ if (!existsSync(join(root, NEW))) return { status: 'noop' };
479
+ const generator = join(root, '.claude', 'scripts', 'build-workspace-context.mjs');
480
+ if (!existsSync(generator)) {
481
+ return { status: 'skipped', notes: ['build-workspace-context.mjs not installed yet'] };
482
+ }
483
+ const r = spawnSync('node', [generator, '--write', '--root', root], { encoding: 'utf-8' });
484
+ if (r.status !== 0) {
485
+ return {
486
+ status: 'error',
487
+ notes: [r.stderr.trim() || 'generator failed'],
488
+ };
489
+ }
490
+ return { status: 'applied', notes: [r.stdout.trim()] };
491
+ });
492
+
493
+ const STEPS = [step1, step2, step3, step4, step5, step6, step7, step8, step9, step10, step11, step12];
494
+
495
+ function migrate(root, options = {}) {
496
+ const absRoot = resolve(root);
497
+ const results = [];
498
+ for (const fn of STEPS) {
499
+ results.push(fn(absRoot, options));
500
+ }
501
+ return results;
502
+ }
503
+
504
+ function main() {
505
+ const args = parseArgs(process.argv);
506
+ const results = migrate(args.root, { dryRun: args.dryRun });
507
+ process.stdout.write(JSON.stringify({ root: args.root, dryRun: args.dryRun, results }, null, 2) + '\n');
508
+ if (results.some((r) => r.status === 'error')) process.exit(1);
509
+ }
510
+
511
+ if (isMainModule(import.meta.url)) {
512
+ try {
513
+ main();
514
+ } catch (err) {
515
+ process.stderr.write(`migrate-to-workspace-context: ${err.message}\n`);
516
+ process.exit(1);
517
+ }
518
+ }
519
+
520
+ export { migrate, STEPS, dirLooksLikeUserScope };
@@ -0,0 +1,177 @@
1
+ #!/usr/bin/env node
2
+ // Sed-style scripted find-replace across a target tree.
3
+ //
4
+ // Mechanizes the long-tail string updates in rule prose, skill instructions,
5
+ // hooks, and code so we don't burn LLM tokens (and chances for typo) doing
6
+ // it by hand. Used by:
7
+ // - the v0.15 template PR (run on template/)
8
+ // - the migrator (run on the dogfood workspace itself)
9
+ //
10
+ // Replacement rules are ordered longest-pattern-first so we don't get
11
+ // cascading double-replacements (e.g. shared-context/locked → workspace-context/shared/locked
12
+ // must run before shared-context/ → workspace-context/).
13
+ //
14
+ // Usage:
15
+ // node sweep-references.mjs --check --target <dir>
16
+ // node sweep-references.mjs --write --target <dir>
17
+ //
18
+ // Skips:
19
+ // - any path containing /release-notes/archive/
20
+ // - any path containing /scaffolder-release-history/
21
+ // - .git/, node_modules/
22
+ // - the script's own file (avoids self-rewrite)
23
+ // - binary files (detected via null-byte heuristic)
24
+
25
+ import { readdirSync, readFileSync, statSync, writeFileSync, realpathSync } from 'node:fs';
26
+ import { join, relative, resolve, sep } from 'node:path';
27
+ import { fileURLToPath } from 'node:url';
28
+
29
+ function isMainModule(metaUrl) {
30
+ if (!process.argv[1]) return false;
31
+ try {
32
+ return realpathSync(fileURLToPath(metaUrl)) === realpathSync(process.argv[1]);
33
+ } catch { return false; }
34
+ }
35
+
36
+ const DEFAULT_RULES = [
37
+ // Order matters — longest patterns first.
38
+ { from: 'shared-context/locked', to: 'workspace-context/shared/locked' },
39
+ { from: 'shared-context/', to: 'workspace-context/' },
40
+ { from: 'shared-context', to: 'workspace-context' },
41
+ { from: 'sharedContextDir', to: 'workspaceContextDir' },
42
+ ];
43
+
44
+ const SKIP_PATH_FRAGMENTS = [
45
+ `${sep}release-notes${sep}archive${sep}`,
46
+ `${sep}scaffolder-release-history${sep}`,
47
+ `${sep}.git${sep}`,
48
+ `${sep}node_modules${sep}`,
49
+ ];
50
+
51
+ // Scripts whose contents intentionally contain the literal "before" strings as
52
+ // part of their behavior (the migrator's constants, test fixtures verifying
53
+ // rule firing). Skipping by basename keeps the sweeper from mangling them.
54
+ const SKIP_BASENAMES = new Set([
55
+ 'migrate-to-workspace-context.mjs',
56
+ 'migrate-to-workspace-context.test.mjs',
57
+ 'sweep-references.test.mjs',
58
+ ]);
59
+
60
+ function parseArgs(argv) {
61
+ const args = { mode: null, target: null };
62
+ for (let i = 2; i < argv.length; i++) {
63
+ const a = argv[i];
64
+ if (a === '--check') args.mode = 'check';
65
+ else if (a === '--write') args.mode = 'write';
66
+ else if (a === '--target') args.target = argv[++i];
67
+ else throw new Error(`Unknown arg: ${a}`);
68
+ }
69
+ if (!args.mode) throw new Error('Specify --check or --write');
70
+ if (!args.target) throw new Error('--target <dir> is required');
71
+ return args;
72
+ }
73
+
74
+ function shouldSkip(absPath, scriptPath) {
75
+ if (absPath === scriptPath) return true;
76
+ for (const frag of SKIP_PATH_FRAGMENTS) {
77
+ if (absPath.includes(frag)) return true;
78
+ }
79
+ const base = absPath.split(sep).pop();
80
+ if (SKIP_BASENAMES.has(base)) return true;
81
+ return false;
82
+ }
83
+
84
+ function* walk(dir) {
85
+ const entries = readdirSync(dir);
86
+ for (const name of entries) {
87
+ const full = join(dir, name);
88
+ let st;
89
+ try { st = statSync(full); } catch { continue; }
90
+ if (st.isDirectory()) {
91
+ yield* walk(full);
92
+ } else if (st.isFile()) {
93
+ yield full;
94
+ }
95
+ }
96
+ }
97
+
98
+ function isLikelyBinary(buffer) {
99
+ // Quick null-byte heuristic, sampling the first 4 KiB
100
+ const sample = buffer.slice(0, Math.min(4096, buffer.length));
101
+ for (let i = 0; i < sample.length; i++) {
102
+ if (sample[i] === 0) return true;
103
+ }
104
+ return false;
105
+ }
106
+
107
+ function applyRules(content, rules = DEFAULT_RULES) {
108
+ let out = content;
109
+ let total = 0;
110
+ const perRule = [];
111
+ for (const rule of rules) {
112
+ let count = 0;
113
+ let idx = out.indexOf(rule.from);
114
+ while (idx !== -1) {
115
+ count++;
116
+ idx = out.indexOf(rule.from, idx + rule.from.length);
117
+ }
118
+ if (count > 0) {
119
+ out = out.split(rule.from).join(rule.to);
120
+ total += count;
121
+ perRule.push({ from: rule.from, to: rule.to, count });
122
+ }
123
+ }
124
+ return { content: out, total, perRule };
125
+ }
126
+
127
+ function sweep(targetDir, { rules = DEFAULT_RULES, write = false, scriptPath = '' } = {}) {
128
+ const absTarget = resolve(targetDir);
129
+ const changes = [];
130
+ for (const path of walk(absTarget)) {
131
+ if (shouldSkip(path, scriptPath)) continue;
132
+ let buf;
133
+ try { buf = readFileSync(path); } catch { continue; }
134
+ if (isLikelyBinary(buf)) continue;
135
+ const text = buf.toString('utf-8');
136
+ const result = applyRules(text, rules);
137
+ if (result.total === 0) continue;
138
+ changes.push({
139
+ path: relative(absTarget, path),
140
+ total: result.total,
141
+ perRule: result.perRule,
142
+ });
143
+ if (write) writeFileSync(path, result.content);
144
+ }
145
+ return changes;
146
+ }
147
+
148
+ function main() {
149
+ const args = parseArgs(process.argv);
150
+ const scriptPath = process.argv[1];
151
+ const changes = sweep(args.target, {
152
+ rules: DEFAULT_RULES,
153
+ write: args.mode === 'write',
154
+ scriptPath,
155
+ });
156
+ process.stdout.write(
157
+ JSON.stringify({
158
+ mode: args.mode,
159
+ target: args.target,
160
+ filesChanged: changes.length,
161
+ totalReplacements: changes.reduce((acc, c) => acc + c.total, 0),
162
+ changes,
163
+ }, null, 2) + '\n',
164
+ );
165
+ if (args.mode === 'check' && changes.length > 0) process.exit(1);
166
+ }
167
+
168
+ if (isMainModule(import.meta.url)) {
169
+ try {
170
+ main();
171
+ } catch (err) {
172
+ process.stderr.write(`sweep-references: ${err.message}\n`);
173
+ process.exit(2);
174
+ }
175
+ }
176
+
177
+ export { applyRules, sweep, DEFAULT_RULES };