start-vibing-stacks 2.23.0 → 2.25.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/README.md CHANGED
@@ -6,7 +6,7 @@ Multi-stack AI workflow for **Claude Code** & **Cursor**. One command installs a
6
6
  npx start-vibing-stacks
7
7
  ```
8
8
 
9
- > Latest: **v2.23.0** — multi-instance coordination layer. Two or more Claude Code sessions in the same folder auto-discover each other through `.claude/state/`, share file-edit awareness, and refuse to overwrite each other's uncommitted work. New `/peers` slash command + `peers list/notify/locks/cleanup` CLI.
9
+ > Latest: **v2.25.0** — `--force-hooks` (alias `--force-legacy`) now also covers legacy commands (`commands/*.md` without `version:` frontmatter), not just hooks. Single command rebuilds installs older than v2.23.0. v2.24.0 smart migrate (versioned hooks/commands, idempotent `settings.json`, runtime dirs, `.gitignore`). v2.23.0 multi-instance coordination layer.
10
10
 
11
11
  ---
12
12
 
@@ -130,13 +130,24 @@ Single-host coordination only. It does **not** replace `git` for cross-machine c
130
130
  ## CLI
131
131
 
132
132
  ```bash
133
- npx start-vibing-stacks # setup or resume current project
134
- npx start-vibing-stacks migrate # show outdated/missing skills
135
- npx start-vibing-stacks migrate --apply # update outdated skills/agents
133
+ npx start-vibing-stacks # setup or resume current project
134
+ npx start-vibing-stacks migrate # dry run: skills + agents + hooks + commands + settings.json + runtime dirs
135
+ npx start-vibing-stacks migrate --apply # apply (idempotent; preserves customizations)
136
+ npx start-vibing-stacks migrate --apply --force-legacy # also overwrite legacy hooks AND commands (no version) — creates .bak
137
+ npx start-vibing-stacks migrate --apply --force-hooks # alias for --force-legacy (back-compat)
136
138
 
137
139
  # flags: --force --no-claude --no-mcp --no-install --help --version
138
140
  ```
139
141
 
142
+ `migrate` is the safe upgrade path between releases. Each release gets:
143
+ - **Hooks** versioned via `// @sv-version: x.y.z` header — semver-compared against installed.
144
+ - **Commands** versioned via frontmatter `version:` — same as skills/agents.
145
+ - **`settings.json#hooks`** — idempotent deep-merge: missing event blocks (`SessionStart`, `PreToolUse`, `PostToolUse`, `SessionEnd`) are appended; existing entries (including yours) are preserved.
146
+ - **Runtime artefacts** — `.claude/state/{sessions,inbox,file-touches}/_archive` auto-created; `_state.README.md` staged.
147
+ - **`.gitignore`** — `.claude/state/` appended if not already covered.
148
+
149
+ Legacy files without versioning — hooks without `// @sv-version` AND commands without `version:` frontmatter (anything installed before the versioning landed) — are reported as `needs-update-legacy` and skipped by default. Pass `--force-legacy` (or its alias `--force-hooks`) to overwrite them — each gets a timestamped `.bak`. Skills and agents are intentionally **not** covered because users customize them; they stay in `modified-no-version` (advisory only).
150
+
140
151
  Global install: `npm i -g start-vibing-stacks` → `svs` (alias).
141
152
 
142
153
  ---
package/dist/index.js CHANGED
@@ -33,6 +33,7 @@ const FLAGS = {
33
33
  help: args.includes('--help') || args.includes('-h'),
34
34
  version: args.includes('--version') || args.includes('-v'),
35
35
  apply: args.includes('--apply'),
36
+ forceHooks: args.includes('--force-hooks') || args.includes('--force-legacy'),
36
37
  };
37
38
  if (FLAGS.version) {
38
39
  console.log(PKG_VERSION);
@@ -52,6 +53,9 @@ if (FLAGS.help) {
52
53
  ${chalk.bold('Options:')}
53
54
  --force Overwrite existing configuration (default command)
54
55
  --apply Apply updates (migrate command)
56
+ --force-legacy Overwrite legacy hooks (no @sv-version) AND legacy commands
57
+ (no version: frontmatter) — each gets a timestamped .bak
58
+ --force-hooks Alias for --force-legacy (kept for back-compat)
55
59
  --no-claude Skip Claude Code installation
56
60
  --no-mcp Skip MCP server selection
57
61
  --no-install Skip dependency installation
@@ -68,7 +72,7 @@ if (FLAGS.help) {
68
72
  // Subcommand: migrate
69
73
  if (SUBCOMMAND === 'migrate') {
70
74
  const { runMigrate } = await import('./migrate.js');
71
- await runMigrate(process.cwd(), { apply: FLAGS.apply });
75
+ await runMigrate(process.cwd(), { apply: FLAGS.apply, forceHooks: FLAGS.forceHooks });
72
76
  process.exit(0);
73
77
  }
74
78
  const AVAILABLE_STACKS = [
package/dist/migrate.d.ts CHANGED
@@ -1,27 +1,49 @@
1
1
  /**
2
- * Start Vibing Stacks — Migrate
2
+ * Start Vibing Stacks — Migrate (v2 — smart, idempotent, settings-aware)
3
3
  *
4
- * Compares the SKILL.md / agent / hook versions installed in .claude/
5
- * against the bundled stacks/<stack>/ and stacks/_shared/ versions.
4
+ * Compares installed vs bundled versions for skills, agents, hooks AND commands.
5
+ * Also reconciles runtime artefacts that earlier migrate versions ignored:
6
6
  *
7
- * Reports outdated, missing, and modified items. Optionally upgrades.
8
- *
9
- * Skill version contract:
10
- * YAML frontmatter at top of SKILL.md must include `version: X.Y.Z`.
11
- * No frontmatter = treated as "v0" / pre-versioning.
7
+ * - hooks: parsed via `// @sv-version: x.y.z` header. Legacy installs (no tag)
8
+ * are skipped with a "needs-update-legacy" warning unless `--force-hooks` is
9
+ * passed (which makes a `.bak` copy before overwriting).
10
+ * - commands: same `version:` frontmatter contract as skills/agents.
11
+ * - .claude/settings.json patched idempotently to ensure the bundled hook
12
+ * chain (SessionStart / UserPromptSubmit / PreToolUse / PostToolUse / Stop /
13
+ * SessionEnd) is wired. Existing entries are preserved; only missing entries
14
+ * are appended. Atomic write (.tmp + rename).
15
+ * - .claude/state/ — runtime layout for multi-instance coordination is
16
+ * auto-created and the `_state.README.md` is staged as `.claude/state/README.md`.
17
+ * - .gitignore — `.claude/state/` is appended if not already covered.
12
18
  */
19
+ type Status = 'missing' | 'outdated' | 'current' | 'ahead' | 'modified-no-version' | 'needs-update-legacy';
13
20
  export interface MigrateItem {
14
- kind: 'skill' | 'agent' | 'hook';
21
+ kind: 'skill' | 'agent' | 'hook' | 'command';
15
22
  name: string;
16
23
  source: string;
17
24
  target: string;
18
25
  bundledVersion: string;
19
26
  installedVersion: string | null;
20
- status: 'missing' | 'outdated' | 'current' | 'ahead' | 'modified-no-version';
27
+ status: Status;
21
28
  }
22
29
  export interface MigrateOptions {
23
30
  apply: boolean;
24
- scope?: 'skills' | 'agents' | 'hooks' | 'all';
31
+ scope?: 'skills' | 'agents' | 'hooks' | 'commands' | 'all';
32
+ forceHooks?: boolean;
25
33
  }
26
34
  export declare function planMigration(projectDir: string, opts: MigrateOptions): MigrateItem[];
35
+ interface SettingsPatchReport {
36
+ added: string[];
37
+ alreadyPresent: string[];
38
+ fileExisted: boolean;
39
+ error?: string;
40
+ }
41
+ export declare function patchSettings(projectDir: string, dryRun: boolean): SettingsPatchReport;
42
+ interface RuntimeReport {
43
+ created: string[];
44
+ copied: string[];
45
+ gitignoreUpdated: boolean;
46
+ }
47
+ export declare function ensureRuntimeArtefacts(projectDir: string, dryRun: boolean): RuntimeReport;
27
48
  export declare function runMigrate(projectDir: string, opts: MigrateOptions): Promise<void>;
49
+ export {};
package/dist/migrate.js CHANGED
@@ -1,18 +1,25 @@
1
1
  /**
2
- * Start Vibing Stacks — Migrate
2
+ * Start Vibing Stacks — Migrate (v2 — smart, idempotent, settings-aware)
3
3
  *
4
- * Compares the SKILL.md / agent / hook versions installed in .claude/
5
- * against the bundled stacks/<stack>/ and stacks/_shared/ versions.
4
+ * Compares installed vs bundled versions for skills, agents, hooks AND commands.
5
+ * Also reconciles runtime artefacts that earlier migrate versions ignored:
6
6
  *
7
- * Reports outdated, missing, and modified items. Optionally upgrades.
8
- *
9
- * Skill version contract:
10
- * YAML frontmatter at top of SKILL.md must include `version: X.Y.Z`.
11
- * No frontmatter = treated as "v0" / pre-versioning.
7
+ * - hooks: parsed via `// @sv-version: x.y.z` header. Legacy installs (no tag)
8
+ * are skipped with a "needs-update-legacy" warning unless `--force-hooks` is
9
+ * passed (which makes a `.bak` copy before overwriting).
10
+ * - commands: same `version:` frontmatter contract as skills/agents.
11
+ * - .claude/settings.json patched idempotently to ensure the bundled hook
12
+ * chain (SessionStart / UserPromptSubmit / PreToolUse / PostToolUse / Stop /
13
+ * SessionEnd) is wired. Existing entries are preserved; only missing entries
14
+ * are appended. Atomic write (.tmp + rename).
15
+ * - .claude/state/ — runtime layout for multi-instance coordination is
16
+ * auto-created and the `_state.README.md` is staged as `.claude/state/README.md`.
17
+ * - .gitignore — `.claude/state/` is appended if not already covered.
12
18
  */
13
- import { existsSync, readFileSync, copyFileSync, mkdirSync, statSync, readdirSync } from 'fs';
19
+ import { existsSync, readFileSync, writeFileSync, copyFileSync, mkdirSync, statSync, readdirSync, renameSync, } from 'fs';
14
20
  import { join, relative, dirname, resolve } from 'path';
15
21
  import { fileURLToPath } from 'url';
22
+ import { randomBytes } from 'crypto';
16
23
  import * as semver from 'semver';
17
24
  import * as ui from './ui.js';
18
25
  const __m_filename = fileURLToPath(import.meta.url);
@@ -20,7 +27,8 @@ const __m_dirname = dirname(__m_filename);
20
27
  const CLI_ROOT = resolve(__m_dirname, '..');
21
28
  const FRONTMATTER_RE = /^---\s*\n([\s\S]*?)\n---/;
22
29
  const VERSION_RE = /^version:\s*["']?([0-9]+\.[0-9]+\.[0-9]+(?:-[A-Za-z0-9.-]+)?)["']?\s*$/m;
23
- function parseVersion(file) {
30
+ const SV_VERSION_RE = /\/\/\s*@sv-version:\s*([0-9]+\.[0-9]+\.[0-9]+(?:-[A-Za-z0-9.-]+)?)/;
31
+ function parseFrontmatterVersion(file) {
24
32
  if (!existsSync(file))
25
33
  return null;
26
34
  try {
@@ -35,7 +43,19 @@ function parseVersion(file) {
35
43
  return null;
36
44
  }
37
45
  }
38
- function statusOf(bundled, installed) {
46
+ function parseHookVersion(file) {
47
+ if (!existsSync(file))
48
+ return null;
49
+ try {
50
+ const content = readFileSync(file, 'utf8').slice(0, 1024);
51
+ const m = SV_VERSION_RE.exec(content);
52
+ return m?.[1] ?? null;
53
+ }
54
+ catch {
55
+ return null;
56
+ }
57
+ }
58
+ function statusFromSemver(bundled, installed) {
39
59
  if (installed === null)
40
60
  return 'modified-no-version';
41
61
  const cmp = semver.compare(installed, bundled);
@@ -62,11 +82,11 @@ function listSubdirs(dir) {
62
82
  return [];
63
83
  }
64
84
  }
65
- function listFiles(dir, suffix) {
85
+ function listFiles(dir, suffixes) {
66
86
  if (!existsSync(dir))
67
87
  return [];
68
88
  try {
69
- return readdirSync(dir).filter(n => n.endsWith(suffix));
89
+ return readdirSync(dir).filter(n => suffixes.some(s => n.endsWith(s)) && !n.startsWith('_state.README'));
70
90
  }
71
91
  catch {
72
92
  return [];
@@ -90,11 +110,16 @@ function listSkillSources(stack, frontendSkillsDir) {
90
110
  }
91
111
  function listAgentSources() {
92
112
  const dir = join(CLI_ROOT, 'stacks', '_shared', 'agents');
93
- return listFiles(dir, '.md').map(n => ({ source: join(dir, n), name: n }));
113
+ return listFiles(dir, ['.md']).map(n => ({ source: join(dir, n), name: n }));
94
114
  }
95
115
  function listHookSources() {
96
116
  const dir = join(CLI_ROOT, 'stacks', '_shared', 'hooks');
97
- return listFiles(dir, '.ts').map(n => ({ source: join(dir, n), name: n }));
117
+ // Track .ts and .sh files (runnable hooks + shared lib). _state.README.md is handled separately.
118
+ return listFiles(dir, ['.ts', '.sh']).map(n => ({ source: join(dir, n), name: n }));
119
+ }
120
+ function listCommandSources() {
121
+ const dir = join(CLI_ROOT, 'stacks', '_shared', 'commands');
122
+ return listFiles(dir, ['.md']).map(n => ({ source: join(dir, n), name: n }));
98
123
  }
99
124
  function loadProjectConfig(projectDir) {
100
125
  const path = join(projectDir, '.claude', 'config', 'active-project.json');
@@ -118,53 +143,223 @@ export function planMigration(projectDir, opts) {
118
143
  if (scope === 'all' || scope === 'skills') {
119
144
  for (const { source, name } of listSkillSources(config.stack, config.frontendSkillsDir)) {
120
145
  const target = join(projectDir, '.claude', 'skills', name, 'SKILL.md');
121
- const bundledVersion = parseVersion(source);
146
+ const bundledVersion = parseFrontmatterVersion(source);
122
147
  if (!bundledVersion)
123
148
  continue;
124
- const installedVersion = parseVersion(target);
125
- const status = !existsSync(target)
126
- ? 'missing'
127
- : statusOf(bundledVersion, installedVersion);
149
+ const installedVersion = parseFrontmatterVersion(target);
150
+ const status = !existsSync(target) ? 'missing' : statusFromSemver(bundledVersion, installedVersion);
128
151
  items.push({ kind: 'skill', name, source, target, bundledVersion, installedVersion, status });
129
152
  }
130
153
  }
131
154
  if (scope === 'all' || scope === 'agents') {
132
155
  for (const { source, name } of listAgentSources()) {
133
156
  const target = join(projectDir, '.claude', 'agents', name);
134
- const bundledVersion = parseVersion(source);
157
+ const bundledVersion = parseFrontmatterVersion(source);
135
158
  if (!bundledVersion)
136
159
  continue;
137
- const installedVersion = parseVersion(target);
138
- const status = !existsSync(target)
139
- ? 'missing'
140
- : statusOf(bundledVersion, installedVersion);
160
+ const installedVersion = parseFrontmatterVersion(target);
161
+ const status = !existsSync(target) ? 'missing' : statusFromSemver(bundledVersion, installedVersion);
141
162
  items.push({ kind: 'agent', name, source, target, bundledVersion, installedVersion, status });
142
163
  }
143
164
  }
144
165
  if (scope === 'all' || scope === 'hooks') {
145
166
  for (const { source, name } of listHookSources()) {
146
167
  const target = join(projectDir, '.claude', 'hooks', name);
147
- const bundledVersion = '0.0.0';
148
- const installedVersion = existsSync(target) ? '0.0.0' : null;
149
- const status = !existsSync(target) ? 'missing' : 'current';
168
+ const bundledVersion = parseHookVersion(source) ?? '0.0.0';
169
+ const installedVersion = parseHookVersion(target);
170
+ let status;
171
+ if (!existsSync(target)) {
172
+ status = 'missing';
173
+ }
174
+ else if (installedVersion === null) {
175
+ status = bundledVersion === '0.0.0' ? 'current' : 'needs-update-legacy';
176
+ }
177
+ else {
178
+ status = statusFromSemver(bundledVersion, installedVersion);
179
+ }
150
180
  items.push({ kind: 'hook', name, source, target, bundledVersion, installedVersion, status });
151
181
  }
152
182
  }
183
+ if (scope === 'all' || scope === 'commands') {
184
+ for (const { source, name } of listCommandSources()) {
185
+ const target = join(projectDir, '.claude', 'commands', name);
186
+ const bundledVersion = parseFrontmatterVersion(source);
187
+ if (!bundledVersion)
188
+ continue;
189
+ const installedVersion = parseFrontmatterVersion(target);
190
+ let status;
191
+ if (!existsSync(target)) {
192
+ status = 'missing';
193
+ }
194
+ else if (installedVersion === null) {
195
+ // Commands are canonical infra files (slash-command routes). When the
196
+ // installed copy lacks `version:` frontmatter it predates versioned
197
+ // commands — treat as legacy so `--force-hooks` can heal it (same
198
+ // .bak machinery as legacy hooks). Skills/agents stay manual-review.
199
+ status = 'needs-update-legacy';
200
+ }
201
+ else {
202
+ status = statusFromSemver(bundledVersion, installedVersion);
203
+ }
204
+ items.push({ kind: 'command', name, source, target, bundledVersion, installedVersion, status });
205
+ }
206
+ }
153
207
  return items;
154
208
  }
155
- function applyOne(item) {
209
+ function backup(target) {
210
+ if (!existsSync(target))
211
+ return null;
212
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
213
+ const dest = `${target}.${stamp}.bak`;
214
+ try {
215
+ copyFileSync(target, dest);
216
+ return dest;
217
+ }
218
+ catch {
219
+ return null;
220
+ }
221
+ }
222
+ function applyOne(item, opts) {
156
223
  mkdirSync(dirname(item.target), { recursive: true });
224
+ if (item.status === 'needs-update-legacy') {
225
+ if (!opts.forceHooks)
226
+ return { applied: false };
227
+ const backupPath = backup(item.target);
228
+ copyFileSync(item.source, item.target);
229
+ return { applied: true, backupPath };
230
+ }
157
231
  copyFileSync(item.source, item.target);
232
+ return { applied: true };
158
233
  }
234
+ const REQUIRED_HOOK_BLOCKS = {
235
+ SessionStart: {
236
+ hooks: [
237
+ { type: 'command', command: 'npx tsx "$CLAUDE_PROJECT_DIR/.claude/hooks/session-start.ts"', timeout: 10 },
238
+ ],
239
+ },
240
+ UserPromptSubmit: {
241
+ matcher: '',
242
+ hooks: [
243
+ { type: 'command', command: 'npx tsx "$CLAUDE_PROJECT_DIR/.claude/hooks/user-prompt-submit.ts"', timeout: 10 },
244
+ ],
245
+ },
246
+ PreToolUse: {
247
+ matcher: 'Edit|Write|MultiEdit|NotebookEdit',
248
+ hooks: [
249
+ { type: 'command', command: 'npx tsx "$CLAUDE_PROJECT_DIR/.claude/hooks/pre-tool-use.ts"', timeout: 5 },
250
+ ],
251
+ },
252
+ PostToolUse: {
253
+ matcher: 'Edit|Write|MultiEdit|NotebookEdit',
254
+ hooks: [
255
+ { type: 'command', command: 'npx tsx "$CLAUDE_PROJECT_DIR/.claude/hooks/post-tool-use.ts"', timeout: 5 },
256
+ ],
257
+ },
258
+ Stop: {
259
+ hooks: [
260
+ { type: 'command', command: 'npx tsx "$CLAUDE_PROJECT_DIR/.claude/hooks/stop-validator.ts"', timeout: 30 },
261
+ ],
262
+ },
263
+ SessionEnd: {
264
+ hooks: [
265
+ { type: 'command', command: 'npx tsx "$CLAUDE_PROJECT_DIR/.claude/hooks/stop-validator.ts"', timeout: 10 },
266
+ ],
267
+ },
268
+ };
269
+ function commandSubstring(cmd) {
270
+ const m = cmd.match(/\.claude\/hooks\/([A-Za-z0-9._-]+\.(?:ts|sh|js|mjs))/);
271
+ return m?.[1] ?? cmd;
272
+ }
273
+ function writeFileAtomic(target, content) {
274
+ const tmp = `${target}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
275
+ writeFileSync(tmp, content);
276
+ renameSync(tmp, target);
277
+ }
278
+ export function patchSettings(projectDir, dryRun) {
279
+ const path = join(projectDir, '.claude', 'settings.json');
280
+ const report = { added: [], alreadyPresent: [], fileExisted: existsSync(path) };
281
+ let settings = {};
282
+ if (existsSync(path)) {
283
+ try {
284
+ settings = JSON.parse(readFileSync(path, 'utf8'));
285
+ }
286
+ catch (err) {
287
+ report.error = `settings.json is not valid JSON (${err.message}) — skipping patch.`;
288
+ return report;
289
+ }
290
+ }
291
+ settings.hooks = settings.hooks || {};
292
+ for (const [event, requiredBlock] of Object.entries(REQUIRED_HOOK_BLOCKS)) {
293
+ const existingBlocks = Array.isArray(settings.hooks[event]) ? settings.hooks[event] : [];
294
+ const requiredScript = commandSubstring(requiredBlock.hooks[0].command);
295
+ const alreadyHas = existingBlocks.some(b => (b.hooks || []).some(h => commandSubstring(h.command).includes(requiredScript)));
296
+ if (alreadyHas) {
297
+ report.alreadyPresent.push(event);
298
+ continue;
299
+ }
300
+ if (!dryRun) {
301
+ settings.hooks[event] = [...existingBlocks, requiredBlock];
302
+ }
303
+ report.added.push(event);
304
+ }
305
+ if (!dryRun && report.added.length > 0 && !report.error) {
306
+ mkdirSync(dirname(path), { recursive: true });
307
+ writeFileAtomic(path, JSON.stringify(settings, null, '\t'));
308
+ }
309
+ return report;
310
+ }
311
+ export function ensureRuntimeArtefacts(projectDir, dryRun) {
312
+ const report = { created: [], copied: [], gitignoreUpdated: false };
313
+ const stateRoot = join(projectDir, '.claude', 'state');
314
+ const dirs = [
315
+ join(stateRoot, 'sessions', '_archive'),
316
+ join(stateRoot, 'inbox'),
317
+ join(stateRoot, 'file-touches', '_archive'),
318
+ ];
319
+ for (const d of dirs) {
320
+ if (!existsSync(d)) {
321
+ if (!dryRun)
322
+ mkdirSync(d, { recursive: true });
323
+ report.created.push(relative(projectDir, d));
324
+ }
325
+ }
326
+ const readmeSrc = join(CLI_ROOT, 'stacks', '_shared', 'hooks', '_state.README.md');
327
+ const readmeDest = join(stateRoot, 'README.md');
328
+ if (existsSync(readmeSrc) && !existsSync(readmeDest)) {
329
+ if (!dryRun) {
330
+ mkdirSync(dirname(readmeDest), { recursive: true });
331
+ copyFileSync(readmeSrc, readmeDest);
332
+ }
333
+ report.copied.push(relative(projectDir, readmeDest));
334
+ }
335
+ const giPath = join(projectDir, '.gitignore');
336
+ if (existsSync(giPath)) {
337
+ const gi = readFileSync(giPath, 'utf8');
338
+ const covered = /^\.claude\/state\/?$/m.test(gi) || /^\.claude\/?$/m.test(gi);
339
+ if (!covered) {
340
+ if (!dryRun) {
341
+ const next = gi.trimEnd() +
342
+ '\n\n# Claude Code multi-instance coordination state (runtime, per-host)\n.claude/state/\n';
343
+ writeFileSync(giPath, next);
344
+ }
345
+ report.gitignoreUpdated = true;
346
+ }
347
+ }
348
+ return report;
349
+ }
350
+ /* ---------------------------------------------------------------------------
351
+ * CLI runner
352
+ * -------------------------------------------------------------------------*/
159
353
  export async function runMigrate(projectDir, opts) {
160
354
  ui.header('🔄 Start Vibing — Migrate');
161
355
  const items = planMigration(projectDir, opts);
162
- if (items.length === 0) {
163
- ui.info('Nothing to migrate.');
164
- return;
165
- }
166
356
  const grouped = {
167
- missing: [], outdated: [], current: [], ahead: [], 'modified-no-version': [],
357
+ missing: [],
358
+ outdated: [],
359
+ current: [],
360
+ ahead: [],
361
+ 'modified-no-version': [],
362
+ 'needs-update-legacy': [],
168
363
  };
169
364
  for (const it of items)
170
365
  grouped[it.status].push(it);
@@ -174,44 +369,119 @@ export async function runMigrate(projectDir, opts) {
174
369
  console.log(`\n ${label} (${list.length}):`);
175
370
  for (const it of list.slice(0, 30)) {
176
371
  const v = it.installedVersion ?? '–';
177
- console.log(` ${it.kind.padEnd(5)} ${it.name.padEnd(32)} installed=${v.padEnd(8)} bundled=${it.bundledVersion}`);
372
+ console.log(` ${it.kind.padEnd(7)} ${it.name.padEnd(32)} installed=${v.padEnd(8)} bundled=${it.bundledVersion}`);
178
373
  }
179
374
  if (list.length > 30)
180
375
  console.log(` ... and ${list.length - 30} more`);
181
376
  };
182
377
  summary('MISSING', grouped.missing);
183
378
  summary('OUTDATED', grouped.outdated);
379
+ summary('NEEDS UPDATE — legacy (hooks without @sv-version, commands without version: frontmatter)', grouped['needs-update-legacy']);
184
380
  summary('AHEAD (installed newer than bundled — kept)', grouped.ahead);
185
381
  summary('UNVERSIONED (manual review)', grouped['modified-no-version']);
186
382
  summary('CURRENT', grouped.current);
187
- const upgradable = [...grouped.missing, ...grouped.outdated];
188
- if (upgradable.length === 0) {
383
+ // Settings.json + runtime — preview always, regardless of items.
384
+ const settingsPreview = patchSettings(projectDir, true);
385
+ const runtimePreview = ensureRuntimeArtefacts(projectDir, true);
386
+ if (settingsPreview.added.length > 0) {
387
+ console.log('\n SETTINGS.JSON — will add hook entries:');
388
+ for (const ev of settingsPreview.added)
389
+ console.log(` + ${ev}`);
390
+ }
391
+ if (settingsPreview.error) {
392
+ console.log(`\n SETTINGS.JSON — ${settingsPreview.error}`);
393
+ }
394
+ if (runtimePreview.created.length > 0) {
395
+ console.log('\n RUNTIME — will create dirs:');
396
+ for (const d of runtimePreview.created)
397
+ console.log(` + ${d}`);
398
+ }
399
+ if (runtimePreview.copied.length > 0) {
400
+ console.log('\n RUNTIME — will copy:');
401
+ for (const f of runtimePreview.copied)
402
+ console.log(` + ${f}`);
403
+ }
404
+ if (runtimePreview.gitignoreUpdated) {
405
+ console.log('\n GITIGNORE — will append `.claude/state/`');
406
+ }
407
+ const upgradable = [
408
+ ...grouped.missing,
409
+ ...grouped.outdated,
410
+ ...(opts.forceHooks ? grouped['needs-update-legacy'] : []),
411
+ ];
412
+ const nothingFile = upgradable.length === 0;
413
+ const nothingSettings = settingsPreview.added.length === 0;
414
+ const nothingRuntime = runtimePreview.created.length === 0 && runtimePreview.copied.length === 0 && !runtimePreview.gitignoreUpdated;
415
+ if (nothingFile && nothingSettings && nothingRuntime) {
189
416
  console.log('');
190
417
  ui.success('Everything up to date.');
191
418
  return;
192
419
  }
193
420
  if (!opts.apply) {
194
421
  console.log('');
195
- ui.info(`Run with --apply to install/update ${upgradable.length} item(s).`);
422
+ const todo = [];
423
+ if (upgradable.length > 0)
424
+ todo.push(`${upgradable.length} file(s)`);
425
+ if (settingsPreview.added.length > 0)
426
+ todo.push(`${settingsPreview.added.length} settings entry(ies)`);
427
+ if (!nothingRuntime)
428
+ todo.push('runtime artefacts');
429
+ ui.info(`Run with --apply to update: ${todo.join(' + ')}.`);
430
+ if (grouped['needs-update-legacy'].length > 0 && !opts.forceHooks) {
431
+ ui.info(`${grouped['needs-update-legacy'].length} legacy file(s) detected (hooks without @sv-version, ` +
432
+ `commands without \`version:\` frontmatter). Use --force-legacy (alias --force-hooks) to ` +
433
+ `update them with a .bak backup.`);
434
+ }
196
435
  return;
197
436
  }
198
437
  console.log('');
438
+ let updated = 0;
439
+ let legacyUpdated = 0;
199
440
  for (const it of upgradable) {
200
441
  try {
201
- applyOne(it);
202
- ui.success(`updated ${it.kind} ${it.name} (${it.installedVersion ?? '–'} → ${it.bundledVersion})`);
442
+ const r = applyOne(it, opts);
443
+ if (!r.applied)
444
+ continue;
445
+ updated++;
446
+ if (it.status === 'needs-update-legacy')
447
+ legacyUpdated++;
448
+ const bak = r.backupPath ? ` (backup: ${relative(projectDir, r.backupPath)})` : '';
449
+ ui.success(`updated ${it.kind} ${it.name} (${it.installedVersion ?? '–'} → ${it.bundledVersion})${bak}`);
203
450
  }
204
451
  catch (err) {
205
452
  ui.warn(`failed ${it.kind} ${it.name}: ${err instanceof Error ? err.message : String(err)}`);
206
453
  }
207
454
  }
455
+ // Apply settings + runtime.
456
+ const settingsApply = patchSettings(projectDir, false);
457
+ const runtimeApply = ensureRuntimeArtefacts(projectDir, false);
458
+ for (const ev of settingsApply.added)
459
+ ui.success(`settings.json: wired ${ev}`);
460
+ if (settingsApply.error)
461
+ ui.warn(settingsApply.error);
462
+ for (const d of runtimeApply.created)
463
+ ui.success(`runtime: created ${d}`);
464
+ for (const f of runtimeApply.copied)
465
+ ui.success(`runtime: copied ${f}`);
466
+ if (runtimeApply.gitignoreUpdated)
467
+ ui.success('gitignore: appended .claude/state/');
208
468
  console.log('');
209
- ui.success(`Migration complete: ${upgradable.length} item(s) updated.`);
469
+ ui.success(`Migration complete: ${updated} file(s), ${settingsApply.added.length} settings entry(ies)` +
470
+ (legacyUpdated > 0 ? `, ${legacyUpdated} legacy file(s) overwritten with backup` : ''));
210
471
  if (grouped['modified-no-version'].length > 0) {
211
472
  console.log('');
212
- ui.warn(`${grouped['modified-no-version'].length} unversioned local item(s) skipped — review manually.`);
473
+ ui.warn(`${grouped['modified-no-version'].length} unversioned local skill/agent/command(s) skipped — review manually.`);
213
474
  for (const it of grouped['modified-no-version'].slice(0, 10)) {
214
475
  console.log(` ${relative(projectDir, it.target)}`);
215
476
  }
216
477
  }
478
+ if (grouped['needs-update-legacy'].length > 0 && !opts.forceHooks) {
479
+ console.log('');
480
+ ui.warn(`${grouped['needs-update-legacy'].length} legacy file(s) skipped (hooks without @sv-version, ` +
481
+ `commands without \`version:\` frontmatter). Re-run with --force-legacy ` +
482
+ `(alias --force-hooks) to update them (each gets a .bak backup).`);
483
+ for (const it of grouped['needs-update-legacy'].slice(0, 10)) {
484
+ console.log(` ${relative(projectDir, it.target)}`);
485
+ }
486
+ }
217
487
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "start-vibing-stacks",
3
- "version": "2.23.0",
3
+ "version": "2.25.0",
4
4
  "description": "AI-powered multi-stack dev workflow for Claude Code. Supports PHP, Node.js, Python and more.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,3 +1,4 @@
1
+ // @sv-version: 1.0.0
1
2
  /**
2
3
  * Multi-Instance Coordination — Shared State Library
3
4
  *
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ // @sv-version: 1.0.0
2
3
  /**
3
4
  * Final Check — executable validator with VETO.
4
5
  *
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ // @sv-version: 1.0.0
2
3
  /**
3
4
  * `peers` — CLI for the multi-instance coordination layer.
4
5
  *
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ // @sv-version: 1.0.0
2
3
  /**
3
4
  * PostToolUse Hook — Multi-Instance Coordination
4
5
  *
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ // @sv-version: 1.0.0
2
3
  /**
3
4
  * PreToolUse Hook — Multi-Instance Coordination
4
5
  *
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ // @sv-version: 1.0.0
2
3
  /**
3
4
  * Universal Hook Runner — Start Vibing Stacks
4
5
  *
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ // @sv-version: 1.0.0
2
3
  /**
3
4
  * SessionStart Hook — Multi-Instance Coordination
4
5
  *
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ // @sv-version: 1.1.0
2
3
  /**
3
4
  * Stop Validator Hook — Start Vibing Stacks (Universal)
4
5
  *
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ // @sv-version: 1.1.0
2
3
  /**
3
4
  * UserPromptSubmit Hook — Start Vibing Stacks
4
5
  *