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/dist/setup.js CHANGED
@@ -68,6 +68,10 @@ export async function setupProject(projectDir, config, options = {}) {
68
68
  mkdirSync(join(claudeDir, 'commands'), { recursive: true });
69
69
  mkdirSync(join(claudeDir, 'rules'), { recursive: true });
70
70
  mkdirSync(join(claudeDir, 'skills', 'codebase-knowledge', 'domains'), { recursive: true });
71
+ // Multi-instance coordination state (gitignored, runtime-managed)
72
+ mkdirSync(join(claudeDir, 'state', 'sessions', '_archive'), { recursive: true });
73
+ mkdirSync(join(claudeDir, 'state', 'inbox'), { recursive: true });
74
+ mkdirSync(join(claudeDir, 'state', 'file-touches', '_archive'), { recursive: true });
71
75
  spinner.text = 'Directory structure created';
72
76
  // 2. Copy shared agents (universal)
73
77
  const sharedAgentsDir = join(PACKAGE_ROOT, 'stacks', '_shared', 'agents');
@@ -79,6 +83,12 @@ export async function setupProject(projectDir, config, options = {}) {
79
83
  // 4. Copy shared hooks
80
84
  const sharedHooksDir = join(PACKAGE_ROOT, 'stacks', '_shared', 'hooks');
81
85
  const hookCount = copyDirRecursive(sharedHooksDir, join(claudeDir, 'hooks'), options.force);
86
+ // 4b. Multi-instance coordination README in state dir
87
+ const stateReadmeSrc = join(PACKAGE_ROOT, 'stacks', '_shared', 'hooks', '_state.README.md');
88
+ const stateReadmeDest = join(claudeDir, 'state', 'README.md');
89
+ if (existsSync(stateReadmeSrc) && (!existsSync(stateReadmeDest) || options.force)) {
90
+ writeFileSync(stateReadmeDest, readFileSync(stateReadmeSrc, 'utf8'));
91
+ }
82
92
  // 5. Copy shared config
83
93
  const sharedConfigDir = join(PACKAGE_ROOT, 'stacks', '_shared', 'config');
84
94
  copyDirRecursive(sharedConfigDir, join(claudeDir, 'config'), options.force);
@@ -140,13 +150,21 @@ export async function setupProject(projectDir, config, options = {}) {
140
150
  spinner.text = 'Imported .cursorrules into Claude config';
141
151
  }
142
152
  }
143
- // 11b. Ensure CLAUDE.local.md is gitignored
153
+ // 11b. Ensure CLAUDE.local.md and .claude/state/ are gitignored
144
154
  const gitignorePath = join(projectDir, '.gitignore');
145
155
  if (existsSync(gitignorePath)) {
146
- const gitignore = readFileSync(gitignorePath, 'utf8');
156
+ let gitignore = readFileSync(gitignorePath, 'utf8');
157
+ let changed = false;
147
158
  if (!gitignore.includes('CLAUDE.local.md')) {
148
- writeFileSync(gitignorePath, gitignore.trimEnd() + '\n\n# Claude Code local preferences\nCLAUDE.local.md\n');
159
+ gitignore = gitignore.trimEnd() + '\n\n# Claude Code local preferences\nCLAUDE.local.md\n';
160
+ changed = true;
161
+ }
162
+ if (!gitignore.match(/^\.claude\/state\/?$/m) && !gitignore.match(/^\.claude\/?$/m)) {
163
+ gitignore = gitignore.trimEnd() + '\n\n# Claude Code multi-instance coordination state (runtime, per-host)\n.claude/state/\n';
164
+ changed = true;
149
165
  }
166
+ if (changed)
167
+ writeFileSync(gitignorePath, gitignore);
150
168
  }
151
169
  // 11c. Save standards review results
152
170
  if (config.standardsReview) {
@@ -230,6 +248,17 @@ export async function setupProject(projectDir, config, options = {}) {
230
248
  deny: [],
231
249
  },
232
250
  hooks: {
251
+ SessionStart: [
252
+ {
253
+ hooks: [
254
+ {
255
+ type: 'command',
256
+ command: 'npx tsx "$CLAUDE_PROJECT_DIR/.claude/hooks/session-start.ts"',
257
+ timeout: 10,
258
+ },
259
+ ],
260
+ },
261
+ ],
233
262
  UserPromptSubmit: [
234
263
  {
235
264
  matcher: '',
@@ -242,6 +271,30 @@ export async function setupProject(projectDir, config, options = {}) {
242
271
  ],
243
272
  },
244
273
  ],
274
+ PreToolUse: [
275
+ {
276
+ matcher: 'Edit|Write|MultiEdit|NotebookEdit',
277
+ hooks: [
278
+ {
279
+ type: 'command',
280
+ command: 'npx tsx "$CLAUDE_PROJECT_DIR/.claude/hooks/pre-tool-use.ts"',
281
+ timeout: 5,
282
+ },
283
+ ],
284
+ },
285
+ ],
286
+ PostToolUse: [
287
+ {
288
+ matcher: 'Edit|Write|MultiEdit|NotebookEdit',
289
+ hooks: [
290
+ {
291
+ type: 'command',
292
+ command: 'npx tsx "$CLAUDE_PROJECT_DIR/.claude/hooks/post-tool-use.ts"',
293
+ timeout: 5,
294
+ },
295
+ ],
296
+ },
297
+ ],
245
298
  Stop: [
246
299
  {
247
300
  hooks: [
@@ -253,6 +306,17 @@ export async function setupProject(projectDir, config, options = {}) {
253
306
  ],
254
307
  },
255
308
  ],
309
+ SessionEnd: [
310
+ {
311
+ hooks: [
312
+ {
313
+ type: 'command',
314
+ command: 'npx tsx "$CLAUDE_PROJECT_DIR/.claude/hooks/stop-validator.ts"',
315
+ timeout: 10,
316
+ },
317
+ ],
318
+ },
319
+ ],
256
320
  },
257
321
  agents: {
258
322
  'research-web': {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "start-vibing-stacks",
3
- "version": "2.22.0",
3
+ "version": "2.24.0",
4
4
  "description": "AI-powered multi-stack dev workflow for Claude Code. Supports PHP, Node.js, Python and more.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,37 @@
1
+ ---
2
+ name: peers
3
+ description: Inspect or talk to other Claude Code instances running in this same project (multi-instance coordination layer).
4
+ version: 1.0.0
5
+ ---
6
+
7
+ # /peers — Multi-Instance Coordination
8
+
9
+ Use this when the user asks something like:
10
+
11
+ - "is there another Claude running in this folder?"
12
+ - "tell the other instance I finished the auth work"
13
+ - "who touched `src/x.ts` recently?"
14
+ - "clean up dead sessions"
15
+
16
+ Run the `peers` CLI via Bash. The script lives at `.claude/hooks/peers.ts` and is installed by `start-vibing-stacks`.
17
+
18
+ ## Usage
19
+
20
+ | Goal | Command |
21
+ |---|---|
22
+ | List active + idle peer sessions | `npx tsx "$CLAUDE_PROJECT_DIR/.claude/hooks/peers.ts" list` |
23
+ | Notify another instance | `npx tsx "$CLAUDE_PROJECT_DIR/.claude/hooks/peers.ts" notify <id-prefix> "message"` |
24
+ | See who touched what file recently | `npx tsx "$CLAUDE_PROJECT_DIR/.claude/hooks/peers.ts" locks --minutes 10` |
25
+ | Remove stale sessions | `npx tsx "$CLAUDE_PROJECT_DIR/.claude/hooks/peers.ts" cleanup` |
26
+
27
+ The `<id-prefix>` is the 8-character short id printed by `peers list`, or any substring of the peer's title.
28
+
29
+ ## Output rules
30
+
31
+ - Always quote the short id (first 8 chars), the title, and the idle time when reporting peers to the user.
32
+ - If `peers notify` succeeds, tell the user the message will surface in the OTHER instance at the start of its next prompt (drained by `user-prompt-submit.ts`).
33
+ - If `peers list` says "No peer sessions", confirm that to the user and DO NOT suggest workarounds.
34
+
35
+ ## Background
36
+
37
+ State lives in `.claude/state/` (gitignored). Hooks `session-start.ts`, `user-prompt-submit.ts`, `pre-tool-use.ts`, `post-tool-use.ts` and `stop-validator.ts` keep it in sync. See `multi-instance-coordination` skill for the full protocol.
@@ -0,0 +1,55 @@
1
+ # `.claude/state/` — Multi-Instance Coordination
2
+
3
+ > This directory is **gitignored** and managed automatically by hooks. Safe to delete; it will be recreated by the next Claude session.
4
+
5
+ When two or more Claude Code instances run in the same project folder, the hooks under `.claude/hooks/` use this directory to:
6
+
7
+ 1. announce themselves to each other (`sessions/<id>.json`)
8
+ 2. record file edits as they happen (`file-touches.jsonl`)
9
+ 3. prevent simultaneous writes to the same file (PreToolUse hook reads the touch log)
10
+ 4. exchange messages (`inbox/<id>.jsonl` — drained at next prompt)
11
+
12
+ ## Layout
13
+
14
+ ```
15
+ .claude/state/
16
+ sessions/
17
+ <session-id>.json one record per active instance (heartbeat-tracked)
18
+ _archive/ sessions ended or stale (>30min idle)
19
+ inbox/
20
+ <session-id>.jsonl queued messages for that session
21
+ file-touches.jsonl append-only Edit/Write log (rotates every 1000 lines)
22
+ file-touches/_archive/ rotated logs
23
+ ```
24
+
25
+ ## Heartbeat thresholds
26
+
27
+ | Last activity | State | Effect |
28
+ | ------------- | -------- | --------------------------------------------------------------------------- |
29
+ | < 60s | active | Counts for collision detection. PreToolUse may **block** Edit/Write. |
30
+ | 60s – 30min | idle | Surfaced as a warning in `systemMessage`. Edits are **not** blocked. |
31
+ | > 30min | stale | Auto-archived on the next sweep. |
32
+ | > 24h | removed | Deleted entirely. |
33
+
34
+ ## Viewing peers
35
+
36
+ From inside Claude or a terminal:
37
+
38
+ ```
39
+ /peers
40
+ ```
41
+
42
+ or directly:
43
+
44
+ ```
45
+ npx tsx .claude/hooks/peers.ts list
46
+ npx tsx .claude/hooks/peers.ts notify <id-prefix> "message"
47
+ npx tsx .claude/hooks/peers.ts locks --minutes 10
48
+ npx tsx .claude/hooks/peers.ts cleanup
49
+ ```
50
+
51
+ ## Safety
52
+
53
+ - All state writes are atomic (`.tmp` + `rename`).
54
+ - All reads are tolerant of corruption — hooks never block Claude on a malformed file.
55
+ - This is a single-host coordination layer. It does **not** replace git; it only prevents two live sessions from stepping on the same uncommitted edit.
@@ -0,0 +1,445 @@
1
+ // @sv-version: 1.0.0
2
+ /**
3
+ * Multi-Instance Coordination — Shared State Library
4
+ *
5
+ * Used by session-start.ts, user-prompt-submit.ts, pre-tool-use.ts,
6
+ * post-tool-use.ts, stop-validator.ts and peers.ts.
7
+ *
8
+ * Layout (project-local, gitignored):
9
+ * .claude/state/
10
+ * sessions/<id>.json one record per active Claude instance
11
+ * sessions/_archive/ sessions ended or stale
12
+ * inbox/<id>.jsonl messages queued for that session
13
+ * file-touches.jsonl append-only edit log
14
+ * file-touches/_archive/ rotated logs
15
+ *
16
+ * Design rules:
17
+ * - All writes are atomic (.tmp + rename) where the file is read by peers.
18
+ * - Every read tolerates corruption (try/catch) — hooks must NEVER block Claude.
19
+ * - Heartbeat thresholds: <60s active, 60s-30min idle, >30min stale, >24h removed.
20
+ */
21
+
22
+ import {
23
+ existsSync,
24
+ mkdirSync,
25
+ readFileSync,
26
+ writeFileSync,
27
+ readdirSync,
28
+ renameSync,
29
+ rmSync,
30
+ statSync,
31
+ appendFileSync,
32
+ openSync,
33
+ readSync,
34
+ closeSync,
35
+ } from 'fs';
36
+ import { join, basename } from 'path';
37
+ import { randomBytes } from 'crypto';
38
+ import { spawnSync } from 'child_process';
39
+
40
+ export const ACTIVE_MS = 60 * 1000;
41
+ export const IDLE_MS = 30 * 60 * 1000;
42
+ export const STALE_MS = 24 * 60 * 60 * 1000;
43
+ export const COLLISION_WINDOW_MS = 5 * 60 * 1000;
44
+
45
+ export const TOUCHES_ROTATE_THRESHOLD = 1000;
46
+ export const TOUCHES_TAIL_LINES = 200;
47
+ export const FILES_TOUCHED_CAP = 50;
48
+
49
+ export interface SessionRecord {
50
+ sessionId: string;
51
+ transcriptPath?: string;
52
+ title: string;
53
+ cwd: string;
54
+ ppid: number;
55
+ gitBranch?: string;
56
+ startedAt: string;
57
+ lastSeenAt: string;
58
+ lastActivity: string;
59
+ filesTouched: string[];
60
+ }
61
+
62
+ export interface FileTouch {
63
+ ts: string;
64
+ sessionId: string;
65
+ tool: string;
66
+ file: string;
67
+ }
68
+
69
+ export interface InboxMessage {
70
+ ts: string;
71
+ fromSessionId: string;
72
+ fromTitle?: string;
73
+ message: string;
74
+ }
75
+
76
+ export function getProjectDir(): string {
77
+ return process.env['CLAUDE_PROJECT_DIR'] || process.cwd();
78
+ }
79
+
80
+ export function getStateDir(projectDir: string = getProjectDir()): string {
81
+ return join(projectDir, '.claude', 'state');
82
+ }
83
+
84
+ export function ensureStateDirs(stateDir: string = getStateDir()): void {
85
+ mkdirSync(join(stateDir, 'sessions', '_archive'), { recursive: true });
86
+ mkdirSync(join(stateDir, 'inbox'), { recursive: true });
87
+ mkdirSync(join(stateDir, 'file-touches', '_archive'), { recursive: true });
88
+ }
89
+
90
+ export function nowIso(): string {
91
+ return new Date().toISOString();
92
+ }
93
+
94
+ export function ageMs(iso: string): number {
95
+ const t = Date.parse(iso);
96
+ if (Number.isNaN(t)) return Number.POSITIVE_INFINITY;
97
+ return Date.now() - t;
98
+ }
99
+
100
+ /** Atomic write: tmp + rename. Survives concurrent writers; the last rename wins. */
101
+ export function writeFileAtomic(target: string, contents: string): void {
102
+ const tmp = `${target}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
103
+ writeFileSync(tmp, contents);
104
+ renameSync(tmp, target);
105
+ }
106
+
107
+ /** Read JSON; returns null on any error. */
108
+ export function readJsonSafe<T>(path: string): T | null {
109
+ try {
110
+ if (!existsSync(path)) return null;
111
+ return JSON.parse(readFileSync(path, 'utf8')) as T;
112
+ } catch {
113
+ return null;
114
+ }
115
+ }
116
+
117
+ export function getSessionPath(stateDir: string, sessionId: string): string {
118
+ return join(stateDir, 'sessions', `${sessionId}.json`);
119
+ }
120
+
121
+ export function readSession(stateDir: string, sessionId: string): SessionRecord | null {
122
+ return readJsonSafe<SessionRecord>(getSessionPath(stateDir, sessionId));
123
+ }
124
+
125
+ export function writeSession(stateDir: string, session: SessionRecord): void {
126
+ ensureStateDirs(stateDir);
127
+ writeFileAtomic(getSessionPath(stateDir, session.sessionId), JSON.stringify(session, null, 2));
128
+ }
129
+
130
+ export function listSessionFiles(stateDir: string): string[] {
131
+ const dir = join(stateDir, 'sessions');
132
+ if (!existsSync(dir)) return [];
133
+ try {
134
+ return readdirSync(dir)
135
+ .filter(f => f.endsWith('.json'))
136
+ .map(f => join(dir, f));
137
+ } catch {
138
+ return [];
139
+ }
140
+ }
141
+
142
+ /**
143
+ * List sessions excluding the caller's own. Auto-archives stale (>30min) and
144
+ * removes very old (>24h) records as a side effect.
145
+ */
146
+ export function listPeerSessions(
147
+ stateDir: string,
148
+ currentSessionId: string | null
149
+ ): SessionRecord[] {
150
+ const peers: SessionRecord[] = [];
151
+ for (const file of listSessionFiles(stateDir)) {
152
+ const rec = readJsonSafe<SessionRecord>(file);
153
+ if (!rec) continue;
154
+ if (rec.sessionId === currentSessionId) continue;
155
+
156
+ const age = ageMs(rec.lastSeenAt);
157
+ if (age > STALE_MS) {
158
+ try {
159
+ rmSync(file);
160
+ } catch {}
161
+ continue;
162
+ }
163
+ if (age > IDLE_MS) {
164
+ archiveSessionFile(stateDir, file);
165
+ continue;
166
+ }
167
+ peers.push(rec);
168
+ }
169
+ return peers;
170
+ }
171
+
172
+ export function archiveSessionFile(stateDir: string, sessionFile: string): void {
173
+ try {
174
+ const dest = join(stateDir, 'sessions', '_archive', basename(sessionFile));
175
+ renameSync(sessionFile, dest);
176
+ } catch {}
177
+ }
178
+
179
+ export function archiveSession(stateDir: string, sessionId: string): void {
180
+ const file = getSessionPath(stateDir, sessionId);
181
+ if (existsSync(file)) archiveSessionFile(stateDir, file);
182
+ }
183
+
184
+ /** Update lastSeenAt + lastActivity for the current session. Idempotent. */
185
+ export function heartbeat(
186
+ stateDir: string,
187
+ sessionId: string,
188
+ activity: string,
189
+ patch: Partial<SessionRecord> = {}
190
+ ): SessionRecord {
191
+ const existing = readSession(stateDir, sessionId);
192
+ const now = nowIso();
193
+ const merged: SessionRecord = existing
194
+ ? { ...existing, ...patch, lastSeenAt: now, lastActivity: activity }
195
+ : {
196
+ sessionId,
197
+ title: patch.title || '(untitled)',
198
+ cwd: patch.cwd || getProjectDir(),
199
+ ppid: patch.ppid ?? process.ppid ?? 0,
200
+ gitBranch: patch.gitBranch,
201
+ transcriptPath: patch.transcriptPath,
202
+ startedAt: now,
203
+ lastSeenAt: now,
204
+ lastActivity: activity,
205
+ filesTouched: patch.filesTouched || [],
206
+ };
207
+ writeSession(stateDir, merged);
208
+ return merged;
209
+ }
210
+
211
+ export function recordFileTouch(stateDir: string, touch: FileTouch): void {
212
+ ensureStateDirs(stateDir);
213
+ const path = join(stateDir, 'file-touches.jsonl');
214
+ try {
215
+ appendFileSync(path, JSON.stringify(touch) + '\n');
216
+ rotateFileTouchesIfNeeded(stateDir);
217
+ } catch {}
218
+ }
219
+
220
+ export function rotateFileTouchesIfNeeded(stateDir: string): void {
221
+ const path = join(stateDir, 'file-touches.jsonl');
222
+ if (!existsSync(path)) return;
223
+ try {
224
+ const size = statSync(path).size;
225
+ if (size < 200_000) return;
226
+ const lines = readFileSync(path, 'utf8').split('\n').filter(Boolean);
227
+ if (lines.length < TOUCHES_ROTATE_THRESHOLD) return;
228
+ const archiveName = `file-touches-${Date.now()}.jsonl`;
229
+ const archivePath = join(stateDir, 'file-touches', '_archive', archiveName);
230
+ writeFileSync(archivePath, lines.slice(0, lines.length - TOUCHES_TAIL_LINES).join('\n') + '\n');
231
+ writeFileSync(path, lines.slice(-TOUCHES_TAIL_LINES).join('\n') + '\n');
232
+ } catch {}
233
+ }
234
+
235
+ /** Read the last N lines of file-touches.jsonl (cheap tail, no full read). */
236
+ export function tailFileTouches(stateDir: string, lines = TOUCHES_TAIL_LINES): FileTouch[] {
237
+ const path = join(stateDir, 'file-touches.jsonl');
238
+ if (!existsSync(path)) return [];
239
+ try {
240
+ const size = statSync(path).size;
241
+ const readBytes = Math.min(size, 64 * 1024);
242
+ const fd = openSync(path, 'r');
243
+ const buf = Buffer.alloc(readBytes);
244
+ readSync(fd, buf, 0, readBytes, size - readBytes);
245
+ closeSync(fd);
246
+ const text = buf.toString('utf8');
247
+ const all = text.split('\n').filter(Boolean);
248
+ const slice = all.slice(-lines);
249
+ const out: FileTouch[] = [];
250
+ for (const line of slice) {
251
+ try {
252
+ out.push(JSON.parse(line));
253
+ } catch {}
254
+ }
255
+ return out;
256
+ } catch {
257
+ return [];
258
+ }
259
+ }
260
+
261
+ export function getInboxPath(stateDir: string, sessionId: string): string {
262
+ return join(stateDir, 'inbox', `${sessionId}.jsonl`);
263
+ }
264
+
265
+ export function appendInbox(
266
+ stateDir: string,
267
+ targetSessionId: string,
268
+ msg: InboxMessage
269
+ ): void {
270
+ ensureStateDirs(stateDir);
271
+ try {
272
+ appendFileSync(getInboxPath(stateDir, targetSessionId), JSON.stringify(msg) + '\n');
273
+ } catch {}
274
+ }
275
+
276
+ /** Read all queued messages and remove the inbox file. */
277
+ export function drainInbox(stateDir: string, sessionId: string): InboxMessage[] {
278
+ const path = getInboxPath(stateDir, sessionId);
279
+ if (!existsSync(path)) return [];
280
+ try {
281
+ const lines = readFileSync(path, 'utf8').split('\n').filter(Boolean);
282
+ rmSync(path);
283
+ const out: InboxMessage[] = [];
284
+ for (const line of lines) {
285
+ try {
286
+ out.push(JSON.parse(line));
287
+ } catch {}
288
+ }
289
+ return out;
290
+ } catch {
291
+ return [];
292
+ }
293
+ }
294
+
295
+ export function clearInbox(stateDir: string, sessionId: string): void {
296
+ const path = getInboxPath(stateDir, sessionId);
297
+ if (existsSync(path)) {
298
+ try {
299
+ rmSync(path);
300
+ } catch {}
301
+ }
302
+ }
303
+
304
+ /** Read up to ~8KB of the transcript and extract a human title. */
305
+ export function extractTitle(transcriptPath?: string, fallbackPrompt?: string): string {
306
+ if (transcriptPath && existsSync(transcriptPath)) {
307
+ try {
308
+ const fd = openSync(transcriptPath, 'r');
309
+ const buf = Buffer.alloc(8192);
310
+ const bytes = readSync(fd, buf, 0, buf.length, 0);
311
+ closeSync(fd);
312
+ const text = buf.subarray(0, bytes).toString('utf8');
313
+ for (const line of text.split('\n')) {
314
+ if (!line.trim()) continue;
315
+ try {
316
+ const rec = JSON.parse(line);
317
+ if (typeof rec.summary === 'string' && rec.summary.trim()) {
318
+ return truncate(rec.summary.trim(), 80);
319
+ }
320
+ if (rec.type === 'user' && rec.message) {
321
+ const content =
322
+ typeof rec.message === 'string'
323
+ ? rec.message
324
+ : typeof rec.message?.content === 'string'
325
+ ? rec.message.content
326
+ : Array.isArray(rec.message?.content)
327
+ ? rec.message.content
328
+ .filter((c: any) => c.type === 'text')
329
+ .map((c: any) => c.text)
330
+ .join(' ')
331
+ : '';
332
+ if (content) return truncate(content.replace(/\s+/g, ' ').trim(), 80);
333
+ }
334
+ } catch {}
335
+ }
336
+ } catch {}
337
+ }
338
+ if (fallbackPrompt) return truncate(fallbackPrompt.replace(/\s+/g, ' ').trim(), 80);
339
+ return '(untitled)';
340
+ }
341
+
342
+ export function truncate(s: string, n: number): string {
343
+ if (s.length <= n) return s;
344
+ return s.slice(0, n - 1) + '…';
345
+ }
346
+
347
+ export function shortId(sessionId: string): string {
348
+ return sessionId.slice(0, 8);
349
+ }
350
+
351
+ export function classifyAge(ageMsec: number): 'active' | 'idle' | 'stale' {
352
+ if (ageMsec < ACTIVE_MS) return 'active';
353
+ if (ageMsec < IDLE_MS) return 'idle';
354
+ return 'stale';
355
+ }
356
+
357
+ /**
358
+ * Inspect tool input and return a list of file paths that will be touched.
359
+ * Returns paths relative to projectDir when possible, else absolute.
360
+ */
361
+ export function extractTargetFiles(toolName: string, toolInput: any, projectDir: string): string[] {
362
+ if (!toolInput || typeof toolInput !== 'object') return [];
363
+ const out: string[] = [];
364
+ const push = (p: any) => {
365
+ if (typeof p !== 'string' || !p) return;
366
+ let rel = p;
367
+ if (p.startsWith(projectDir + '/')) rel = p.slice(projectDir.length + 1);
368
+ out.push(rel);
369
+ };
370
+ if (toolName === 'Edit' || toolName === 'Write') {
371
+ push(toolInput.file_path || toolInput.path);
372
+ } else if (toolName === 'MultiEdit') {
373
+ push(toolInput.file_path || toolInput.path);
374
+ } else if (toolName === 'NotebookEdit') {
375
+ push(toolInput.notebook_path || toolInput.target_notebook);
376
+ }
377
+ return out;
378
+ }
379
+
380
+ /** Read JSON from stdin with a timeout fallback. */
381
+ export async function readStdinJson(timeoutMs = 1500): Promise<any> {
382
+ return new Promise(resolve => {
383
+ let data = '';
384
+ const timer = setTimeout(() => {
385
+ try {
386
+ process.stdin.destroy();
387
+ } catch {}
388
+ try {
389
+ resolve(JSON.parse(data || '{}'));
390
+ } catch {
391
+ resolve({});
392
+ }
393
+ }, timeoutMs);
394
+ process.stdin.setEncoding('utf8');
395
+ process.stdin.on('data', c => {
396
+ data += c;
397
+ });
398
+ process.stdin.on('end', () => {
399
+ clearTimeout(timer);
400
+ try {
401
+ resolve(JSON.parse(data || '{}'));
402
+ } catch {
403
+ resolve({});
404
+ }
405
+ });
406
+ process.stdin.on('error', () => {
407
+ clearTimeout(timer);
408
+ resolve({});
409
+ });
410
+ });
411
+ }
412
+
413
+ /** Resolve current git branch via spawnSync (no shell, no injection). */
414
+ export function getGitBranch(projectDir: string): string | undefined {
415
+ try {
416
+ const r = spawnSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
417
+ cwd: projectDir,
418
+ encoding: 'utf8',
419
+ stdio: ['pipe', 'pipe', 'pipe'],
420
+ timeout: 2000,
421
+ });
422
+ if (r.status !== 0) return undefined;
423
+ const out = (r.stdout || '').toString().trim();
424
+ return out || undefined;
425
+ } catch {
426
+ return undefined;
427
+ }
428
+ }
429
+
430
+ /**
431
+ * Format a peer session for systemMessage embedding.
432
+ */
433
+ export function formatPeer(peer: SessionRecord): string {
434
+ const idle = ageMs(peer.lastSeenAt);
435
+ const klass = classifyAge(idle);
436
+ const idleSec = Math.round(idle / 1000);
437
+ const idleStr =
438
+ idleSec < 90
439
+ ? `${idleSec}s`
440
+ : idleSec < 60 * 60
441
+ ? `${Math.round(idleSec / 60)}min`
442
+ : `${Math.round(idleSec / 3600)}h`;
443
+ const branch = peer.gitBranch ? ` @${peer.gitBranch}` : '';
444
+ return `[${klass}] ${shortId(peer.sessionId)} "${peer.title}"${branch} idle=${idleStr}`;
445
+ }
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ // @sv-version: 1.0.0
2
3
  /**
3
4
  * Final Check — executable validator with VETO.
4
5
  *