start-vibing-stacks 2.22.0 → 2.23.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 +42 -5
- 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 +444 -0
- package/stacks/_shared/hooks/peers.ts +250 -0
- package/stacks/_shared/hooks/post-tool-use.ts +79 -0
- package/stacks/_shared/hooks/pre-tool-use.ts +175 -0
- package/stacks/_shared/hooks/session-start.ts +124 -0
- package/stacks/_shared/hooks/stop-validator.ts +37 -0
- package/stacks/_shared/hooks/user-prompt-submit.ts +96 -19
- package/stacks/_shared/skills/multi-instance-coordination/SKILL.md +90 -0
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* PostToolUse Hook — Multi-Instance Coordination
|
|
4
|
+
*
|
|
5
|
+
* Wired with matcher `Edit|Write|MultiEdit|NotebookEdit`. Runs AFTER a write
|
|
6
|
+
* succeeds. Responsibilities:
|
|
7
|
+
* 1. Append a `FileTouch` record to `.claude/state/file-touches.jsonl`.
|
|
8
|
+
* 2. Update the session's heartbeat + `filesTouched` (capped at 50, deduped).
|
|
9
|
+
*
|
|
10
|
+
* On any internal error the hook exits silently — coordination must NEVER
|
|
11
|
+
* disturb Claude after a successful tool call.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
FILES_TOUCHED_CAP,
|
|
16
|
+
ensureStateDirs,
|
|
17
|
+
extractTargetFiles,
|
|
18
|
+
getProjectDir,
|
|
19
|
+
getStateDir,
|
|
20
|
+
heartbeat,
|
|
21
|
+
nowIso,
|
|
22
|
+
readSession,
|
|
23
|
+
readStdinJson,
|
|
24
|
+
recordFileTouch,
|
|
25
|
+
} from './_state.js';
|
|
26
|
+
|
|
27
|
+
async function main(): Promise<void> {
|
|
28
|
+
const input = await readStdinJson(1500);
|
|
29
|
+
const sessionId: string | undefined = input.session_id || input.sessionId;
|
|
30
|
+
const toolName: string = input.tool_name || input.toolName || '';
|
|
31
|
+
const toolInput: any = input.tool_input || input.toolInput || {};
|
|
32
|
+
const toolResponse: any = input.tool_response || input.toolResponse;
|
|
33
|
+
|
|
34
|
+
if (!sessionId || !/^(Edit|Write|MultiEdit|NotebookEdit)$/.test(toolName)) {
|
|
35
|
+
console.log(JSON.stringify({ continue: true }));
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Some tool responses surface a `success: false` — do not record those.
|
|
40
|
+
if (toolResponse && typeof toolResponse === 'object' && toolResponse.success === false) {
|
|
41
|
+
console.log(JSON.stringify({ continue: true }));
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const projectDir = getProjectDir();
|
|
46
|
+
const stateDir = getStateDir(projectDir);
|
|
47
|
+
ensureStateDirs(stateDir);
|
|
48
|
+
|
|
49
|
+
const targets = extractTargetFiles(toolName, toolInput, projectDir);
|
|
50
|
+
const ts = nowIso();
|
|
51
|
+
for (const file of targets) {
|
|
52
|
+
recordFileTouch(stateDir, { ts, sessionId, tool: toolName, file });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const session = readSession(stateDir, sessionId);
|
|
56
|
+
const previous = session?.filesTouched || [];
|
|
57
|
+
const merged = dedupeKeepLast([...previous, ...targets], FILES_TOUCHED_CAP);
|
|
58
|
+
heartbeat(stateDir, sessionId, `PostToolUse:${toolName}`, { filesTouched: merged });
|
|
59
|
+
|
|
60
|
+
console.log(JSON.stringify({ continue: true }));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function dedupeKeepLast(items: string[], cap: number): string[] {
|
|
64
|
+
const seen = new Set<string>();
|
|
65
|
+
const reversed: string[] = [];
|
|
66
|
+
for (let i = items.length - 1; i >= 0; i--) {
|
|
67
|
+
const x = items[i]!;
|
|
68
|
+
if (seen.has(x)) continue;
|
|
69
|
+
seen.add(x);
|
|
70
|
+
reversed.push(x);
|
|
71
|
+
if (reversed.length >= cap) break;
|
|
72
|
+
}
|
|
73
|
+
return reversed.reverse();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
main().catch(() => {
|
|
77
|
+
console.log(JSON.stringify({ continue: true }));
|
|
78
|
+
process.exit(0);
|
|
79
|
+
});
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* PreToolUse Hook — Multi-Instance Coordination
|
|
4
|
+
*
|
|
5
|
+
* Wired with matcher `Edit|Write|MultiEdit|NotebookEdit`. Reads the file-touches
|
|
6
|
+
* log + active peer sessions and decides:
|
|
7
|
+
*
|
|
8
|
+
* - BLOCK if a peer is currently ACTIVE (heartbeat < 60s) AND touched the same
|
|
9
|
+
* file within the last 5 minutes. The reason explains how to recover.
|
|
10
|
+
* - WARN (approve + systemMessage) if a peer touched the file recently but is
|
|
11
|
+
* only IDLE (60s — 5min).
|
|
12
|
+
* - APPROVE silently otherwise.
|
|
13
|
+
*
|
|
14
|
+
* Hook input:
|
|
15
|
+
* { session_id, tool_name, tool_input, hook_event_name, ... }
|
|
16
|
+
*
|
|
17
|
+
* Output schema (JSON):
|
|
18
|
+
* { decision?: 'block', reason?: string, continue: true, systemMessage?: string }
|
|
19
|
+
*
|
|
20
|
+
* On any internal error, the hook approves silently — coordination must NEVER
|
|
21
|
+
* break Claude.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import {
|
|
25
|
+
ACTIVE_MS,
|
|
26
|
+
COLLISION_WINDOW_MS,
|
|
27
|
+
ageMs,
|
|
28
|
+
classifyAge,
|
|
29
|
+
ensureStateDirs,
|
|
30
|
+
extractTargetFiles,
|
|
31
|
+
getProjectDir,
|
|
32
|
+
getStateDir,
|
|
33
|
+
heartbeat,
|
|
34
|
+
listPeerSessions,
|
|
35
|
+
readStdinJson,
|
|
36
|
+
shortId,
|
|
37
|
+
tailFileTouches,
|
|
38
|
+
type FileTouch,
|
|
39
|
+
type SessionRecord,
|
|
40
|
+
} from './_state.js';
|
|
41
|
+
|
|
42
|
+
interface Verdict {
|
|
43
|
+
block: boolean;
|
|
44
|
+
reason?: string;
|
|
45
|
+
warning?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function evaluate(
|
|
49
|
+
targetFiles: string[],
|
|
50
|
+
peers: SessionRecord[],
|
|
51
|
+
touches: FileTouch[],
|
|
52
|
+
selfSessionId: string
|
|
53
|
+
): Verdict {
|
|
54
|
+
if (targetFiles.length === 0) return { block: false };
|
|
55
|
+
|
|
56
|
+
const peerById = new Map<string, SessionRecord>();
|
|
57
|
+
for (const p of peers) peerById.set(p.sessionId, p);
|
|
58
|
+
|
|
59
|
+
// Most-recent peer touch per (file).
|
|
60
|
+
const recent = new Map<string, FileTouch>();
|
|
61
|
+
for (const t of touches) {
|
|
62
|
+
if (t.sessionId === selfSessionId) continue;
|
|
63
|
+
if (!targetFiles.includes(t.file)) continue;
|
|
64
|
+
if (ageMs(t.ts) > COLLISION_WINDOW_MS) continue;
|
|
65
|
+
const prev = recent.get(t.file);
|
|
66
|
+
if (!prev || Date.parse(t.ts) > Date.parse(prev.ts)) recent.set(t.file, t);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (recent.size === 0) return { block: false };
|
|
70
|
+
|
|
71
|
+
const blockers: string[] = [];
|
|
72
|
+
const warns: string[] = [];
|
|
73
|
+
|
|
74
|
+
for (const [file, touch] of recent) {
|
|
75
|
+
const peer = peerById.get(touch.sessionId);
|
|
76
|
+
const peerActive = peer && ageMs(peer.lastSeenAt) < ACTIVE_MS;
|
|
77
|
+
const touchAgeSec = Math.round(ageMs(touch.ts) / 1000);
|
|
78
|
+
const peerLabel = peer
|
|
79
|
+
? `${shortId(peer.sessionId)} "${peer.title}"${peer.gitBranch ? ` @${peer.gitBranch}` : ''}`
|
|
80
|
+
: `${shortId(touch.sessionId)} (session record gone)`;
|
|
81
|
+
|
|
82
|
+
if (peerActive) {
|
|
83
|
+
blockers.push(
|
|
84
|
+
` - ${file}\n last touched ${touchAgeSec}s ago by peer ${peerLabel} (HEARTBEAT ACTIVE)`
|
|
85
|
+
);
|
|
86
|
+
} else {
|
|
87
|
+
const klass = peer ? classifyAge(ageMs(peer.lastSeenAt)) : 'stale';
|
|
88
|
+
warns.push(
|
|
89
|
+
` - ${file}: peer ${peerLabel} (${klass}) touched it ${touchAgeSec}s ago`
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (blockers.length > 0) {
|
|
95
|
+
const reason =
|
|
96
|
+
`BLOCKED by multi-instance coordination — another active Claude session is editing the same file.\n` +
|
|
97
|
+
`Active collision(s):\n${blockers.join('\n')}\n\n` +
|
|
98
|
+
`Recommended actions:\n` +
|
|
99
|
+
` 1. Run \`npx tsx .claude/hooks/peers.ts list\` to see who is active.\n` +
|
|
100
|
+
` 2. Notify them: \`npx tsx .claude/hooks/peers.ts notify <id-prefix> "I need to edit <file>, can you commit/stash?"\`\n` +
|
|
101
|
+
` 3. Wait for them to commit, then retry — or have them call \`peers.ts cleanup\` if you confirm they are no longer editing.\n` +
|
|
102
|
+
` 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).`;
|
|
103
|
+
return { block: true, reason };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (warns.length > 0) {
|
|
107
|
+
const warning =
|
|
108
|
+
`MULTI-INSTANCE WARNING — files you are about to edit were recently touched by an idle peer:\n${warns.join('\n')}\n` +
|
|
109
|
+
`Edit is allowed (peer is not actively typing). Consider rebasing on their work after they commit.`;
|
|
110
|
+
return { block: false, warning };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return { block: false };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function main(): Promise<void> {
|
|
117
|
+
const input = await readStdinJson(1500);
|
|
118
|
+
const sessionId: string | undefined = input.session_id || input.sessionId;
|
|
119
|
+
const toolName: string = input.tool_name || input.toolName || '';
|
|
120
|
+
const toolInput: any = input.tool_input || input.toolInput || {};
|
|
121
|
+
|
|
122
|
+
// Defensive: only act on edit-class tools.
|
|
123
|
+
if (!/^(Edit|Write|MultiEdit|NotebookEdit)$/.test(toolName)) {
|
|
124
|
+
console.log(JSON.stringify({ continue: true }));
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const projectDir = getProjectDir();
|
|
129
|
+
const stateDir = getStateDir(projectDir);
|
|
130
|
+
ensureStateDirs(stateDir);
|
|
131
|
+
|
|
132
|
+
if (sessionId) {
|
|
133
|
+
heartbeat(stateDir, sessionId, `PreToolUse:${toolName}`);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const targetFiles = extractTargetFiles(toolName, toolInput, projectDir);
|
|
137
|
+
if (targetFiles.length === 0) {
|
|
138
|
+
console.log(JSON.stringify({ continue: true }));
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const peers = listPeerSessions(stateDir, sessionId || null);
|
|
143
|
+
const touches = tailFileTouches(stateDir);
|
|
144
|
+
|
|
145
|
+
const verdict = evaluate(targetFiles, peers, touches, sessionId || '');
|
|
146
|
+
|
|
147
|
+
if (verdict.block) {
|
|
148
|
+
console.log(
|
|
149
|
+
JSON.stringify({
|
|
150
|
+
continue: true,
|
|
151
|
+
decision: 'block',
|
|
152
|
+
reason: verdict.reason,
|
|
153
|
+
})
|
|
154
|
+
);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (verdict.warning) {
|
|
159
|
+
console.log(
|
|
160
|
+
JSON.stringify({
|
|
161
|
+
continue: true,
|
|
162
|
+
systemMessage: verdict.warning,
|
|
163
|
+
})
|
|
164
|
+
);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
console.log(JSON.stringify({ continue: true }));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
main().catch(() => {
|
|
172
|
+
// Coordination must never block Claude on its own bug.
|
|
173
|
+
console.log(JSON.stringify({ continue: true }));
|
|
174
|
+
process.exit(0);
|
|
175
|
+
});
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* SessionStart Hook — Multi-Instance Coordination
|
|
4
|
+
*
|
|
5
|
+
* Runs once per Claude session start. Responsibilities:
|
|
6
|
+
* 1. Register this session in `.claude/state/sessions/<id>.json`.
|
|
7
|
+
* 2. Extract a human title from the transcript (matches what `claude --resume` shows).
|
|
8
|
+
* 3. Scan for peer sessions in the same project; warn if any are active.
|
|
9
|
+
* 4. Drain the inbox so messages from peers are surfaced in the first prompt.
|
|
10
|
+
*
|
|
11
|
+
* Hook input (JSON via stdin):
|
|
12
|
+
* { session_id, transcript_path, cwd, hook_event_name, source }
|
|
13
|
+
*
|
|
14
|
+
* Output (JSON to stdout):
|
|
15
|
+
* { continue: true, systemMessage?: string }
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
ageMs,
|
|
20
|
+
appendInbox,
|
|
21
|
+
drainInbox,
|
|
22
|
+
ensureStateDirs,
|
|
23
|
+
extractTitle,
|
|
24
|
+
formatPeer,
|
|
25
|
+
getGitBranch,
|
|
26
|
+
getProjectDir,
|
|
27
|
+
getStateDir,
|
|
28
|
+
heartbeat,
|
|
29
|
+
listPeerSessions,
|
|
30
|
+
nowIso,
|
|
31
|
+
readSession,
|
|
32
|
+
readStdinJson,
|
|
33
|
+
shortId,
|
|
34
|
+
type InboxMessage,
|
|
35
|
+
} from './_state.js';
|
|
36
|
+
|
|
37
|
+
async function main(): Promise<void> {
|
|
38
|
+
const input = await readStdinJson(1500);
|
|
39
|
+
const sessionId: string | undefined = input.session_id || input.sessionId;
|
|
40
|
+
const transcriptPath: string | undefined = input.transcript_path || input.transcriptPath;
|
|
41
|
+
const projectDir = getProjectDir();
|
|
42
|
+
const stateDir = getStateDir(projectDir);
|
|
43
|
+
|
|
44
|
+
if (!sessionId) {
|
|
45
|
+
console.log(JSON.stringify({ continue: true }));
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
ensureStateDirs(stateDir);
|
|
50
|
+
|
|
51
|
+
// Resolve title: prefer the existing record (so we don't lose a real title
|
|
52
|
+
// captured by user-prompt-submit when the SessionStart event also fires for
|
|
53
|
+
// a `/resume` flow that already had a title).
|
|
54
|
+
const existing = readSession(stateDir, sessionId);
|
|
55
|
+
const title = existing?.title && existing.title !== '(untitled)'
|
|
56
|
+
? existing.title
|
|
57
|
+
: extractTitle(transcriptPath);
|
|
58
|
+
|
|
59
|
+
const branch = getGitBranch(projectDir);
|
|
60
|
+
|
|
61
|
+
heartbeat(stateDir, sessionId, 'SessionStart', {
|
|
62
|
+
transcriptPath,
|
|
63
|
+
title,
|
|
64
|
+
cwd: projectDir,
|
|
65
|
+
gitBranch: branch,
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// Discover peers (active or idle within 30min).
|
|
69
|
+
const peers = listPeerSessions(stateDir, sessionId);
|
|
70
|
+
|
|
71
|
+
// Drain inbox messages addressed to this session.
|
|
72
|
+
const inbox = drainInbox(stateDir, sessionId);
|
|
73
|
+
|
|
74
|
+
const messageParts: string[] = [];
|
|
75
|
+
messageParts.push(
|
|
76
|
+
`MULTI-INSTANCE COORDINATION ACTIVE — this Claude session is registered as ${shortId(sessionId)} ("${title}"). State at .claude/state/.`
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
if (peers.length > 0) {
|
|
80
|
+
messageParts.push('');
|
|
81
|
+
messageParts.push(`PEERS DETECTED in this project (${peers.length}):`);
|
|
82
|
+
for (const p of peers) messageParts.push(` - ${formatPeer(p)}`);
|
|
83
|
+
const anyActive = peers.some(p => ageMs(p.lastSeenAt) < 60 * 1000);
|
|
84
|
+
if (anyActive) {
|
|
85
|
+
messageParts.push('');
|
|
86
|
+
messageParts.push(
|
|
87
|
+
'WARNING: at least one peer is ACTIVE right now. Edit/Write of a file ' +
|
|
88
|
+
'a peer just touched will be BLOCKED by the PreToolUse hook to prevent ' +
|
|
89
|
+
'overwriting their uncommitted work. Coordinate via `/peers notify <id> "msg"` ' +
|
|
90
|
+
'before touching shared files.'
|
|
91
|
+
);
|
|
92
|
+
} else {
|
|
93
|
+
messageParts.push('');
|
|
94
|
+
messageParts.push(
|
|
95
|
+
'Peers are idle (>60s). Edits will be allowed but you will see a notice if ' +
|
|
96
|
+
'you touch files they recently modified.'
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (inbox.length > 0) {
|
|
102
|
+
messageParts.push('');
|
|
103
|
+
messageParts.push(`INBOX (${inbox.length} message${inbox.length === 1 ? '' : 's'} from peers):`);
|
|
104
|
+
for (const m of inbox) messageParts.push(formatInbox(m));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const systemMessage = messageParts.join('\n');
|
|
108
|
+
|
|
109
|
+
console.log(JSON.stringify({ continue: true, systemMessage }));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function formatInbox(m: InboxMessage): string {
|
|
113
|
+
const from = m.fromTitle ? `${shortId(m.fromSessionId)} "${m.fromTitle}"` : shortId(m.fromSessionId);
|
|
114
|
+
return ` [${m.ts}] from ${from}: ${m.message}`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
main().catch(() => {
|
|
118
|
+
console.log(JSON.stringify({ continue: true }));
|
|
119
|
+
process.exit(0);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// Silence unused-import warning when type-only imports are tree-shaken
|
|
123
|
+
void nowIso;
|
|
124
|
+
void appendInbox;
|
|
@@ -15,6 +15,16 @@
|
|
|
15
15
|
import { execSync } from 'child_process';
|
|
16
16
|
import { existsSync, readFileSync } from 'fs';
|
|
17
17
|
import { join } from 'path';
|
|
18
|
+
import {
|
|
19
|
+
ACTIVE_MS,
|
|
20
|
+
ageMs,
|
|
21
|
+
archiveSession,
|
|
22
|
+
clearInbox,
|
|
23
|
+
ensureStateDirs,
|
|
24
|
+
formatPeer,
|
|
25
|
+
getStateDir,
|
|
26
|
+
listPeerSessions,
|
|
27
|
+
} from './_state.js';
|
|
18
28
|
|
|
19
29
|
const PROJECT_DIR = process.env['CLAUDE_PROJECT_DIR'] || process.cwd();
|
|
20
30
|
const CLAUDE_MD = join(PROJECT_DIR, 'CLAUDE.md');
|
|
@@ -237,6 +247,33 @@ async function main(): Promise<void> {
|
|
|
237
247
|
}
|
|
238
248
|
|
|
239
249
|
const result = validate();
|
|
250
|
+
|
|
251
|
+
// Multi-instance coordination side effects.
|
|
252
|
+
const sessionId: string | undefined = hookInput.session_id || hookInput.sessionId;
|
|
253
|
+
const eventName: string = hookInput.hook_event_name || hookInput.hookEventName || '';
|
|
254
|
+
const isSessionEnd = /SessionEnd/i.test(eventName);
|
|
255
|
+
|
|
256
|
+
try {
|
|
257
|
+
const stateDir = getStateDir(PROJECT_DIR);
|
|
258
|
+
ensureStateDirs(stateDir);
|
|
259
|
+
|
|
260
|
+
if (result.decision === 'approve' && sessionId) {
|
|
261
|
+
const peers = listPeerSessions(stateDir, sessionId);
|
|
262
|
+
const activePeers = peers.filter(p => ageMs(p.lastSeenAt) < ACTIVE_MS);
|
|
263
|
+
if (activePeers.length > 0) {
|
|
264
|
+
const lines = activePeers.map(p => ` - ${formatPeer(p)}`).join('\n');
|
|
265
|
+
result.reason +=
|
|
266
|
+
`\n\nNOTE: ${activePeers.length} peer instance(s) still active in this project:\n${lines}\n` +
|
|
267
|
+
`If you committed and pushed, your work is now visible to them.`;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (isSessionEnd && sessionId) {
|
|
272
|
+
archiveSession(stateDir, sessionId);
|
|
273
|
+
clearInbox(stateDir, sessionId);
|
|
274
|
+
}
|
|
275
|
+
} catch {}
|
|
276
|
+
|
|
240
277
|
console.log(JSON.stringify(result));
|
|
241
278
|
process.exit(0);
|
|
242
279
|
}
|
|
@@ -2,14 +2,37 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* UserPromptSubmit Hook — Start Vibing Stacks
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
5
|
+
* Two responsibilities:
|
|
6
|
+
* 1. Inject the per-stack workflow + project-standards context block.
|
|
7
|
+
* 2. Multi-instance coordination: heartbeat the session, capture the title
|
|
8
|
+
* from the first prompt (matches `claude --resume`), drain the inbox so
|
|
9
|
+
* peer messages reach the user before this turn runs, and warn if active
|
|
10
|
+
* peers exist.
|
|
7
11
|
*/
|
|
8
12
|
|
|
9
13
|
import { existsSync, readFileSync } from 'fs';
|
|
10
14
|
import { join } from 'path';
|
|
11
|
-
|
|
12
|
-
|
|
15
|
+
import {
|
|
16
|
+
ACTIVE_MS,
|
|
17
|
+
ageMs,
|
|
18
|
+
drainInbox,
|
|
19
|
+
ensureStateDirs,
|
|
20
|
+
extractTitle,
|
|
21
|
+
formatPeer,
|
|
22
|
+
getGitBranch,
|
|
23
|
+
getProjectDir,
|
|
24
|
+
getStateDir,
|
|
25
|
+
heartbeat,
|
|
26
|
+
listPeerSessions,
|
|
27
|
+
readSession,
|
|
28
|
+
readStdinJson,
|
|
29
|
+
shortId,
|
|
30
|
+
truncate,
|
|
31
|
+
type InboxMessage,
|
|
32
|
+
type SessionRecord,
|
|
33
|
+
} from './_state.js';
|
|
34
|
+
|
|
35
|
+
const PROJECT_DIR = getProjectDir();
|
|
13
36
|
const ACTIVE_PROJECT = join(PROJECT_DIR, '.claude', 'config', 'active-project.json');
|
|
14
37
|
const STANDARDS_REVIEW = join(PROJECT_DIR, '.claude', 'config', 'standards-review.json');
|
|
15
38
|
|
|
@@ -36,7 +59,8 @@ interface ReviewFile {
|
|
|
36
59
|
let standardsContext = '';
|
|
37
60
|
try {
|
|
38
61
|
if (!existsSync(STANDARDS_REVIEW)) {
|
|
39
|
-
standardsContext =
|
|
62
|
+
standardsContext =
|
|
63
|
+
`\n\nSTANDARDS REVIEW NEEDED: No standards-review.json found. ` +
|
|
40
64
|
`This project may have existing coding standards (.cursorrules, composer.json configs). ` +
|
|
41
65
|
`Ask the user: "I noticed this project hasn't been scanned for existing standards. ` +
|
|
42
66
|
`Would you like me to review your codebase patterns and adapt my behavior, ` +
|
|
@@ -45,26 +69,66 @@ try {
|
|
|
45
69
|
const review: ReviewFile = JSON.parse(readFileSync(STANDARDS_REVIEW, 'utf8'));
|
|
46
70
|
if (review.status === 'adapted' && review.patterns && review.patterns.length > 0) {
|
|
47
71
|
const patternList = review.patterns.map(p => `- [${p.category}] ${p.name}`).join('\n');
|
|
48
|
-
standardsContext =
|
|
72
|
+
standardsContext =
|
|
73
|
+
`\n\nPROJECT STANDARDS (scanned from ${(review.sources || []).join(', ')}):\n${patternList}\n` +
|
|
49
74
|
`Follow these project-specific patterns. They take priority over generic defaults.`;
|
|
50
75
|
}
|
|
51
76
|
}
|
|
52
77
|
} catch {}
|
|
53
78
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
79
|
+
function formatInbox(m: InboxMessage): string {
|
|
80
|
+
const from = m.fromTitle ? `${shortId(m.fromSessionId)} "${m.fromTitle}"` : shortId(m.fromSessionId);
|
|
81
|
+
return ` [${m.ts}] from ${from}: ${m.message}`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function buildPeersBlock(stateDir: string, sessionId: string, prompt: string): string {
|
|
85
|
+
ensureStateDirs(stateDir);
|
|
86
|
+
|
|
87
|
+
// Heartbeat + capture title on first prompt of the session.
|
|
88
|
+
const existing = readSession(stateDir, sessionId);
|
|
89
|
+
const titleNeeded = !existing || !existing.title || existing.title === '(untitled)';
|
|
90
|
+
const title = titleNeeded
|
|
91
|
+
? extractTitle(existing?.transcriptPath, prompt)
|
|
92
|
+
: existing!.title;
|
|
93
|
+
|
|
94
|
+
const branch = existing?.gitBranch ?? getGitBranch(PROJECT_DIR);
|
|
95
|
+
|
|
96
|
+
heartbeat(stateDir, sessionId, 'UserPromptSubmit', {
|
|
97
|
+
title,
|
|
98
|
+
cwd: PROJECT_DIR,
|
|
99
|
+
gitBranch: branch,
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
const peers: SessionRecord[] = listPeerSessions(stateDir, sessionId);
|
|
103
|
+
const inbox = drainInbox(stateDir, sessionId);
|
|
104
|
+
|
|
105
|
+
const parts: string[] = [];
|
|
106
|
+
if (peers.length > 0) {
|
|
107
|
+
const activeCount = peers.filter(p => ageMs(p.lastSeenAt) < ACTIVE_MS).length;
|
|
108
|
+
parts.push('');
|
|
109
|
+
parts.push(`PEER INSTANCES IN THIS PROJECT (${peers.length}, ${activeCount} active):`);
|
|
110
|
+
for (const p of peers) parts.push(` - ${formatPeer(p)}`);
|
|
111
|
+
if (activeCount > 0) {
|
|
112
|
+
parts.push(
|
|
113
|
+
' ! Edit/Write of files an active peer just touched will be BLOCKED. ' +
|
|
114
|
+
'Use `/peers notify <id> "message"` to coordinate.'
|
|
115
|
+
);
|
|
62
116
|
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (inbox.length > 0) {
|
|
120
|
+
parts.push('');
|
|
121
|
+
parts.push(`INBOX (${inbox.length} message${inbox.length === 1 ? '' : 's'} from peers — surface to the user):`);
|
|
122
|
+
for (const m of inbox) parts.push(formatInbox(m));
|
|
123
|
+
}
|
|
66
124
|
|
|
67
|
-
|
|
125
|
+
return parts.join('\n');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function main(): Promise<void> {
|
|
129
|
+
const hookInput = await readStdinJson(1500);
|
|
130
|
+
const prompt: string =
|
|
131
|
+
hookInput.user_prompt || hookInput.prompt || hookInput.userPrompt || '';
|
|
68
132
|
if (!prompt.trim()) {
|
|
69
133
|
console.log(JSON.stringify({ continue: true }));
|
|
70
134
|
process.exit(0);
|
|
@@ -72,6 +136,17 @@ async function main(): Promise<void> {
|
|
|
72
136
|
|
|
73
137
|
const today = new Date().toISOString().split('T')[0];
|
|
74
138
|
|
|
139
|
+
let peersBlock = '';
|
|
140
|
+
const sessionId: string | undefined = hookInput.session_id || hookInput.sessionId;
|
|
141
|
+
if (sessionId) {
|
|
142
|
+
const stateDir = getStateDir(PROJECT_DIR);
|
|
143
|
+
try {
|
|
144
|
+
peersBlock = buildPeersBlock(stateDir, sessionId, prompt);
|
|
145
|
+
} catch {
|
|
146
|
+
peersBlock = '';
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
75
150
|
const systemMessage = `TASK WORKFLOW (Stack: ${stackName}):
|
|
76
151
|
|
|
77
152
|
0. READ both CLAUDE.md and .claude/config/active-project.json before changes.
|
|
@@ -89,7 +164,7 @@ async function main(): Promise<void> {
|
|
|
89
164
|
a. "## Last Change" (date: ${today}, branch, summary)
|
|
90
165
|
b. Update ALL affected rule/flow sections
|
|
91
166
|
|
|
92
|
-
6. Run stop-validator before finishing.${standardsContext}`;
|
|
167
|
+
6. Run stop-validator before finishing.${standardsContext}${peersBlock}`;
|
|
93
168
|
|
|
94
169
|
console.log(JSON.stringify({ continue: true, systemMessage }));
|
|
95
170
|
process.exit(0);
|
|
@@ -99,3 +174,5 @@ main().catch(() => {
|
|
|
99
174
|
console.log(JSON.stringify({ continue: true }));
|
|
100
175
|
process.exit(0);
|
|
101
176
|
});
|
|
177
|
+
|
|
178
|
+
void truncate;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: multi-instance-coordination
|
|
3
|
+
version: 1.0.0
|
|
4
|
+
description: Coordination protocol when multiple Claude Code instances run in the same project folder. State layout under `.claude/state/`, peer detection, file-edit collision avoidance (hybrid block/warn), and the `/peers` CLI for cross-instance messaging. Use when the user mentions another Claude instance, parallel work, concurrent sessions, the word "peers", or asks about /resume titles, file-touches.jsonl, or `.claude/state/`.
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Multi-Instance Coordination
|
|
8
|
+
|
|
9
|
+
When two or more Claude Code instances are running in the same project folder, they auto-discover each other through `.claude/state/` and refuse to overwrite each other's uncommitted work.
|
|
10
|
+
|
|
11
|
+
## Mental model
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
.claude/state/
|
|
15
|
+
sessions/<id>.json registry (heartbeat = lastSeenAt)
|
|
16
|
+
sessions/_archive/ sessions ended or gone idle >30min
|
|
17
|
+
inbox/<id>.jsonl messages queued FOR that session
|
|
18
|
+
file-touches.jsonl append-only log of every Edit/Write
|
|
19
|
+
file-touches/_archive/ rotated logs (every 1000 lines)
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Every hook updates `lastSeenAt` (heartbeat). Thresholds:
|
|
23
|
+
|
|
24
|
+
| Age of last activity | State | Effect |
|
|
25
|
+
| -------------------- | -------- | ---------------------------------------------------------------------- |
|
|
26
|
+
| < 60s | active | Counts for collision detection; PreToolUse may BLOCK Edit/Write. |
|
|
27
|
+
| 60s – 30min | idle | Surfaced as a warning; edits NOT blocked. |
|
|
28
|
+
| > 30min | stale | Auto-archived on the next sweep. |
|
|
29
|
+
| > 24h | removed | Deleted entirely. |
|
|
30
|
+
|
|
31
|
+
## How a Claude session announces itself
|
|
32
|
+
|
|
33
|
+
1. `SessionStart` runs once. It registers the session, extracts a `title` from the transcript (this is the same title that appears in `claude --resume`), lists peers, and drains the inbox.
|
|
34
|
+
2. `UserPromptSubmit` keeps the heartbeat fresh on every user turn. It re-drains the inbox so messages arrive between turns, not stuck waiting for a SessionStart.
|
|
35
|
+
3. `PreToolUse` (matcher `Edit|Write|MultiEdit|NotebookEdit`) checks `file-touches.jsonl` against the target file BEFORE the edit runs.
|
|
36
|
+
4. `PostToolUse` (same matcher) records the touch AFTER a successful edit.
|
|
37
|
+
5. `Stop` / `SessionEnd` archives the session and clears its inbox.
|
|
38
|
+
|
|
39
|
+
## PreToolUse decision matrix
|
|
40
|
+
|
|
41
|
+
| Peer who touched the same file last | Peer's heartbeat | Touch age | Decision |
|
|
42
|
+
| ----------------------------------- | ---------------- | --------- | --------------------------------------------- |
|
|
43
|
+
| any | active (<60s) | < 5min | **BLOCK** with a recovery hint |
|
|
44
|
+
| any | idle (60s–30min) | < 5min | APPROVE + warning in `systemMessage` |
|
|
45
|
+
| any | stale or gone | any | APPROVE silently |
|
|
46
|
+
| no peer touched the file | n/a | n/a | APPROVE silently |
|
|
47
|
+
|
|
48
|
+
Override path: wait until the active peer's heartbeat goes idle (60s of no activity), then retry — the hook will downgrade to a warning. If the user explicitly tells you to override, ask them to run `peers notify <id> "I'm taking over <file>"` first so the other instance gets the heads-up.
|
|
49
|
+
|
|
50
|
+
## Talking to a peer
|
|
51
|
+
|
|
52
|
+
Always use the `peers` CLI. NEVER write to `.claude/state/inbox/` by hand.
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
# See who is around
|
|
56
|
+
npx tsx "$CLAUDE_PROJECT_DIR/.claude/hooks/peers.ts" list
|
|
57
|
+
|
|
58
|
+
# Send a message (id-prefix is the 8-char short id from `list`)
|
|
59
|
+
npx tsx "$CLAUDE_PROJECT_DIR/.claude/hooks/peers.ts" notify a1b2c3d4 "I just committed auth changes"
|
|
60
|
+
|
|
61
|
+
# See recent file edits across instances
|
|
62
|
+
npx tsx "$CLAUDE_PROJECT_DIR/.claude/hooks/peers.ts" locks --minutes 10
|
|
63
|
+
|
|
64
|
+
# Cleanup zombie sessions (>1h idle)
|
|
65
|
+
npx tsx "$CLAUDE_PROJECT_DIR/.claude/hooks/peers.ts" cleanup
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
The `/peers` slash command wraps these.
|
|
69
|
+
|
|
70
|
+
## Guardrails
|
|
71
|
+
|
|
72
|
+
- The coordination layer is **single-host**. It does not replace `git pull` or PR review across machines.
|
|
73
|
+
- All state writes are atomic (`.tmp` + `rename`); reads are tolerant of corruption — hooks never block Claude on a broken state file.
|
|
74
|
+
- `.claude/state/` MUST stay gitignored. Committing it would leak per-host PIDs and transcript paths.
|
|
75
|
+
- `peers` is the user-facing surface; the hooks are internal plumbing. Do not invoke `_state.ts` directly.
|
|
76
|
+
|
|
77
|
+
## Failure modes (and what to do)
|
|
78
|
+
|
|
79
|
+
| Symptom | Cause | Fix |
|
|
80
|
+
| -------------------------------------------------------- | ------------------------------------ | ------------------------------------------------------ |
|
|
81
|
+
| `peers list` shows a phantom session | Crashed instance left a record | `peers cleanup` (archives idle, removes >24h) |
|
|
82
|
+
| Edit blocked but the other instance is gone | Stale heartbeat, peer never archived | Wait 30min, or run `peers cleanup` |
|
|
83
|
+
| Inbox messages didn't arrive | Peer is paused / no `UserPromptSubmit` | They will appear on the next prompt the peer submits |
|
|
84
|
+
| Two edits hit the same file in the same second | Race condition (rare) | The append-only log captures both; review with `git diff` |
|
|
85
|
+
|
|
86
|
+
## See Also
|
|
87
|
+
|
|
88
|
+
- `git-workflow` — branch and commit hygiene; the coordination layer is an in-flight collision shield, not a replacement for branches.
|
|
89
|
+
- `_state.ts` — shared TypeScript helpers used by every hook.
|
|
90
|
+
- `.claude/state/` README at `.claude/hooks/_state.README.md`.
|