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.
@@ -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
- * Injects workflow instructions before each user prompt.
6
- * Reads active-project.json for stack-specific context.
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
- const PROJECT_DIR = process.env['CLAUDE_PROJECT_DIR'] || process.cwd();
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 = `\n\nSTANDARDS REVIEW NEEDED: No standards-review.json found. ` +
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 = `\n\nPROJECT STANDARDS (scanned from ${(review.sources || []).join(', ')}):\n${patternList}\n` +
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
- async function main(): Promise<void> {
55
- let hookInput: any = {};
56
- try {
57
- const chunks: string[] = [];
58
- process.stdin.setEncoding('utf8');
59
- const timeout = setTimeout(() => process.stdin.destroy(), 1000);
60
- for await (const chunk of process.stdin) {
61
- chunks.push(chunk);
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
- clearTimeout(timeout);
64
- hookInput = JSON.parse(chunks.join('') || '{}');
65
- } catch {}
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
- const prompt = hookInput.user_prompt || hookInput.prompt || '';
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`.