start-vibing-stacks 2.25.2 → 2.28.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/dist/migrate.d.ts +3 -1
- package/dist/migrate.js +23 -1
- package/dist/setup.js +7 -0
- package/package.json +1 -1
- package/stacks/_shared/agents/claude-md-compactor.md +38 -4
- package/stacks/_shared/agents/commit-manager.md +100 -36
- package/stacks/_shared/agents/documenter.md +11 -6
- package/stacks/_shared/agents/domain-updater.md +107 -42
- package/stacks/_shared/commands/commit-mine.md +78 -0
- package/stacks/_shared/commands/feature.md +4 -2
- package/stacks/_shared/commands/fix.md +6 -4
- package/stacks/_shared/hooks/_state.README.md +2 -2
- package/stacks/_shared/hooks/_state.ts +25 -9
- package/stacks/_shared/hooks/pre-tool-use.ts +6 -5
- package/stacks/_shared/hooks/scope.ts +478 -0
- package/stacks/_shared/hooks/session-start.ts +3 -2
- package/stacks/_shared/hooks/stop-validator.ts +79 -14
- 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/stacks/_shared/skills/multi-instance-coordination/SKILL.md +5 -5
- 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,478 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// @sv-version: 1.1.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 200, dedup-keep-last).
|
|
13
|
+
*
|
|
14
|
+
* Concurrency model (v1.1.0): two instances in the SAME working directory share
|
|
15
|
+
* ONE `.git/index`. A `git reset` + `git add` + `git commit` sequence is therefore
|
|
16
|
+
* NOT atomic across peers — peer B's `git add` between our reset and commit would
|
|
17
|
+
* be folded into our commit. To be genuinely safe we commit by pathspec:
|
|
18
|
+
* `git add -- <ours>` (additive, never disturbs a peer's staged entries) then
|
|
19
|
+
* `git commit -o -- <ours>` (--only: commits EXACTLY these paths regardless of
|
|
20
|
+
* what else is staged). No global `git reset`.
|
|
21
|
+
* For true isolation across concurrent commits, prefer one git worktree per
|
|
22
|
+
* instance (`git worktree add`); this CLI is the best-effort same-worktree path.
|
|
23
|
+
*
|
|
24
|
+
* Usage:
|
|
25
|
+
* scope status Show this session's files vs peers' files vs untracked.
|
|
26
|
+
* scope stage [--include-conflicted] `git add` only this session's dirty files (additive;
|
|
27
|
+
* resets the index only when no peer is active). Prefer
|
|
28
|
+
* `scope commit` for the safe atomic path.
|
|
29
|
+
* scope diff `git diff` for files this session touched.
|
|
30
|
+
* scope commit "<msg>" [--push] commit ONLY this session's files via `git commit -o`.
|
|
31
|
+
*
|
|
32
|
+
* Session discovery: --session <id>, else $CLAUDE_SESSION_ID, else single active session.
|
|
33
|
+
* NOTE: with ≥2 active sessions and CLAUDE_SESSION_ID unset, discovery fails by
|
|
34
|
+
* design (we will not guess) — pass --session <id> or export CLAUDE_SESSION_ID.
|
|
35
|
+
*
|
|
36
|
+
* Exit codes:
|
|
37
|
+
* 0 ok
|
|
38
|
+
* 1 argument / state error
|
|
39
|
+
* 2 refused (collision detected; pass --include-conflicted to override)
|
|
40
|
+
*
|
|
41
|
+
* Run: npx tsx "$CLAUDE_PROJECT_DIR/.claude/hooks/scope.ts" <command>
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
import { existsSync } from 'fs';
|
|
45
|
+
import { spawnSync } from 'child_process';
|
|
46
|
+
import { join } from 'path';
|
|
47
|
+
import {
|
|
48
|
+
ACTIVE_MS,
|
|
49
|
+
COLLISION_WINDOW_MS,
|
|
50
|
+
ageMs,
|
|
51
|
+
getProjectDir,
|
|
52
|
+
getStateDir,
|
|
53
|
+
listSessionFiles,
|
|
54
|
+
readJsonSafe,
|
|
55
|
+
readSession,
|
|
56
|
+
shortId,
|
|
57
|
+
tailFileTouches,
|
|
58
|
+
type FileTouch,
|
|
59
|
+
type SessionRecord,
|
|
60
|
+
} from './_state.js';
|
|
61
|
+
|
|
62
|
+
interface GitStatus {
|
|
63
|
+
modified: Set<string>;
|
|
64
|
+
staged: Set<string>;
|
|
65
|
+
untracked: Set<string>;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
interface ScopeReport {
|
|
69
|
+
mine: string[];
|
|
70
|
+
conflicted: { file: string; peer: SessionRecord | null; ageSec: number }[];
|
|
71
|
+
otherDirty: string[];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function parseFlag(args: string[], flag: string): string | undefined {
|
|
75
|
+
const i = args.indexOf(flag);
|
|
76
|
+
if (i === -1) return undefined;
|
|
77
|
+
return args[i + 1];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function hasFlag(args: string[], flag: string): boolean {
|
|
81
|
+
return args.includes(flag);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function loadAllSessions(stateDir: string): SessionRecord[] {
|
|
85
|
+
const out: SessionRecord[] = [];
|
|
86
|
+
for (const file of listSessionFiles(stateDir)) {
|
|
87
|
+
const rec = readJsonSafe<SessionRecord>(file);
|
|
88
|
+
if (rec) out.push(rec);
|
|
89
|
+
}
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function resolveSessionId(stateDir: string, explicit?: string): string | null {
|
|
94
|
+
if (explicit) return explicit;
|
|
95
|
+
const fromEnv = process.env['CLAUDE_SESSION_ID'];
|
|
96
|
+
if (fromEnv) return fromEnv;
|
|
97
|
+
const active = loadAllSessions(stateDir).filter(s => ageMs(s.lastSeenAt) < ACTIVE_MS);
|
|
98
|
+
if (active.length === 1) return active[0]!.sessionId;
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function git(args: string[], cwd: string): { code: number; stdout: string; stderr: string } {
|
|
103
|
+
const r = spawnSync('git', args, { cwd, encoding: 'utf8' });
|
|
104
|
+
return { code: r.status ?? 1, stdout: r.stdout || '', stderr: r.stderr || '' };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function gitDirty(projectDir: string): GitStatus {
|
|
108
|
+
const r = git(['status', '--porcelain=v1', '-z'], projectDir);
|
|
109
|
+
const out: GitStatus = {
|
|
110
|
+
modified: new Set<string>(),
|
|
111
|
+
staged: new Set<string>(),
|
|
112
|
+
untracked: new Set<string>(),
|
|
113
|
+
};
|
|
114
|
+
if (r.code !== 0) return out;
|
|
115
|
+
// -z = NUL-terminated. Format: `XY<space>path\0` (renames add a second `\0orig`)
|
|
116
|
+
// We deliberately treat the rename path as the new name only; orig is consumed.
|
|
117
|
+
const parts = r.stdout.split('\0').filter(Boolean);
|
|
118
|
+
for (let i = 0; i < parts.length; i++) {
|
|
119
|
+
const entry = parts[i]!;
|
|
120
|
+
if (entry.length < 4) continue;
|
|
121
|
+
const xy = entry.slice(0, 2);
|
|
122
|
+
const path = entry.slice(3);
|
|
123
|
+
const X = xy[0]!;
|
|
124
|
+
const Y = xy[1]!;
|
|
125
|
+
if (X === 'R' || Y === 'R') {
|
|
126
|
+
// Next NUL-token is the original path; skip it for our purposes.
|
|
127
|
+
i++;
|
|
128
|
+
}
|
|
129
|
+
if (X !== ' ' && X !== '?') out.staged.add(path);
|
|
130
|
+
if (Y !== ' ' && Y !== '?') out.modified.add(path);
|
|
131
|
+
if (X === '?' && Y === '?') out.untracked.add(path);
|
|
132
|
+
}
|
|
133
|
+
return out;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function classify(stateDir: string, sessionId: string, projectDir: string): ScopeReport {
|
|
137
|
+
const session = readSession(stateDir, sessionId);
|
|
138
|
+
const tracked = new Set(session?.filesTouched || []);
|
|
139
|
+
const status = gitDirty(projectDir);
|
|
140
|
+
const dirty = new Set<string>([...status.modified, ...status.untracked]);
|
|
141
|
+
|
|
142
|
+
const touches = tailFileTouches(stateDir, 1000);
|
|
143
|
+
const peerById = new Map(loadAllSessions(stateDir).map(p => [p.sessionId, p]));
|
|
144
|
+
const peerTouches = new Map<string, FileTouch>();
|
|
145
|
+
for (const t of touches) {
|
|
146
|
+
if (t.sessionId === sessionId) continue;
|
|
147
|
+
if (ageMs(t.ts) > COLLISION_WINDOW_MS) continue;
|
|
148
|
+
const prev = peerTouches.get(t.file);
|
|
149
|
+
if (!prev || Date.parse(t.ts) > Date.parse(prev.ts)) peerTouches.set(t.file, t);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const mine: string[] = [];
|
|
153
|
+
const conflicted: ScopeReport['conflicted'] = [];
|
|
154
|
+
for (const file of tracked) {
|
|
155
|
+
if (!dirty.has(file)) continue;
|
|
156
|
+
const conflict = peerTouches.get(file);
|
|
157
|
+
if (conflict) {
|
|
158
|
+
conflicted.push({
|
|
159
|
+
file,
|
|
160
|
+
peer: peerById.get(conflict.sessionId) || null,
|
|
161
|
+
ageSec: Math.round(ageMs(conflict.ts) / 1000),
|
|
162
|
+
});
|
|
163
|
+
} else {
|
|
164
|
+
mine.push(file);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const otherDirty: string[] = [];
|
|
169
|
+
for (const f of dirty) {
|
|
170
|
+
if (!tracked.has(f)) otherDirty.push(f);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return { mine, conflicted, otherDirty };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function cmdStatus(stateDir: string, projectDir: string, args: string[]): number {
|
|
177
|
+
const sessionId = resolveSessionId(stateDir, parseFlag(args, '--session'));
|
|
178
|
+
if (!sessionId) {
|
|
179
|
+
console.error('Cannot resolve session ID. Set CLAUDE_SESSION_ID or pass --session <id>.');
|
|
180
|
+
console.error('Tip: run `npx tsx "$CLAUDE_PROJECT_DIR/.claude/hooks/peers.ts" list`.');
|
|
181
|
+
return 1;
|
|
182
|
+
}
|
|
183
|
+
const session = readSession(stateDir, sessionId);
|
|
184
|
+
if (!session) {
|
|
185
|
+
console.error(`Session ${shortId(sessionId)} not registered. Has the SessionStart hook run?`);
|
|
186
|
+
return 1;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const { mine, conflicted, otherDirty } = classify(stateDir, sessionId, projectDir);
|
|
190
|
+
const status = gitDirty(projectDir);
|
|
191
|
+
|
|
192
|
+
console.log(
|
|
193
|
+
`Session ${shortId(sessionId)} "${session.title}" (branch ${session.gitBranch || '?'})`
|
|
194
|
+
);
|
|
195
|
+
console.log(`Files in session.filesTouched: ${session.filesTouched.length} (cap 200, dedup)`);
|
|
196
|
+
console.log();
|
|
197
|
+
console.log(`SAFE TO STAGE (${mine.length}):`);
|
|
198
|
+
if (mine.length === 0) {
|
|
199
|
+
console.log(' (none)');
|
|
200
|
+
} else {
|
|
201
|
+
for (const f of mine) console.log(` + ${f}`);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (conflicted.length > 0) {
|
|
205
|
+
console.log();
|
|
206
|
+
console.log(`CONFLICTED — peer also touched in last 5 min (${conflicted.length}):`);
|
|
207
|
+
for (const c of conflicted) {
|
|
208
|
+
const who = c.peer
|
|
209
|
+
? `${shortId(c.peer.sessionId)} "${c.peer.title}"`
|
|
210
|
+
: '(unknown peer)';
|
|
211
|
+
console.log(` ! ${c.file} ← ${who}, ${c.ageSec}s ago`);
|
|
212
|
+
}
|
|
213
|
+
console.log(' Coordinate via `/peers notify <id> "..."`, OR re-run with --include-conflicted.');
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (otherDirty.length > 0) {
|
|
217
|
+
console.log();
|
|
218
|
+
console.log(`NOT YOURS — dirty but not in filesTouched (${otherDirty.length}):`);
|
|
219
|
+
for (const f of otherDirty) console.log(` · ${f}`);
|
|
220
|
+
console.log(' `scope stage` will LEAVE these alone (this is the whole point).');
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (status.staged.size > 0) {
|
|
224
|
+
console.log();
|
|
225
|
+
console.log(`CURRENTLY STAGED (${status.staged.size}):`);
|
|
226
|
+
for (const f of status.staged) console.log(` ✓ ${f}`);
|
|
227
|
+
console.log(' `scope stage` runs `git reset` first — this state will be replaced.');
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return 0;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function cmdStage(stateDir: string, projectDir: string, args: string[]): number {
|
|
234
|
+
const sessionId = resolveSessionId(stateDir, parseFlag(args, '--session'));
|
|
235
|
+
if (!sessionId) {
|
|
236
|
+
console.error('Cannot resolve session ID. Set CLAUDE_SESSION_ID or pass --session <id>.');
|
|
237
|
+
return 1;
|
|
238
|
+
}
|
|
239
|
+
const session = readSession(stateDir, sessionId);
|
|
240
|
+
if (!session) {
|
|
241
|
+
console.error(`Session ${shortId(sessionId)} not registered.`);
|
|
242
|
+
return 1;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const { mine, conflicted } = classify(stateDir, sessionId, projectDir);
|
|
246
|
+
const includeConflicted = hasFlag(args, '--include-conflicted');
|
|
247
|
+
const conflictedFiles = conflicted.map(c => c.file);
|
|
248
|
+
const toStage = includeConflicted ? [...mine, ...conflictedFiles] : mine;
|
|
249
|
+
|
|
250
|
+
if (toStage.length === 0) {
|
|
251
|
+
if (conflicted.length > 0) {
|
|
252
|
+
console.error(
|
|
253
|
+
`No safe files to stage. ${conflicted.length} conflicted file(s) — pass --include-conflicted to override.`
|
|
254
|
+
);
|
|
255
|
+
for (const c of conflicted) {
|
|
256
|
+
const who = c.peer
|
|
257
|
+
? `${shortId(c.peer.sessionId)} "${c.peer.title}"`
|
|
258
|
+
: '(unknown)';
|
|
259
|
+
console.error(` ! ${c.file} ← ${who}, ${c.ageSec}s ago`);
|
|
260
|
+
}
|
|
261
|
+
return 2;
|
|
262
|
+
}
|
|
263
|
+
console.error(
|
|
264
|
+
'No files to stage (this session has no dirty files in filesTouched).'
|
|
265
|
+
);
|
|
266
|
+
return 1;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Index hygiene: a shared `.git/index` means a global `git reset` would wipe a
|
|
270
|
+
// peer's staged work. Only reset when NO peer is active (solo / safe). When a
|
|
271
|
+
// peer is active we stage ADDITIVELY and rely on `scope commit` (git commit -o)
|
|
272
|
+
// to commit exactly our paths regardless of what else is staged.
|
|
273
|
+
const activePeers = loadAllSessions(stateDir).filter(
|
|
274
|
+
s => s.sessionId !== sessionId && ageMs(s.lastSeenAt) < ACTIVE_MS
|
|
275
|
+
);
|
|
276
|
+
if (activePeers.length === 0) {
|
|
277
|
+
const resetR = git(['reset'], projectDir);
|
|
278
|
+
if (resetR.code !== 0) {
|
|
279
|
+
console.error(`git reset failed: ${resetR.stderr.trim()}`);
|
|
280
|
+
return 1;
|
|
281
|
+
}
|
|
282
|
+
} else {
|
|
283
|
+
console.log(
|
|
284
|
+
`${activePeers.length} active peer(s) — staging additively (no \`git reset\`) to protect their index.`
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
const addR = git(['add', '--', ...toStage], projectDir);
|
|
288
|
+
if (addR.code !== 0) {
|
|
289
|
+
console.error(`git add failed: ${addR.stderr.trim()}`);
|
|
290
|
+
return 1;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
console.log(`Staged ${toStage.length} file(s) from session ${shortId(sessionId)}:`);
|
|
294
|
+
for (const f of toStage) console.log(` + ${f}`);
|
|
295
|
+
console.log();
|
|
296
|
+
console.log('SAFEST commit path (never bundles peers): scope commit "<message>"');
|
|
297
|
+
|
|
298
|
+
if (conflicted.length > 0 && !includeConflicted) {
|
|
299
|
+
console.log();
|
|
300
|
+
console.log(`Skipped ${conflicted.length} conflicted file(s):`);
|
|
301
|
+
for (const c of conflicted) {
|
|
302
|
+
const who = c.peer
|
|
303
|
+
? `${shortId(c.peer.sessionId)} "${c.peer.title}"`
|
|
304
|
+
: '(unknown)';
|
|
305
|
+
console.log(` ! ${c.file} ← ${who}, ${c.ageSec}s ago`);
|
|
306
|
+
}
|
|
307
|
+
console.log(' Pass --include-conflicted to stage them anyway.');
|
|
308
|
+
console.log(' Review with: git diff --cached --stat');
|
|
309
|
+
return 2;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
console.log('Review with: git diff --cached --stat');
|
|
313
|
+
return 0;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function cmdDiff(stateDir: string, projectDir: string, args: string[]): number {
|
|
317
|
+
const sessionId = resolveSessionId(stateDir, parseFlag(args, '--session'));
|
|
318
|
+
if (!sessionId) {
|
|
319
|
+
console.error('Cannot resolve session ID. Set CLAUDE_SESSION_ID or pass --session <id>.');
|
|
320
|
+
return 1;
|
|
321
|
+
}
|
|
322
|
+
const session = readSession(stateDir, sessionId);
|
|
323
|
+
if (!session) {
|
|
324
|
+
console.error(`Session ${shortId(sessionId)} not registered.`);
|
|
325
|
+
return 1;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const files = session.filesTouched.filter(f => existsSync(join(projectDir, f)));
|
|
329
|
+
if (files.length === 0) {
|
|
330
|
+
console.error('This session has no tracked files (or none exist on disk).');
|
|
331
|
+
return 1;
|
|
332
|
+
}
|
|
333
|
+
const r = spawnSync('git', ['diff', '--', ...files], {
|
|
334
|
+
cwd: projectDir,
|
|
335
|
+
stdio: 'inherit',
|
|
336
|
+
});
|
|
337
|
+
return r.status ?? 1;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Flags that take a value (the next token is consumed as the value, not as a positional).
|
|
341
|
+
const VALUE_FLAGS = new Set(['--session']);
|
|
342
|
+
|
|
343
|
+
function firstPositional(args: string[]): string | undefined {
|
|
344
|
+
for (let i = 0; i < args.length; i++) {
|
|
345
|
+
const a = args[i]!;
|
|
346
|
+
if (a.startsWith('--')) continue;
|
|
347
|
+
const prev = i > 0 ? args[i - 1]! : '';
|
|
348
|
+
if (VALUE_FLAGS.has(prev)) continue;
|
|
349
|
+
return a;
|
|
350
|
+
}
|
|
351
|
+
return undefined;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function cmdCommit(stateDir: string, projectDir: string, args: string[]): number {
|
|
355
|
+
const message = firstPositional(args);
|
|
356
|
+
if (!message) {
|
|
357
|
+
console.error('Usage: scope commit "<message>" [--push] [--include-conflicted] [--session <id>]');
|
|
358
|
+
return 1;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const sessionId = resolveSessionId(stateDir, parseFlag(args, '--session'));
|
|
362
|
+
if (!sessionId) {
|
|
363
|
+
console.error('Cannot resolve session ID. Set CLAUDE_SESSION_ID or pass --session <id>.');
|
|
364
|
+
console.error('Tip: run `npx tsx "$CLAUDE_PROJECT_DIR/.claude/hooks/peers.ts" list`.');
|
|
365
|
+
return 1;
|
|
366
|
+
}
|
|
367
|
+
const session = readSession(stateDir, sessionId);
|
|
368
|
+
if (!session) {
|
|
369
|
+
console.error(`Session ${shortId(sessionId)} not registered.`);
|
|
370
|
+
return 1;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const { mine, conflicted } = classify(stateDir, sessionId, projectDir);
|
|
374
|
+
const includeConflicted = hasFlag(args, '--include-conflicted');
|
|
375
|
+
const conflictedFiles = conflicted.map(c => c.file);
|
|
376
|
+
const toCommit = includeConflicted ? [...mine, ...conflictedFiles] : mine;
|
|
377
|
+
|
|
378
|
+
if (toCommit.length === 0) {
|
|
379
|
+
if (conflicted.length > 0) {
|
|
380
|
+
console.error(
|
|
381
|
+
`No safe files to commit. ${conflicted.length} conflicted file(s) — pass --include-conflicted to override.`
|
|
382
|
+
);
|
|
383
|
+
for (const c of conflicted) {
|
|
384
|
+
const who = c.peer ? `${shortId(c.peer.sessionId)} "${c.peer.title}"` : '(unknown)';
|
|
385
|
+
console.error(` ! ${c.file} ← ${who}, ${c.ageSec}s ago`);
|
|
386
|
+
}
|
|
387
|
+
return 2;
|
|
388
|
+
}
|
|
389
|
+
console.error('No files to commit (this session has no dirty files in filesTouched).');
|
|
390
|
+
return 1;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
if (conflicted.length > 0 && !includeConflicted) {
|
|
394
|
+
console.error(
|
|
395
|
+
`Refusing to commit: ${conflicted.length} of your files were also touched by an active peer in the last 5 min.`
|
|
396
|
+
);
|
|
397
|
+
for (const c of conflicted) {
|
|
398
|
+
const who = c.peer ? `${shortId(c.peer.sessionId)} "${c.peer.title}"` : '(unknown)';
|
|
399
|
+
console.error(` ! ${c.file} ← ${who}, ${c.ageSec}s ago`);
|
|
400
|
+
}
|
|
401
|
+
console.error('Coordinate via `peers.ts notify <id> "..."`, OR re-run with --include-conflicted.');
|
|
402
|
+
return 2;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// Atomic, shared-index-safe commit. `git add` is additive (never touches a
|
|
406
|
+
// peer's staged entries); `git commit -o -- <paths>` commits EXACTLY those
|
|
407
|
+
// paths regardless of what else is staged, so a peer's concurrently-staged
|
|
408
|
+
// files can never be folded into this commit. No global `git reset`.
|
|
409
|
+
const addR = git(['add', '--', ...toCommit], projectDir);
|
|
410
|
+
if (addR.code !== 0) {
|
|
411
|
+
console.error(`git add failed: ${addR.stderr.trim()}`);
|
|
412
|
+
return 1;
|
|
413
|
+
}
|
|
414
|
+
const commitR = git(['commit', '-o', '-m', message, '--', ...toCommit], projectDir);
|
|
415
|
+
process.stdout.write(commitR.stdout);
|
|
416
|
+
process.stderr.write(commitR.stderr);
|
|
417
|
+
if (commitR.code !== 0) return 1;
|
|
418
|
+
|
|
419
|
+
console.log(`Committed ${toCommit.length} file(s) from session ${shortId(sessionId)}.`);
|
|
420
|
+
|
|
421
|
+
if (hasFlag(args, '--push')) {
|
|
422
|
+
const pushR = spawnSync('git', ['push'], { cwd: projectDir, stdio: 'inherit' });
|
|
423
|
+
return pushR.status ?? 1;
|
|
424
|
+
}
|
|
425
|
+
return 0;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function usage(): void {
|
|
429
|
+
console.log(`scope — per-instance commit scoping CLI
|
|
430
|
+
|
|
431
|
+
Commands:
|
|
432
|
+
scope status [--session <id>]
|
|
433
|
+
scope stage [--session <id>] [--include-conflicted]
|
|
434
|
+
scope diff [--session <id>]
|
|
435
|
+
scope commit "<message>" [--session <id>] [--include-conflicted] [--push]
|
|
436
|
+
|
|
437
|
+
Commits ONLY the files this Claude session edited (per
|
|
438
|
+
\`.claude/state/sessions/<id>.json#filesTouched\`), via \`git commit -o\` so a
|
|
439
|
+
peer's concurrently-staged files are never bundled in. Refuses files a peer
|
|
440
|
+
session touched in the last 5 min unless --include-conflicted.
|
|
441
|
+
|
|
442
|
+
Exit codes: 0=ok, 1=arg/state error, 2=conflict refusal.
|
|
443
|
+
`);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function main(): void {
|
|
447
|
+
const [, , cmd, ...rest] = process.argv;
|
|
448
|
+
const projectDir = getProjectDir();
|
|
449
|
+
const stateDir = getStateDir(projectDir);
|
|
450
|
+
|
|
451
|
+
switch (cmd) {
|
|
452
|
+
case 'status':
|
|
453
|
+
process.exit(cmdStatus(stateDir, projectDir, rest));
|
|
454
|
+
break;
|
|
455
|
+
case 'stage':
|
|
456
|
+
process.exit(cmdStage(stateDir, projectDir, rest));
|
|
457
|
+
break;
|
|
458
|
+
case 'diff':
|
|
459
|
+
process.exit(cmdDiff(stateDir, projectDir, rest));
|
|
460
|
+
break;
|
|
461
|
+
case 'commit':
|
|
462
|
+
process.exit(cmdCommit(stateDir, projectDir, rest));
|
|
463
|
+
break;
|
|
464
|
+
case 'help':
|
|
465
|
+
case '--help':
|
|
466
|
+
case '-h':
|
|
467
|
+
case undefined:
|
|
468
|
+
usage();
|
|
469
|
+
process.exit(0);
|
|
470
|
+
break;
|
|
471
|
+
default:
|
|
472
|
+
console.error(`Unknown command: ${cmd}`);
|
|
473
|
+
usage();
|
|
474
|
+
process.exit(1);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
main();
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
import {
|
|
20
|
+
ACTIVE_MS,
|
|
20
21
|
ageMs,
|
|
21
22
|
appendInbox,
|
|
22
23
|
drainInbox,
|
|
@@ -81,7 +82,7 @@ async function main(): Promise<void> {
|
|
|
81
82
|
messageParts.push('');
|
|
82
83
|
messageParts.push(`PEERS DETECTED in this project (${peers.length}):`);
|
|
83
84
|
for (const p of peers) messageParts.push(` - ${formatPeer(p)}`);
|
|
84
|
-
const anyActive = peers.some(p => ageMs(p.lastSeenAt) <
|
|
85
|
+
const anyActive = peers.some(p => ageMs(p.lastSeenAt) < ACTIVE_MS);
|
|
85
86
|
if (anyActive) {
|
|
86
87
|
messageParts.push('');
|
|
87
88
|
messageParts.push(
|
|
@@ -93,7 +94,7 @@ async function main(): Promise<void> {
|
|
|
93
94
|
} else {
|
|
94
95
|
messageParts.push('');
|
|
95
96
|
messageParts.push(
|
|
96
|
-
'Peers are idle (>
|
|
97
|
+
'Peers are idle (>3min). Edits will be allowed but you will see a notice if ' +
|
|
97
98
|
'you touch files they recently modified.'
|
|
98
99
|
);
|
|
99
100
|
}
|
|
@@ -1,16 +1,29 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// @sv-version: 1.
|
|
2
|
+
// @sv-version: 1.3.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`.
|
|
20
|
+
*
|
|
21
|
+
* v1.3.0: on APPROVE, surface "orphan" dirty files — files dirty in the worktree
|
|
22
|
+
* that are NOT in this session's filesTouched (e.g. changed via Bash, codegen, or
|
|
23
|
+
* formatters, which post-tool-use cannot capture). These are scoped OUT of the
|
|
24
|
+
* blocking check (so a peer's work never blocks you), but silently leaving them
|
|
25
|
+
* behind is exactly what makes the NEXT instance "see files and hesitate to
|
|
26
|
+
* deploy". We never block on them; we WARN so the agent can verify/own them.
|
|
14
27
|
*/
|
|
15
28
|
|
|
16
29
|
import { execSync } from 'child_process';
|
|
@@ -25,6 +38,7 @@ import {
|
|
|
25
38
|
formatPeer,
|
|
26
39
|
getStateDir,
|
|
27
40
|
listPeerSessions,
|
|
41
|
+
readSession,
|
|
28
42
|
} from './_state.js';
|
|
29
43
|
|
|
30
44
|
const PROJECT_DIR = process.env['CLAUDE_PROJECT_DIR'] || process.cwd();
|
|
@@ -77,6 +91,31 @@ function getModifiedFiles(): string[] {
|
|
|
77
91
|
return [...new Set([...staged, ...unstaged, ...untracked])];
|
|
78
92
|
}
|
|
79
93
|
|
|
94
|
+
/**
|
|
95
|
+
* Per-instance scoping: if a session id is provided AND state has filesTouched,
|
|
96
|
+
* return only the dirty files THIS session edited. Otherwise return the full
|
|
97
|
+
* dirty list (backward compatible). Falls back gracefully on any error.
|
|
98
|
+
*/
|
|
99
|
+
function getScopedDirtyFiles(sessionId: string | undefined): {
|
|
100
|
+
scoped: string[];
|
|
101
|
+
perInstance: boolean;
|
|
102
|
+
totalDirty: number;
|
|
103
|
+
} {
|
|
104
|
+
const allDirty = getModifiedFiles();
|
|
105
|
+
if (!sessionId) return { scoped: allDirty, perInstance: false, totalDirty: allDirty.length };
|
|
106
|
+
try {
|
|
107
|
+
const stateDir = getStateDir(PROJECT_DIR);
|
|
108
|
+
const sess = readSession(stateDir, sessionId);
|
|
109
|
+
if (!sess || !Array.isArray(sess.filesTouched)) {
|
|
110
|
+
return { scoped: allDirty, perInstance: false, totalDirty: allDirty.length };
|
|
111
|
+
}
|
|
112
|
+
const set = new Set(sess.filesTouched);
|
|
113
|
+
return { scoped: allDirty.filter(f => set.has(f)), perInstance: true, totalDirty: allDirty.length };
|
|
114
|
+
} catch {
|
|
115
|
+
return { scoped: allDirty, perInstance: false, totalDirty: allDirty.length };
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
80
119
|
/**
|
|
81
120
|
* Run a command capturing stdout, stderr and exit code without throwing.
|
|
82
121
|
*/
|
|
@@ -142,18 +181,27 @@ function scanSecrets(): string[] {
|
|
|
142
181
|
return findings;
|
|
143
182
|
}
|
|
144
183
|
|
|
145
|
-
function validate(): HookResult {
|
|
184
|
+
function validate(sessionId: string | undefined): HookResult {
|
|
146
185
|
const branch = getBranch();
|
|
147
186
|
const isMain = branch === 'main' || branch === 'master';
|
|
148
|
-
const modified =
|
|
187
|
+
const { scoped: modified, perInstance, totalDirty } = getScopedDirtyFiles(sessionId);
|
|
149
188
|
const isClean = modified.length === 0;
|
|
189
|
+
const scopeNote = perInstance
|
|
190
|
+
? ` (this-session-scoped: ${modified.length}/${totalDirty} dirty; peer files ignored)`
|
|
191
|
+
: '';
|
|
192
|
+
|
|
193
|
+
// 1. Must be on main with clean tree (scoped to THIS session when possible).
|
|
194
|
+
// Recommendation uses `/commit-mine` rather than `git add -A`, which would
|
|
195
|
+
// pull in peer-session changes (see CLAUDE.md NRY "Instance N's commit bundling...").
|
|
196
|
+
const commitGuide = perInstance
|
|
197
|
+
? `\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}`
|
|
198
|
+
: `\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
199
|
|
|
151
|
-
// 1. Must be on main with clean tree
|
|
152
200
|
if (!isMain && modified.length > 0) {
|
|
153
201
|
return {
|
|
154
202
|
continue: true,
|
|
155
203
|
decision: 'block',
|
|
156
|
-
reason: `BLOCKED: On branch '${branch}' with ${modified.length} modified files
|
|
204
|
+
reason: `BLOCKED: On branch '${branch}' with ${modified.length} modified files${scopeNote}.${commitGuide}`,
|
|
157
205
|
};
|
|
158
206
|
}
|
|
159
207
|
|
|
@@ -166,10 +214,13 @@ function validate(): HookResult {
|
|
|
166
214
|
}
|
|
167
215
|
|
|
168
216
|
if (!isClean) {
|
|
217
|
+
const stageHint = perInstance
|
|
218
|
+
? `\n\nThese files were edited by THIS session. Commit only your scope:\n npx tsx "$CLAUDE_PROJECT_DIR/.claude/hooks/scope.ts" commit "<message>"`
|
|
219
|
+
: `\n\nCommit or stash before completing.`;
|
|
169
220
|
return {
|
|
170
221
|
continue: true,
|
|
171
222
|
decision: 'block',
|
|
172
|
-
reason: `BLOCKED: ${modified.length} uncommitted files:\n${modified.slice(0, 10).map(f => ` - ${f}`).join('\n')}
|
|
223
|
+
reason: `BLOCKED: ${modified.length} uncommitted files${scopeNote}:\n${modified.slice(0, 10).map(f => ` - ${f}`).join('\n')}${stageHint}`,
|
|
173
224
|
};
|
|
174
225
|
}
|
|
175
226
|
|
|
@@ -193,10 +244,11 @@ function validate(): HookResult {
|
|
|
193
244
|
};
|
|
194
245
|
}
|
|
195
246
|
|
|
196
|
-
// 4. Required sections
|
|
247
|
+
// 4. Required sections — `## Last Change` (single, overwritten) OR `## Recent Changes`
|
|
248
|
+
// (append-only LIFO, multi-instance safe) both satisfy the changelog slot.
|
|
197
249
|
const required = [
|
|
198
250
|
{ pattern: /^# .+/m, name: 'Project Title (H1)' },
|
|
199
|
-
{ pattern: /^## Last Change/m, name: 'Last Change' },
|
|
251
|
+
{ pattern: /^## (Last Change|Recent Changes)/m, name: 'Last Change OR Recent Changes' },
|
|
200
252
|
{ pattern: /^## Stack/m, name: 'Stack' },
|
|
201
253
|
];
|
|
202
254
|
|
|
@@ -219,11 +271,24 @@ function validate(): HookResult {
|
|
|
219
271
|
};
|
|
220
272
|
}
|
|
221
273
|
|
|
222
|
-
// All good
|
|
274
|
+
// All good. If per-instance scoping hid orphan dirty files (dirty, but not in
|
|
275
|
+
// THIS session's filesTouched), surface them — they may be your own Bash/codegen
|
|
276
|
+
// changes that post-tool-use could not attribute. We do NOT block on them.
|
|
277
|
+
let orphanNote = '';
|
|
278
|
+
if (perInstance && totalDirty > modified.length) {
|
|
279
|
+
const orphans = getModifiedFiles().filter(f => !modified.includes(f));
|
|
280
|
+
orphanNote =
|
|
281
|
+
`\n\nNOTE: ${orphans.length} dirty file(s) are NOT attributed to this session ` +
|
|
282
|
+
`(e.g. changed via Bash, a formatter, or codegen):\n` +
|
|
283
|
+
orphans.slice(0, 10).map(f => ` ? ${f}`).join('\n') +
|
|
284
|
+
`\nIf any are yours, commit them with \`scope.ts commit\` (only your files). ` +
|
|
285
|
+
`If they belong to a peer, leave them. Do not \`git add -A\`.`;
|
|
286
|
+
}
|
|
287
|
+
|
|
223
288
|
return {
|
|
224
289
|
continue: false,
|
|
225
290
|
decision: 'approve',
|
|
226
|
-
reason: `ALL CHECKS PASSED ✅\nStack: ${stackId}\nBranch: ${branch}\nTree: Clean\nSecrets: clean`,
|
|
291
|
+
reason: `ALL CHECKS PASSED ✅\nStack: ${stackId}\nBranch: ${branch}\nTree: Clean (this-session scope)\nSecrets: clean${orphanNote}`,
|
|
227
292
|
};
|
|
228
293
|
}
|
|
229
294
|
|
|
@@ -247,10 +312,10 @@ async function main(): Promise<void> {
|
|
|
247
312
|
process.exit(0);
|
|
248
313
|
}
|
|
249
314
|
|
|
250
|
-
const result = validate();
|
|
251
|
-
|
|
252
315
|
// Multi-instance coordination side effects.
|
|
253
316
|
const sessionId: string | undefined = hookInput.session_id || hookInput.sessionId;
|
|
317
|
+
|
|
318
|
+
const result = validate(sessionId);
|
|
254
319
|
const eventName: string = hookInput.hook_event_name || hookInput.hookEventName || '';
|
|
255
320
|
const isSessionEnd = /SessionEnd/i.test(eventName);
|
|
256
321
|
|