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 +3 -1
- package/dist/migrate.js +23 -1
- package/dist/setup.js +7 -0
- package/package.json +1 -1
- package/stacks/_shared/agents/commit-manager.md +50 -45
- package/stacks/_shared/commands/commit-mine.md +21 -16
- package/stacks/_shared/hooks/_state.README.md +2 -2
- package/stacks/_shared/hooks/_state.ts +25 -9
- package/stacks/_shared/hooks/pre-tool-use.ts +6 -5
- package/stacks/_shared/hooks/scope.ts +94 -26
- package/stacks/_shared/hooks/session-start.ts +3 -2
- package/stacks/_shared/hooks/stop-validator.ts +23 -3
- package/stacks/_shared/skills/multi-instance-coordination/SKILL.md +5 -5
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
|
|
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
|
|
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,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: commit-manager
|
|
3
|
-
version: 3.
|
|
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,
|
|
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.
|
|
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`.
|
|
137
|
-
|
|
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:
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
#
|
|
153
|
-
|
|
154
|
-
echo "
|
|
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
|
-
#
|
|
158
|
-
#
|
|
159
|
-
echo "
|
|
160
|
-
|
|
177
|
+
# Session could not be resolved (≥2 active, no $CLAUDE_SESSION_ID) or no dirty
|
|
178
|
+
# files in scope. STOP — do 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/) —
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
|
35
|
-
|
|
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
|
|
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
|
-
| <
|
|
30
|
-
|
|
|
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.
|
|
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: <
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
367
|
-
|
|
368
|
-
|
|
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.
|
|
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 <
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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]
|
|
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
|
|
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
|
-
//
|
|
258
|
-
//
|
|
259
|
-
//
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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
|
|
339
|
-
if (
|
|
340
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
371
|
-
\`.claude/state/sessions/<id>.json#filesTouched\`)
|
|
372
|
-
files
|
|
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) <
|
|
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 (>
|
|
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
|
+
// @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
|
-
| <
|
|
27
|
-
|
|
|
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 (<
|
|
44
|
-
| any | idle (
|
|
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 (
|
|
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
|
|