start-vibing-stacks 2.22.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.12.0** — `security-auditor` rewritten as stack-aware adversarial auditor with VETO power; `documenter` rewritten with search-optimized memory layer (YAML frontmatter + `_index.json` sidecar); `research-web` MCP-first; CI on Node 22.
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
 
@@ -16,8 +16,8 @@ npx start-vibing-stacks
16
16
  |---|---|---|
17
17
  | Agents | **7** universal | `research-web` (MCP-first) · `documenter` (memory layer) · `domain-updater` · `commit-manager` · `tester` · `claude-md-compactor` (compaction) · **`security-auditor` (VETO)** |
18
18
  | Skills | **22** shared + **7–14** stack-specific + **2–7** frontend | Versioned (`version:` frontmatter), upgradable via `migrate` |
19
- | Hooks | `stop-validator` · `final-check` · `user-prompt-submit` | Block completion on git/docs/secrets/code-quality issues |
20
- | Commands | `/feature` · `/fix` · `/research` · `/validate` | Slash commands |
19
+ | Hooks | `session-start` · `user-prompt-submit` · `pre-tool-use` · `post-tool-use` · `stop-validator` · `final-check` | Multi-instance coordination + git/docs/secrets/code-quality gates |
20
+ | Commands | `/feature` · `/fix` · `/research` · `/validate` · `/peers` | Slash commands |
21
21
  | Workflows | `ci.yml` + `security.yml` per stack | Copied to `.github/workflows/` when target is empty |
22
22
  | Configs | `active-project.json` · `domain-mapping.json` · `security-rules.json` · `standards-review.json` | Drives every agent's stack detection |
23
23
 
@@ -82,24 +82,71 @@ your-project/
82
82
  │ │ │ ├── _index.json # machine-readable, regenerated by documenter
83
83
  │ │ │ └── domains/ # one file per domain (≤ 8 KB each)
84
84
  │ │ └── <other skills>/
85
- │ ├── hooks/ # stop-validator, final-check, prompt-submit
86
- │ ├── commands/ # /feature, /fix, /research, /validate
85
+ │ ├── hooks/ # session-start, pre/post-tool-use, stop-validator, final-check, prompt-submit, peers (CLI)
86
+ │ ├── commands/ # /feature, /fix, /research, /validate, /peers
87
+ │ ├── state/ # multi-instance coordination (gitignored, runtime-managed)
87
88
  │ └── config/ # active-project, domain-mapping, security-rules, ...
88
89
  └── .github/workflows/ # ci.yml + security.yml (if dir was empty)
89
90
  ```
90
91
 
91
92
  ---
92
93
 
94
+ ## Multiple Claude instances in the same folder
95
+
96
+ When two or more Claude Code sessions run in the same project, the installed hooks coordinate automatically through `.claude/state/` (gitignored, per-host).
97
+
98
+ What you get:
99
+
100
+ - **Auto-discovery** — `SessionStart` registers each session, captures the same title that appears in `claude --resume`, and tells you who else is around.
101
+ - **File-edit collision shield** — `PreToolUse` BLOCKS Edit/Write on a file a peer is actively editing (heartbeat <60s, touched <5min ago). Idle peers (60s–30min) downgrade to a warning; stale peers are ignored.
102
+ - **Cross-instance messaging** — `peers notify <id-prefix> "msg"` queues a message that surfaces in the OTHER instance at its next prompt. The user-prompt-submit hook drains the inbox automatically.
103
+
104
+ ### `/peers` CLI
105
+
106
+ ```bash
107
+ # Inside Claude:
108
+ /peers # delegates to the script
109
+
110
+ # Or from any terminal:
111
+ npx tsx .claude/hooks/peers.ts list
112
+ npx tsx .claude/hooks/peers.ts notify a1b2c3d4 "I just committed auth changes"
113
+ npx tsx .claude/hooks/peers.ts locks --minutes 10
114
+ npx tsx .claude/hooks/peers.ts cleanup
115
+ ```
116
+
117
+ ### Heartbeat thresholds
118
+
119
+ | Last activity | State | Effect |
120
+ | ------------- | -------- | -------------------------------------------------------- |
121
+ | < 60s | active | Counts for collision detection; BLOCKS conflicting edits |
122
+ | 60s – 30min | idle | Surfaced as a warning; edits NOT blocked |
123
+ | > 30min | stale | Auto-archived |
124
+ | > 24h | removed | Deleted |
125
+
126
+ Single-host coordination only. It does **not** replace `git` for cross-machine collaboration; it prevents two live sessions on the same laptop from stepping on the same uncommitted file.
127
+
128
+ ---
129
+
93
130
  ## CLI
94
131
 
95
132
  ```bash
96
- npx start-vibing-stacks # setup or resume current project
97
- npx start-vibing-stacks migrate # show outdated/missing skills
98
- 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
99
137
 
100
138
  # flags: --force --no-claude --no-mcp --no-install --help --version
101
139
  ```
102
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
+
103
150
  Global install: `npm i -g start-vibing-stacks` → `svs` (alias).
104
151
 
105
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
  }