start-vibing-stacks 2.26.0 → 2.28.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/migrate.d.ts CHANGED
@@ -10,7 +10,9 @@
10
10
  * - commands: same `version:` frontmatter contract as skills/agents.
11
11
  * - .claude/settings.json — patched idempotently to ensure the bundled hook
12
12
  * chain (SessionStart / UserPromptSubmit / PreToolUse / PostToolUse / Stop /
13
- * SessionEnd) is wired. Existing entries are preserved; only missing entries
13
+ * SessionEnd) is wired, plus high-reasoning defaults `effortLevel: "xhigh"`
14
+ * and `permissions.defaultMode: "plan"` (only when MISSING — user choices are
15
+ * never clobbered). Existing entries are preserved; only missing entries
14
16
  * are appended. Atomic write (.tmp + rename).
15
17
  * - .claude/state/ — runtime layout for multi-instance coordination is
16
18
  * auto-created and the `_state.README.md` is staged as `.claude/state/README.md`.
package/dist/migrate.js CHANGED
@@ -10,7 +10,9 @@
10
10
  * - commands: same `version:` frontmatter contract as skills/agents.
11
11
  * - .claude/settings.json — patched idempotently to ensure the bundled hook
12
12
  * chain (SessionStart / UserPromptSubmit / PreToolUse / PostToolUse / Stop /
13
- * SessionEnd) is wired. Existing entries are preserved; only missing entries
13
+ * SessionEnd) is wired, plus high-reasoning defaults `effortLevel: "xhigh"`
14
+ * and `permissions.defaultMode: "plan"` (only when MISSING — user choices are
15
+ * never clobbered). Existing entries are preserved; only missing entries
14
16
  * are appended. Atomic write (.tmp + rename).
15
17
  * - .claude/state/ — runtime layout for multi-instance coordination is
16
18
  * auto-created and the `_state.README.md` is staged as `.claude/state/README.md`.
@@ -302,6 +304,26 @@ export function patchSettings(projectDir, dryRun) {
302
304
  }
303
305
  report.added.push(event);
304
306
  }
307
+ // High-reasoning defaults (v2.28.0). Only set when MISSING so we never clobber
308
+ // a user's explicit choice. `ultracode` is intentionally never written here:
309
+ // it is session-only (/effort) and cannot be persisted in settings.json.
310
+ if (settings.effortLevel === undefined) {
311
+ if (!dryRun)
312
+ settings.effortLevel = 'xhigh';
313
+ report.added.push('effortLevel:xhigh');
314
+ }
315
+ else {
316
+ report.alreadyPresent.push('effortLevel');
317
+ }
318
+ settings.permissions = settings.permissions || {};
319
+ if (settings.permissions.defaultMode === undefined) {
320
+ if (!dryRun)
321
+ settings.permissions.defaultMode = 'plan';
322
+ report.added.push('permissions.defaultMode:plan');
323
+ }
324
+ else {
325
+ report.alreadyPresent.push('permissions.defaultMode');
326
+ }
305
327
  if (!dryRun && report.added.length > 0 && !report.error) {
306
328
  mkdirSync(dirname(path), { recursive: true });
307
329
  writeFileAtomic(path, JSON.stringify(settings, null, '\t'));
package/dist/setup.js CHANGED
@@ -226,6 +226,10 @@ export async function setupProject(projectDir, config, options = {}) {
226
226
  model: 'sonnet',
227
227
  max_tokens: 8192,
228
228
  max_turns: 100,
229
+ // Persistent high-reasoning effort. Note: 'ultracode' is session-only
230
+ // (set via /effort) and cannot be persisted here; 'xhigh' is the highest
231
+ // persistable level. On 'sonnet' it falls back to 'high'.
232
+ effortLevel: 'xhigh',
229
233
  enableAllProjectMcpServers: true,
230
234
  autoMemoryEnabled: true,
231
235
  context: {
@@ -235,6 +239,9 @@ export async function setupProject(projectDir, config, options = {}) {
235
239
  memory_files: ['.claude/CLAUDE.md', 'CLAUDE.md'],
236
240
  },
237
241
  permissions: {
242
+ // Always start sessions in plan mode (read-only): the agent must
243
+ // present a plan before editing. Shift+Tab still cycles modes per turn.
244
+ defaultMode: 'plan',
238
245
  allow: [
239
246
  'Bash(*)',
240
247
  'Read(*)',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "start-vibing-stacks",
3
- "version": "2.26.0",
3
+ "version": "2.28.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": {
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: commit-manager
3
- version: 3.0.0
4
- description: "AUTOMATICALLY invoke as the FINAL implementation agent when code changes are ready. Verifies security-auditor and quality-gate passed (HARD GATE — will NOT commit if vetoed), analyzes the diff for a precise conventional commit message, stages ONLY this session's files via `scope.ts` when state is available (falls back to `git add -A` for solo projects), commits, pushes, and triggers the post-commit chain (documenter → domain-updater). Multi-instance safe: never bundles peer sessions' uncommitted files into your commit. Supports branch-merge and direct-to-main flows. v3.0.0 (May-2026): per-instance scoped staging + Recent Changes (LIFO) chain alignment."
3
+ version: 3.1.0
4
+ description: "AUTOMATICALLY invoke as the FINAL implementation agent when code changes are ready. Verifies security-auditor and quality-gate passed (HARD GATE — will NOT commit if vetoed), analyzes the diff for a precise conventional commit message, commits ONLY this session's files via `scope.ts commit` (atomic `git commit -o`, never bundles a peer's staged work; `git add -A` only in solo projects), pushes, and triggers the post-commit chain (documenter → domain-updater). Multi-instance safe: never bundles peer sessions' uncommitted files into your commit, and STOPS rather than falling back to `git add -A` when the session cannot be resolved. Supports branch-merge and direct-to-main flows. v3.1.0 (May-2026): atomic pathspec commit + safe (no -A) fallback + CLAUDE_SESSION_ID requirement."
5
5
  model: sonnet
6
6
  tools: Read, Bash, Grep, Glob
7
7
  skills: git-workflow
@@ -130,68 +130,73 @@ Read at most 3 files in full diff. For the rest, rely on the stat + file names.
130
130
 
131
131
  ## Step 4 — Stage and commit (per-instance scoped)
132
132
 
133
- ### 4a. Stage `scope.ts` first, `git add -A` as fallback
133
+ ### 4a. Compose the message (HEREDOC, shell-safe multi-line)
134
+
135
+ ```bash
136
+ MSG="$(cat <<'EOF'
137
+ <type>(<scope>): <subject>
138
+
139
+ <body — 2-5 bullets if ≥ 3 files>
140
+ EOF
141
+ )"
142
+ ```
143
+
144
+ Rules:
145
+ - **No** `Co-Authored-By` header unless the user explicitly asked for it.
146
+ - **No** `Generated with Claude Code` footer — it adds noise to git log.
147
+ - Subject line ≤ 72 chars. Body wraps at 80 chars. Empty body is fine for single-file changes.
148
+
149
+ ### 4b. Commit — `scope.ts commit` (multi-instance) or `git add -A` (solo only)
134
150
 
135
151
  The truth of "what did THIS session edit?" lives in `.claude/state/sessions/<id>.json#filesTouched`,
136
- maintained by `post-tool-use.ts`. Stage from that, NOT from the worktree at large — otherwise
137
- you risk bundling a peer session's uncommitted files into your commit.
152
+ maintained by `post-tool-use.ts`. In a multi-instance project, commit ONLY those files. `scope.ts
153
+ commit` does this atomically via `git commit -o -- <files>` (commits exactly your paths regardless
154
+ of what a peer has staged) — there is NO `git add -A`, because that is precisely what bundles a
155
+ peer's uncommitted work into your commit.
138
156
 
139
157
  ```bash
140
158
  SCOPE_TS="$CLAUDE_PROJECT_DIR/.claude/hooks/scope.ts"
141
159
  STATE_DIR="$CLAUDE_PROJECT_DIR/.claude/state"
142
160
 
143
161
  if [ -f "$SCOPE_TS" ] && [ -d "$STATE_DIR" ]; then
144
- # Multi-instance project: stage this session's files only.
145
- npx tsx "$SCOPE_TS" stage
146
- STAGE_CODE=$?
147
- case "$STAGE_CODE" in
148
- 0)
149
- # All safe files staged. Continue.
150
- ;;
162
+ # Multi-instance project: commit this session's files only (never -A).
163
+ # scope.ts resolves the session via $CLAUDE_SESSION_ID, else the single active
164
+ # session. With ≥2 active sessions and no $CLAUDE_SESSION_ID it returns 1.
165
+ npx tsx "$SCOPE_TS" commit "$MSG"
166
+ COMMIT_CODE=$?
167
+ case "$COMMIT_CODE" in
168
+ 0) ;; # committed your scope cleanly
151
169
  2)
152
- # Some safe files staged; conflicted ones skipped intentionally.
153
- # scope.ts already printed the warning. Continue committing the safe scope is correct.
154
- echo "ℹ️ Conflicted files skipped (peer also touched in last 5 min). Proceeding with safe scope."
170
+ # A peer touched one of your files in the last 5 min. Do NOT override blindly.
171
+ echo "⛔ Conflict: a peer is editing one of your files. Coordinate via:"
172
+ echo " npx tsx \"$CLAUDE_PROJECT_DIR/.claude/hooks/peers.ts\" notify <id> \"...\""
173
+ echo " Then re-run, or (only if you own the change) add --include-conflicted."
174
+ exit 1
155
175
  ;;
156
176
  *)
157
- # scope.ts couldn't resolve session or found no dirty files in scope.
158
- # Fall back to legacy behavior should NOT happen in a healthy multi-instance project.
159
- echo "⚠️ scope.ts returned $STAGE_CODE; falling back to legacy `git add -A` (multi-instance risk!)"
160
- git add -A
177
+ # Session could not be resolved (≥2 active, no $CLAUDE_SESSION_ID) or no dirty
178
+ # files in scope. STOPdo NOT fall back to `git add -A` (it bundles peers).
179
+ echo "scope.ts could not commit this session's scope (code $COMMIT_CODE)."
180
+ echo " Set CLAUDE_SESSION_ID or pass --session <id>, then re-run."
181
+ echo " Inspect with: npx tsx \"$SCOPE_TS\" status"
182
+ exit 1
161
183
  ;;
162
184
  esac
163
185
  else
164
- # Solo project (no .claude/state/) — legacy flow is correct.
186
+ # Solo project (no .claude/state/) — only here is `git add -A` safe.
165
187
  git add -A
188
+ # Guard against incidental junk (multi-instance path is immune: scope commits
189
+ # only filesTouched, so these never get staged in the first place).
190
+ git reset HEAD -- .DS_Store .env *.log 2>/dev/null || true
191
+ git commit -m "$MSG"
166
192
  fi
167
193
 
168
194
  git status --short
169
195
  ```
170
196
 
171
- Verify no unexpected files (`.DS_Store`, `.env`, `*.log`). If found:
172
-
173
- ```bash
174
- git reset HEAD -- .DS_Store .env *.log 2>/dev/null || true
175
- ```
176
-
177
- ### 4b. Commit with HEREDOC (shell-safe multi-line)
178
-
179
- ```bash
180
- git commit -m "$(cat <<'EOF'
181
- <type>(<scope>): <subject>
182
-
183
- <body — 2-5 bullets if ≥ 3 files>
184
-
185
- EOF
186
- )"
187
- ```
188
-
189
- Rules:
190
- - **No** `Co-Authored-By` header unless the user explicitly asked for it.
191
- - **No** `Generated with Claude Code` footer — it adds noise to git log.
192
- - Subject line ≤ 72 chars.
193
- - Body wraps at 80 chars.
194
- - Empty body is fine for single-file changes.
197
+ > Operational requirement: `scope.ts` needs to know which session is committing.
198
+ > Ensure `CLAUDE_SESSION_ID` is exported to the shell, OR run with `--session <id>`
199
+ > when more than one instance is active. The fallback is to STOP, never `git add -A`.
195
200
 
196
201
  ---
197
202
 
@@ -279,7 +284,7 @@ Action: <what the user should do>
279
284
  ## Critical rules
280
285
 
281
286
  1. **GATE CHECK FIRST** — NEVER commit if `security-auditor` has open CRITICAL/HIGH/MEDIUM findings or `quality-gate` failed. This is non-negotiable.
282
- 2. **PER-INSTANCE STAGING** — when `.claude/state/` exists, ALWAYS stage via `scope.ts` (Step 4a). NEVER `git add -A` blindly in a multi-instance project; it bundles peers' uncommitted files into your commit. See CLAUDE.md NRY "Instance N's commit bundling instance M's uncommitted files".
287
+ 2. **PER-INSTANCE COMMIT** — when `.claude/state/` exists, ALWAYS commit via `scope.ts commit` (Step 4b), which uses `git commit -o -- <files>` to commit EXACTLY your files. NEVER `git add -A` in a multi-instance project; it bundles peers' uncommitted files. If `scope.ts` cannot resolve the session, STOP — do NOT fall back to `git add -A`. See CLAUDE.md NRY "Instance N's commit bundling instance M's uncommitted files".
283
288
  3. **DIFF-DRIVEN MESSAGES** — read the actual diff (stat first, full only when needed). Never generate generic messages.
284
289
  4. **HEREDOC** — always use `cat <<'EOF'` for commit messages. Never inline multi-line strings.
285
290
  5. **NO FORCE PUSH** — never `git push --force` or `--force-with-lease` to main/master unless the user explicitly requests it.
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: commit-mine
3
3
  description: Commit ONLY this Claude session's edited files (multi-instance safe). Replaces raw `git add . && git commit` to prevent your commit from bundling a peer session's uncommitted changes.
4
- version: 1.0.0
4
+ version: 1.1.0
5
5
  ---
6
6
 
7
7
  # /commit-mine — Per-Instance Commit
@@ -14,25 +14,30 @@ cleanly. This command stages and commits ONLY the files your session actually ed
14
14
  **Source of truth:** `.claude/state/sessions/<your-id>.json#filesTouched`, maintained by
15
15
  `post-tool-use.ts` (one entry per successful Edit / Write / MultiEdit / NotebookEdit).
16
16
 
17
- | # | Step | Tool |
18
- |---|---|---|
19
- | 1 | **Inspect scope** — list SAFE / CONFLICTED / NOT-YOURS / STAGED | `npx tsx "$CLAUDE_PROJECT_DIR/.claude/hooks/scope.ts" status` |
20
- | 2 | **Resolve conflicts** if any (peer touched same file in last 5 min) | `npx tsx "$CLAUDE_PROJECT_DIR/.claude/hooks/peers.ts" notify <id> "msg"` |
21
- | 3 | **Stage** — `git reset` then `git add` only this session's dirty files | `npx tsx "$CLAUDE_PROJECT_DIR/.claude/hooks/scope.ts" stage` |
22
- | 4 | **Review** — confirm the staged diff is what you expect | `git diff --cached --stat` and (optionally) `git diff --cached` |
23
- | 5 | **Commit** — conventional message (`feat`, `fix`, `docs`, etc.) + diff-driven body | `git commit -m "<message>"` OR delegate to `commit-manager` agent |
24
- | 6 | **Push** (optional) | `git push` |
25
-
26
- ## Shortcut (single command)
27
-
28
- If steps 1–5 are routine and you already know what you edited:
17
+ **Recommended (single atomic command):**
29
18
 
30
19
  ```bash
31
20
  npx tsx "$CLAUDE_PROJECT_DIR/.claude/hooks/scope.ts" commit "<message>" [--push]
32
21
  ```
33
22
 
34
- This runs stage + commit (+ optional push) atomically. It will REFUSE to proceed if a
35
- peer touched any of your files in the last 5 min unless you pass `--include-conflicted`.
23
+ This commits ONLY your session's files via `git commit -o -- <files>` it commits
24
+ exactly your paths regardless of what a peer has staged, so it can never bundle their
25
+ work, and it never runs a global `git reset`. It REFUSES if a peer touched any of your
26
+ files in the last 5 min unless you pass `--include-conflicted`.
27
+
28
+ **Step-by-step (when you want to review first):**
29
+
30
+ | # | Step | Tool |
31
+ |---|---|---|
32
+ | 1 | **Inspect scope** — list SAFE / CONFLICTED / NOT-YOURS / STAGED | `npx tsx "$CLAUDE_PROJECT_DIR/.claude/hooks/scope.ts" status` |
33
+ | 2 | **Resolve conflicts** if any (peer touched same file in last 5 min) | `npx tsx "$CLAUDE_PROJECT_DIR/.claude/hooks/peers.ts" notify <id> "msg"` |
34
+ | 3 | **Review** the diff of your files | `npx tsx "$CLAUDE_PROJECT_DIR/.claude/hooks/scope.ts" diff` |
35
+ | 4 | **Commit** — atomic, only your files | `npx tsx "$CLAUDE_PROJECT_DIR/.claude/hooks/scope.ts" commit "<message>"` |
36
+ | 5 | **Push** (optional) | add `--push` to step 4, or `git push` |
37
+
38
+ > `scope stage` still exists for manual index inspection, but it is best-effort in a
39
+ > shared worktree (a peer's staged files can linger). Prefer `scope commit` — it is the
40
+ > only path that is atomic against a concurrent peer.
36
41
 
37
42
  ## Forbidden patterns (NEVER do these in a multi-instance project)
38
43
 
@@ -68,6 +73,6 @@ manual edits not via Claude tools) are skipped — they appear in `status` under
68
73
 
69
74
  - `/fix` step 7 ("Commit") and `/feature` workflows that previously implied `git add .`
70
75
  should now route through `/commit-mine` whenever `peers.ts list` shows ≥ 1 peer.
71
- - The `commit-manager` agent (if used) MUST call `scope stage` before any `git commit`
76
+ - The `commit-manager` agent (if used) MUST commit via `scope commit` (never `git add -A`)
72
77
  in a multi-instance project.
73
78
  - See `CLAUDE.md` NRY: "Instance N's commit bundling instance M's uncommitted files".
@@ -26,8 +26,8 @@ When two or more Claude Code instances run in the same project folder, the hooks
26
26
 
27
27
  | Last activity | State | Effect |
28
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. |
29
+ | < 180s | active | Counts for collision detection. PreToolUse may **block** Edit/Write. |
30
+ | 180s – 30min | idle | Surfaced as a warning in `systemMessage`. Edits are **not** blocked. |
31
31
  | > 30min | stale | Auto-archived on the next sweep. |
32
32
  | > 24h | removed | Deleted entirely. |
33
33
 
@@ -1,4 +1,4 @@
1
- // @sv-version: 1.0.0
1
+ // @sv-version: 1.1.0
2
2
  /**
3
3
  * Multi-Instance Coordination — Shared State Library
4
4
  *
@@ -16,7 +16,13 @@
16
16
  * Design rules:
17
17
  * - All writes are atomic (.tmp + rename) where the file is read by peers.
18
18
  * - Every read tolerates corruption (try/catch) — hooks must NEVER block Claude.
19
- * - Heartbeat thresholds: <60s active, 60s-30min idle, >30min stale, >24h removed.
19
+ * - Heartbeat thresholds: <180s active, 180s-30min idle, >30min stale, >24h removed.
20
+ *
21
+ * v1.1.0: ACTIVE window 60s→180s (agent think/generate time between tool calls
22
+ * routinely exceeds 60s — a 60s window mis-classifies busy peers as IDLE);
23
+ * FILES_TOUCHED_CAP 50→200 (large sessions were dropping early edits, which then
24
+ * showed up as orphaned dirty files a peer could not attribute); path
25
+ * normalization in extractTargetFiles is now repo-root-stable.
20
26
  */
21
27
 
22
28
  import {
@@ -33,18 +39,18 @@ import {
33
39
  readSync,
34
40
  closeSync,
35
41
  } from 'fs';
36
- import { join, basename } from 'path';
42
+ import { join, basename, resolve, relative, isAbsolute } from 'path';
37
43
  import { randomBytes } from 'crypto';
38
44
  import { spawnSync } from 'child_process';
39
45
 
40
- export const ACTIVE_MS = 60 * 1000;
46
+ export const ACTIVE_MS = 180 * 1000;
41
47
  export const IDLE_MS = 30 * 60 * 1000;
42
48
  export const STALE_MS = 24 * 60 * 60 * 1000;
43
49
  export const COLLISION_WINDOW_MS = 5 * 60 * 1000;
44
50
 
45
51
  export const TOUCHES_ROTATE_THRESHOLD = 1000;
46
52
  export const TOUCHES_TAIL_LINES = 200;
47
- export const FILES_TOUCHED_CAP = 50;
53
+ export const FILES_TOUCHED_CAP = 200;
48
54
 
49
55
  export interface SessionRecord {
50
56
  sessionId: string;
@@ -238,7 +244,9 @@ export function tailFileTouches(stateDir: string, lines = TOUCHES_TAIL_LINES): F
238
244
  if (!existsSync(path)) return [];
239
245
  try {
240
246
  const size = statSync(path).size;
241
- const readBytes = Math.min(size, 64 * 1024);
247
+ // 512KB tail comfortably holds ≳1000 touch records; collision decisions read
248
+ // up to 1000 lines (see callers), so the byte window must not truncate them.
249
+ const readBytes = Math.min(size, 512 * 1024);
242
250
  const fd = openSync(path, 'r');
243
251
  const buf = Buffer.alloc(readBytes);
244
252
  readSync(fd, buf, 0, readBytes, size - readBytes);
@@ -361,11 +369,19 @@ export function classifyAge(ageMsec: number): 'active' | 'idle' | 'stale' {
361
369
  export function extractTargetFiles(toolName: string, toolInput: any, projectDir: string): string[] {
362
370
  if (!toolInput || typeof toolInput !== 'object') return [];
363
371
  const out: string[] = [];
372
+ // Normalize every path to the SAME shape `git status --porcelain` emits:
373
+ // forward-slash, relative to the project (≈ repo) root. Tools may hand us an
374
+ // absolute path, a path relative to a subdirectory cwd, or already-relative.
375
+ // A mismatch here is the #1 cause of "I edited this file but scope.ts says it
376
+ // is NOT MINE" — the string simply fails to equal the git porcelain path.
364
377
  const push = (p: any) => {
365
378
  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);
379
+ const abs = isAbsolute(p) ? p : resolve(projectDir, p);
380
+ let rel = relative(projectDir, abs);
381
+ // Outside the project tree (rare): keep the original so we never crash; it
382
+ // simply will not match a git path, which is the correct, safe outcome.
383
+ if (!rel || rel.startsWith('..')) rel = p;
384
+ out.push(rel.split('\\').join('/'));
369
385
  };
370
386
  if (toolName === 'Edit' || toolName === 'Write') {
371
387
  push(toolInput.file_path || toolInput.path);
@@ -1,15 +1,15 @@
1
1
  #!/usr/bin/env node
2
- // @sv-version: 1.0.0
2
+ // @sv-version: 1.1.0
3
3
  /**
4
4
  * PreToolUse Hook — Multi-Instance Coordination
5
5
  *
6
6
  * Wired with matcher `Edit|Write|MultiEdit|NotebookEdit`. Reads the file-touches
7
7
  * log + active peer sessions and decides:
8
8
  *
9
- * - BLOCK if a peer is currently ACTIVE (heartbeat < 60s) AND touched the same
9
+ * - BLOCK if a peer is currently ACTIVE (heartbeat < 180s) AND touched the same
10
10
  * file within the last 5 minutes. The reason explains how to recover.
11
11
  * - WARN (approve + systemMessage) if a peer touched the file recently but is
12
- * only IDLE (60s — 5min).
12
+ * only IDLE (180s — 5min).
13
13
  * - APPROVE silently otherwise.
14
14
  *
15
15
  * Hook input:
@@ -100,7 +100,7 @@ function evaluate(
100
100
  ` 1. Run \`npx tsx .claude/hooks/peers.ts list\` to see who is active.\n` +
101
101
  ` 2. Notify them: \`npx tsx .claude/hooks/peers.ts notify <id-prefix> "I need to edit <file>, can you commit/stash?"\`\n` +
102
102
  ` 3. Wait for them to commit, then retry — or have them call \`peers.ts cleanup\` if you confirm they are no longer editing.\n` +
103
- ` 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
+ ` 4. If you must override: re-run the same Edit after 180s of peer inactivity (their heartbeat will go IDLE and the hook will downgrade to a warning).`;
104
104
  return { block: true, reason };
105
105
  }
106
106
 
@@ -141,7 +141,8 @@ async function main(): Promise<void> {
141
141
  }
142
142
 
143
143
  const peers = listPeerSessions(stateDir, sessionId || null);
144
- const touches = tailFileTouches(stateDir);
144
+ // Read a wide tail so a peer's collision touch is not missed under heavy load.
145
+ const touches = tailFileTouches(stateDir, 1000);
145
146
 
146
147
  const verdict = evaluate(targetFiles, peers, touches, sessionId || '');
147
148
 
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- // @sv-version: 1.0.0
2
+ // @sv-version: 1.1.0
3
3
  /**
4
4
  * `scope` — Per-Instance Commit Scoping CLI
5
5
  *
@@ -9,17 +9,29 @@
9
9
  *
10
10
  * Source of truth for "what did THIS session edit?":
11
11
  * `.claude/state/sessions/<id>.json#filesTouched`
12
- * which is maintained by `post-tool-use.ts` (capped at 50, dedup-keep-last).
12
+ * which is maintained by `post-tool-use.ts` (capped at 200, dedup-keep-last).
13
+ *
14
+ * Concurrency model (v1.1.0): two instances in the SAME working directory share
15
+ * ONE `.git/index`. A `git reset` + `git add` + `git commit` sequence is therefore
16
+ * NOT atomic across peers — peer B's `git add` between our reset and commit would
17
+ * be folded into our commit. To be genuinely safe we commit by pathspec:
18
+ * `git add -- <ours>` (additive, never disturbs a peer's staged entries) then
19
+ * `git commit -o -- <ours>` (--only: commits EXACTLY these paths regardless of
20
+ * what else is staged). No global `git reset`.
21
+ * For true isolation across concurrent commits, prefer one git worktree per
22
+ * instance (`git worktree add`); this CLI is the best-effort same-worktree path.
13
23
  *
14
24
  * Usage:
15
25
  * scope status Show this session's files vs peers' files vs untracked.
16
- * scope stage [--include-conflicted] `git reset`, then `git add` only this session's dirty files.
17
- * Refuses files a peer touched in the last 5 min unless
18
- * --include-conflicted.
26
+ * scope stage [--include-conflicted] `git add` only this session's dirty files (additive;
27
+ * resets the index only when no peer is active). Prefer
28
+ * `scope commit` for the safe atomic path.
19
29
  * scope diff `git diff` for files this session touched.
20
- * scope commit "<msg>" [--push] stage + commit + optional push.
30
+ * scope commit "<msg>" [--push] commit ONLY this session's files via `git commit -o`.
21
31
  *
22
32
  * Session discovery: --session <id>, else $CLAUDE_SESSION_ID, else single active session.
33
+ * NOTE: with ≥2 active sessions and CLAUDE_SESSION_ID unset, discovery fails by
34
+ * design (we will not guess) — pass --session <id> or export CLAUDE_SESSION_ID.
23
35
  *
24
36
  * Exit codes:
25
37
  * 0 ok
@@ -127,7 +139,7 @@ function classify(stateDir: string, sessionId: string, projectDir: string): Scop
127
139
  const status = gitDirty(projectDir);
128
140
  const dirty = new Set<string>([...status.modified, ...status.untracked]);
129
141
 
130
- const touches = tailFileTouches(stateDir);
142
+ const touches = tailFileTouches(stateDir, 1000);
131
143
  const peerById = new Map(loadAllSessions(stateDir).map(p => [p.sessionId, p]));
132
144
  const peerTouches = new Map<string, FileTouch>();
133
145
  for (const t of touches) {
@@ -180,7 +192,7 @@ function cmdStatus(stateDir: string, projectDir: string, args: string[]): number
180
192
  console.log(
181
193
  `Session ${shortId(sessionId)} "${session.title}" (branch ${session.gitBranch || '?'})`
182
194
  );
183
- console.log(`Files in session.filesTouched: ${session.filesTouched.length} (cap 50, dedup)`);
195
+ console.log(`Files in session.filesTouched: ${session.filesTouched.length} (cap 200, dedup)`);
184
196
  console.log();
185
197
  console.log(`SAFE TO STAGE (${mine.length}):`);
186
198
  if (mine.length === 0) {
@@ -254,13 +266,23 @@ function cmdStage(stateDir: string, projectDir: string, args: string[]): number
254
266
  return 1;
255
267
  }
256
268
 
257
- // The `git reset` here is intentional: scope semantics own the index.
258
- // Anything a peer staged previously will be unstaged; they will re-stage
259
- // their own files via `scope stage` when they commit.
260
- const resetR = git(['reset'], projectDir);
261
- if (resetR.code !== 0) {
262
- console.error(`git reset failed: ${resetR.stderr.trim()}`);
263
- return 1;
269
+ // Index hygiene: a shared `.git/index` means a global `git reset` would wipe a
270
+ // peer's staged work. Only reset when NO peer is active (solo / safe). When a
271
+ // peer is active we stage ADDITIVELY and rely on `scope commit` (git commit -o)
272
+ // to commit exactly our paths regardless of what else is staged.
273
+ const activePeers = loadAllSessions(stateDir).filter(
274
+ s => s.sessionId !== sessionId && ageMs(s.lastSeenAt) < ACTIVE_MS
275
+ );
276
+ if (activePeers.length === 0) {
277
+ const resetR = git(['reset'], projectDir);
278
+ if (resetR.code !== 0) {
279
+ console.error(`git reset failed: ${resetR.stderr.trim()}`);
280
+ return 1;
281
+ }
282
+ } else {
283
+ console.log(
284
+ `${activePeers.length} active peer(s) — staging additively (no \`git reset\`) to protect their index.`
285
+ );
264
286
  }
265
287
  const addR = git(['add', '--', ...toStage], projectDir);
266
288
  if (addR.code !== 0) {
@@ -270,6 +292,8 @@ function cmdStage(stateDir: string, projectDir: string, args: string[]): number
270
292
 
271
293
  console.log(`Staged ${toStage.length} file(s) from session ${shortId(sessionId)}:`);
272
294
  for (const f of toStage) console.log(` + ${f}`);
295
+ console.log();
296
+ console.log('SAFEST commit path (never bundles peers): scope commit "<message>"');
273
297
 
274
298
  if (conflicted.length > 0 && !includeConflicted) {
275
299
  console.log();
@@ -285,7 +309,6 @@ function cmdStage(stateDir: string, projectDir: string, args: string[]): number
285
309
  return 2;
286
310
  }
287
311
 
288
- console.log();
289
312
  console.log('Review with: git diff --cached --stat');
290
313
  return 0;
291
314
  }
@@ -335,22 +358,66 @@ function cmdCommit(stateDir: string, projectDir: string, args: string[]): number
335
358
  return 1;
336
359
  }
337
360
 
338
- const stageCode = cmdStage(stateDir, projectDir, args);
339
- if (stageCode === 1) return 1;
340
- if (stageCode === 2 && !hasFlag(args, '--include-conflicted')) {
341
- console.error();
361
+ const sessionId = resolveSessionId(stateDir, parseFlag(args, '--session'));
362
+ if (!sessionId) {
363
+ console.error('Cannot resolve session ID. Set CLAUDE_SESSION_ID or pass --session <id>.');
364
+ console.error('Tip: run `npx tsx "$CLAUDE_PROJECT_DIR/.claude/hooks/peers.ts" list`.');
365
+ return 1;
366
+ }
367
+ const session = readSession(stateDir, sessionId);
368
+ if (!session) {
369
+ console.error(`Session ${shortId(sessionId)} not registered.`);
370
+ return 1;
371
+ }
372
+
373
+ const { mine, conflicted } = classify(stateDir, sessionId, projectDir);
374
+ const includeConflicted = hasFlag(args, '--include-conflicted');
375
+ const conflictedFiles = conflicted.map(c => c.file);
376
+ const toCommit = includeConflicted ? [...mine, ...conflictedFiles] : mine;
377
+
378
+ if (toCommit.length === 0) {
379
+ if (conflicted.length > 0) {
380
+ console.error(
381
+ `No safe files to commit. ${conflicted.length} conflicted file(s) — pass --include-conflicted to override.`
382
+ );
383
+ for (const c of conflicted) {
384
+ const who = c.peer ? `${shortId(c.peer.sessionId)} "${c.peer.title}"` : '(unknown)';
385
+ console.error(` ! ${c.file} ← ${who}, ${c.ageSec}s ago`);
386
+ }
387
+ return 2;
388
+ }
389
+ console.error('No files to commit (this session has no dirty files in filesTouched).');
390
+ return 1;
391
+ }
392
+
393
+ if (conflicted.length > 0 && !includeConflicted) {
342
394
  console.error(
343
- 'Refusing to commit while conflicted files were skipped. ' +
344
- 'Resolve, OR re-run with --include-conflicted.'
395
+ `Refusing to commit: ${conflicted.length} of your files were also touched by an active peer in the last 5 min.`
345
396
  );
397
+ for (const c of conflicted) {
398
+ const who = c.peer ? `${shortId(c.peer.sessionId)} "${c.peer.title}"` : '(unknown)';
399
+ console.error(` ! ${c.file} ← ${who}, ${c.ageSec}s ago`);
400
+ }
401
+ console.error('Coordinate via `peers.ts notify <id> "..."`, OR re-run with --include-conflicted.');
346
402
  return 2;
347
403
  }
348
404
 
349
- const commitR = git(['commit', '-m', message], projectDir);
405
+ // Atomic, shared-index-safe commit. `git add` is additive (never touches a
406
+ // peer's staged entries); `git commit -o -- <paths>` commits EXACTLY those
407
+ // paths regardless of what else is staged, so a peer's concurrently-staged
408
+ // files can never be folded into this commit. No global `git reset`.
409
+ const addR = git(['add', '--', ...toCommit], projectDir);
410
+ if (addR.code !== 0) {
411
+ console.error(`git add failed: ${addR.stderr.trim()}`);
412
+ return 1;
413
+ }
414
+ const commitR = git(['commit', '-o', '-m', message, '--', ...toCommit], projectDir);
350
415
  process.stdout.write(commitR.stdout);
351
416
  process.stderr.write(commitR.stderr);
352
417
  if (commitR.code !== 0) return 1;
353
418
 
419
+ console.log(`Committed ${toCommit.length} file(s) from session ${shortId(sessionId)}.`);
420
+
354
421
  if (hasFlag(args, '--push')) {
355
422
  const pushR = spawnSync('git', ['push'], { cwd: projectDir, stdio: 'inherit' });
356
423
  return pushR.status ?? 1;
@@ -367,9 +434,10 @@ Commands:
367
434
  scope diff [--session <id>]
368
435
  scope commit "<message>" [--session <id>] [--include-conflicted] [--push]
369
436
 
370
- Stages and commits ONLY the files this Claude session edited (per
371
- \`.claude/state/sessions/<id>.json#filesTouched\`). Refuses to stage
372
- files a peer session touched in the last 5 min unless --include-conflicted.
437
+ Commits ONLY the files this Claude session edited (per
438
+ \`.claude/state/sessions/<id>.json#filesTouched\`), via \`git commit -o\` so a
439
+ peer's concurrently-staged files are never bundled in. Refuses files a peer
440
+ session touched in the last 5 min unless --include-conflicted.
373
441
 
374
442
  Exit codes: 0=ok, 1=arg/state error, 2=conflict refusal.
375
443
  `);
@@ -17,6 +17,7 @@
17
17
  */
18
18
 
19
19
  import {
20
+ ACTIVE_MS,
20
21
  ageMs,
21
22
  appendInbox,
22
23
  drainInbox,
@@ -81,7 +82,7 @@ async function main(): Promise<void> {
81
82
  messageParts.push('');
82
83
  messageParts.push(`PEERS DETECTED in this project (${peers.length}):`);
83
84
  for (const p of peers) messageParts.push(` - ${formatPeer(p)}`);
84
- const anyActive = peers.some(p => ageMs(p.lastSeenAt) < 60 * 1000);
85
+ const anyActive = peers.some(p => ageMs(p.lastSeenAt) < ACTIVE_MS);
85
86
  if (anyActive) {
86
87
  messageParts.push('');
87
88
  messageParts.push(
@@ -93,7 +94,7 @@ async function main(): Promise<void> {
93
94
  } else {
94
95
  messageParts.push('');
95
96
  messageParts.push(
96
- 'Peers are idle (>60s). Edits will be allowed but you will see a notice if ' +
97
+ 'Peers are idle (>3min). Edits will be allowed but you will see a notice if ' +
97
98
  'you touch files they recently modified.'
98
99
  );
99
100
  }
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- // @sv-version: 1.2.0
2
+ // @sv-version: 1.3.0
3
3
  /**
4
4
  * Stop Validator Hook — Start Vibing Stacks (Universal)
5
5
  *
@@ -17,6 +17,13 @@
17
17
  * (append-only LIFO) as a valid changelog section. Source of truth for "what did
18
18
  * THIS session edit?" is `.claude/state/sessions/<id>.json#filesTouched`, maintained
19
19
  * by `post-tool-use.ts`.
20
+ *
21
+ * v1.3.0: on APPROVE, surface "orphan" dirty files — files dirty in the worktree
22
+ * that are NOT in this session's filesTouched (e.g. changed via Bash, codegen, or
23
+ * formatters, which post-tool-use cannot capture). These are scoped OUT of the
24
+ * blocking check (so a peer's work never blocks you), but silently leaving them
25
+ * behind is exactly what makes the NEXT instance "see files and hesitate to
26
+ * deploy". We never block on them; we WARN so the agent can verify/own them.
20
27
  */
21
28
 
22
29
  import { execSync } from 'child_process';
@@ -264,11 +271,24 @@ function validate(sessionId: string | undefined): HookResult {
264
271
  };
265
272
  }
266
273
 
267
- // All good
274
+ // All good. If per-instance scoping hid orphan dirty files (dirty, but not in
275
+ // THIS session's filesTouched), surface them — they may be your own Bash/codegen
276
+ // changes that post-tool-use could not attribute. We do NOT block on them.
277
+ let orphanNote = '';
278
+ if (perInstance && totalDirty > modified.length) {
279
+ const orphans = getModifiedFiles().filter(f => !modified.includes(f));
280
+ orphanNote =
281
+ `\n\nNOTE: ${orphans.length} dirty file(s) are NOT attributed to this session ` +
282
+ `(e.g. changed via Bash, a formatter, or codegen):\n` +
283
+ orphans.slice(0, 10).map(f => ` ? ${f}`).join('\n') +
284
+ `\nIf any are yours, commit them with \`scope.ts commit\` (only your files). ` +
285
+ `If they belong to a peer, leave them. Do not \`git add -A\`.`;
286
+ }
287
+
268
288
  return {
269
289
  continue: false,
270
290
  decision: 'approve',
271
- reason: `ALL CHECKS PASSED ✅\nStack: ${stackId}\nBranch: ${branch}\nTree: Clean\nSecrets: clean`,
291
+ reason: `ALL CHECKS PASSED ✅\nStack: ${stackId}\nBranch: ${branch}\nTree: Clean (this-session scope)\nSecrets: clean${orphanNote}`,
272
292
  };
273
293
  }
274
294
 
@@ -23,8 +23,8 @@ Every hook updates `lastSeenAt` (heartbeat). Thresholds:
23
23
 
24
24
  | Age of last activity | State | Effect |
25
25
  | -------------------- | -------- | ---------------------------------------------------------------------- |
26
- | < 60s | active | Counts for collision detection; PreToolUse may BLOCK Edit/Write. |
27
- | 60s – 30min | idle | Surfaced as a warning; edits NOT blocked. |
26
+ | < 180s | active | Counts for collision detection; PreToolUse may BLOCK Edit/Write. |
27
+ | 180s – 30min | idle | Surfaced as a warning; edits NOT blocked. |
28
28
  | > 30min | stale | Auto-archived on the next sweep. |
29
29
  | > 24h | removed | Deleted entirely. |
30
30
 
@@ -40,12 +40,12 @@ Every hook updates `lastSeenAt` (heartbeat). Thresholds:
40
40
 
41
41
  | Peer who touched the same file last | Peer's heartbeat | Touch age | Decision |
42
42
  | ----------------------------------- | ---------------- | --------- | --------------------------------------------- |
43
- | any | active (<60s) | < 5min | **BLOCK** with a recovery hint |
44
- | any | idle (60s–30min) | < 5min | APPROVE + warning in `systemMessage` |
43
+ | any | active (<180s) | < 5min | **BLOCK** with a recovery hint |
44
+ | any | idle (180s–30min)| < 5min | APPROVE + warning in `systemMessage` |
45
45
  | any | stale or gone | any | APPROVE silently |
46
46
  | no peer touched the file | n/a | n/a | APPROVE silently |
47
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.
48
+ Override path: wait until the active peer's heartbeat goes idle (180s 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
49
 
50
50
  ## Talking to a peer
51
51