start-vibing-stacks 2.25.0 → 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.
@@ -1,28 +1,113 @@
1
1
  ---
2
2
  name: validate
3
- description: Run the full quality gate (typecheck → lint → test → build) using the stack's commands from active-project.json.
4
- version: 1.0.0
3
+ description: Run the full quality gate (typecheck → lint → test → build) using THIS project's `active-project.json#qualityGates`. Per-stack reference is below.
4
+ version: 1.1.0
5
5
  ---
6
6
 
7
7
  # /validate — Run Full Validation
8
8
 
9
- Read the quality gates from `.claude/config/active-project.json#qualityGates` and run them in order. Stop at the first failure.
9
+ ## How it works (canonical always use this)
10
+
11
+ 1. **Read the gates from `.claude/config/active-project.json#qualityGates`.** This is the **only** source of truth — it was populated at install time from the active stack and reflects this project's actual tooling.
12
+ 2. Run each gate in `order` ascending; stop at the first failure that has `required: true`.
13
+ 3. If a gate has `appliesTo: ["nextjs", ...]`, only run it when `framework` in `active-project.json` matches.
10
14
 
11
15
  ```bash
12
- jq -r '.qualityGates' .claude/config/active-project.json
16
+ jq -r '.qualityGates | sort_by(.order) | .[] | "\(.order). \(.name) (required=\(.required)): \(.command)"' .claude/config/active-project.json
17
+ ```
18
+
19
+ `commit-manager` refuses to commit while `/validate` is failing.
20
+
21
+ ---
22
+
23
+ ## Per-stack reference (suggested — only used if `active-project.json` is missing or corrupted)
24
+
25
+ This block exists so the model has a stack-aware fallback. **Do not pick from here when `active-project.json#qualityGates` is present and valid** — that JSON always wins.
26
+
27
+ ```json
28
+ {
29
+ "python": {
30
+ "_default": {
31
+ "typecheck": "mypy .",
32
+ "lint": "ruff check .",
33
+ "format": "ruff format .",
34
+ "test": "pytest --tb=short",
35
+ "serve": "uvicorn app.main:app --reload"
36
+ },
37
+ "frameworks": {
38
+ "fastapi": { "test": "pytest --tb=short" },
39
+ "django": { "test": "python manage.py test", "migrate": "python manage.py migrate" },
40
+ "flask": { "test": "pytest --tb=short" },
41
+ "scripts": { "test": "pytest --tb=short" }
42
+ },
43
+ "_runOrder": ["typecheck", "lint", "test"]
44
+ },
45
+
46
+ "nodejs": {
47
+ "_default": {
48
+ "typecheck": "bun run typecheck",
49
+ "lint": "bun run lint",
50
+ "format": "bun run format",
51
+ "test": "bun run test",
52
+ "build": "bun run build",
53
+ "serve": "bun run dev"
54
+ },
55
+ "frameworks": {
56
+ "nextjs": {
57
+ "_extra": [
58
+ { "name": "RouteSlugs", "command": "node scripts/check-route-slugs.mjs", "order": 4, "required": true, "why": "next build does not catch dynamic-route slug mismatch" },
59
+ { "name": "BuildScripts", "command": "node scripts/check-build-scripts.mjs", "order": 5, "required": true, "why": "Vercel/Docker strip devDeps; scripts.build calling tsx/ts-node/vitest crash exit 127" }
60
+ ]
61
+ },
62
+ "nuxt": { "build": "bun run build" },
63
+ "astro": { "build": "bun run build" },
64
+ "express": { "test": "bun run test" },
65
+ "fastify": { "test": "bun run test" },
66
+ "vanilla": { "test": "bun run test" }
67
+ },
68
+ "_runOrder": ["typecheck", "lint", "test", "_extra", "build"]
69
+ },
70
+
71
+ "php": {
72
+ "_default": {
73
+ "static": "vendor/bin/phpstan analyse --level=6",
74
+ "test": "vendor/bin/phpunit",
75
+ "format": "vendor/bin/php-cs-fixer fix",
76
+ "serve": "php artisan serve"
77
+ },
78
+ "frameworks": {
79
+ "laravel-octane": {
80
+ "serve": "php artisan octane:start --watch",
81
+ "_extraIfFrontend": ["typecheck", "lint", "viteManifest"]
82
+ },
83
+ "laravel": { "_extraIfFrontend": ["typecheck", "lint", "viteManifest"] }
84
+ },
85
+ "_frontendChecks": {
86
+ "typecheck": "npx tsc --noEmit",
87
+ "lint": "npx eslint resources/js/",
88
+ "viteManifest": "node scripts/check-vite-manifest.mjs"
89
+ },
90
+ "_runOrder": ["static", "test", "typecheck", "lint", "viteManifest"]
91
+ }
92
+ }
13
93
  ```
14
94
 
15
- Execution order (each step blocks the next):
95
+ ### How to read this fallback
96
+
97
+ - `_default` — commands that always apply within the stack.
98
+ - `frameworks.<framework>` — overrides or extras gated by `framework` in `active-project.json`.
99
+ - `_extra` — additional gates with explicit `order` (PHP/Next.js use this for stack-specific static checks like vite-manifest, route-slugs, build-scripts).
100
+ - `_runOrder` — order to chain when no `qualityGates` is available.
16
101
 
17
- | # | Gate | Typical commands |
18
- |---|---|---|
19
- | 1 | **Typecheck** | `npx tsc --noEmit` · `mypy .` · `vendor/bin/phpstan analyse` |
20
- | 2 | **Lint** | `npx eslint .` · `ruff check .` · `vendor/bin/pint --test` |
21
- | 3 | **Unit tests** | `npx vitest run` · `pytest` · `vendor/bin/phpunit` |
22
- | 4 | **E2E** (only if configured) | `npx playwright test` |
23
- | 5 | **Build** | `npm run build` · `composer dump-autoload --optimize` |
102
+ If `.claude/config/active-project.json#qualityGates` is missing, **do not silently fall back** — instead:
103
+
104
+ 1. Print a warning: `WARN: active-project.json#qualityGates missing using fallback for stack=<X>, framework=<Y>`.
105
+ 2. Run the fallback in `_runOrder`.
106
+ 3. Recommend the user re-run `npx start-vibing-stacks` (or `migrate --apply`) to repair the config.
107
+
108
+ ---
24
109
 
25
- Output format (one line per gate):
110
+ ## Output format
26
111
 
27
112
  ```
28
113
  typecheck: PASS (1.4s)
@@ -34,4 +119,4 @@ build: PASS (3.1s)
34
119
  ✅ All gates passed — safe to invoke commit-manager
35
120
  ```
36
121
 
37
- If any gate fails, print the failure output and exit non-zero. `commit-manager` will refuse to commit while validation is failing.
122
+ If any required gate fails, print its full output and exit non-zero. Optional gates (`required: false`) print a warning but do not block.
@@ -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