start-vibing-stacks 2.25.2 → 2.26.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.
@@ -0,0 +1,410 @@
1
+ #!/usr/bin/env node
2
+ // @sv-version: 1.0.0
3
+ /**
4
+ * `scope` — Per-Instance Commit Scoping CLI
5
+ *
6
+ * Solves: in multi-instance Claude sessions, `git add .` / `git add -A` pulls
7
+ * in changes from peer sessions, producing tangled commits where instance N
8
+ * unintentionally ships instance M's uncommitted work.
9
+ *
10
+ * Source of truth for "what did THIS session edit?":
11
+ * `.claude/state/sessions/<id>.json#filesTouched`
12
+ * which is maintained by `post-tool-use.ts` (capped at 50, dedup-keep-last).
13
+ *
14
+ * Usage:
15
+ * scope status Show this session's files vs peers' files vs untracked.
16
+ * scope stage [--include-conflicted] `git reset`, then `git add` only this session's dirty files.
17
+ * Refuses files a peer touched in the last 5 min unless
18
+ * --include-conflicted.
19
+ * scope diff `git diff` for files this session touched.
20
+ * scope commit "<msg>" [--push] stage + commit + optional push.
21
+ *
22
+ * Session discovery: --session <id>, else $CLAUDE_SESSION_ID, else single active session.
23
+ *
24
+ * Exit codes:
25
+ * 0 ok
26
+ * 1 argument / state error
27
+ * 2 refused (collision detected; pass --include-conflicted to override)
28
+ *
29
+ * Run: npx tsx "$CLAUDE_PROJECT_DIR/.claude/hooks/scope.ts" <command>
30
+ */
31
+
32
+ import { existsSync } from 'fs';
33
+ import { spawnSync } from 'child_process';
34
+ import { join } from 'path';
35
+ import {
36
+ ACTIVE_MS,
37
+ COLLISION_WINDOW_MS,
38
+ ageMs,
39
+ getProjectDir,
40
+ getStateDir,
41
+ listSessionFiles,
42
+ readJsonSafe,
43
+ readSession,
44
+ shortId,
45
+ tailFileTouches,
46
+ type FileTouch,
47
+ type SessionRecord,
48
+ } from './_state.js';
49
+
50
+ interface GitStatus {
51
+ modified: Set<string>;
52
+ staged: Set<string>;
53
+ untracked: Set<string>;
54
+ }
55
+
56
+ interface ScopeReport {
57
+ mine: string[];
58
+ conflicted: { file: string; peer: SessionRecord | null; ageSec: number }[];
59
+ otherDirty: string[];
60
+ }
61
+
62
+ function parseFlag(args: string[], flag: string): string | undefined {
63
+ const i = args.indexOf(flag);
64
+ if (i === -1) return undefined;
65
+ return args[i + 1];
66
+ }
67
+
68
+ function hasFlag(args: string[], flag: string): boolean {
69
+ return args.includes(flag);
70
+ }
71
+
72
+ function loadAllSessions(stateDir: string): SessionRecord[] {
73
+ const out: SessionRecord[] = [];
74
+ for (const file of listSessionFiles(stateDir)) {
75
+ const rec = readJsonSafe<SessionRecord>(file);
76
+ if (rec) out.push(rec);
77
+ }
78
+ return out;
79
+ }
80
+
81
+ function resolveSessionId(stateDir: string, explicit?: string): string | null {
82
+ if (explicit) return explicit;
83
+ const fromEnv = process.env['CLAUDE_SESSION_ID'];
84
+ if (fromEnv) return fromEnv;
85
+ const active = loadAllSessions(stateDir).filter(s => ageMs(s.lastSeenAt) < ACTIVE_MS);
86
+ if (active.length === 1) return active[0]!.sessionId;
87
+ return null;
88
+ }
89
+
90
+ function git(args: string[], cwd: string): { code: number; stdout: string; stderr: string } {
91
+ const r = spawnSync('git', args, { cwd, encoding: 'utf8' });
92
+ return { code: r.status ?? 1, stdout: r.stdout || '', stderr: r.stderr || '' };
93
+ }
94
+
95
+ function gitDirty(projectDir: string): GitStatus {
96
+ const r = git(['status', '--porcelain=v1', '-z'], projectDir);
97
+ const out: GitStatus = {
98
+ modified: new Set<string>(),
99
+ staged: new Set<string>(),
100
+ untracked: new Set<string>(),
101
+ };
102
+ if (r.code !== 0) return out;
103
+ // -z = NUL-terminated. Format: `XY<space>path\0` (renames add a second `\0orig`)
104
+ // We deliberately treat the rename path as the new name only; orig is consumed.
105
+ const parts = r.stdout.split('\0').filter(Boolean);
106
+ for (let i = 0; i < parts.length; i++) {
107
+ const entry = parts[i]!;
108
+ if (entry.length < 4) continue;
109
+ const xy = entry.slice(0, 2);
110
+ const path = entry.slice(3);
111
+ const X = xy[0]!;
112
+ const Y = xy[1]!;
113
+ if (X === 'R' || Y === 'R') {
114
+ // Next NUL-token is the original path; skip it for our purposes.
115
+ i++;
116
+ }
117
+ if (X !== ' ' && X !== '?') out.staged.add(path);
118
+ if (Y !== ' ' && Y !== '?') out.modified.add(path);
119
+ if (X === '?' && Y === '?') out.untracked.add(path);
120
+ }
121
+ return out;
122
+ }
123
+
124
+ function classify(stateDir: string, sessionId: string, projectDir: string): ScopeReport {
125
+ const session = readSession(stateDir, sessionId);
126
+ const tracked = new Set(session?.filesTouched || []);
127
+ const status = gitDirty(projectDir);
128
+ const dirty = new Set<string>([...status.modified, ...status.untracked]);
129
+
130
+ const touches = tailFileTouches(stateDir);
131
+ const peerById = new Map(loadAllSessions(stateDir).map(p => [p.sessionId, p]));
132
+ const peerTouches = new Map<string, FileTouch>();
133
+ for (const t of touches) {
134
+ if (t.sessionId === sessionId) continue;
135
+ if (ageMs(t.ts) > COLLISION_WINDOW_MS) continue;
136
+ const prev = peerTouches.get(t.file);
137
+ if (!prev || Date.parse(t.ts) > Date.parse(prev.ts)) peerTouches.set(t.file, t);
138
+ }
139
+
140
+ const mine: string[] = [];
141
+ const conflicted: ScopeReport['conflicted'] = [];
142
+ for (const file of tracked) {
143
+ if (!dirty.has(file)) continue;
144
+ const conflict = peerTouches.get(file);
145
+ if (conflict) {
146
+ conflicted.push({
147
+ file,
148
+ peer: peerById.get(conflict.sessionId) || null,
149
+ ageSec: Math.round(ageMs(conflict.ts) / 1000),
150
+ });
151
+ } else {
152
+ mine.push(file);
153
+ }
154
+ }
155
+
156
+ const otherDirty: string[] = [];
157
+ for (const f of dirty) {
158
+ if (!tracked.has(f)) otherDirty.push(f);
159
+ }
160
+
161
+ return { mine, conflicted, otherDirty };
162
+ }
163
+
164
+ function cmdStatus(stateDir: string, projectDir: string, args: string[]): number {
165
+ const sessionId = resolveSessionId(stateDir, parseFlag(args, '--session'));
166
+ if (!sessionId) {
167
+ console.error('Cannot resolve session ID. Set CLAUDE_SESSION_ID or pass --session <id>.');
168
+ console.error('Tip: run `npx tsx "$CLAUDE_PROJECT_DIR/.claude/hooks/peers.ts" list`.');
169
+ return 1;
170
+ }
171
+ const session = readSession(stateDir, sessionId);
172
+ if (!session) {
173
+ console.error(`Session ${shortId(sessionId)} not registered. Has the SessionStart hook run?`);
174
+ return 1;
175
+ }
176
+
177
+ const { mine, conflicted, otherDirty } = classify(stateDir, sessionId, projectDir);
178
+ const status = gitDirty(projectDir);
179
+
180
+ console.log(
181
+ `Session ${shortId(sessionId)} "${session.title}" (branch ${session.gitBranch || '?'})`
182
+ );
183
+ console.log(`Files in session.filesTouched: ${session.filesTouched.length} (cap 50, dedup)`);
184
+ console.log();
185
+ console.log(`SAFE TO STAGE (${mine.length}):`);
186
+ if (mine.length === 0) {
187
+ console.log(' (none)');
188
+ } else {
189
+ for (const f of mine) console.log(` + ${f}`);
190
+ }
191
+
192
+ if (conflicted.length > 0) {
193
+ console.log();
194
+ console.log(`CONFLICTED — peer also touched in last 5 min (${conflicted.length}):`);
195
+ for (const c of conflicted) {
196
+ const who = c.peer
197
+ ? `${shortId(c.peer.sessionId)} "${c.peer.title}"`
198
+ : '(unknown peer)';
199
+ console.log(` ! ${c.file} ← ${who}, ${c.ageSec}s ago`);
200
+ }
201
+ console.log(' Coordinate via `/peers notify <id> "..."`, OR re-run with --include-conflicted.');
202
+ }
203
+
204
+ if (otherDirty.length > 0) {
205
+ console.log();
206
+ console.log(`NOT YOURS — dirty but not in filesTouched (${otherDirty.length}):`);
207
+ for (const f of otherDirty) console.log(` · ${f}`);
208
+ console.log(' `scope stage` will LEAVE these alone (this is the whole point).');
209
+ }
210
+
211
+ if (status.staged.size > 0) {
212
+ console.log();
213
+ console.log(`CURRENTLY STAGED (${status.staged.size}):`);
214
+ for (const f of status.staged) console.log(` ✓ ${f}`);
215
+ console.log(' `scope stage` runs `git reset` first — this state will be replaced.');
216
+ }
217
+
218
+ return 0;
219
+ }
220
+
221
+ function cmdStage(stateDir: string, projectDir: string, args: string[]): number {
222
+ const sessionId = resolveSessionId(stateDir, parseFlag(args, '--session'));
223
+ if (!sessionId) {
224
+ console.error('Cannot resolve session ID. Set CLAUDE_SESSION_ID or pass --session <id>.');
225
+ return 1;
226
+ }
227
+ const session = readSession(stateDir, sessionId);
228
+ if (!session) {
229
+ console.error(`Session ${shortId(sessionId)} not registered.`);
230
+ return 1;
231
+ }
232
+
233
+ const { mine, conflicted } = classify(stateDir, sessionId, projectDir);
234
+ const includeConflicted = hasFlag(args, '--include-conflicted');
235
+ const conflictedFiles = conflicted.map(c => c.file);
236
+ const toStage = includeConflicted ? [...mine, ...conflictedFiles] : mine;
237
+
238
+ if (toStage.length === 0) {
239
+ if (conflicted.length > 0) {
240
+ console.error(
241
+ `No safe files to stage. ${conflicted.length} conflicted file(s) — pass --include-conflicted to override.`
242
+ );
243
+ for (const c of conflicted) {
244
+ const who = c.peer
245
+ ? `${shortId(c.peer.sessionId)} "${c.peer.title}"`
246
+ : '(unknown)';
247
+ console.error(` ! ${c.file} ← ${who}, ${c.ageSec}s ago`);
248
+ }
249
+ return 2;
250
+ }
251
+ console.error(
252
+ 'No files to stage (this session has no dirty files in filesTouched).'
253
+ );
254
+ return 1;
255
+ }
256
+
257
+ // The `git reset` here is intentional: scope semantics own the index.
258
+ // Anything a peer staged previously will be unstaged; they will re-stage
259
+ // their own files via `scope stage` when they commit.
260
+ const resetR = git(['reset'], projectDir);
261
+ if (resetR.code !== 0) {
262
+ console.error(`git reset failed: ${resetR.stderr.trim()}`);
263
+ return 1;
264
+ }
265
+ const addR = git(['add', '--', ...toStage], projectDir);
266
+ if (addR.code !== 0) {
267
+ console.error(`git add failed: ${addR.stderr.trim()}`);
268
+ return 1;
269
+ }
270
+
271
+ console.log(`Staged ${toStage.length} file(s) from session ${shortId(sessionId)}:`);
272
+ for (const f of toStage) console.log(` + ${f}`);
273
+
274
+ if (conflicted.length > 0 && !includeConflicted) {
275
+ console.log();
276
+ console.log(`Skipped ${conflicted.length} conflicted file(s):`);
277
+ for (const c of conflicted) {
278
+ const who = c.peer
279
+ ? `${shortId(c.peer.sessionId)} "${c.peer.title}"`
280
+ : '(unknown)';
281
+ console.log(` ! ${c.file} ← ${who}, ${c.ageSec}s ago`);
282
+ }
283
+ console.log(' Pass --include-conflicted to stage them anyway.');
284
+ console.log(' Review with: git diff --cached --stat');
285
+ return 2;
286
+ }
287
+
288
+ console.log();
289
+ console.log('Review with: git diff --cached --stat');
290
+ return 0;
291
+ }
292
+
293
+ function cmdDiff(stateDir: string, projectDir: string, args: string[]): number {
294
+ const sessionId = resolveSessionId(stateDir, parseFlag(args, '--session'));
295
+ if (!sessionId) {
296
+ console.error('Cannot resolve session ID. Set CLAUDE_SESSION_ID or pass --session <id>.');
297
+ return 1;
298
+ }
299
+ const session = readSession(stateDir, sessionId);
300
+ if (!session) {
301
+ console.error(`Session ${shortId(sessionId)} not registered.`);
302
+ return 1;
303
+ }
304
+
305
+ const files = session.filesTouched.filter(f => existsSync(join(projectDir, f)));
306
+ if (files.length === 0) {
307
+ console.error('This session has no tracked files (or none exist on disk).');
308
+ return 1;
309
+ }
310
+ const r = spawnSync('git', ['diff', '--', ...files], {
311
+ cwd: projectDir,
312
+ stdio: 'inherit',
313
+ });
314
+ return r.status ?? 1;
315
+ }
316
+
317
+ // Flags that take a value (the next token is consumed as the value, not as a positional).
318
+ const VALUE_FLAGS = new Set(['--session']);
319
+
320
+ function firstPositional(args: string[]): string | undefined {
321
+ for (let i = 0; i < args.length; i++) {
322
+ const a = args[i]!;
323
+ if (a.startsWith('--')) continue;
324
+ const prev = i > 0 ? args[i - 1]! : '';
325
+ if (VALUE_FLAGS.has(prev)) continue;
326
+ return a;
327
+ }
328
+ return undefined;
329
+ }
330
+
331
+ function cmdCommit(stateDir: string, projectDir: string, args: string[]): number {
332
+ const message = firstPositional(args);
333
+ if (!message) {
334
+ console.error('Usage: scope commit "<message>" [--push] [--include-conflicted] [--session <id>]');
335
+ return 1;
336
+ }
337
+
338
+ const stageCode = cmdStage(stateDir, projectDir, args);
339
+ if (stageCode === 1) return 1;
340
+ if (stageCode === 2 && !hasFlag(args, '--include-conflicted')) {
341
+ console.error();
342
+ console.error(
343
+ 'Refusing to commit while conflicted files were skipped. ' +
344
+ 'Resolve, OR re-run with --include-conflicted.'
345
+ );
346
+ return 2;
347
+ }
348
+
349
+ const commitR = git(['commit', '-m', message], projectDir);
350
+ process.stdout.write(commitR.stdout);
351
+ process.stderr.write(commitR.stderr);
352
+ if (commitR.code !== 0) return 1;
353
+
354
+ if (hasFlag(args, '--push')) {
355
+ const pushR = spawnSync('git', ['push'], { cwd: projectDir, stdio: 'inherit' });
356
+ return pushR.status ?? 1;
357
+ }
358
+ return 0;
359
+ }
360
+
361
+ function usage(): void {
362
+ console.log(`scope — per-instance commit scoping CLI
363
+
364
+ Commands:
365
+ scope status [--session <id>]
366
+ scope stage [--session <id>] [--include-conflicted]
367
+ scope diff [--session <id>]
368
+ scope commit "<message>" [--session <id>] [--include-conflicted] [--push]
369
+
370
+ Stages and commits ONLY the files this Claude session edited (per
371
+ \`.claude/state/sessions/<id>.json#filesTouched\`). Refuses to stage
372
+ files a peer session touched in the last 5 min unless --include-conflicted.
373
+
374
+ Exit codes: 0=ok, 1=arg/state error, 2=conflict refusal.
375
+ `);
376
+ }
377
+
378
+ function main(): void {
379
+ const [, , cmd, ...rest] = process.argv;
380
+ const projectDir = getProjectDir();
381
+ const stateDir = getStateDir(projectDir);
382
+
383
+ switch (cmd) {
384
+ case 'status':
385
+ process.exit(cmdStatus(stateDir, projectDir, rest));
386
+ break;
387
+ case 'stage':
388
+ process.exit(cmdStage(stateDir, projectDir, rest));
389
+ break;
390
+ case 'diff':
391
+ process.exit(cmdDiff(stateDir, projectDir, rest));
392
+ break;
393
+ case 'commit':
394
+ process.exit(cmdCommit(stateDir, projectDir, rest));
395
+ break;
396
+ case 'help':
397
+ case '--help':
398
+ case '-h':
399
+ case undefined:
400
+ usage();
401
+ process.exit(0);
402
+ break;
403
+ default:
404
+ console.error(`Unknown command: ${cmd}`);
405
+ usage();
406
+ process.exit(1);
407
+ }
408
+ }
409
+
410
+ main();
@@ -1,16 +1,22 @@
1
1
  #!/usr/bin/env node
2
- // @sv-version: 1.1.0
2
+ // @sv-version: 1.2.0
3
3
  /**
4
4
  * Stop Validator Hook — Start Vibing Stacks (Universal)
5
5
  *
6
6
  * Reads active-project.json to determine stack-specific validations.
7
7
  * Blocks task completion if:
8
8
  * 1. Branch != main (work must be merged)
9
- * 2. Git tree not clean
9
+ * 2. Git tree not clean — SCOPED TO THIS SESSION'S filesTouched when available
10
+ * (multi-instance safe: peer N's dirty files do not block instance M's Stop)
10
11
  * 3. CLAUDE.md not updated
11
- * 4. CLAUDE.md missing required sections
12
+ * 4. CLAUDE.md missing required sections — accepts `## Last Change` OR `## Recent Changes`
12
13
  * 5. CLAUDE.md exceeds 40k chars
13
14
  * 6. Secret pattern detected in committed/staged files (uses gitleaks if present, fallback to regex)
15
+ *
16
+ * v1.2.0: per-instance scoping for the dirty-tree check + accept `## Recent Changes`
17
+ * (append-only LIFO) as a valid changelog section. Source of truth for "what did
18
+ * THIS session edit?" is `.claude/state/sessions/<id>.json#filesTouched`, maintained
19
+ * by `post-tool-use.ts`.
14
20
  */
15
21
 
16
22
  import { execSync } from 'child_process';
@@ -25,6 +31,7 @@ import {
25
31
  formatPeer,
26
32
  getStateDir,
27
33
  listPeerSessions,
34
+ readSession,
28
35
  } from './_state.js';
29
36
 
30
37
  const PROJECT_DIR = process.env['CLAUDE_PROJECT_DIR'] || process.cwd();
@@ -77,6 +84,31 @@ function getModifiedFiles(): string[] {
77
84
  return [...new Set([...staged, ...unstaged, ...untracked])];
78
85
  }
79
86
 
87
+ /**
88
+ * Per-instance scoping: if a session id is provided AND state has filesTouched,
89
+ * return only the dirty files THIS session edited. Otherwise return the full
90
+ * dirty list (backward compatible). Falls back gracefully on any error.
91
+ */
92
+ function getScopedDirtyFiles(sessionId: string | undefined): {
93
+ scoped: string[];
94
+ perInstance: boolean;
95
+ totalDirty: number;
96
+ } {
97
+ const allDirty = getModifiedFiles();
98
+ if (!sessionId) return { scoped: allDirty, perInstance: false, totalDirty: allDirty.length };
99
+ try {
100
+ const stateDir = getStateDir(PROJECT_DIR);
101
+ const sess = readSession(stateDir, sessionId);
102
+ if (!sess || !Array.isArray(sess.filesTouched)) {
103
+ return { scoped: allDirty, perInstance: false, totalDirty: allDirty.length };
104
+ }
105
+ const set = new Set(sess.filesTouched);
106
+ return { scoped: allDirty.filter(f => set.has(f)), perInstance: true, totalDirty: allDirty.length };
107
+ } catch {
108
+ return { scoped: allDirty, perInstance: false, totalDirty: allDirty.length };
109
+ }
110
+ }
111
+
80
112
  /**
81
113
  * Run a command capturing stdout, stderr and exit code without throwing.
82
114
  */
@@ -142,18 +174,27 @@ function scanSecrets(): string[] {
142
174
  return findings;
143
175
  }
144
176
 
145
- function validate(): HookResult {
177
+ function validate(sessionId: string | undefined): HookResult {
146
178
  const branch = getBranch();
147
179
  const isMain = branch === 'main' || branch === 'master';
148
- const modified = getModifiedFiles();
180
+ const { scoped: modified, perInstance, totalDirty } = getScopedDirtyFiles(sessionId);
149
181
  const isClean = modified.length === 0;
182
+ const scopeNote = perInstance
183
+ ? ` (this-session-scoped: ${modified.length}/${totalDirty} dirty; peer files ignored)`
184
+ : '';
185
+
186
+ // 1. Must be on main with clean tree (scoped to THIS session when possible).
187
+ // Recommendation uses `/commit-mine` rather than `git add -A`, which would
188
+ // pull in peer-session changes (see CLAUDE.md NRY "Instance N's commit bundling...").
189
+ const commitGuide = perInstance
190
+ ? `\n\nRecommended workflow (multi-instance safe):\n1. npx tsx "$CLAUDE_PROJECT_DIR/.claude/hooks/scope.ts" status\n2. npx tsx "$CLAUDE_PROJECT_DIR/.claude/hooks/scope.ts" stage\n3. git commit -m "type: description"\n4. git checkout main && git merge ${branch} && git push origin main && git branch -d ${branch}`
191
+ : `\n\nComplete git workflow:\n1. git add -A\n2. git commit -m "type: description"\n3. git checkout main\n4. git merge ${branch}\n5. git push origin main\n6. git branch -d ${branch}`;
150
192
 
151
- // 1. Must be on main with clean tree
152
193
  if (!isMain && modified.length > 0) {
153
194
  return {
154
195
  continue: true,
155
196
  decision: 'block',
156
- reason: `BLOCKED: On branch '${branch}' with ${modified.length} modified files.\n\nComplete git workflow:\n1. git add -A\n2. git commit -m "type: description"\n3. git checkout main\n4. git merge ${branch}\n5. git push origin main\n6. git branch -d ${branch}`,
197
+ reason: `BLOCKED: On branch '${branch}' with ${modified.length} modified files${scopeNote}.${commitGuide}`,
157
198
  };
158
199
  }
159
200
 
@@ -166,10 +207,13 @@ function validate(): HookResult {
166
207
  }
167
208
 
168
209
  if (!isClean) {
210
+ const stageHint = perInstance
211
+ ? `\n\nThese files were edited by THIS session. Commit only your scope:\n npx tsx "$CLAUDE_PROJECT_DIR/.claude/hooks/scope.ts" commit "<message>"`
212
+ : `\n\nCommit or stash before completing.`;
169
213
  return {
170
214
  continue: true,
171
215
  decision: 'block',
172
- reason: `BLOCKED: ${modified.length} uncommitted files:\n${modified.slice(0, 10).map(f => ` - ${f}`).join('\n')}\n\nCommit or stash before completing.`,
216
+ reason: `BLOCKED: ${modified.length} uncommitted files${scopeNote}:\n${modified.slice(0, 10).map(f => ` - ${f}`).join('\n')}${stageHint}`,
173
217
  };
174
218
  }
175
219
 
@@ -193,10 +237,11 @@ function validate(): HookResult {
193
237
  };
194
238
  }
195
239
 
196
- // 4. Required sections
240
+ // 4. Required sections — `## Last Change` (single, overwritten) OR `## Recent Changes`
241
+ // (append-only LIFO, multi-instance safe) both satisfy the changelog slot.
197
242
  const required = [
198
243
  { pattern: /^# .+/m, name: 'Project Title (H1)' },
199
- { pattern: /^## Last Change/m, name: 'Last Change' },
244
+ { pattern: /^## (Last Change|Recent Changes)/m, name: 'Last Change OR Recent Changes' },
200
245
  { pattern: /^## Stack/m, name: 'Stack' },
201
246
  ];
202
247
 
@@ -247,10 +292,10 @@ async function main(): Promise<void> {
247
292
  process.exit(0);
248
293
  }
249
294
 
250
- const result = validate();
251
-
252
295
  // Multi-instance coordination side effects.
253
296
  const sessionId: string | undefined = hookInput.session_id || hookInput.sessionId;
297
+
298
+ const result = validate(sessionId);
254
299
  const eventName: string = hookInput.hook_event_name || hookInput.hookEventName || '';
255
300
  const isSessionEnd = /SessionEnd/i.test(eventName);
256
301
 
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- // @sv-version: 1.1.0
2
+ // @sv-version: 1.2.0
3
3
  /**
4
4
  * UserPromptSubmit Hook — Start Vibing Stacks
5
5
  *
@@ -9,6 +9,10 @@
9
9
  * from the first prompt (matches `claude --resume`), drain the inbox so
10
10
  * peer messages reach the user before this turn runs, and warn if active
11
11
  * peers exist.
12
+ *
13
+ * v1.2.0: workflow guidance now points at `## Recent Changes` (append-only LIFO)
14
+ * instead of `## Last Change` (overwritten). Aligned with `commit-manager` v3.0.0
15
+ * and `domain-updater` v3.0.0 — multi-instance safe.
12
16
  */
13
17
 
14
18
  import { existsSync, readFileSync } from 'fs';
@@ -159,11 +163,15 @@ async function main(): Promise<void> {
159
163
 
160
164
  3. Run quality gates: ${qualityCmd}
161
165
 
162
- 4. COMMIT using conventional commits via commit-manager agent.
166
+ 4. COMMIT using conventional commits via commit-manager agent (v3.0.0+: stages only THIS session's files via scope.ts when .claude/state/ exists — peers' uncommitted files are never bundled).
163
167
 
164
168
  5. UPDATE CLAUDE.md:
165
- a. "## Last Change" (date: ${today}, branch, summary)
166
- b. Update ALL affected rule/flow sections
169
+ a. PREPEND a new entry under "## Recent Changes" heading exactly:
170
+ "### ${today} · <branch> · <version-or-tag>" followed by 1-4 plain-text lines.
171
+ Append-only LIFO; cap 10 (drop ONLY the oldest if exceeded). Multi-instance safe.
172
+ Triggered automatically by domain-updater v3.0.0; do it manually only if running outside the chain.
173
+ b. Update ALL affected rule/flow sections (Critical Rules, FORBIDDEN, NRY) when the change
174
+ modifies how the project works.
167
175
 
168
176
  6. Run stop-validator before finishing.${standardsContext}${peersBlock}`;
169
177
 
@@ -123,7 +123,7 @@ End state in **both flows**: clean tree, on `main`, in sync with `origin/main`.
123
123
  - [ ] Quality gate passed (typecheck / lint / tests / build)
124
124
  - [ ] Security gate passed (`security-auditor` clean)
125
125
  - [ ] No secrets in diff (gitleaks)
126
- - [ ] CLAUDE.md `Last Change` updated when scope warrants it
126
+ - [ ] CLAUDE.md `## Recent Changes` updated (PREPEND a new `### YYYY-MM-DD · branch · vX.Y.Z` block — append-only LIFO, cap 10; `domain-updater` v3.0.0+ does this automatically post-commit)
127
127
  - [ ] Conventional commit message drafted
128
128
  - [ ] Push target confirmed (`origin main` vs `origin feature/*`)
129
129
 
@@ -221,7 +221,11 @@ const issues: string[] = [];
221
221
 
222
222
  if (dirty) issues.push(`GIT_TREE_NOT_CLEAN: ${dirty.split('\n').length} file(s)`);
223
223
  if (existsSync('CLAUDE.md') &&
224
- !readFileSync('CLAUDE.md', 'utf8').includes('Last Change')) issues.push('CLAUDE_MD_NOT_UPDATED');
224
+ !/^## (Last Change|Recent Changes)/m.test(readFileSync('CLAUDE.md', 'utf8'))) {
225
+ // Accepts the legacy `## Last Change` (single, overwritten) OR `## Recent Changes`
226
+ // (append-only LIFO, multi-instance safe — see claude-md-compactor §6.1).
227
+ issues.push('CLAUDE_MD_NOT_UPDATED');
228
+ }
225
229
 
226
230
  const result = issues.length === 0
227
231
  ? { continue: false, decision: 'approve', reason: 'All checks passed' }
@@ -1,10 +1,14 @@
1
1
  # {{PROJECT_NAME}}
2
2
 
3
- ## Last Change
3
+ ## Recent Changes
4
4
 
5
- **Branch:** main
6
- **Date:** {{DATE}}
7
- **Summary:** Initial project setup with start-vibing-stacks
5
+ <!-- APPEND-ONLY LIFO. Each Claude instance PREPENDS a new `### YYYY-MM-DD · branch · vX.Y.Z` heading
6
+ + 1-4 lines below it. Drop only the OLDEST entry when count > 10. NEVER edit a peer's entry.
7
+ `domain-updater` v3.0.0+ does this automatically post-commit.
8
+ Compactor (`claude-md-compactor.md §5-§6`) enforces the cap, not the prepend. -->
9
+
10
+ ### {{DATE}} · main · v0.1.0
11
+ Initial project setup with start-vibing-stacks.
8
12
 
9
13
  ## 30 Seconds Overview
10
14
 
@@ -2,11 +2,15 @@
2
2
 
3
3
  > **CHARACTER LIMIT**: Max 40,000 chars. Validate with `wc -m CLAUDE.md` before commit.
4
4
 
5
- ## Last Change
5
+ ## Recent Changes
6
6
 
7
- **Branch:** main
8
- **Date:** {{DATE}}
9
- **Summary:** Initial project setup with start-vibing-stacks (Node.js)
7
+ <!-- APPEND-ONLY LIFO. Each Claude instance PREPENDS a new `### YYYY-MM-DD · branch · vX.Y.Z` heading
8
+ + 1-4 lines below it. Drop only the OLDEST entry when count > 10. NEVER edit a peer's entry.
9
+ `domain-updater` v3.0.0+ does this automatically post-commit.
10
+ Compactor (`claude-md-compactor.md §5-§6`) enforces the cap, not the prepend. -->
11
+
12
+ ### {{DATE}} · main · v0.1.0
13
+ Initial project setup with start-vibing-stacks (Node.js).
10
14
 
11
15
  ## 30 Seconds Overview
12
16
 
@@ -90,7 +94,7 @@ project/
90
94
 
91
95
  | Change Type | Sections to Update |
92
96
  |-------------|-------------------|
93
- | Any file change | Last Change (branch, date, summary) |
97
+ | Any file change | PREPEND new entry to `## Recent Changes` (heading: `### YYYY-MM-DD · branch · vX.Y.Z` + 1-4 lines) |
94
98
  | API/routes | Critical Rules, Architecture |
95
99
  | UI components | Architecture, Component Organization |
96
100
  | New feature | 30s Overview, Architecture |
@@ -98,10 +102,10 @@ project/
98
102
  | New dependency | Stack |
99
103
  | Workflow change | Workflow section |
100
104
 
101
- 1. **Last Change** documents WHAT was done
102
- 2. **Other sections** document HOW things work NOW
103
- 3. **Both must be current** — updating only Last Change is insufficient
104
- 4. Keep only the LATEST Last Change entry (no stacking)
105
+ 1. **`## Recent Changes`** documents WHAT was done across recent sessions (append-only LIFO, cap 10).
106
+ 2. **Other sections** document HOW things work NOW.
107
+ 3. **Both must be current** — prepending to Recent Changes is insufficient if rule sections went stale.
108
+ 4. **APPEND-ONLY** — PREPEND your entry below the HTML comment anchor; drop only the OLDEST entry when count > 10. NEVER edit a peer's entry, NEVER collapse two entries into one. Multi-instance safe by construction.
105
109
 
106
110
  ## Agent System
107
111
 
@@ -247,7 +251,7 @@ source: 'listed' as const; // CORRECT (literal type)
247
251
  | Skip todo list creation | Loses track of tasks |
248
252
  | Skip documenter agent | Documentation is mandatory |
249
253
  | Skip domain documentation | MUST update domains/*.md |
250
- | Stack Last Change entries | Keep only the LATEST |
254
+ | Overwrite `## Recent Changes` or collapse entries | PREPEND only; drop oldest when > 10 (NEVER edit peer entries) |
251
255
  | Use MUI/Chakra | Use shadcn/ui + Radix |
252
256
  | Skip CLAUDE.md update | MUST update after implementations |
253
257