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
|
@@ -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
|
-
*
|
|
6
|
-
*
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
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`.
|