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.
- package/README.md +2 -2
- package/package.json +1 -1
- package/stacks/_shared/agents/claude-md-compactor.md +38 -4
- package/stacks/_shared/agents/commit-manager.md +78 -19
- package/stacks/_shared/agents/documenter.md +11 -6
- package/stacks/_shared/agents/domain-updater.md +107 -42
- package/stacks/_shared/commands/commit-mine.md +73 -0
- package/stacks/_shared/commands/feature.md +4 -2
- package/stacks/_shared/commands/fix.md +6 -4
- package/stacks/_shared/hooks/scope.ts +410 -0
- package/stacks/_shared/hooks/stop-validator.ts +57 -12
- package/stacks/_shared/hooks/user-prompt-submit.ts +12 -4
- package/stacks/_shared/skills/git-workflow/SKILL.md +1 -1
- package/stacks/_shared/skills/hook-development/SKILL.md +5 -1
- package/templates/CLAUDE-default.md +8 -4
- package/templates/CLAUDE-nodejs.md +14 -10
- package/templates/CLAUDE-php.md +14 -10
- package/templates/CLAUDE-python.md +23 -10
|
@@ -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.
|
|
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 =
|
|
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
|
|
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')}
|
|
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.
|
|
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. "##
|
|
166
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
##
|
|
3
|
+
## Recent Changes
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
##
|
|
5
|
+
## Recent Changes
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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 |
|
|
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.
|
|
102
|
-
2. **Other sections** document HOW things work NOW
|
|
103
|
-
3. **Both must be current** —
|
|
104
|
-
4.
|
|
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
|
-
|
|
|
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
|
|