start-vibing-stacks 2.22.0 → 2.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +55 -8
- package/dist/index.js +3 -1
- package/dist/migrate.d.ts +33 -11
- package/dist/migrate.js +297 -42
- package/dist/setup.js +67 -3
- package/package.json +1 -1
- package/stacks/_shared/commands/peers.md +37 -0
- package/stacks/_shared/hooks/_state.README.md +55 -0
- package/stacks/_shared/hooks/_state.ts +445 -0
- package/stacks/_shared/hooks/final-check.ts +1 -0
- package/stacks/_shared/hooks/peers.ts +251 -0
- package/stacks/_shared/hooks/post-tool-use.ts +80 -0
- package/stacks/_shared/hooks/pre-tool-use.ts +176 -0
- package/stacks/_shared/hooks/run-hook.ts +1 -0
- package/stacks/_shared/hooks/session-start.ts +125 -0
- package/stacks/_shared/hooks/stop-validator.ts +38 -0
- package/stacks/_shared/hooks/user-prompt-submit.ts +97 -19
- package/stacks/_shared/skills/multi-instance-coordination/SKILL.md +90 -0
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// @sv-version: 1.0.0
|
|
3
|
+
/**
|
|
4
|
+
* `peers` — CLI for the multi-instance coordination layer.
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* peers list List active + idle peer sessions.
|
|
8
|
+
* peers notify <id-prefix-or-substring> "msg"
|
|
9
|
+
* Queue a message for that peer.
|
|
10
|
+
* peers locks [--minutes 10] Show files touched in the window.
|
|
11
|
+
* peers cleanup Remove stale sessions (>1h idle).
|
|
12
|
+
*
|
|
13
|
+
* Exit codes:
|
|
14
|
+
* 0 success (and at least one peer for `list`)
|
|
15
|
+
* 1 argument / state error
|
|
16
|
+
* 2 no peers found (for `list`)
|
|
17
|
+
*
|
|
18
|
+
* Run:
|
|
19
|
+
* npx tsx .claude/hooks/peers.ts <command>
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { existsSync, readdirSync, readFileSync, renameSync, rmSync, statSync } from 'fs';
|
|
23
|
+
import { join, basename } from 'path';
|
|
24
|
+
import {
|
|
25
|
+
ACTIVE_MS,
|
|
26
|
+
IDLE_MS,
|
|
27
|
+
STALE_MS,
|
|
28
|
+
ageMs,
|
|
29
|
+
appendInbox,
|
|
30
|
+
classifyAge,
|
|
31
|
+
ensureStateDirs,
|
|
32
|
+
formatPeer,
|
|
33
|
+
getProjectDir,
|
|
34
|
+
getStateDir,
|
|
35
|
+
listSessionFiles,
|
|
36
|
+
nowIso,
|
|
37
|
+
readJsonSafe,
|
|
38
|
+
shortId,
|
|
39
|
+
tailFileTouches,
|
|
40
|
+
type SessionRecord,
|
|
41
|
+
} from './_state.js';
|
|
42
|
+
|
|
43
|
+
function parseFlag(args: string[], flag: string, fallback?: string): string | undefined {
|
|
44
|
+
const i = args.indexOf(flag);
|
|
45
|
+
if (i === -1) return fallback;
|
|
46
|
+
return args[i + 1];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function loadSessions(stateDir: string): SessionRecord[] {
|
|
50
|
+
const out: SessionRecord[] = [];
|
|
51
|
+
for (const file of listSessionFiles(stateDir)) {
|
|
52
|
+
const rec = readJsonSafe<SessionRecord>(file);
|
|
53
|
+
if (rec) out.push(rec);
|
|
54
|
+
}
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function findCurrentSessionId(stateDir: string): string | null {
|
|
59
|
+
const fromEnv = process.env['CLAUDE_SESSION_ID'];
|
|
60
|
+
if (fromEnv) return fromEnv;
|
|
61
|
+
const sessions = loadSessions(stateDir).filter(s => ageMs(s.lastSeenAt) < ACTIVE_MS);
|
|
62
|
+
if (sessions.length === 1) return sessions[0]!.sessionId;
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function cmdList(stateDir: string): number {
|
|
67
|
+
const all = loadSessions(stateDir);
|
|
68
|
+
const fresh = all.filter(s => ageMs(s.lastSeenAt) < IDLE_MS);
|
|
69
|
+
if (fresh.length === 0) {
|
|
70
|
+
console.log('No peer sessions in this project.');
|
|
71
|
+
return 2;
|
|
72
|
+
}
|
|
73
|
+
fresh.sort((a, b) => ageMs(a.lastSeenAt) - ageMs(b.lastSeenAt));
|
|
74
|
+
console.log(`Sessions registered for ${getProjectDir()}:`);
|
|
75
|
+
for (const s of fresh) console.log(` ${formatPeer(s)}`);
|
|
76
|
+
return 0;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function cmdNotify(stateDir: string, args: string[]): number {
|
|
80
|
+
if (args.length < 2) {
|
|
81
|
+
console.error('Usage: peers notify <id-prefix-or-title-substring> "<message>"');
|
|
82
|
+
return 1;
|
|
83
|
+
}
|
|
84
|
+
const target = args[0]!;
|
|
85
|
+
const message = args.slice(1).join(' ');
|
|
86
|
+
if (!message.trim()) {
|
|
87
|
+
console.error('Empty message — nothing to send.');
|
|
88
|
+
return 1;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const all = loadSessions(stateDir).filter(s => ageMs(s.lastSeenAt) < STALE_MS);
|
|
92
|
+
const matches = all.filter(s => {
|
|
93
|
+
if (s.sessionId.startsWith(target)) return true;
|
|
94
|
+
if (s.title && s.title.toLowerCase().includes(target.toLowerCase())) return true;
|
|
95
|
+
return false;
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
if (matches.length === 0) {
|
|
99
|
+
console.error(`No peer session matches "${target}". Run \`peers list\`.`);
|
|
100
|
+
return 1;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (matches.length > 1) {
|
|
104
|
+
console.error(`Ambiguous target "${target}" — ${matches.length} matches:`);
|
|
105
|
+
for (const m of matches) console.error(` - ${formatPeer(m)}`);
|
|
106
|
+
console.error('Use a longer id prefix to disambiguate.');
|
|
107
|
+
return 1;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const target_ = matches[0]!;
|
|
111
|
+
const fromSessionId = findCurrentSessionId(stateDir) || 'cli';
|
|
112
|
+
const fromTitle = loadSessions(stateDir).find(s => s.sessionId === fromSessionId)?.title;
|
|
113
|
+
|
|
114
|
+
appendInbox(stateDir, target_.sessionId, {
|
|
115
|
+
ts: nowIso(),
|
|
116
|
+
fromSessionId,
|
|
117
|
+
fromTitle,
|
|
118
|
+
message,
|
|
119
|
+
});
|
|
120
|
+
console.log(`Queued message for ${shortId(target_.sessionId)} "${target_.title}".`);
|
|
121
|
+
return 0;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function cmdLocks(stateDir: string, args: string[]): number {
|
|
125
|
+
const minutes = Number(parseFlag(args, '--minutes', '10') || '10');
|
|
126
|
+
if (!Number.isFinite(minutes) || minutes <= 0) {
|
|
127
|
+
console.error('--minutes must be a positive number.');
|
|
128
|
+
return 1;
|
|
129
|
+
}
|
|
130
|
+
const windowMs = minutes * 60 * 1000;
|
|
131
|
+
const touches = tailFileTouches(stateDir, 1000).filter(t => ageMs(t.ts) <= windowMs);
|
|
132
|
+
if (touches.length === 0) {
|
|
133
|
+
console.log(`No file touches in the last ${minutes}min.`);
|
|
134
|
+
return 0;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const sessions = loadSessions(stateDir);
|
|
138
|
+
const sessById = new Map(sessions.map(s => [s.sessionId, s]));
|
|
139
|
+
|
|
140
|
+
type Row = { file: string; sessionId: string; ts: string; tool: string };
|
|
141
|
+
const byFile = new Map<string, Row>();
|
|
142
|
+
for (const t of touches) {
|
|
143
|
+
const prev = byFile.get(t.file);
|
|
144
|
+
if (!prev || Date.parse(t.ts) > Date.parse(prev.ts)) {
|
|
145
|
+
byFile.set(t.file, { file: t.file, sessionId: t.sessionId, ts: t.ts, tool: t.tool });
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const rows = [...byFile.values()].sort(
|
|
150
|
+
(a, b) => Date.parse(b.ts) - Date.parse(a.ts)
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
console.log(`File touches in the last ${minutes}min (most-recent first):`);
|
|
154
|
+
for (const r of rows) {
|
|
155
|
+
const sess = sessById.get(r.sessionId);
|
|
156
|
+
const klass = sess ? classifyAge(ageMs(sess.lastSeenAt)) : 'gone';
|
|
157
|
+
const who = sess ? `${shortId(sess.sessionId)} "${sess.title}"` : `${shortId(r.sessionId)}(gone)`;
|
|
158
|
+
const ageSec = Math.round(ageMs(r.ts) / 1000);
|
|
159
|
+
console.log(` ${r.file} <- ${who} [${klass}] via ${r.tool}, ${ageSec}s ago`);
|
|
160
|
+
}
|
|
161
|
+
return 0;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function cmdCleanup(stateDir: string): number {
|
|
165
|
+
ensureStateDirs(stateDir);
|
|
166
|
+
let archived = 0;
|
|
167
|
+
let removed = 0;
|
|
168
|
+
for (const file of listSessionFiles(stateDir)) {
|
|
169
|
+
const rec = readJsonSafe<SessionRecord>(file);
|
|
170
|
+
if (!rec) continue;
|
|
171
|
+
const age = ageMs(rec.lastSeenAt);
|
|
172
|
+
if (age > STALE_MS) {
|
|
173
|
+
try {
|
|
174
|
+
rmSync(file);
|
|
175
|
+
removed++;
|
|
176
|
+
} catch {}
|
|
177
|
+
} else if (age > IDLE_MS) {
|
|
178
|
+
try {
|
|
179
|
+
const dest = join(stateDir, 'sessions', '_archive', basename(file));
|
|
180
|
+
renameSync(file, dest);
|
|
181
|
+
archived++;
|
|
182
|
+
} catch {}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Drop empty inbox files older than 7 days.
|
|
187
|
+
const inboxDir = join(stateDir, 'inbox');
|
|
188
|
+
if (existsSync(inboxDir)) {
|
|
189
|
+
try {
|
|
190
|
+
for (const f of readdirSync(inboxDir)) {
|
|
191
|
+
const p = join(inboxDir, f);
|
|
192
|
+
try {
|
|
193
|
+
const s = statSync(p);
|
|
194
|
+
if (s.size === 0 && Date.now() - s.mtimeMs > 7 * 24 * 60 * 60 * 1000) {
|
|
195
|
+
rmSync(p);
|
|
196
|
+
} else if (s.size > 0) {
|
|
197
|
+
const lines = readFileSync(p, 'utf8').split('\n').filter(Boolean);
|
|
198
|
+
if (lines.length === 0) rmSync(p);
|
|
199
|
+
}
|
|
200
|
+
} catch {}
|
|
201
|
+
}
|
|
202
|
+
} catch {}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
console.log(`Cleanup done. Archived: ${archived}. Removed: ${removed}.`);
|
|
206
|
+
return 0;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function usage(): void {
|
|
210
|
+
console.log(`peers — multi-instance coordination CLI
|
|
211
|
+
|
|
212
|
+
Commands:
|
|
213
|
+
peers list
|
|
214
|
+
peers notify <id-prefix-or-title-substring> "<message>"
|
|
215
|
+
peers locks [--minutes 10]
|
|
216
|
+
peers cleanup
|
|
217
|
+
`);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function main(): void {
|
|
221
|
+
const [, , cmd, ...rest] = process.argv;
|
|
222
|
+
const stateDir = getStateDir();
|
|
223
|
+
|
|
224
|
+
switch (cmd) {
|
|
225
|
+
case 'list':
|
|
226
|
+
process.exit(cmdList(stateDir));
|
|
227
|
+
break;
|
|
228
|
+
case 'notify':
|
|
229
|
+
process.exit(cmdNotify(stateDir, rest));
|
|
230
|
+
break;
|
|
231
|
+
case 'locks':
|
|
232
|
+
process.exit(cmdLocks(stateDir, rest));
|
|
233
|
+
break;
|
|
234
|
+
case 'cleanup':
|
|
235
|
+
process.exit(cmdCleanup(stateDir));
|
|
236
|
+
break;
|
|
237
|
+
case 'help':
|
|
238
|
+
case '--help':
|
|
239
|
+
case '-h':
|
|
240
|
+
case undefined:
|
|
241
|
+
usage();
|
|
242
|
+
process.exit(0);
|
|
243
|
+
break;
|
|
244
|
+
default:
|
|
245
|
+
console.error(`Unknown command: ${cmd}`);
|
|
246
|
+
usage();
|
|
247
|
+
process.exit(1);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
main();
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// @sv-version: 1.0.0
|
|
3
|
+
/**
|
|
4
|
+
* PostToolUse Hook — Multi-Instance Coordination
|
|
5
|
+
*
|
|
6
|
+
* Wired with matcher `Edit|Write|MultiEdit|NotebookEdit`. Runs AFTER a write
|
|
7
|
+
* succeeds. Responsibilities:
|
|
8
|
+
* 1. Append a `FileTouch` record to `.claude/state/file-touches.jsonl`.
|
|
9
|
+
* 2. Update the session's heartbeat + `filesTouched` (capped at 50, deduped).
|
|
10
|
+
*
|
|
11
|
+
* On any internal error the hook exits silently — coordination must NEVER
|
|
12
|
+
* disturb Claude after a successful tool call.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
FILES_TOUCHED_CAP,
|
|
17
|
+
ensureStateDirs,
|
|
18
|
+
extractTargetFiles,
|
|
19
|
+
getProjectDir,
|
|
20
|
+
getStateDir,
|
|
21
|
+
heartbeat,
|
|
22
|
+
nowIso,
|
|
23
|
+
readSession,
|
|
24
|
+
readStdinJson,
|
|
25
|
+
recordFileTouch,
|
|
26
|
+
} from './_state.js';
|
|
27
|
+
|
|
28
|
+
async function main(): Promise<void> {
|
|
29
|
+
const input = await readStdinJson(1500);
|
|
30
|
+
const sessionId: string | undefined = input.session_id || input.sessionId;
|
|
31
|
+
const toolName: string = input.tool_name || input.toolName || '';
|
|
32
|
+
const toolInput: any = input.tool_input || input.toolInput || {};
|
|
33
|
+
const toolResponse: any = input.tool_response || input.toolResponse;
|
|
34
|
+
|
|
35
|
+
if (!sessionId || !/^(Edit|Write|MultiEdit|NotebookEdit)$/.test(toolName)) {
|
|
36
|
+
console.log(JSON.stringify({ continue: true }));
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Some tool responses surface a `success: false` — do not record those.
|
|
41
|
+
if (toolResponse && typeof toolResponse === 'object' && toolResponse.success === false) {
|
|
42
|
+
console.log(JSON.stringify({ continue: true }));
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const projectDir = getProjectDir();
|
|
47
|
+
const stateDir = getStateDir(projectDir);
|
|
48
|
+
ensureStateDirs(stateDir);
|
|
49
|
+
|
|
50
|
+
const targets = extractTargetFiles(toolName, toolInput, projectDir);
|
|
51
|
+
const ts = nowIso();
|
|
52
|
+
for (const file of targets) {
|
|
53
|
+
recordFileTouch(stateDir, { ts, sessionId, tool: toolName, file });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const session = readSession(stateDir, sessionId);
|
|
57
|
+
const previous = session?.filesTouched || [];
|
|
58
|
+
const merged = dedupeKeepLast([...previous, ...targets], FILES_TOUCHED_CAP);
|
|
59
|
+
heartbeat(stateDir, sessionId, `PostToolUse:${toolName}`, { filesTouched: merged });
|
|
60
|
+
|
|
61
|
+
console.log(JSON.stringify({ continue: true }));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function dedupeKeepLast(items: string[], cap: number): string[] {
|
|
65
|
+
const seen = new Set<string>();
|
|
66
|
+
const reversed: string[] = [];
|
|
67
|
+
for (let i = items.length - 1; i >= 0; i--) {
|
|
68
|
+
const x = items[i]!;
|
|
69
|
+
if (seen.has(x)) continue;
|
|
70
|
+
seen.add(x);
|
|
71
|
+
reversed.push(x);
|
|
72
|
+
if (reversed.length >= cap) break;
|
|
73
|
+
}
|
|
74
|
+
return reversed.reverse();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
main().catch(() => {
|
|
78
|
+
console.log(JSON.stringify({ continue: true }));
|
|
79
|
+
process.exit(0);
|
|
80
|
+
});
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// @sv-version: 1.0.0
|
|
3
|
+
/**
|
|
4
|
+
* PreToolUse Hook — Multi-Instance Coordination
|
|
5
|
+
*
|
|
6
|
+
* Wired with matcher `Edit|Write|MultiEdit|NotebookEdit`. Reads the file-touches
|
|
7
|
+
* log + active peer sessions and decides:
|
|
8
|
+
*
|
|
9
|
+
* - BLOCK if a peer is currently ACTIVE (heartbeat < 60s) AND touched the same
|
|
10
|
+
* file within the last 5 minutes. The reason explains how to recover.
|
|
11
|
+
* - WARN (approve + systemMessage) if a peer touched the file recently but is
|
|
12
|
+
* only IDLE (60s — 5min).
|
|
13
|
+
* - APPROVE silently otherwise.
|
|
14
|
+
*
|
|
15
|
+
* Hook input:
|
|
16
|
+
* { session_id, tool_name, tool_input, hook_event_name, ... }
|
|
17
|
+
*
|
|
18
|
+
* Output schema (JSON):
|
|
19
|
+
* { decision?: 'block', reason?: string, continue: true, systemMessage?: string }
|
|
20
|
+
*
|
|
21
|
+
* On any internal error, the hook approves silently — coordination must NEVER
|
|
22
|
+
* break Claude.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import {
|
|
26
|
+
ACTIVE_MS,
|
|
27
|
+
COLLISION_WINDOW_MS,
|
|
28
|
+
ageMs,
|
|
29
|
+
classifyAge,
|
|
30
|
+
ensureStateDirs,
|
|
31
|
+
extractTargetFiles,
|
|
32
|
+
getProjectDir,
|
|
33
|
+
getStateDir,
|
|
34
|
+
heartbeat,
|
|
35
|
+
listPeerSessions,
|
|
36
|
+
readStdinJson,
|
|
37
|
+
shortId,
|
|
38
|
+
tailFileTouches,
|
|
39
|
+
type FileTouch,
|
|
40
|
+
type SessionRecord,
|
|
41
|
+
} from './_state.js';
|
|
42
|
+
|
|
43
|
+
interface Verdict {
|
|
44
|
+
block: boolean;
|
|
45
|
+
reason?: string;
|
|
46
|
+
warning?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function evaluate(
|
|
50
|
+
targetFiles: string[],
|
|
51
|
+
peers: SessionRecord[],
|
|
52
|
+
touches: FileTouch[],
|
|
53
|
+
selfSessionId: string
|
|
54
|
+
): Verdict {
|
|
55
|
+
if (targetFiles.length === 0) return { block: false };
|
|
56
|
+
|
|
57
|
+
const peerById = new Map<string, SessionRecord>();
|
|
58
|
+
for (const p of peers) peerById.set(p.sessionId, p);
|
|
59
|
+
|
|
60
|
+
// Most-recent peer touch per (file).
|
|
61
|
+
const recent = new Map<string, FileTouch>();
|
|
62
|
+
for (const t of touches) {
|
|
63
|
+
if (t.sessionId === selfSessionId) continue;
|
|
64
|
+
if (!targetFiles.includes(t.file)) continue;
|
|
65
|
+
if (ageMs(t.ts) > COLLISION_WINDOW_MS) continue;
|
|
66
|
+
const prev = recent.get(t.file);
|
|
67
|
+
if (!prev || Date.parse(t.ts) > Date.parse(prev.ts)) recent.set(t.file, t);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (recent.size === 0) return { block: false };
|
|
71
|
+
|
|
72
|
+
const blockers: string[] = [];
|
|
73
|
+
const warns: string[] = [];
|
|
74
|
+
|
|
75
|
+
for (const [file, touch] of recent) {
|
|
76
|
+
const peer = peerById.get(touch.sessionId);
|
|
77
|
+
const peerActive = peer && ageMs(peer.lastSeenAt) < ACTIVE_MS;
|
|
78
|
+
const touchAgeSec = Math.round(ageMs(touch.ts) / 1000);
|
|
79
|
+
const peerLabel = peer
|
|
80
|
+
? `${shortId(peer.sessionId)} "${peer.title}"${peer.gitBranch ? ` @${peer.gitBranch}` : ''}`
|
|
81
|
+
: `${shortId(touch.sessionId)} (session record gone)`;
|
|
82
|
+
|
|
83
|
+
if (peerActive) {
|
|
84
|
+
blockers.push(
|
|
85
|
+
` - ${file}\n last touched ${touchAgeSec}s ago by peer ${peerLabel} (HEARTBEAT ACTIVE)`
|
|
86
|
+
);
|
|
87
|
+
} else {
|
|
88
|
+
const klass = peer ? classifyAge(ageMs(peer.lastSeenAt)) : 'stale';
|
|
89
|
+
warns.push(
|
|
90
|
+
` - ${file}: peer ${peerLabel} (${klass}) touched it ${touchAgeSec}s ago`
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (blockers.length > 0) {
|
|
96
|
+
const reason =
|
|
97
|
+
`BLOCKED by multi-instance coordination — another active Claude session is editing the same file.\n` +
|
|
98
|
+
`Active collision(s):\n${blockers.join('\n')}\n\n` +
|
|
99
|
+
`Recommended actions:\n` +
|
|
100
|
+
` 1. Run \`npx tsx .claude/hooks/peers.ts list\` to see who is active.\n` +
|
|
101
|
+
` 2. Notify them: \`npx tsx .claude/hooks/peers.ts notify <id-prefix> "I need to edit <file>, can you commit/stash?"\`\n` +
|
|
102
|
+
` 3. Wait for them to commit, then retry — or have them call \`peers.ts cleanup\` if you confirm they are no longer editing.\n` +
|
|
103
|
+
` 4. If you must override: re-run the same Edit after 60s of peer inactivity (their heartbeat will go IDLE and the hook will downgrade to a warning).`;
|
|
104
|
+
return { block: true, reason };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (warns.length > 0) {
|
|
108
|
+
const warning =
|
|
109
|
+
`MULTI-INSTANCE WARNING — files you are about to edit were recently touched by an idle peer:\n${warns.join('\n')}\n` +
|
|
110
|
+
`Edit is allowed (peer is not actively typing). Consider rebasing on their work after they commit.`;
|
|
111
|
+
return { block: false, warning };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return { block: false };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function main(): Promise<void> {
|
|
118
|
+
const input = await readStdinJson(1500);
|
|
119
|
+
const sessionId: string | undefined = input.session_id || input.sessionId;
|
|
120
|
+
const toolName: string = input.tool_name || input.toolName || '';
|
|
121
|
+
const toolInput: any = input.tool_input || input.toolInput || {};
|
|
122
|
+
|
|
123
|
+
// Defensive: only act on edit-class tools.
|
|
124
|
+
if (!/^(Edit|Write|MultiEdit|NotebookEdit)$/.test(toolName)) {
|
|
125
|
+
console.log(JSON.stringify({ continue: true }));
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const projectDir = getProjectDir();
|
|
130
|
+
const stateDir = getStateDir(projectDir);
|
|
131
|
+
ensureStateDirs(stateDir);
|
|
132
|
+
|
|
133
|
+
if (sessionId) {
|
|
134
|
+
heartbeat(stateDir, sessionId, `PreToolUse:${toolName}`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const targetFiles = extractTargetFiles(toolName, toolInput, projectDir);
|
|
138
|
+
if (targetFiles.length === 0) {
|
|
139
|
+
console.log(JSON.stringify({ continue: true }));
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const peers = listPeerSessions(stateDir, sessionId || null);
|
|
144
|
+
const touches = tailFileTouches(stateDir);
|
|
145
|
+
|
|
146
|
+
const verdict = evaluate(targetFiles, peers, touches, sessionId || '');
|
|
147
|
+
|
|
148
|
+
if (verdict.block) {
|
|
149
|
+
console.log(
|
|
150
|
+
JSON.stringify({
|
|
151
|
+
continue: true,
|
|
152
|
+
decision: 'block',
|
|
153
|
+
reason: verdict.reason,
|
|
154
|
+
})
|
|
155
|
+
);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (verdict.warning) {
|
|
160
|
+
console.log(
|
|
161
|
+
JSON.stringify({
|
|
162
|
+
continue: true,
|
|
163
|
+
systemMessage: verdict.warning,
|
|
164
|
+
})
|
|
165
|
+
);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
console.log(JSON.stringify({ continue: true }));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
main().catch(() => {
|
|
173
|
+
// Coordination must never block Claude on its own bug.
|
|
174
|
+
console.log(JSON.stringify({ continue: true }));
|
|
175
|
+
process.exit(0);
|
|
176
|
+
});
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// @sv-version: 1.0.0
|
|
3
|
+
/**
|
|
4
|
+
* SessionStart Hook — Multi-Instance Coordination
|
|
5
|
+
*
|
|
6
|
+
* Runs once per Claude session start. Responsibilities:
|
|
7
|
+
* 1. Register this session in `.claude/state/sessions/<id>.json`.
|
|
8
|
+
* 2. Extract a human title from the transcript (matches what `claude --resume` shows).
|
|
9
|
+
* 3. Scan for peer sessions in the same project; warn if any are active.
|
|
10
|
+
* 4. Drain the inbox so messages from peers are surfaced in the first prompt.
|
|
11
|
+
*
|
|
12
|
+
* Hook input (JSON via stdin):
|
|
13
|
+
* { session_id, transcript_path, cwd, hook_event_name, source }
|
|
14
|
+
*
|
|
15
|
+
* Output (JSON to stdout):
|
|
16
|
+
* { continue: true, systemMessage?: string }
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
ageMs,
|
|
21
|
+
appendInbox,
|
|
22
|
+
drainInbox,
|
|
23
|
+
ensureStateDirs,
|
|
24
|
+
extractTitle,
|
|
25
|
+
formatPeer,
|
|
26
|
+
getGitBranch,
|
|
27
|
+
getProjectDir,
|
|
28
|
+
getStateDir,
|
|
29
|
+
heartbeat,
|
|
30
|
+
listPeerSessions,
|
|
31
|
+
nowIso,
|
|
32
|
+
readSession,
|
|
33
|
+
readStdinJson,
|
|
34
|
+
shortId,
|
|
35
|
+
type InboxMessage,
|
|
36
|
+
} from './_state.js';
|
|
37
|
+
|
|
38
|
+
async function main(): Promise<void> {
|
|
39
|
+
const input = await readStdinJson(1500);
|
|
40
|
+
const sessionId: string | undefined = input.session_id || input.sessionId;
|
|
41
|
+
const transcriptPath: string | undefined = input.transcript_path || input.transcriptPath;
|
|
42
|
+
const projectDir = getProjectDir();
|
|
43
|
+
const stateDir = getStateDir(projectDir);
|
|
44
|
+
|
|
45
|
+
if (!sessionId) {
|
|
46
|
+
console.log(JSON.stringify({ continue: true }));
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
ensureStateDirs(stateDir);
|
|
51
|
+
|
|
52
|
+
// Resolve title: prefer the existing record (so we don't lose a real title
|
|
53
|
+
// captured by user-prompt-submit when the SessionStart event also fires for
|
|
54
|
+
// a `/resume` flow that already had a title).
|
|
55
|
+
const existing = readSession(stateDir, sessionId);
|
|
56
|
+
const title = existing?.title && existing.title !== '(untitled)'
|
|
57
|
+
? existing.title
|
|
58
|
+
: extractTitle(transcriptPath);
|
|
59
|
+
|
|
60
|
+
const branch = getGitBranch(projectDir);
|
|
61
|
+
|
|
62
|
+
heartbeat(stateDir, sessionId, 'SessionStart', {
|
|
63
|
+
transcriptPath,
|
|
64
|
+
title,
|
|
65
|
+
cwd: projectDir,
|
|
66
|
+
gitBranch: branch,
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// Discover peers (active or idle within 30min).
|
|
70
|
+
const peers = listPeerSessions(stateDir, sessionId);
|
|
71
|
+
|
|
72
|
+
// Drain inbox messages addressed to this session.
|
|
73
|
+
const inbox = drainInbox(stateDir, sessionId);
|
|
74
|
+
|
|
75
|
+
const messageParts: string[] = [];
|
|
76
|
+
messageParts.push(
|
|
77
|
+
`MULTI-INSTANCE COORDINATION ACTIVE — this Claude session is registered as ${shortId(sessionId)} ("${title}"). State at .claude/state/.`
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
if (peers.length > 0) {
|
|
81
|
+
messageParts.push('');
|
|
82
|
+
messageParts.push(`PEERS DETECTED in this project (${peers.length}):`);
|
|
83
|
+
for (const p of peers) messageParts.push(` - ${formatPeer(p)}`);
|
|
84
|
+
const anyActive = peers.some(p => ageMs(p.lastSeenAt) < 60 * 1000);
|
|
85
|
+
if (anyActive) {
|
|
86
|
+
messageParts.push('');
|
|
87
|
+
messageParts.push(
|
|
88
|
+
'WARNING: at least one peer is ACTIVE right now. Edit/Write of a file ' +
|
|
89
|
+
'a peer just touched will be BLOCKED by the PreToolUse hook to prevent ' +
|
|
90
|
+
'overwriting their uncommitted work. Coordinate via `/peers notify <id> "msg"` ' +
|
|
91
|
+
'before touching shared files.'
|
|
92
|
+
);
|
|
93
|
+
} else {
|
|
94
|
+
messageParts.push('');
|
|
95
|
+
messageParts.push(
|
|
96
|
+
'Peers are idle (>60s). Edits will be allowed but you will see a notice if ' +
|
|
97
|
+
'you touch files they recently modified.'
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (inbox.length > 0) {
|
|
103
|
+
messageParts.push('');
|
|
104
|
+
messageParts.push(`INBOX (${inbox.length} message${inbox.length === 1 ? '' : 's'} from peers):`);
|
|
105
|
+
for (const m of inbox) messageParts.push(formatInbox(m));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const systemMessage = messageParts.join('\n');
|
|
109
|
+
|
|
110
|
+
console.log(JSON.stringify({ continue: true, systemMessage }));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function formatInbox(m: InboxMessage): string {
|
|
114
|
+
const from = m.fromTitle ? `${shortId(m.fromSessionId)} "${m.fromTitle}"` : shortId(m.fromSessionId);
|
|
115
|
+
return ` [${m.ts}] from ${from}: ${m.message}`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
main().catch(() => {
|
|
119
|
+
console.log(JSON.stringify({ continue: true }));
|
|
120
|
+
process.exit(0);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// Silence unused-import warning when type-only imports are tree-shaken
|
|
124
|
+
void nowIso;
|
|
125
|
+
void appendInbox;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
// @sv-version: 1.1.0
|
|
2
3
|
/**
|
|
3
4
|
* Stop Validator Hook — Start Vibing Stacks (Universal)
|
|
4
5
|
*
|
|
@@ -15,6 +16,16 @@
|
|
|
15
16
|
import { execSync } from 'child_process';
|
|
16
17
|
import { existsSync, readFileSync } from 'fs';
|
|
17
18
|
import { join } from 'path';
|
|
19
|
+
import {
|
|
20
|
+
ACTIVE_MS,
|
|
21
|
+
ageMs,
|
|
22
|
+
archiveSession,
|
|
23
|
+
clearInbox,
|
|
24
|
+
ensureStateDirs,
|
|
25
|
+
formatPeer,
|
|
26
|
+
getStateDir,
|
|
27
|
+
listPeerSessions,
|
|
28
|
+
} from './_state.js';
|
|
18
29
|
|
|
19
30
|
const PROJECT_DIR = process.env['CLAUDE_PROJECT_DIR'] || process.cwd();
|
|
20
31
|
const CLAUDE_MD = join(PROJECT_DIR, 'CLAUDE.md');
|
|
@@ -237,6 +248,33 @@ async function main(): Promise<void> {
|
|
|
237
248
|
}
|
|
238
249
|
|
|
239
250
|
const result = validate();
|
|
251
|
+
|
|
252
|
+
// Multi-instance coordination side effects.
|
|
253
|
+
const sessionId: string | undefined = hookInput.session_id || hookInput.sessionId;
|
|
254
|
+
const eventName: string = hookInput.hook_event_name || hookInput.hookEventName || '';
|
|
255
|
+
const isSessionEnd = /SessionEnd/i.test(eventName);
|
|
256
|
+
|
|
257
|
+
try {
|
|
258
|
+
const stateDir = getStateDir(PROJECT_DIR);
|
|
259
|
+
ensureStateDirs(stateDir);
|
|
260
|
+
|
|
261
|
+
if (result.decision === 'approve' && sessionId) {
|
|
262
|
+
const peers = listPeerSessions(stateDir, sessionId);
|
|
263
|
+
const activePeers = peers.filter(p => ageMs(p.lastSeenAt) < ACTIVE_MS);
|
|
264
|
+
if (activePeers.length > 0) {
|
|
265
|
+
const lines = activePeers.map(p => ` - ${formatPeer(p)}`).join('\n');
|
|
266
|
+
result.reason +=
|
|
267
|
+
`\n\nNOTE: ${activePeers.length} peer instance(s) still active in this project:\n${lines}\n` +
|
|
268
|
+
`If you committed and pushed, your work is now visible to them.`;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (isSessionEnd && sessionId) {
|
|
273
|
+
archiveSession(stateDir, sessionId);
|
|
274
|
+
clearInbox(stateDir, sessionId);
|
|
275
|
+
}
|
|
276
|
+
} catch {}
|
|
277
|
+
|
|
240
278
|
console.log(JSON.stringify(result));
|
|
241
279
|
process.exit(0);
|
|
242
280
|
}
|