start-vibing-stacks 2.25.0 → 2.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/package.json +1 -1
- package/stacks/_shared/agents/claude-md-compactor.md +38 -4
- package/stacks/_shared/agents/commit-manager.md +78 -19
- package/stacks/_shared/agents/documenter.md +11 -6
- package/stacks/_shared/agents/domain-updater.md +107 -42
- package/stacks/_shared/commands/commit-mine.md +73 -0
- package/stacks/_shared/commands/feature.md +4 -2
- package/stacks/_shared/commands/fix.md +6 -4
- package/stacks/_shared/commands/validate.md +99 -14
- package/stacks/_shared/hooks/scope.ts +410 -0
- package/stacks/_shared/hooks/stop-validator.ts +57 -12
- package/stacks/_shared/hooks/user-prompt-submit.ts +12 -4
- package/stacks/_shared/skills/git-workflow/SKILL.md +1 -1
- package/stacks/_shared/skills/hook-development/SKILL.md +5 -1
- package/templates/CLAUDE-default.md +8 -4
- package/templates/CLAUDE-nodejs.md +14 -10
- package/templates/CLAUDE-php.md +14 -10
- package/templates/CLAUDE-python.md +23 -10
|
@@ -1,28 +1,113 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: validate
|
|
3
|
-
description: Run the full quality gate (typecheck → lint → test → build) using
|
|
4
|
-
version: 1.
|
|
3
|
+
description: Run the full quality gate (typecheck → lint → test → build) using THIS project's `active-project.json#qualityGates`. Per-stack reference is below.
|
|
4
|
+
version: 1.1.0
|
|
5
5
|
---
|
|
6
6
|
|
|
7
7
|
# /validate — Run Full Validation
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
## How it works (canonical — always use this)
|
|
10
|
+
|
|
11
|
+
1. **Read the gates from `.claude/config/active-project.json#qualityGates`.** This is the **only** source of truth — it was populated at install time from the active stack and reflects this project's actual tooling.
|
|
12
|
+
2. Run each gate in `order` ascending; stop at the first failure that has `required: true`.
|
|
13
|
+
3. If a gate has `appliesTo: ["nextjs", ...]`, only run it when `framework` in `active-project.json` matches.
|
|
10
14
|
|
|
11
15
|
```bash
|
|
12
|
-
jq -r '.qualityGates' .claude/config/active-project.json
|
|
16
|
+
jq -r '.qualityGates | sort_by(.order) | .[] | "\(.order). \(.name) (required=\(.required)): \(.command)"' .claude/config/active-project.json
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
`commit-manager` refuses to commit while `/validate` is failing.
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## Per-stack reference (suggested — only used if `active-project.json` is missing or corrupted)
|
|
24
|
+
|
|
25
|
+
This block exists so the model has a stack-aware fallback. **Do not pick from here when `active-project.json#qualityGates` is present and valid** — that JSON always wins.
|
|
26
|
+
|
|
27
|
+
```json
|
|
28
|
+
{
|
|
29
|
+
"python": {
|
|
30
|
+
"_default": {
|
|
31
|
+
"typecheck": "mypy .",
|
|
32
|
+
"lint": "ruff check .",
|
|
33
|
+
"format": "ruff format .",
|
|
34
|
+
"test": "pytest --tb=short",
|
|
35
|
+
"serve": "uvicorn app.main:app --reload"
|
|
36
|
+
},
|
|
37
|
+
"frameworks": {
|
|
38
|
+
"fastapi": { "test": "pytest --tb=short" },
|
|
39
|
+
"django": { "test": "python manage.py test", "migrate": "python manage.py migrate" },
|
|
40
|
+
"flask": { "test": "pytest --tb=short" },
|
|
41
|
+
"scripts": { "test": "pytest --tb=short" }
|
|
42
|
+
},
|
|
43
|
+
"_runOrder": ["typecheck", "lint", "test"]
|
|
44
|
+
},
|
|
45
|
+
|
|
46
|
+
"nodejs": {
|
|
47
|
+
"_default": {
|
|
48
|
+
"typecheck": "bun run typecheck",
|
|
49
|
+
"lint": "bun run lint",
|
|
50
|
+
"format": "bun run format",
|
|
51
|
+
"test": "bun run test",
|
|
52
|
+
"build": "bun run build",
|
|
53
|
+
"serve": "bun run dev"
|
|
54
|
+
},
|
|
55
|
+
"frameworks": {
|
|
56
|
+
"nextjs": {
|
|
57
|
+
"_extra": [
|
|
58
|
+
{ "name": "RouteSlugs", "command": "node scripts/check-route-slugs.mjs", "order": 4, "required": true, "why": "next build does not catch dynamic-route slug mismatch" },
|
|
59
|
+
{ "name": "BuildScripts", "command": "node scripts/check-build-scripts.mjs", "order": 5, "required": true, "why": "Vercel/Docker strip devDeps; scripts.build calling tsx/ts-node/vitest crash exit 127" }
|
|
60
|
+
]
|
|
61
|
+
},
|
|
62
|
+
"nuxt": { "build": "bun run build" },
|
|
63
|
+
"astro": { "build": "bun run build" },
|
|
64
|
+
"express": { "test": "bun run test" },
|
|
65
|
+
"fastify": { "test": "bun run test" },
|
|
66
|
+
"vanilla": { "test": "bun run test" }
|
|
67
|
+
},
|
|
68
|
+
"_runOrder": ["typecheck", "lint", "test", "_extra", "build"]
|
|
69
|
+
},
|
|
70
|
+
|
|
71
|
+
"php": {
|
|
72
|
+
"_default": {
|
|
73
|
+
"static": "vendor/bin/phpstan analyse --level=6",
|
|
74
|
+
"test": "vendor/bin/phpunit",
|
|
75
|
+
"format": "vendor/bin/php-cs-fixer fix",
|
|
76
|
+
"serve": "php artisan serve"
|
|
77
|
+
},
|
|
78
|
+
"frameworks": {
|
|
79
|
+
"laravel-octane": {
|
|
80
|
+
"serve": "php artisan octane:start --watch",
|
|
81
|
+
"_extraIfFrontend": ["typecheck", "lint", "viteManifest"]
|
|
82
|
+
},
|
|
83
|
+
"laravel": { "_extraIfFrontend": ["typecheck", "lint", "viteManifest"] }
|
|
84
|
+
},
|
|
85
|
+
"_frontendChecks": {
|
|
86
|
+
"typecheck": "npx tsc --noEmit",
|
|
87
|
+
"lint": "npx eslint resources/js/",
|
|
88
|
+
"viteManifest": "node scripts/check-vite-manifest.mjs"
|
|
89
|
+
},
|
|
90
|
+
"_runOrder": ["static", "test", "typecheck", "lint", "viteManifest"]
|
|
91
|
+
}
|
|
92
|
+
}
|
|
13
93
|
```
|
|
14
94
|
|
|
15
|
-
|
|
95
|
+
### How to read this fallback
|
|
96
|
+
|
|
97
|
+
- `_default` — commands that always apply within the stack.
|
|
98
|
+
- `frameworks.<framework>` — overrides or extras gated by `framework` in `active-project.json`.
|
|
99
|
+
- `_extra` — additional gates with explicit `order` (PHP/Next.js use this for stack-specific static checks like vite-manifest, route-slugs, build-scripts).
|
|
100
|
+
- `_runOrder` — order to chain when no `qualityGates` is available.
|
|
16
101
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
102
|
+
If `.claude/config/active-project.json#qualityGates` is missing, **do not silently fall back** — instead:
|
|
103
|
+
|
|
104
|
+
1. Print a warning: `WARN: active-project.json#qualityGates missing — using fallback for stack=<X>, framework=<Y>`.
|
|
105
|
+
2. Run the fallback in `_runOrder`.
|
|
106
|
+
3. Recommend the user re-run `npx start-vibing-stacks` (or `migrate --apply`) to repair the config.
|
|
107
|
+
|
|
108
|
+
---
|
|
24
109
|
|
|
25
|
-
Output format
|
|
110
|
+
## Output format
|
|
26
111
|
|
|
27
112
|
```
|
|
28
113
|
typecheck: PASS (1.4s)
|
|
@@ -34,4 +119,4 @@ build: PASS (3.1s)
|
|
|
34
119
|
✅ All gates passed — safe to invoke commit-manager
|
|
35
120
|
```
|
|
36
121
|
|
|
37
|
-
If any gate fails, print
|
|
122
|
+
If any required gate fails, print its full output and exit non-zero. Optional gates (`required: false`) print a warning but do not block.
|
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// @sv-version: 1.0.0
|
|
3
|
+
/**
|
|
4
|
+
* `scope` — Per-Instance Commit Scoping CLI
|
|
5
|
+
*
|
|
6
|
+
* Solves: in multi-instance Claude sessions, `git add .` / `git add -A` pulls
|
|
7
|
+
* in changes from peer sessions, producing tangled commits where instance N
|
|
8
|
+
* unintentionally ships instance M's uncommitted work.
|
|
9
|
+
*
|
|
10
|
+
* Source of truth for "what did THIS session edit?":
|
|
11
|
+
* `.claude/state/sessions/<id>.json#filesTouched`
|
|
12
|
+
* which is maintained by `post-tool-use.ts` (capped at 50, dedup-keep-last).
|
|
13
|
+
*
|
|
14
|
+
* Usage:
|
|
15
|
+
* 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.
|
|
19
|
+
* scope diff `git diff` for files this session touched.
|
|
20
|
+
* scope commit "<msg>" [--push] stage + commit + optional push.
|
|
21
|
+
*
|
|
22
|
+
* Session discovery: --session <id>, else $CLAUDE_SESSION_ID, else single active session.
|
|
23
|
+
*
|
|
24
|
+
* Exit codes:
|
|
25
|
+
* 0 ok
|
|
26
|
+
* 1 argument / state error
|
|
27
|
+
* 2 refused (collision detected; pass --include-conflicted to override)
|
|
28
|
+
*
|
|
29
|
+
* Run: npx tsx "$CLAUDE_PROJECT_DIR/.claude/hooks/scope.ts" <command>
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { existsSync } from 'fs';
|
|
33
|
+
import { spawnSync } from 'child_process';
|
|
34
|
+
import { join } from 'path';
|
|
35
|
+
import {
|
|
36
|
+
ACTIVE_MS,
|
|
37
|
+
COLLISION_WINDOW_MS,
|
|
38
|
+
ageMs,
|
|
39
|
+
getProjectDir,
|
|
40
|
+
getStateDir,
|
|
41
|
+
listSessionFiles,
|
|
42
|
+
readJsonSafe,
|
|
43
|
+
readSession,
|
|
44
|
+
shortId,
|
|
45
|
+
tailFileTouches,
|
|
46
|
+
type FileTouch,
|
|
47
|
+
type SessionRecord,
|
|
48
|
+
} from './_state.js';
|
|
49
|
+
|
|
50
|
+
interface GitStatus {
|
|
51
|
+
modified: Set<string>;
|
|
52
|
+
staged: Set<string>;
|
|
53
|
+
untracked: Set<string>;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface ScopeReport {
|
|
57
|
+
mine: string[];
|
|
58
|
+
conflicted: { file: string; peer: SessionRecord | null; ageSec: number }[];
|
|
59
|
+
otherDirty: string[];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function parseFlag(args: string[], flag: string): string | undefined {
|
|
63
|
+
const i = args.indexOf(flag);
|
|
64
|
+
if (i === -1) return undefined;
|
|
65
|
+
return args[i + 1];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function hasFlag(args: string[], flag: string): boolean {
|
|
69
|
+
return args.includes(flag);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function loadAllSessions(stateDir: string): SessionRecord[] {
|
|
73
|
+
const out: SessionRecord[] = [];
|
|
74
|
+
for (const file of listSessionFiles(stateDir)) {
|
|
75
|
+
const rec = readJsonSafe<SessionRecord>(file);
|
|
76
|
+
if (rec) out.push(rec);
|
|
77
|
+
}
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function resolveSessionId(stateDir: string, explicit?: string): string | null {
|
|
82
|
+
if (explicit) return explicit;
|
|
83
|
+
const fromEnv = process.env['CLAUDE_SESSION_ID'];
|
|
84
|
+
if (fromEnv) return fromEnv;
|
|
85
|
+
const active = loadAllSessions(stateDir).filter(s => ageMs(s.lastSeenAt) < ACTIVE_MS);
|
|
86
|
+
if (active.length === 1) return active[0]!.sessionId;
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function git(args: string[], cwd: string): { code: number; stdout: string; stderr: string } {
|
|
91
|
+
const r = spawnSync('git', args, { cwd, encoding: 'utf8' });
|
|
92
|
+
return { code: r.status ?? 1, stdout: r.stdout || '', stderr: r.stderr || '' };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function gitDirty(projectDir: string): GitStatus {
|
|
96
|
+
const r = git(['status', '--porcelain=v1', '-z'], projectDir);
|
|
97
|
+
const out: GitStatus = {
|
|
98
|
+
modified: new Set<string>(),
|
|
99
|
+
staged: new Set<string>(),
|
|
100
|
+
untracked: new Set<string>(),
|
|
101
|
+
};
|
|
102
|
+
if (r.code !== 0) return out;
|
|
103
|
+
// -z = NUL-terminated. Format: `XY<space>path\0` (renames add a second `\0orig`)
|
|
104
|
+
// We deliberately treat the rename path as the new name only; orig is consumed.
|
|
105
|
+
const parts = r.stdout.split('\0').filter(Boolean);
|
|
106
|
+
for (let i = 0; i < parts.length; i++) {
|
|
107
|
+
const entry = parts[i]!;
|
|
108
|
+
if (entry.length < 4) continue;
|
|
109
|
+
const xy = entry.slice(0, 2);
|
|
110
|
+
const path = entry.slice(3);
|
|
111
|
+
const X = xy[0]!;
|
|
112
|
+
const Y = xy[1]!;
|
|
113
|
+
if (X === 'R' || Y === 'R') {
|
|
114
|
+
// Next NUL-token is the original path; skip it for our purposes.
|
|
115
|
+
i++;
|
|
116
|
+
}
|
|
117
|
+
if (X !== ' ' && X !== '?') out.staged.add(path);
|
|
118
|
+
if (Y !== ' ' && Y !== '?') out.modified.add(path);
|
|
119
|
+
if (X === '?' && Y === '?') out.untracked.add(path);
|
|
120
|
+
}
|
|
121
|
+
return out;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function classify(stateDir: string, sessionId: string, projectDir: string): ScopeReport {
|
|
125
|
+
const session = readSession(stateDir, sessionId);
|
|
126
|
+
const tracked = new Set(session?.filesTouched || []);
|
|
127
|
+
const status = gitDirty(projectDir);
|
|
128
|
+
const dirty = new Set<string>([...status.modified, ...status.untracked]);
|
|
129
|
+
|
|
130
|
+
const touches = tailFileTouches(stateDir);
|
|
131
|
+
const peerById = new Map(loadAllSessions(stateDir).map(p => [p.sessionId, p]));
|
|
132
|
+
const peerTouches = new Map<string, FileTouch>();
|
|
133
|
+
for (const t of touches) {
|
|
134
|
+
if (t.sessionId === sessionId) continue;
|
|
135
|
+
if (ageMs(t.ts) > COLLISION_WINDOW_MS) continue;
|
|
136
|
+
const prev = peerTouches.get(t.file);
|
|
137
|
+
if (!prev || Date.parse(t.ts) > Date.parse(prev.ts)) peerTouches.set(t.file, t);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const mine: string[] = [];
|
|
141
|
+
const conflicted: ScopeReport['conflicted'] = [];
|
|
142
|
+
for (const file of tracked) {
|
|
143
|
+
if (!dirty.has(file)) continue;
|
|
144
|
+
const conflict = peerTouches.get(file);
|
|
145
|
+
if (conflict) {
|
|
146
|
+
conflicted.push({
|
|
147
|
+
file,
|
|
148
|
+
peer: peerById.get(conflict.sessionId) || null,
|
|
149
|
+
ageSec: Math.round(ageMs(conflict.ts) / 1000),
|
|
150
|
+
});
|
|
151
|
+
} else {
|
|
152
|
+
mine.push(file);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const otherDirty: string[] = [];
|
|
157
|
+
for (const f of dirty) {
|
|
158
|
+
if (!tracked.has(f)) otherDirty.push(f);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return { mine, conflicted, otherDirty };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function cmdStatus(stateDir: string, projectDir: string, args: string[]): number {
|
|
165
|
+
const sessionId = resolveSessionId(stateDir, parseFlag(args, '--session'));
|
|
166
|
+
if (!sessionId) {
|
|
167
|
+
console.error('Cannot resolve session ID. Set CLAUDE_SESSION_ID or pass --session <id>.');
|
|
168
|
+
console.error('Tip: run `npx tsx "$CLAUDE_PROJECT_DIR/.claude/hooks/peers.ts" list`.');
|
|
169
|
+
return 1;
|
|
170
|
+
}
|
|
171
|
+
const session = readSession(stateDir, sessionId);
|
|
172
|
+
if (!session) {
|
|
173
|
+
console.error(`Session ${shortId(sessionId)} not registered. Has the SessionStart hook run?`);
|
|
174
|
+
return 1;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const { mine, conflicted, otherDirty } = classify(stateDir, sessionId, projectDir);
|
|
178
|
+
const status = gitDirty(projectDir);
|
|
179
|
+
|
|
180
|
+
console.log(
|
|
181
|
+
`Session ${shortId(sessionId)} "${session.title}" (branch ${session.gitBranch || '?'})`
|
|
182
|
+
);
|
|
183
|
+
console.log(`Files in session.filesTouched: ${session.filesTouched.length} (cap 50, dedup)`);
|
|
184
|
+
console.log();
|
|
185
|
+
console.log(`SAFE TO STAGE (${mine.length}):`);
|
|
186
|
+
if (mine.length === 0) {
|
|
187
|
+
console.log(' (none)');
|
|
188
|
+
} else {
|
|
189
|
+
for (const f of mine) console.log(` + ${f}`);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (conflicted.length > 0) {
|
|
193
|
+
console.log();
|
|
194
|
+
console.log(`CONFLICTED — peer also touched in last 5 min (${conflicted.length}):`);
|
|
195
|
+
for (const c of conflicted) {
|
|
196
|
+
const who = c.peer
|
|
197
|
+
? `${shortId(c.peer.sessionId)} "${c.peer.title}"`
|
|
198
|
+
: '(unknown peer)';
|
|
199
|
+
console.log(` ! ${c.file} ← ${who}, ${c.ageSec}s ago`);
|
|
200
|
+
}
|
|
201
|
+
console.log(' Coordinate via `/peers notify <id> "..."`, OR re-run with --include-conflicted.');
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (otherDirty.length > 0) {
|
|
205
|
+
console.log();
|
|
206
|
+
console.log(`NOT YOURS — dirty but not in filesTouched (${otherDirty.length}):`);
|
|
207
|
+
for (const f of otherDirty) console.log(` · ${f}`);
|
|
208
|
+
console.log(' `scope stage` will LEAVE these alone (this is the whole point).');
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (status.staged.size > 0) {
|
|
212
|
+
console.log();
|
|
213
|
+
console.log(`CURRENTLY STAGED (${status.staged.size}):`);
|
|
214
|
+
for (const f of status.staged) console.log(` ✓ ${f}`);
|
|
215
|
+
console.log(' `scope stage` runs `git reset` first — this state will be replaced.');
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return 0;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function cmdStage(stateDir: string, projectDir: string, args: string[]): number {
|
|
222
|
+
const sessionId = resolveSessionId(stateDir, parseFlag(args, '--session'));
|
|
223
|
+
if (!sessionId) {
|
|
224
|
+
console.error('Cannot resolve session ID. Set CLAUDE_SESSION_ID or pass --session <id>.');
|
|
225
|
+
return 1;
|
|
226
|
+
}
|
|
227
|
+
const session = readSession(stateDir, sessionId);
|
|
228
|
+
if (!session) {
|
|
229
|
+
console.error(`Session ${shortId(sessionId)} not registered.`);
|
|
230
|
+
return 1;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const { mine, conflicted } = classify(stateDir, sessionId, projectDir);
|
|
234
|
+
const includeConflicted = hasFlag(args, '--include-conflicted');
|
|
235
|
+
const conflictedFiles = conflicted.map(c => c.file);
|
|
236
|
+
const toStage = includeConflicted ? [...mine, ...conflictedFiles] : mine;
|
|
237
|
+
|
|
238
|
+
if (toStage.length === 0) {
|
|
239
|
+
if (conflicted.length > 0) {
|
|
240
|
+
console.error(
|
|
241
|
+
`No safe files to stage. ${conflicted.length} conflicted file(s) — pass --include-conflicted to override.`
|
|
242
|
+
);
|
|
243
|
+
for (const c of conflicted) {
|
|
244
|
+
const who = c.peer
|
|
245
|
+
? `${shortId(c.peer.sessionId)} "${c.peer.title}"`
|
|
246
|
+
: '(unknown)';
|
|
247
|
+
console.error(` ! ${c.file} ← ${who}, ${c.ageSec}s ago`);
|
|
248
|
+
}
|
|
249
|
+
return 2;
|
|
250
|
+
}
|
|
251
|
+
console.error(
|
|
252
|
+
'No files to stage (this session has no dirty files in filesTouched).'
|
|
253
|
+
);
|
|
254
|
+
return 1;
|
|
255
|
+
}
|
|
256
|
+
|
|
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;
|
|
264
|
+
}
|
|
265
|
+
const addR = git(['add', '--', ...toStage], projectDir);
|
|
266
|
+
if (addR.code !== 0) {
|
|
267
|
+
console.error(`git add failed: ${addR.stderr.trim()}`);
|
|
268
|
+
return 1;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
console.log(`Staged ${toStage.length} file(s) from session ${shortId(sessionId)}:`);
|
|
272
|
+
for (const f of toStage) console.log(` + ${f}`);
|
|
273
|
+
|
|
274
|
+
if (conflicted.length > 0 && !includeConflicted) {
|
|
275
|
+
console.log();
|
|
276
|
+
console.log(`Skipped ${conflicted.length} conflicted file(s):`);
|
|
277
|
+
for (const c of conflicted) {
|
|
278
|
+
const who = c.peer
|
|
279
|
+
? `${shortId(c.peer.sessionId)} "${c.peer.title}"`
|
|
280
|
+
: '(unknown)';
|
|
281
|
+
console.log(` ! ${c.file} ← ${who}, ${c.ageSec}s ago`);
|
|
282
|
+
}
|
|
283
|
+
console.log(' Pass --include-conflicted to stage them anyway.');
|
|
284
|
+
console.log(' Review with: git diff --cached --stat');
|
|
285
|
+
return 2;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
console.log();
|
|
289
|
+
console.log('Review with: git diff --cached --stat');
|
|
290
|
+
return 0;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function cmdDiff(stateDir: string, projectDir: string, args: string[]): number {
|
|
294
|
+
const sessionId = resolveSessionId(stateDir, parseFlag(args, '--session'));
|
|
295
|
+
if (!sessionId) {
|
|
296
|
+
console.error('Cannot resolve session ID. Set CLAUDE_SESSION_ID or pass --session <id>.');
|
|
297
|
+
return 1;
|
|
298
|
+
}
|
|
299
|
+
const session = readSession(stateDir, sessionId);
|
|
300
|
+
if (!session) {
|
|
301
|
+
console.error(`Session ${shortId(sessionId)} not registered.`);
|
|
302
|
+
return 1;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const files = session.filesTouched.filter(f => existsSync(join(projectDir, f)));
|
|
306
|
+
if (files.length === 0) {
|
|
307
|
+
console.error('This session has no tracked files (or none exist on disk).');
|
|
308
|
+
return 1;
|
|
309
|
+
}
|
|
310
|
+
const r = spawnSync('git', ['diff', '--', ...files], {
|
|
311
|
+
cwd: projectDir,
|
|
312
|
+
stdio: 'inherit',
|
|
313
|
+
});
|
|
314
|
+
return r.status ?? 1;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Flags that take a value (the next token is consumed as the value, not as a positional).
|
|
318
|
+
const VALUE_FLAGS = new Set(['--session']);
|
|
319
|
+
|
|
320
|
+
function firstPositional(args: string[]): string | undefined {
|
|
321
|
+
for (let i = 0; i < args.length; i++) {
|
|
322
|
+
const a = args[i]!;
|
|
323
|
+
if (a.startsWith('--')) continue;
|
|
324
|
+
const prev = i > 0 ? args[i - 1]! : '';
|
|
325
|
+
if (VALUE_FLAGS.has(prev)) continue;
|
|
326
|
+
return a;
|
|
327
|
+
}
|
|
328
|
+
return undefined;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function cmdCommit(stateDir: string, projectDir: string, args: string[]): number {
|
|
332
|
+
const message = firstPositional(args);
|
|
333
|
+
if (!message) {
|
|
334
|
+
console.error('Usage: scope commit "<message>" [--push] [--include-conflicted] [--session <id>]');
|
|
335
|
+
return 1;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const stageCode = cmdStage(stateDir, projectDir, args);
|
|
339
|
+
if (stageCode === 1) return 1;
|
|
340
|
+
if (stageCode === 2 && !hasFlag(args, '--include-conflicted')) {
|
|
341
|
+
console.error();
|
|
342
|
+
console.error(
|
|
343
|
+
'Refusing to commit while conflicted files were skipped. ' +
|
|
344
|
+
'Resolve, OR re-run with --include-conflicted.'
|
|
345
|
+
);
|
|
346
|
+
return 2;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const commitR = git(['commit', '-m', message], projectDir);
|
|
350
|
+
process.stdout.write(commitR.stdout);
|
|
351
|
+
process.stderr.write(commitR.stderr);
|
|
352
|
+
if (commitR.code !== 0) return 1;
|
|
353
|
+
|
|
354
|
+
if (hasFlag(args, '--push')) {
|
|
355
|
+
const pushR = spawnSync('git', ['push'], { cwd: projectDir, stdio: 'inherit' });
|
|
356
|
+
return pushR.status ?? 1;
|
|
357
|
+
}
|
|
358
|
+
return 0;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function usage(): void {
|
|
362
|
+
console.log(`scope — per-instance commit scoping CLI
|
|
363
|
+
|
|
364
|
+
Commands:
|
|
365
|
+
scope status [--session <id>]
|
|
366
|
+
scope stage [--session <id>] [--include-conflicted]
|
|
367
|
+
scope diff [--session <id>]
|
|
368
|
+
scope commit "<message>" [--session <id>] [--include-conflicted] [--push]
|
|
369
|
+
|
|
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.
|
|
373
|
+
|
|
374
|
+
Exit codes: 0=ok, 1=arg/state error, 2=conflict refusal.
|
|
375
|
+
`);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function main(): void {
|
|
379
|
+
const [, , cmd, ...rest] = process.argv;
|
|
380
|
+
const projectDir = getProjectDir();
|
|
381
|
+
const stateDir = getStateDir(projectDir);
|
|
382
|
+
|
|
383
|
+
switch (cmd) {
|
|
384
|
+
case 'status':
|
|
385
|
+
process.exit(cmdStatus(stateDir, projectDir, rest));
|
|
386
|
+
break;
|
|
387
|
+
case 'stage':
|
|
388
|
+
process.exit(cmdStage(stateDir, projectDir, rest));
|
|
389
|
+
break;
|
|
390
|
+
case 'diff':
|
|
391
|
+
process.exit(cmdDiff(stateDir, projectDir, rest));
|
|
392
|
+
break;
|
|
393
|
+
case 'commit':
|
|
394
|
+
process.exit(cmdCommit(stateDir, projectDir, rest));
|
|
395
|
+
break;
|
|
396
|
+
case 'help':
|
|
397
|
+
case '--help':
|
|
398
|
+
case '-h':
|
|
399
|
+
case undefined:
|
|
400
|
+
usage();
|
|
401
|
+
process.exit(0);
|
|
402
|
+
break;
|
|
403
|
+
default:
|
|
404
|
+
console.error(`Unknown command: ${cmd}`);
|
|
405
|
+
usage();
|
|
406
|
+
process.exit(1);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
main();
|
|
@@ -1,16 +1,22 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// @sv-version: 1.
|
|
2
|
+
// @sv-version: 1.2.0
|
|
3
3
|
/**
|
|
4
4
|
* Stop Validator Hook — Start Vibing Stacks (Universal)
|
|
5
5
|
*
|
|
6
6
|
* Reads active-project.json to determine stack-specific validations.
|
|
7
7
|
* Blocks task completion if:
|
|
8
8
|
* 1. Branch != main (work must be merged)
|
|
9
|
-
* 2. Git tree not clean
|
|
9
|
+
* 2. Git tree not clean — SCOPED TO THIS SESSION'S filesTouched when available
|
|
10
|
+
* (multi-instance safe: peer N's dirty files do not block instance M's Stop)
|
|
10
11
|
* 3. CLAUDE.md not updated
|
|
11
|
-
* 4. CLAUDE.md missing required sections
|
|
12
|
+
* 4. CLAUDE.md missing required sections — accepts `## Last Change` OR `## Recent Changes`
|
|
12
13
|
* 5. CLAUDE.md exceeds 40k chars
|
|
13
14
|
* 6. Secret pattern detected in committed/staged files (uses gitleaks if present, fallback to regex)
|
|
15
|
+
*
|
|
16
|
+
* v1.2.0: per-instance scoping for the dirty-tree check + accept `## Recent Changes`
|
|
17
|
+
* (append-only LIFO) as a valid changelog section. Source of truth for "what did
|
|
18
|
+
* THIS session edit?" is `.claude/state/sessions/<id>.json#filesTouched`, maintained
|
|
19
|
+
* by `post-tool-use.ts`.
|
|
14
20
|
*/
|
|
15
21
|
|
|
16
22
|
import { execSync } from 'child_process';
|
|
@@ -25,6 +31,7 @@ import {
|
|
|
25
31
|
formatPeer,
|
|
26
32
|
getStateDir,
|
|
27
33
|
listPeerSessions,
|
|
34
|
+
readSession,
|
|
28
35
|
} from './_state.js';
|
|
29
36
|
|
|
30
37
|
const PROJECT_DIR = process.env['CLAUDE_PROJECT_DIR'] || process.cwd();
|
|
@@ -77,6 +84,31 @@ function getModifiedFiles(): string[] {
|
|
|
77
84
|
return [...new Set([...staged, ...unstaged, ...untracked])];
|
|
78
85
|
}
|
|
79
86
|
|
|
87
|
+
/**
|
|
88
|
+
* Per-instance scoping: if a session id is provided AND state has filesTouched,
|
|
89
|
+
* return only the dirty files THIS session edited. Otherwise return the full
|
|
90
|
+
* dirty list (backward compatible). Falls back gracefully on any error.
|
|
91
|
+
*/
|
|
92
|
+
function getScopedDirtyFiles(sessionId: string | undefined): {
|
|
93
|
+
scoped: string[];
|
|
94
|
+
perInstance: boolean;
|
|
95
|
+
totalDirty: number;
|
|
96
|
+
} {
|
|
97
|
+
const allDirty = getModifiedFiles();
|
|
98
|
+
if (!sessionId) return { scoped: allDirty, perInstance: false, totalDirty: allDirty.length };
|
|
99
|
+
try {
|
|
100
|
+
const stateDir = getStateDir(PROJECT_DIR);
|
|
101
|
+
const sess = readSession(stateDir, sessionId);
|
|
102
|
+
if (!sess || !Array.isArray(sess.filesTouched)) {
|
|
103
|
+
return { scoped: allDirty, perInstance: false, totalDirty: allDirty.length };
|
|
104
|
+
}
|
|
105
|
+
const set = new Set(sess.filesTouched);
|
|
106
|
+
return { scoped: allDirty.filter(f => set.has(f)), perInstance: true, totalDirty: allDirty.length };
|
|
107
|
+
} catch {
|
|
108
|
+
return { scoped: allDirty, perInstance: false, totalDirty: allDirty.length };
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
80
112
|
/**
|
|
81
113
|
* Run a command capturing stdout, stderr and exit code without throwing.
|
|
82
114
|
*/
|
|
@@ -142,18 +174,27 @@ function scanSecrets(): string[] {
|
|
|
142
174
|
return findings;
|
|
143
175
|
}
|
|
144
176
|
|
|
145
|
-
function validate(): HookResult {
|
|
177
|
+
function validate(sessionId: string | undefined): HookResult {
|
|
146
178
|
const branch = getBranch();
|
|
147
179
|
const isMain = branch === 'main' || branch === 'master';
|
|
148
|
-
const modified =
|
|
180
|
+
const { scoped: modified, perInstance, totalDirty } = getScopedDirtyFiles(sessionId);
|
|
149
181
|
const isClean = modified.length === 0;
|
|
182
|
+
const scopeNote = perInstance
|
|
183
|
+
? ` (this-session-scoped: ${modified.length}/${totalDirty} dirty; peer files ignored)`
|
|
184
|
+
: '';
|
|
185
|
+
|
|
186
|
+
// 1. Must be on main with clean tree (scoped to THIS session when possible).
|
|
187
|
+
// Recommendation uses `/commit-mine` rather than `git add -A`, which would
|
|
188
|
+
// pull in peer-session changes (see CLAUDE.md NRY "Instance N's commit bundling...").
|
|
189
|
+
const commitGuide = perInstance
|
|
190
|
+
? `\n\nRecommended workflow (multi-instance safe):\n1. npx tsx "$CLAUDE_PROJECT_DIR/.claude/hooks/scope.ts" status\n2. npx tsx "$CLAUDE_PROJECT_DIR/.claude/hooks/scope.ts" stage\n3. git commit -m "type: description"\n4. git checkout main && git merge ${branch} && git push origin main && git branch -d ${branch}`
|
|
191
|
+
: `\n\nComplete git workflow:\n1. git add -A\n2. git commit -m "type: description"\n3. git checkout main\n4. git merge ${branch}\n5. git push origin main\n6. git branch -d ${branch}`;
|
|
150
192
|
|
|
151
|
-
// 1. Must be on main with clean tree
|
|
152
193
|
if (!isMain && modified.length > 0) {
|
|
153
194
|
return {
|
|
154
195
|
continue: true,
|
|
155
196
|
decision: 'block',
|
|
156
|
-
reason: `BLOCKED: On branch '${branch}' with ${modified.length} modified files
|
|
197
|
+
reason: `BLOCKED: On branch '${branch}' with ${modified.length} modified files${scopeNote}.${commitGuide}`,
|
|
157
198
|
};
|
|
158
199
|
}
|
|
159
200
|
|
|
@@ -166,10 +207,13 @@ function validate(): HookResult {
|
|
|
166
207
|
}
|
|
167
208
|
|
|
168
209
|
if (!isClean) {
|
|
210
|
+
const stageHint = perInstance
|
|
211
|
+
? `\n\nThese files were edited by THIS session. Commit only your scope:\n npx tsx "$CLAUDE_PROJECT_DIR/.claude/hooks/scope.ts" commit "<message>"`
|
|
212
|
+
: `\n\nCommit or stash before completing.`;
|
|
169
213
|
return {
|
|
170
214
|
continue: true,
|
|
171
215
|
decision: 'block',
|
|
172
|
-
reason: `BLOCKED: ${modified.length} uncommitted files:\n${modified.slice(0, 10).map(f => ` - ${f}`).join('\n')}
|
|
216
|
+
reason: `BLOCKED: ${modified.length} uncommitted files${scopeNote}:\n${modified.slice(0, 10).map(f => ` - ${f}`).join('\n')}${stageHint}`,
|
|
173
217
|
};
|
|
174
218
|
}
|
|
175
219
|
|
|
@@ -193,10 +237,11 @@ function validate(): HookResult {
|
|
|
193
237
|
};
|
|
194
238
|
}
|
|
195
239
|
|
|
196
|
-
// 4. Required sections
|
|
240
|
+
// 4. Required sections — `## Last Change` (single, overwritten) OR `## Recent Changes`
|
|
241
|
+
// (append-only LIFO, multi-instance safe) both satisfy the changelog slot.
|
|
197
242
|
const required = [
|
|
198
243
|
{ pattern: /^# .+/m, name: 'Project Title (H1)' },
|
|
199
|
-
{ pattern: /^## Last Change/m, name: 'Last Change' },
|
|
244
|
+
{ pattern: /^## (Last Change|Recent Changes)/m, name: 'Last Change OR Recent Changes' },
|
|
200
245
|
{ pattern: /^## Stack/m, name: 'Stack' },
|
|
201
246
|
];
|
|
202
247
|
|
|
@@ -247,10 +292,10 @@ async function main(): Promise<void> {
|
|
|
247
292
|
process.exit(0);
|
|
248
293
|
}
|
|
249
294
|
|
|
250
|
-
const result = validate();
|
|
251
|
-
|
|
252
295
|
// Multi-instance coordination side effects.
|
|
253
296
|
const sessionId: string | undefined = hookInput.session_id || hookInput.sessionId;
|
|
297
|
+
|
|
298
|
+
const result = validate(sessionId);
|
|
254
299
|
const eventName: string = hookInput.hook_event_name || hookInput.hookEventName || '';
|
|
255
300
|
const isSessionEnd = /SessionEnd/i.test(eventName);
|
|
256
301
|
|