start-vibing-stacks 2.23.0 → 2.24.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.24.0** — `migrate` is now smart and complete. Versioned hooks (`// @sv-version`), versioned commands, idempotent `settings.json` patcher, runtime dirs auto-creation, `.gitignore` update all reachable via `npx start-vibing-stacks migrate --apply`. New `--force-hooks` flag overwrites legacy hooks (no `@sv-version`) with timestamped `.bak` backup. v2.23.0 multi-instance coordination layer.
10
10
 
11
11
  ---
12
12
 
@@ -130,13 +130,23 @@ 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-hooks # also overwrite legacy hooks (no @sv-version) — creates .bak
136
137
 
137
138
  # flags: --force --no-claude --no-mcp --no-install --help --version
138
139
  ```
139
140
 
141
+ `migrate` is the safe upgrade path between releases. Each release gets:
142
+ - **Hooks** versioned via `// @sv-version: x.y.z` header — semver-compared against installed.
143
+ - **Commands** versioned via frontmatter `version:` — same as skills/agents.
144
+ - **`settings.json#hooks`** — idempotent deep-merge: missing event blocks (`SessionStart`, `PreToolUse`, `PostToolUse`, `SessionEnd`) are appended; existing entries (including yours) are preserved.
145
+ - **Runtime artefacts** — `.claude/state/{sessions,inbox,file-touches}/_archive` auto-created; `_state.README.md` staged.
146
+ - **`.gitignore`** — `.claude/state/` appended if not already covered.
147
+
148
+ Legacy hooks without `@sv-version` (anything older than v2.24.0) are reported as `needs-update-legacy` and skipped by default. Pass `--force-hooks` to overwrite them — each gets a timestamped `.bak`.
149
+
140
150
  Global install: `npm i -g start-vibing-stacks` → `svs` (alias).
141
151
 
142
152
  ---
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'),
36
37
  };
37
38
  if (FLAGS.version) {
38
39
  console.log(PKG_VERSION);
@@ -52,6 +53,7 @@ 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-hooks Overwrite legacy hooks without @sv-version (migrate; creates .bak)
55
57
  --no-claude Skip Claude Code installation
56
58
  --no-mcp Skip MCP server selection
57
59
  --no-install Skip dependency installation
@@ -68,7 +70,7 @@ if (FLAGS.help) {
68
70
  // Subcommand: migrate
69
71
  if (SUBCOMMAND === 'migrate') {
70
72
  const { runMigrate } = await import('./migrate.js');
71
- await runMigrate(process.cwd(), { apply: FLAGS.apply });
73
+ await runMigrate(process.cwd(), { apply: FLAGS.apply, forceHooks: FLAGS.forceHooks });
72
74
  process.exit(0);
73
75
  }
74
76
  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,210 @@ 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
+ const status = !existsSync(target) ? 'missing' : statusFromSemver(bundledVersion, installedVersion);
191
+ items.push({ kind: 'command', name, source, target, bundledVersion, installedVersion, status });
192
+ }
193
+ }
153
194
  return items;
154
195
  }
155
- function applyOne(item) {
196
+ function backup(target) {
197
+ if (!existsSync(target))
198
+ return null;
199
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
200
+ const dest = `${target}.${stamp}.bak`;
201
+ try {
202
+ copyFileSync(target, dest);
203
+ return dest;
204
+ }
205
+ catch {
206
+ return null;
207
+ }
208
+ }
209
+ function applyOne(item, opts) {
156
210
  mkdirSync(dirname(item.target), { recursive: true });
211
+ if (item.status === 'needs-update-legacy') {
212
+ if (!opts.forceHooks)
213
+ return { applied: false };
214
+ const backupPath = backup(item.target);
215
+ copyFileSync(item.source, item.target);
216
+ return { applied: true, backupPath };
217
+ }
157
218
  copyFileSync(item.source, item.target);
219
+ return { applied: true };
220
+ }
221
+ const REQUIRED_HOOK_BLOCKS = {
222
+ SessionStart: {
223
+ hooks: [
224
+ { type: 'command', command: 'npx tsx "$CLAUDE_PROJECT_DIR/.claude/hooks/session-start.ts"', timeout: 10 },
225
+ ],
226
+ },
227
+ UserPromptSubmit: {
228
+ matcher: '',
229
+ hooks: [
230
+ { type: 'command', command: 'npx tsx "$CLAUDE_PROJECT_DIR/.claude/hooks/user-prompt-submit.ts"', timeout: 10 },
231
+ ],
232
+ },
233
+ PreToolUse: {
234
+ matcher: 'Edit|Write|MultiEdit|NotebookEdit',
235
+ hooks: [
236
+ { type: 'command', command: 'npx tsx "$CLAUDE_PROJECT_DIR/.claude/hooks/pre-tool-use.ts"', timeout: 5 },
237
+ ],
238
+ },
239
+ PostToolUse: {
240
+ matcher: 'Edit|Write|MultiEdit|NotebookEdit',
241
+ hooks: [
242
+ { type: 'command', command: 'npx tsx "$CLAUDE_PROJECT_DIR/.claude/hooks/post-tool-use.ts"', timeout: 5 },
243
+ ],
244
+ },
245
+ Stop: {
246
+ hooks: [
247
+ { type: 'command', command: 'npx tsx "$CLAUDE_PROJECT_DIR/.claude/hooks/stop-validator.ts"', timeout: 30 },
248
+ ],
249
+ },
250
+ SessionEnd: {
251
+ hooks: [
252
+ { type: 'command', command: 'npx tsx "$CLAUDE_PROJECT_DIR/.claude/hooks/stop-validator.ts"', timeout: 10 },
253
+ ],
254
+ },
255
+ };
256
+ function commandSubstring(cmd) {
257
+ const m = cmd.match(/\.claude\/hooks\/([A-Za-z0-9._-]+\.(?:ts|sh|js|mjs))/);
258
+ return m?.[1] ?? cmd;
259
+ }
260
+ function writeFileAtomic(target, content) {
261
+ const tmp = `${target}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
262
+ writeFileSync(tmp, content);
263
+ renameSync(tmp, target);
264
+ }
265
+ export function patchSettings(projectDir, dryRun) {
266
+ const path = join(projectDir, '.claude', 'settings.json');
267
+ const report = { added: [], alreadyPresent: [], fileExisted: existsSync(path) };
268
+ let settings = {};
269
+ if (existsSync(path)) {
270
+ try {
271
+ settings = JSON.parse(readFileSync(path, 'utf8'));
272
+ }
273
+ catch (err) {
274
+ report.error = `settings.json is not valid JSON (${err.message}) — skipping patch.`;
275
+ return report;
276
+ }
277
+ }
278
+ settings.hooks = settings.hooks || {};
279
+ for (const [event, requiredBlock] of Object.entries(REQUIRED_HOOK_BLOCKS)) {
280
+ const existingBlocks = Array.isArray(settings.hooks[event]) ? settings.hooks[event] : [];
281
+ const requiredScript = commandSubstring(requiredBlock.hooks[0].command);
282
+ const alreadyHas = existingBlocks.some(b => (b.hooks || []).some(h => commandSubstring(h.command).includes(requiredScript)));
283
+ if (alreadyHas) {
284
+ report.alreadyPresent.push(event);
285
+ continue;
286
+ }
287
+ if (!dryRun) {
288
+ settings.hooks[event] = [...existingBlocks, requiredBlock];
289
+ }
290
+ report.added.push(event);
291
+ }
292
+ if (!dryRun && report.added.length > 0 && !report.error) {
293
+ mkdirSync(dirname(path), { recursive: true });
294
+ writeFileAtomic(path, JSON.stringify(settings, null, '\t'));
295
+ }
296
+ return report;
158
297
  }
298
+ export function ensureRuntimeArtefacts(projectDir, dryRun) {
299
+ const report = { created: [], copied: [], gitignoreUpdated: false };
300
+ const stateRoot = join(projectDir, '.claude', 'state');
301
+ const dirs = [
302
+ join(stateRoot, 'sessions', '_archive'),
303
+ join(stateRoot, 'inbox'),
304
+ join(stateRoot, 'file-touches', '_archive'),
305
+ ];
306
+ for (const d of dirs) {
307
+ if (!existsSync(d)) {
308
+ if (!dryRun)
309
+ mkdirSync(d, { recursive: true });
310
+ report.created.push(relative(projectDir, d));
311
+ }
312
+ }
313
+ const readmeSrc = join(CLI_ROOT, 'stacks', '_shared', 'hooks', '_state.README.md');
314
+ const readmeDest = join(stateRoot, 'README.md');
315
+ if (existsSync(readmeSrc) && !existsSync(readmeDest)) {
316
+ if (!dryRun) {
317
+ mkdirSync(dirname(readmeDest), { recursive: true });
318
+ copyFileSync(readmeSrc, readmeDest);
319
+ }
320
+ report.copied.push(relative(projectDir, readmeDest));
321
+ }
322
+ const giPath = join(projectDir, '.gitignore');
323
+ if (existsSync(giPath)) {
324
+ const gi = readFileSync(giPath, 'utf8');
325
+ const covered = /^\.claude\/state\/?$/m.test(gi) || /^\.claude\/?$/m.test(gi);
326
+ if (!covered) {
327
+ if (!dryRun) {
328
+ const next = gi.trimEnd() +
329
+ '\n\n# Claude Code multi-instance coordination state (runtime, per-host)\n.claude/state/\n';
330
+ writeFileSync(giPath, next);
331
+ }
332
+ report.gitignoreUpdated = true;
333
+ }
334
+ }
335
+ return report;
336
+ }
337
+ /* ---------------------------------------------------------------------------
338
+ * CLI runner
339
+ * -------------------------------------------------------------------------*/
159
340
  export async function runMigrate(projectDir, opts) {
160
341
  ui.header('🔄 Start Vibing — Migrate');
161
342
  const items = planMigration(projectDir, opts);
162
- if (items.length === 0) {
163
- ui.info('Nothing to migrate.');
164
- return;
165
- }
166
343
  const grouped = {
167
- missing: [], outdated: [], current: [], ahead: [], 'modified-no-version': [],
344
+ missing: [],
345
+ outdated: [],
346
+ current: [],
347
+ ahead: [],
348
+ 'modified-no-version': [],
349
+ 'needs-update-legacy': [],
168
350
  };
169
351
  for (const it of items)
170
352
  grouped[it.status].push(it);
@@ -174,44 +356,117 @@ export async function runMigrate(projectDir, opts) {
174
356
  console.log(`\n ${label} (${list.length}):`);
175
357
  for (const it of list.slice(0, 30)) {
176
358
  const v = it.installedVersion ?? '–';
177
- console.log(` ${it.kind.padEnd(5)} ${it.name.padEnd(32)} installed=${v.padEnd(8)} bundled=${it.bundledVersion}`);
359
+ console.log(` ${it.kind.padEnd(7)} ${it.name.padEnd(32)} installed=${v.padEnd(8)} bundled=${it.bundledVersion}`);
178
360
  }
179
361
  if (list.length > 30)
180
362
  console.log(` ... and ${list.length - 30} more`);
181
363
  };
182
364
  summary('MISSING', grouped.missing);
183
365
  summary('OUTDATED', grouped.outdated);
366
+ summary('NEEDS UPDATE — legacy hooks (no @sv-version tag)', grouped['needs-update-legacy']);
184
367
  summary('AHEAD (installed newer than bundled — kept)', grouped.ahead);
185
368
  summary('UNVERSIONED (manual review)', grouped['modified-no-version']);
186
369
  summary('CURRENT', grouped.current);
187
- const upgradable = [...grouped.missing, ...grouped.outdated];
188
- if (upgradable.length === 0) {
370
+ // Settings.json + runtime — preview always, regardless of items.
371
+ const settingsPreview = patchSettings(projectDir, true);
372
+ const runtimePreview = ensureRuntimeArtefacts(projectDir, true);
373
+ if (settingsPreview.added.length > 0) {
374
+ console.log('\n SETTINGS.JSON — will add hook entries:');
375
+ for (const ev of settingsPreview.added)
376
+ console.log(` + ${ev}`);
377
+ }
378
+ if (settingsPreview.error) {
379
+ console.log(`\n SETTINGS.JSON — ${settingsPreview.error}`);
380
+ }
381
+ if (runtimePreview.created.length > 0) {
382
+ console.log('\n RUNTIME — will create dirs:');
383
+ for (const d of runtimePreview.created)
384
+ console.log(` + ${d}`);
385
+ }
386
+ if (runtimePreview.copied.length > 0) {
387
+ console.log('\n RUNTIME — will copy:');
388
+ for (const f of runtimePreview.copied)
389
+ console.log(` + ${f}`);
390
+ }
391
+ if (runtimePreview.gitignoreUpdated) {
392
+ console.log('\n GITIGNORE — will append `.claude/state/`');
393
+ }
394
+ const upgradable = [
395
+ ...grouped.missing,
396
+ ...grouped.outdated,
397
+ ...(opts.forceHooks ? grouped['needs-update-legacy'] : []),
398
+ ];
399
+ const nothingFile = upgradable.length === 0;
400
+ const nothingSettings = settingsPreview.added.length === 0;
401
+ const nothingRuntime = runtimePreview.created.length === 0 && runtimePreview.copied.length === 0 && !runtimePreview.gitignoreUpdated;
402
+ if (nothingFile && nothingSettings && nothingRuntime) {
189
403
  console.log('');
190
404
  ui.success('Everything up to date.');
191
405
  return;
192
406
  }
193
407
  if (!opts.apply) {
194
408
  console.log('');
195
- ui.info(`Run with --apply to install/update ${upgradable.length} item(s).`);
409
+ const todo = [];
410
+ if (upgradable.length > 0)
411
+ todo.push(`${upgradable.length} file(s)`);
412
+ if (settingsPreview.added.length > 0)
413
+ todo.push(`${settingsPreview.added.length} settings entry(ies)`);
414
+ if (!nothingRuntime)
415
+ todo.push('runtime artefacts');
416
+ ui.info(`Run with --apply to update: ${todo.join(' + ')}.`);
417
+ if (grouped['needs-update-legacy'].length > 0 && !opts.forceHooks) {
418
+ ui.info(`${grouped['needs-update-legacy'].length} legacy hook(s) without @sv-version detected. ` +
419
+ `Use --force-hooks to update them with a .bak backup.`);
420
+ }
196
421
  return;
197
422
  }
198
423
  console.log('');
424
+ let updated = 0;
425
+ let legacyUpdated = 0;
199
426
  for (const it of upgradable) {
200
427
  try {
201
- applyOne(it);
202
- ui.success(`updated ${it.kind} ${it.name} (${it.installedVersion ?? '–'} → ${it.bundledVersion})`);
428
+ const r = applyOne(it, opts);
429
+ if (!r.applied)
430
+ continue;
431
+ updated++;
432
+ if (it.status === 'needs-update-legacy')
433
+ legacyUpdated++;
434
+ const bak = r.backupPath ? ` (backup: ${relative(projectDir, r.backupPath)})` : '';
435
+ ui.success(`updated ${it.kind} ${it.name} (${it.installedVersion ?? '–'} → ${it.bundledVersion})${bak}`);
203
436
  }
204
437
  catch (err) {
205
438
  ui.warn(`failed ${it.kind} ${it.name}: ${err instanceof Error ? err.message : String(err)}`);
206
439
  }
207
440
  }
441
+ // Apply settings + runtime.
442
+ const settingsApply = patchSettings(projectDir, false);
443
+ const runtimeApply = ensureRuntimeArtefacts(projectDir, false);
444
+ for (const ev of settingsApply.added)
445
+ ui.success(`settings.json: wired ${ev}`);
446
+ if (settingsApply.error)
447
+ ui.warn(settingsApply.error);
448
+ for (const d of runtimeApply.created)
449
+ ui.success(`runtime: created ${d}`);
450
+ for (const f of runtimeApply.copied)
451
+ ui.success(`runtime: copied ${f}`);
452
+ if (runtimeApply.gitignoreUpdated)
453
+ ui.success('gitignore: appended .claude/state/');
208
454
  console.log('');
209
- ui.success(`Migration complete: ${upgradable.length} item(s) updated.`);
455
+ ui.success(`Migration complete: ${updated} file(s), ${settingsApply.added.length} settings entry(ies)` +
456
+ (legacyUpdated > 0 ? `, ${legacyUpdated} legacy hook(s) overwritten with backup` : ''));
210
457
  if (grouped['modified-no-version'].length > 0) {
211
458
  console.log('');
212
- ui.warn(`${grouped['modified-no-version'].length} unversioned local item(s) skipped — review manually.`);
459
+ ui.warn(`${grouped['modified-no-version'].length} unversioned local skill/agent/command(s) skipped — review manually.`);
213
460
  for (const it of grouped['modified-no-version'].slice(0, 10)) {
214
461
  console.log(` ${relative(projectDir, it.target)}`);
215
462
  }
216
463
  }
464
+ if (grouped['needs-update-legacy'].length > 0 && !opts.forceHooks) {
465
+ console.log('');
466
+ ui.warn(`${grouped['needs-update-legacy'].length} legacy hook(s) without @sv-version were skipped. ` +
467
+ `Re-run with --force-hooks to update them (each gets a .bak backup).`);
468
+ for (const it of grouped['needs-update-legacy'].slice(0, 10)) {
469
+ console.log(` ${relative(projectDir, it.target)}`);
470
+ }
471
+ }
217
472
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "start-vibing-stacks",
3
- "version": "2.23.0",
3
+ "version": "2.24.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
  *