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.
@@ -1,15 +1,39 @@
1
1
  #!/usr/bin/env node
2
+ // @sv-version: 1.1.0
2
3
  /**
3
4
  * UserPromptSubmit Hook — Start Vibing Stacks
4
5
  *
5
- * Injects workflow instructions before each user prompt.
6
- * Reads active-project.json for stack-specific context.
6
+ * Two responsibilities:
7
+ * 1. Inject the per-stack workflow + project-standards context block.
8
+ * 2. Multi-instance coordination: heartbeat the session, capture the title
9
+ * from the first prompt (matches `claude --resume`), drain the inbox so
10
+ * peer messages reach the user before this turn runs, and warn if active
11
+ * peers exist.
7
12
  */
8
13
 
9
14
  import { existsSync, readFileSync } from 'fs';
10
15
  import { join } from 'path';
11
-
12
- const PROJECT_DIR = process.env['CLAUDE_PROJECT_DIR'] || process.cwd();
16
+ import {
17
+ ACTIVE_MS,
18
+ ageMs,
19
+ drainInbox,
20
+ ensureStateDirs,
21
+ extractTitle,
22
+ formatPeer,
23
+ getGitBranch,
24
+ getProjectDir,
25
+ getStateDir,
26
+ heartbeat,
27
+ listPeerSessions,
28
+ readSession,
29
+ readStdinJson,
30
+ shortId,
31
+ truncate,
32
+ type InboxMessage,
33
+ type SessionRecord,
34
+ } from './_state.js';
35
+
36
+ const PROJECT_DIR = getProjectDir();
13
37
  const ACTIVE_PROJECT = join(PROJECT_DIR, '.claude', 'config', 'active-project.json');
14
38
  const STANDARDS_REVIEW = join(PROJECT_DIR, '.claude', 'config', 'standards-review.json');
15
39
 
@@ -36,7 +60,8 @@ interface ReviewFile {
36
60
  let standardsContext = '';
37
61
  try {
38
62
  if (!existsSync(STANDARDS_REVIEW)) {
39
- standardsContext = `\n\nSTANDARDS REVIEW NEEDED: No standards-review.json found. ` +
63
+ standardsContext =
64
+ `\n\nSTANDARDS REVIEW NEEDED: No standards-review.json found. ` +
40
65
  `This project may have existing coding standards (.cursorrules, composer.json configs). ` +
41
66
  `Ask the user: "I noticed this project hasn't been scanned for existing standards. ` +
42
67
  `Would you like me to review your codebase patterns and adapt my behavior, ` +
@@ -45,26 +70,66 @@ try {
45
70
  const review: ReviewFile = JSON.parse(readFileSync(STANDARDS_REVIEW, 'utf8'));
46
71
  if (review.status === 'adapted' && review.patterns && review.patterns.length > 0) {
47
72
  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` +
73
+ standardsContext =
74
+ `\n\nPROJECT STANDARDS (scanned from ${(review.sources || []).join(', ')}):\n${patternList}\n` +
49
75
  `Follow these project-specific patterns. They take priority over generic defaults.`;
50
76
  }
51
77
  }
52
78
  } catch {}
53
79
 
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);
80
+ function formatInbox(m: InboxMessage): string {
81
+ const from = m.fromTitle ? `${shortId(m.fromSessionId)} "${m.fromTitle}"` : shortId(m.fromSessionId);
82
+ return ` [${m.ts}] from ${from}: ${m.message}`;
83
+ }
84
+
85
+ function buildPeersBlock(stateDir: string, sessionId: string, prompt: string): string {
86
+ ensureStateDirs(stateDir);
87
+
88
+ // Heartbeat + capture title on first prompt of the session.
89
+ const existing = readSession(stateDir, sessionId);
90
+ const titleNeeded = !existing || !existing.title || existing.title === '(untitled)';
91
+ const title = titleNeeded
92
+ ? extractTitle(existing?.transcriptPath, prompt)
93
+ : existing!.title;
94
+
95
+ const branch = existing?.gitBranch ?? getGitBranch(PROJECT_DIR);
96
+
97
+ heartbeat(stateDir, sessionId, 'UserPromptSubmit', {
98
+ title,
99
+ cwd: PROJECT_DIR,
100
+ gitBranch: branch,
101
+ });
102
+
103
+ const peers: SessionRecord[] = listPeerSessions(stateDir, sessionId);
104
+ const inbox = drainInbox(stateDir, sessionId);
105
+
106
+ const parts: string[] = [];
107
+ if (peers.length > 0) {
108
+ const activeCount = peers.filter(p => ageMs(p.lastSeenAt) < ACTIVE_MS).length;
109
+ parts.push('');
110
+ parts.push(`PEER INSTANCES IN THIS PROJECT (${peers.length}, ${activeCount} active):`);
111
+ for (const p of peers) parts.push(` - ${formatPeer(p)}`);
112
+ if (activeCount > 0) {
113
+ parts.push(
114
+ ' ! Edit/Write of files an active peer just touched will be BLOCKED. ' +
115
+ 'Use `/peers notify <id> "message"` to coordinate.'
116
+ );
62
117
  }
63
- clearTimeout(timeout);
64
- hookInput = JSON.parse(chunks.join('') || '{}');
65
- } catch {}
118
+ }
119
+
120
+ if (inbox.length > 0) {
121
+ parts.push('');
122
+ parts.push(`INBOX (${inbox.length} message${inbox.length === 1 ? '' : 's'} from peers — surface to the user):`);
123
+ for (const m of inbox) parts.push(formatInbox(m));
124
+ }
66
125
 
67
- const prompt = hookInput.user_prompt || hookInput.prompt || '';
126
+ return parts.join('\n');
127
+ }
128
+
129
+ async function main(): Promise<void> {
130
+ const hookInput = await readStdinJson(1500);
131
+ const prompt: string =
132
+ hookInput.user_prompt || hookInput.prompt || hookInput.userPrompt || '';
68
133
  if (!prompt.trim()) {
69
134
  console.log(JSON.stringify({ continue: true }));
70
135
  process.exit(0);
@@ -72,6 +137,17 @@ async function main(): Promise<void> {
72
137
 
73
138
  const today = new Date().toISOString().split('T')[0];
74
139
 
140
+ let peersBlock = '';
141
+ const sessionId: string | undefined = hookInput.session_id || hookInput.sessionId;
142
+ if (sessionId) {
143
+ const stateDir = getStateDir(PROJECT_DIR);
144
+ try {
145
+ peersBlock = buildPeersBlock(stateDir, sessionId, prompt);
146
+ } catch {
147
+ peersBlock = '';
148
+ }
149
+ }
150
+
75
151
  const systemMessage = `TASK WORKFLOW (Stack: ${stackName}):
76
152
 
77
153
  0. READ both CLAUDE.md and .claude/config/active-project.json before changes.
@@ -89,7 +165,7 @@ async function main(): Promise<void> {
89
165
  a. "## Last Change" (date: ${today}, branch, summary)
90
166
  b. Update ALL affected rule/flow sections
91
167
 
92
- 6. Run stop-validator before finishing.${standardsContext}`;
168
+ 6. Run stop-validator before finishing.${standardsContext}${peersBlock}`;
93
169
 
94
170
  console.log(JSON.stringify({ continue: true, systemMessage }));
95
171
  process.exit(0);
@@ -99,3 +175,5 @@ main().catch(() => {
99
175
  console.log(JSON.stringify({ continue: true }));
100
176
  process.exit(0);
101
177
  });
178
+
179
+ 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`.