start-vibing-stacks 2.28.0 → 2.29.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.js +81 -43
- package/dist/setup.js +5 -0
- package/package.json +1 -1
- package/stacks/_shared/hooks/_state.ts +8 -2
- package/stacks/_shared/hooks/plan-gate.ts +138 -0
package/dist/migrate.js
CHANGED
|
@@ -233,40 +233,56 @@ function applyOne(item, opts) {
|
|
|
233
233
|
copyFileSync(item.source, item.target);
|
|
234
234
|
return { applied: true };
|
|
235
235
|
}
|
|
236
|
+
// Keyed by event; each event carries one or more independent blocks. PreToolUse
|
|
237
|
+
// runs two: the multi-instance collision guard AND the plan-gate nudge. Each
|
|
238
|
+
// block is checked/appended individually so backfill is precise per-script.
|
|
236
239
|
const REQUIRED_HOOK_BLOCKS = {
|
|
237
|
-
SessionStart:
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
240
|
+
SessionStart: [
|
|
241
|
+
{
|
|
242
|
+
hooks: [
|
|
243
|
+
{ type: 'command', command: 'npx tsx "$CLAUDE_PROJECT_DIR/.claude/hooks/session-start.ts"', timeout: 10 },
|
|
244
|
+
],
|
|
245
|
+
},
|
|
246
|
+
],
|
|
247
|
+
UserPromptSubmit: [
|
|
248
|
+
{
|
|
249
|
+
matcher: '',
|
|
250
|
+
hooks: [
|
|
251
|
+
{ type: 'command', command: 'npx tsx "$CLAUDE_PROJECT_DIR/.claude/hooks/user-prompt-submit.ts"', timeout: 10 },
|
|
252
|
+
],
|
|
253
|
+
},
|
|
254
|
+
],
|
|
255
|
+
PreToolUse: [
|
|
256
|
+
{
|
|
257
|
+
matcher: 'Edit|Write|MultiEdit|NotebookEdit',
|
|
258
|
+
hooks: [
|
|
259
|
+
{ type: 'command', command: 'npx tsx "$CLAUDE_PROJECT_DIR/.claude/hooks/pre-tool-use.ts"', timeout: 5 },
|
|
260
|
+
{ type: 'command', command: 'npx tsx "$CLAUDE_PROJECT_DIR/.claude/hooks/plan-gate.ts"', timeout: 5 },
|
|
261
|
+
],
|
|
262
|
+
},
|
|
263
|
+
],
|
|
264
|
+
PostToolUse: [
|
|
265
|
+
{
|
|
266
|
+
matcher: 'Edit|Write|MultiEdit|NotebookEdit',
|
|
267
|
+
hooks: [
|
|
268
|
+
{ type: 'command', command: 'npx tsx "$CLAUDE_PROJECT_DIR/.claude/hooks/post-tool-use.ts"', timeout: 5 },
|
|
269
|
+
],
|
|
270
|
+
},
|
|
271
|
+
],
|
|
272
|
+
Stop: [
|
|
273
|
+
{
|
|
274
|
+
hooks: [
|
|
275
|
+
{ type: 'command', command: 'npx tsx "$CLAUDE_PROJECT_DIR/.claude/hooks/stop-validator.ts"', timeout: 30 },
|
|
276
|
+
],
|
|
277
|
+
},
|
|
278
|
+
],
|
|
279
|
+
SessionEnd: [
|
|
280
|
+
{
|
|
281
|
+
hooks: [
|
|
282
|
+
{ type: 'command', command: 'npx tsx "$CLAUDE_PROJECT_DIR/.claude/hooks/stop-validator.ts"', timeout: 10 },
|
|
283
|
+
],
|
|
284
|
+
},
|
|
285
|
+
],
|
|
270
286
|
};
|
|
271
287
|
function commandSubstring(cmd) {
|
|
272
288
|
const m = cmd.match(/\.claude\/hooks\/([A-Za-z0-9._-]+\.(?:ts|sh|js|mjs))/);
|
|
@@ -291,18 +307,40 @@ export function patchSettings(projectDir, dryRun) {
|
|
|
291
307
|
}
|
|
292
308
|
}
|
|
293
309
|
settings.hooks = settings.hooks || {};
|
|
294
|
-
for (const [event,
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
310
|
+
for (const [event, requiredBlocks] of Object.entries(REQUIRED_HOOK_BLOCKS)) {
|
|
311
|
+
let existingBlocks = Array.isArray(settings.hooks[event]) ? settings.hooks[event] : [];
|
|
312
|
+
let mutated = false;
|
|
313
|
+
// Check at the SCRIPT (hook) level, not the block level: an install may
|
|
314
|
+
// already have pre-tool-use.ts but be missing the newer plan-gate.ts in the
|
|
315
|
+
// same PreToolUse matcher — a block-level check would wrongly skip it.
|
|
316
|
+
for (const requiredBlock of requiredBlocks) {
|
|
317
|
+
for (const requiredHook of requiredBlock.hooks) {
|
|
318
|
+
const requiredScript = commandSubstring(requiredHook.command);
|
|
319
|
+
const alreadyHas = existingBlocks.some(b => (b.hooks || []).some(h => commandSubstring(h.command).includes(requiredScript)));
|
|
320
|
+
const label = `${event}:${requiredScript}`;
|
|
321
|
+
if (alreadyHas) {
|
|
322
|
+
report.alreadyPresent.push(label);
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
if (!dryRun) {
|
|
326
|
+
const sameMatcher = existingBlocks.find(b => (b.matcher ?? '') === (requiredBlock.matcher ?? ''));
|
|
327
|
+
if (sameMatcher) {
|
|
328
|
+
sameMatcher.hooks = [...(sameMatcher.hooks || []), requiredHook];
|
|
329
|
+
}
|
|
330
|
+
else {
|
|
331
|
+
existingBlocks = [
|
|
332
|
+
...existingBlocks,
|
|
333
|
+
{ matcher: requiredBlock.matcher, hooks: [requiredHook] },
|
|
334
|
+
];
|
|
335
|
+
}
|
|
336
|
+
mutated = true;
|
|
337
|
+
}
|
|
338
|
+
report.added.push(label);
|
|
339
|
+
}
|
|
301
340
|
}
|
|
302
|
-
if (!dryRun) {
|
|
303
|
-
settings.hooks[event] =
|
|
341
|
+
if (!dryRun && mutated) {
|
|
342
|
+
settings.hooks[event] = existingBlocks;
|
|
304
343
|
}
|
|
305
|
-
report.added.push(event);
|
|
306
344
|
}
|
|
307
345
|
// High-reasoning defaults (v2.28.0). Only set when MISSING so we never clobber
|
|
308
346
|
// a user's explicit choice. `ultracode` is intentionally never written here:
|
package/dist/setup.js
CHANGED
|
@@ -287,6 +287,11 @@ export async function setupProject(projectDir, config, options = {}) {
|
|
|
287
287
|
command: 'npx tsx "$CLAUDE_PROJECT_DIR/.claude/hooks/pre-tool-use.ts"',
|
|
288
288
|
timeout: 5,
|
|
289
289
|
},
|
|
290
|
+
{
|
|
291
|
+
type: 'command',
|
|
292
|
+
command: 'npx tsx "$CLAUDE_PROJECT_DIR/.claude/hooks/plan-gate.ts"',
|
|
293
|
+
timeout: 5,
|
|
294
|
+
},
|
|
290
295
|
],
|
|
291
296
|
},
|
|
292
297
|
],
|
package/package.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
// @sv-version: 1.
|
|
1
|
+
// @sv-version: 1.2.0
|
|
2
2
|
/**
|
|
3
3
|
* Multi-Instance Coordination — Shared State Library
|
|
4
4
|
*
|
|
5
5
|
* Used by session-start.ts, user-prompt-submit.ts, pre-tool-use.ts,
|
|
6
|
-
* post-tool-use.ts, stop-validator.ts and peers.ts.
|
|
6
|
+
* post-tool-use.ts, stop-validator.ts, plan-gate.ts and peers.ts.
|
|
7
7
|
*
|
|
8
8
|
* Layout (project-local, gitignored):
|
|
9
9
|
* .claude/state/
|
|
@@ -23,6 +23,10 @@
|
|
|
23
23
|
* FILES_TOUCHED_CAP 50→200 (large sessions were dropping early edits, which then
|
|
24
24
|
* showed up as orphaned dirty files a peer could not attribute); path
|
|
25
25
|
* normalization in extractTargetFiles is now repo-root-stable.
|
|
26
|
+
*
|
|
27
|
+
* v1.2.0: added optional `planGateNotifiedAt` to SessionRecord — a one-shot
|
|
28
|
+
* marker so plan-gate.ts nudges the user toward `/effort ultracode` only once
|
|
29
|
+
* per session when a change crosses the distinct-file threshold.
|
|
26
30
|
*/
|
|
27
31
|
|
|
28
32
|
import {
|
|
@@ -63,6 +67,8 @@ export interface SessionRecord {
|
|
|
63
67
|
lastSeenAt: string;
|
|
64
68
|
lastActivity: string;
|
|
65
69
|
filesTouched: string[];
|
|
70
|
+
/** ISO timestamp set once when plan-gate fired its high-reasoning nudge. */
|
|
71
|
+
planGateNotifiedAt?: string;
|
|
66
72
|
}
|
|
67
73
|
|
|
68
74
|
export interface FileTouch {
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// @sv-version: 1.0.0
|
|
3
|
+
/**
|
|
4
|
+
* PreToolUse Hook — Plan Gate (high-reasoning nudge)
|
|
5
|
+
*
|
|
6
|
+
* Wired with matcher `Edit|Write|MultiEdit|NotebookEdit`, alongside pre-tool-use.ts.
|
|
7
|
+
* When a SINGLE session is about to touch MORE THAN the threshold (default 4)
|
|
8
|
+
* DISTINCT files, it fires ONCE per session: returns `permissionDecision: "ask"`
|
|
9
|
+
* so Claude Code prompts the USER, recommending they cancel, run `/effort` and
|
|
10
|
+
* select `ultracode`, then confirm a short plan before the large change proceeds.
|
|
11
|
+
*
|
|
12
|
+
* WHY ONLY A RECOMMENDATION (hard limitation, confirmed in the hooks docs):
|
|
13
|
+
* "Command hooks communicate through stdout, stderr, and exit codes only. They
|
|
14
|
+
* cannot trigger `/` commands or tool calls."
|
|
15
|
+
* Effort level (`ultracode`) is also read-only metadata to hooks and is not
|
|
16
|
+
* persistable in settings.json. So switching to ultracode is necessarily a user
|
|
17
|
+
* keystroke — this hook just makes the moment impossible to miss. The
|
|
18
|
+
* `permissions.defaultMode: "plan"` setting handles the "plan first" half.
|
|
19
|
+
*
|
|
20
|
+
* Distinct-file count = union(session.filesTouched, files this Edit will touch).
|
|
21
|
+
* The one-shot marker (`SessionRecord.planGateNotifiedAt`) prevents re-prompting on
|
|
22
|
+
* every subsequent edit in the same large task.
|
|
23
|
+
*
|
|
24
|
+
* Config (env):
|
|
25
|
+
* PLAN_GATE_DISABLED=1 — turn the gate off entirely
|
|
26
|
+
* PLAN_GATE_THRESHOLD=<n> — fire when distinct files exceed <n> (default 4)
|
|
27
|
+
*
|
|
28
|
+
* Hook input:
|
|
29
|
+
* { session_id, tool_name, tool_input, hook_event_name, ... }
|
|
30
|
+
*
|
|
31
|
+
* Output schema (JSON):
|
|
32
|
+
* { continue: true, hookSpecificOutput?: { hookEventName, permissionDecision, permissionDecisionReason } }
|
|
33
|
+
*
|
|
34
|
+
* Fail-open: any error → approve silently. A nudge must NEVER break editing.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
import {
|
|
38
|
+
extractTargetFiles,
|
|
39
|
+
getProjectDir,
|
|
40
|
+
getStateDir,
|
|
41
|
+
nowIso,
|
|
42
|
+
readSession,
|
|
43
|
+
readStdinJson,
|
|
44
|
+
writeSession,
|
|
45
|
+
} from './_state.js';
|
|
46
|
+
|
|
47
|
+
const DEFAULT_THRESHOLD = 4;
|
|
48
|
+
|
|
49
|
+
function approve(): void {
|
|
50
|
+
console.log(JSON.stringify({ continue: true }));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function parseThreshold(raw: string | undefined): number {
|
|
54
|
+
if (!raw) return DEFAULT_THRESHOLD;
|
|
55
|
+
const n = Number.parseInt(raw, 10);
|
|
56
|
+
return Number.isFinite(n) && n >= 1 ? n : DEFAULT_THRESHOLD;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function main(): Promise<void> {
|
|
60
|
+
if (process.env['PLAN_GATE_DISABLED'] === '1') {
|
|
61
|
+
approve();
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const input = await readStdinJson(1500);
|
|
66
|
+
const sessionId: string | undefined = input.session_id || input.sessionId;
|
|
67
|
+
const toolName: string = input.tool_name || input.toolName || '';
|
|
68
|
+
const toolInput: any = input.tool_input || input.toolInput || {};
|
|
69
|
+
|
|
70
|
+
if (!/^(Edit|Write|MultiEdit|NotebookEdit)$/.test(toolName)) {
|
|
71
|
+
approve();
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Without a session we can neither count distinct files reliably nor record the
|
|
76
|
+
// one-shot marker — stay out of the way.
|
|
77
|
+
if (!sessionId) {
|
|
78
|
+
approve();
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const projectDir = getProjectDir();
|
|
83
|
+
const stateDir = getStateDir(projectDir);
|
|
84
|
+
const session = readSession(stateDir, sessionId);
|
|
85
|
+
|
|
86
|
+
// session-start.ts hasn't registered yet (or state is unavailable): do nothing.
|
|
87
|
+
if (!session) {
|
|
88
|
+
approve();
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Already nudged this session — never nag again.
|
|
93
|
+
if (session.planGateNotifiedAt) {
|
|
94
|
+
approve();
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const threshold = parseThreshold(process.env['PLAN_GATE_THRESHOLD']);
|
|
99
|
+
const distinct = new Set(session.filesTouched || []);
|
|
100
|
+
for (const f of extractTargetFiles(toolName, toolInput, projectDir)) distinct.add(f);
|
|
101
|
+
|
|
102
|
+
if (distinct.size <= threshold) {
|
|
103
|
+
approve();
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Fire once: set the marker BEFORE returning so it never re-prompts regardless
|
|
108
|
+
// of whether the user approves or cancels this particular edit.
|
|
109
|
+
try {
|
|
110
|
+
writeSession(stateDir, { ...session, planGateNotifiedAt: nowIso() });
|
|
111
|
+
} catch {}
|
|
112
|
+
|
|
113
|
+
const reason =
|
|
114
|
+
`This task is now touching ${distinct.size} distinct files (> ${threshold}). ` +
|
|
115
|
+
`For a change this size, consider switching to maximum reasoning before continuing:\n` +
|
|
116
|
+
` 1. Press ESC to cancel this edit.\n` +
|
|
117
|
+
` 2. Run /effort and select "ultracode".\n` +
|
|
118
|
+
` 3. Ask Claude for a short plan, confirm it, then implement.\n\n` +
|
|
119
|
+
`Hooks cannot run /effort for you (effort is read-only to hooks), so this is a ` +
|
|
120
|
+
`one-time nudge per session. Approve to continue WITHOUT switching.`;
|
|
121
|
+
|
|
122
|
+
console.log(
|
|
123
|
+
JSON.stringify({
|
|
124
|
+
continue: true,
|
|
125
|
+
hookSpecificOutput: {
|
|
126
|
+
hookEventName: 'PreToolUse',
|
|
127
|
+
permissionDecision: 'ask',
|
|
128
|
+
permissionDecisionReason: reason,
|
|
129
|
+
},
|
|
130
|
+
})
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
main().catch(() => {
|
|
135
|
+
// A high-reasoning nudge must never block Claude on its own bug.
|
|
136
|
+
console.log(JSON.stringify({ continue: true }));
|
|
137
|
+
process.exit(0);
|
|
138
|
+
});
|