@skillcap/gdh 0.26.8 → 0.26.9
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/INSTALL-BUNDLE.json +1 -1
- package/RELEASE-SPAN-UPDATE-CONTRACTS.json +62 -0
- package/node_modules/@gdh/adapters/dist/claude-settings-patch.d.ts.map +1 -1
- package/node_modules/@gdh/adapters/dist/claude-settings-patch.js +7 -6
- package/node_modules/@gdh/adapters/dist/claude-settings-patch.js.map +1 -1
- package/node_modules/@gdh/adapters/dist/skill-rendering.js +2 -2
- package/node_modules/@gdh/adapters/dist/skill-rendering.js.map +1 -1
- package/node_modules/@gdh/adapters/dist/templates/authoring-hook.js.tpl +187 -213
- package/node_modules/@gdh/adapters/package.json +8 -8
- package/node_modules/@gdh/authoring/package.json +2 -2
- package/node_modules/@gdh/cli/package.json +10 -10
- package/node_modules/@gdh/core/dist/index.d.ts +5 -3
- package/node_modules/@gdh/core/dist/index.d.ts.map +1 -1
- package/node_modules/@gdh/core/dist/index.js +5 -3
- package/node_modules/@gdh/core/dist/index.js.map +1 -1
- package/node_modules/@gdh/core/package.json +1 -1
- package/node_modules/@gdh/docs/dist/templates/guidance/authoring-and-validation.md.tpl +6 -5
- package/node_modules/@gdh/docs/package.json +2 -2
- package/node_modules/@gdh/mcp/package.json +8 -8
- package/node_modules/@gdh/observability/package.json +2 -2
- package/node_modules/@gdh/runtime/package.json +2 -2
- package/node_modules/@gdh/scan/package.json +3 -3
- package/node_modules/@gdh/verify/package.json +7 -7
- package/package.json +11 -11
|
@@ -15,20 +15,13 @@ const STATE_RELATIVE_PATH_FROM_HOOK = {{stateRelativePathFromHookJson}};
|
|
|
15
15
|
const fs = require('fs');
|
|
16
16
|
const path = require('path');
|
|
17
17
|
const crypto = require('crypto');
|
|
18
|
-
const { pathToFileURL } = require('url');
|
|
18
|
+
const { fileURLToPath, pathToFileURL } = require('url');
|
|
19
19
|
const { spawnSync } = require('child_process');
|
|
20
|
+
const os = require('os');
|
|
20
21
|
|
|
21
|
-
const AUTHORING_EXTENSIONS = new Set(['.gd'
|
|
22
|
-
const CHECK_TIMEOUT_MS = readBoundedPositiveIntEnv('GDH_AUTHORING_HOOK_CHECK_TIMEOUT_MS', 2500, 10000);
|
|
22
|
+
const AUTHORING_EXTENSIONS = new Set(['.gd']);
|
|
23
23
|
const FOREGROUND_REFRESH_TIMEOUT_MS = readBoundedPositiveIntEnv('GDH_AUTHORING_HOOK_REFRESH_TIMEOUT_MS', 8000, 30000);
|
|
24
|
-
// Phase 82 / LSP-03: session-end ("Stop") freshness barrier. Default 60 s gives
|
|
25
|
-
// real Godot LSP startup + final-mode authoring check headroom; hard cap 300 s
|
|
26
|
-
// keeps the env var from becoming a denial-of-service surface.
|
|
27
|
-
const STOP_TIMEOUT_MS = readBoundedPositiveIntEnv('GDH_AUTHORING_STOP_HOOK_TIMEOUT_MS', 60000, 300000);
|
|
28
24
|
const MAX_BLOCK_OUTPUT_CHARS = 4000;
|
|
29
|
-
// Phase 82 / Pitfall 6: bound Stop additionalContext to 8000 chars (2000-char
|
|
30
|
-
// headroom under Claude's 10000 cap so the hook payload always lands).
|
|
31
|
-
const MAX_STOP_CONTEXT_CHARS = 8000;
|
|
32
25
|
let CURRENT_EVENT = '';
|
|
33
26
|
|
|
34
27
|
main();
|
|
@@ -51,7 +44,7 @@ function main() {
|
|
|
51
44
|
|
|
52
45
|
function handlePostEdit(input, targetRoot) {
|
|
53
46
|
const changed = collectChangedFiles(input, targetRoot);
|
|
54
|
-
const authoring = changed.filter(
|
|
47
|
+
const authoring = changed.filter(file => isExistingAuthoringValidationPath(targetRoot, file));
|
|
55
48
|
if (authoring.length === 0) return allow();
|
|
56
49
|
let output;
|
|
57
50
|
try {
|
|
@@ -67,7 +60,7 @@ function handlePostEdit(input, targetRoot) {
|
|
|
67
60
|
// but the dispatch retains them as forward-compat branches in case a future
|
|
68
61
|
// reader extension surfaces them.
|
|
69
62
|
if (/^\[failed\]/m.test(output) || /^\[partial\]/m.test(output)) {
|
|
70
|
-
return block(formatBlockingReason(output));
|
|
63
|
+
return block(formatBlockingReason(output, targetRoot));
|
|
71
64
|
}
|
|
72
65
|
if (/^\[fresh\]/m.test(output)) {
|
|
73
66
|
return allow();
|
|
@@ -116,7 +109,7 @@ function handlePostToolBatch(input, targetRoot) {
|
|
|
116
109
|
for (const file of parsePatchFileNames(command)) addFile(files, file, targetRoot, baseCwd);
|
|
117
110
|
}
|
|
118
111
|
// Pitfall 4: order-stable dedup via unique() (Set + sort) — same dedup as handlePostEdit.
|
|
119
|
-
const authoring = unique(files).filter(
|
|
112
|
+
const authoring = unique(files).filter(file => isExistingAuthoringValidationPath(targetRoot, file));
|
|
120
113
|
if (authoring.length === 0) return allow(); // Pitfall 2: no authoring files — no work
|
|
121
114
|
|
|
122
115
|
let output;
|
|
@@ -131,7 +124,7 @@ function handlePostToolBatch(input, targetRoot) {
|
|
|
131
124
|
// severity brackets do not match the line-leading status token. Forward-compat
|
|
132
125
|
// [partial] / [timeout] branches retained per quick-task 260504-ix2 plan note.
|
|
133
126
|
if (/^\[failed\]/m.test(output) || /^\[partial\]/m.test(output)) {
|
|
134
|
-
return block(formatBlockingReason(output));
|
|
127
|
+
return block(formatBlockingReason(output, targetRoot));
|
|
135
128
|
}
|
|
136
129
|
if (/^\[fresh\]/m.test(output)) {
|
|
137
130
|
return allow();
|
|
@@ -174,60 +167,21 @@ function handlePostToolBatch(input, targetRoot) {
|
|
|
174
167
|
// 4. If foreground refresh cannot prove within budget, spawn detached refresh
|
|
175
168
|
// as a fallback and emit non-blocking context.
|
|
176
169
|
function runPostEditDiagnostics(targetRoot, authoringFiles) {
|
|
177
|
-
const
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
let sceneFreshOutput = '';
|
|
181
|
-
|
|
182
|
-
if (groups.gd.length > 0) {
|
|
183
|
-
output = runEmbeddedDiagnosticsRead(targetRoot, groups.gd);
|
|
184
|
-
if (isBlockingDiagnosticsOutput(output)) return output;
|
|
185
|
-
if (isConclusiveDiagnosticsOutput(output) && groups.scene.length === 0 && groups.resource.length === 0 && groups.project.length === 0) {
|
|
186
|
-
return output;
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
const refreshed = runForegroundDiagnosticsRefresh(targetRoot, groups.gd);
|
|
190
|
-
if (refreshed) {
|
|
191
|
-
output = runEmbeddedDiagnosticsRead(targetRoot, groups.gd);
|
|
192
|
-
if (isBlockingDiagnosticsOutput(output)) return output;
|
|
193
|
-
if (isConclusiveDiagnosticsOutput(output) && groups.scene.length === 0 && groups.resource.length === 0 && groups.project.length === 0) {
|
|
194
|
-
return output;
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
gdFresh = /^\[fresh\]/m.test(output);
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
if (groups.scene.length > 0) {
|
|
201
|
-
const sceneOutput = runForegroundSceneAuthoringCheck(targetRoot, groups.scene);
|
|
202
|
-
if (sceneOutput && isBlockingDiagnosticsOutput(sceneOutput)) return sceneOutput;
|
|
203
|
-
if (!sceneOutput || !/^\[fresh\]/m.test(sceneOutput)) {
|
|
204
|
-
return sceneOutput || formatStatus('pending', ['scene_validation_not_confirmed']);
|
|
205
|
-
}
|
|
206
|
-
sceneFreshOutput = sceneOutput;
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
if (groups.project.length > 0) {
|
|
210
|
-
return formatStatus('pending', ['project_godot_requires_final_authoring_check']);
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
if (groups.resource.length > 0) {
|
|
214
|
-
return formatStatus('pending', ['tres_scoped_validation_ignored']);
|
|
215
|
-
}
|
|
170
|
+
const gdFiles = unique(Array.isArray(authoringFiles) ? authoringFiles : [])
|
|
171
|
+
.filter(file => isExistingAuthoringValidationPath(targetRoot, file));
|
|
172
|
+
if (gdFiles.length === 0) return '[fresh] 0 errors, 0 warnings, 0 info. Completion allowed.';
|
|
216
173
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
return output || formatStatus('pending', ['broker_not_yet_primed']);
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
if (groups.scene.length > 0) {
|
|
223
|
-
return sceneFreshOutput;
|
|
224
|
-
}
|
|
174
|
+
let output = runEmbeddedDiagnosticsRead(targetRoot, gdFiles);
|
|
175
|
+
if (isBlockingDiagnosticsOutput(output) || isConclusiveDiagnosticsOutput(output)) return output;
|
|
225
176
|
|
|
226
|
-
|
|
227
|
-
|
|
177
|
+
const refreshed = runForegroundDiagnosticsRefresh(targetRoot, gdFiles);
|
|
178
|
+
if (refreshed) {
|
|
179
|
+
output = runEmbeddedDiagnosticsRead(targetRoot, gdFiles);
|
|
180
|
+
if (isBlockingDiagnosticsOutput(output) || isConclusiveDiagnosticsOutput(output)) return output;
|
|
228
181
|
}
|
|
229
182
|
|
|
230
|
-
|
|
183
|
+
spawnDetachedRefresh(targetRoot, gdFiles);
|
|
184
|
+
return output || formatStatus('pending', ['broker_not_yet_primed']);
|
|
231
185
|
}
|
|
232
186
|
|
|
233
187
|
function isConclusiveDiagnosticsOutput(output) {
|
|
@@ -238,23 +192,14 @@ function isBlockingDiagnosticsOutput(output) {
|
|
|
238
192
|
return /^\[failed\]/m.test(output) || /^\[partial\]/m.test(output);
|
|
239
193
|
}
|
|
240
194
|
|
|
241
|
-
function splitAuthoringFiles(files) {
|
|
242
|
-
const inputFiles = Array.isArray(files) ? unique(files) : [];
|
|
243
|
-
return {
|
|
244
|
-
gd: inputFiles.filter(f => path.extname(f).toLowerCase() === '.gd'),
|
|
245
|
-
scene: inputFiles.filter(f => path.extname(f).toLowerCase() === '.tscn'),
|
|
246
|
-
resource: inputFiles.filter(f => path.extname(f).toLowerCase() === '.tres'),
|
|
247
|
-
project: inputFiles.filter(f => path.basename(f) === 'project.godot'),
|
|
248
|
-
};
|
|
249
|
-
}
|
|
250
|
-
|
|
251
195
|
function runForegroundDiagnosticsRefresh(targetRoot, files) {
|
|
252
196
|
const gdFiles = Array.isArray(files) ? unique(files).filter(f => path.extname(f).toLowerCase() === '.gd') : [];
|
|
253
197
|
if (gdFiles.length === 0) return false;
|
|
254
198
|
try {
|
|
255
|
-
const
|
|
199
|
+
const invocation = resolveGdhInvocation(targetRoot);
|
|
200
|
+
const args = [...invocation.args, 'authoring', 'diagnostics', 'refresh', '--target', targetRoot];
|
|
256
201
|
for (const f of gdFiles) args.push('--changed', f);
|
|
257
|
-
const result = spawnSync(
|
|
202
|
+
const result = spawnSync(invocation.command, args, {
|
|
258
203
|
cwd: targetRoot,
|
|
259
204
|
encoding: 'utf8',
|
|
260
205
|
timeout: FOREGROUND_REFRESH_TIMEOUT_MS,
|
|
@@ -266,27 +211,6 @@ function runForegroundDiagnosticsRefresh(targetRoot, files) {
|
|
|
266
211
|
}
|
|
267
212
|
}
|
|
268
213
|
|
|
269
|
-
function runForegroundSceneAuthoringCheck(targetRoot, files) {
|
|
270
|
-
const sceneFiles = Array.isArray(files) ? unique(files).filter(f => path.extname(f).toLowerCase() === '.tscn') : [];
|
|
271
|
-
if (sceneFiles.length === 0) return '';
|
|
272
|
-
try {
|
|
273
|
-
const args = ['-y', `@skillcap/gdh@${PINNED_VERSION}`, 'authoring', 'check', '--mode', 'post-edit', '--format', 'compact', '--target', targetRoot];
|
|
274
|
-
for (const f of sceneFiles) args.push('--changed', f);
|
|
275
|
-
const result = spawnSync('npx', args, {
|
|
276
|
-
cwd: targetRoot,
|
|
277
|
-
encoding: 'utf8',
|
|
278
|
-
timeout: FOREGROUND_REFRESH_TIMEOUT_MS,
|
|
279
|
-
windowsHide: true,
|
|
280
|
-
});
|
|
281
|
-
if (!result || result.error || result.signal || result.status !== 0) {
|
|
282
|
-
return formatStatus('pending', ['scene_validation_not_confirmed']);
|
|
283
|
-
}
|
|
284
|
-
return String(result.stdout || '').trim();
|
|
285
|
-
} catch (_error) {
|
|
286
|
-
return formatStatus('pending', ['scene_validation_not_confirmed']);
|
|
287
|
-
}
|
|
288
|
-
}
|
|
289
|
-
|
|
290
214
|
// Quick task 260504-o6w. Fire-and-forget scoped diagnostics refresh from the
|
|
291
215
|
// post-edit hook. The CLI verb calls `refreshAuthoringDiagnostics` which boots
|
|
292
216
|
// Godot LSP if needed, opens scoped files via LSP, drains diagnostics, and
|
|
@@ -304,14 +228,15 @@ function runForegroundSceneAuthoringCheck(targetRoot, files) {
|
|
|
304
228
|
function spawnDetachedRefresh(targetRoot, files) {
|
|
305
229
|
if (Array.isArray(files) && files.length === 0) return;
|
|
306
230
|
try {
|
|
307
|
-
const
|
|
231
|
+
const invocation = resolveGdhInvocation(targetRoot);
|
|
232
|
+
const args = [...invocation.args, 'authoring', 'diagnostics', 'refresh', '--target', targetRoot];
|
|
308
233
|
if (Array.isArray(files)) {
|
|
309
234
|
for (const f of files) {
|
|
310
235
|
args.push('--changed', f);
|
|
311
236
|
}
|
|
312
237
|
}
|
|
313
238
|
const child = require('child_process').spawn(
|
|
314
|
-
|
|
239
|
+
invocation.command,
|
|
315
240
|
args,
|
|
316
241
|
{
|
|
317
242
|
cwd: targetRoot,
|
|
@@ -326,39 +251,99 @@ function spawnDetachedRefresh(targetRoot, files) {
|
|
|
326
251
|
}
|
|
327
252
|
}
|
|
328
253
|
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
254
|
+
function resolveGdhInvocation(targetRoot) {
|
|
255
|
+
const directBin = resolveGdhBin(targetRoot);
|
|
256
|
+
if (directBin) return { command: directBin, args: [] };
|
|
257
|
+
return { command: 'npx', args: ['-y', `@skillcap/gdh@${PINNED_VERSION}`] };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function resolveGdhBin(targetRoot) {
|
|
261
|
+
const envBin = process.env['GDH_AUTHORING_HOOK_GDH_BIN'];
|
|
262
|
+
if (isExecutableFile(envBin)) return envBin;
|
|
263
|
+
|
|
264
|
+
const roots = unique([
|
|
265
|
+
targetRoot,
|
|
266
|
+
process.cwd(),
|
|
267
|
+
__dirname,
|
|
268
|
+
...parentDirs(targetRoot, 8),
|
|
269
|
+
...parentDirs(__dirname, 8),
|
|
270
|
+
]);
|
|
271
|
+
for (const root of roots) {
|
|
272
|
+
const packageBin = resolvePackageBin(path.join(root, 'node_modules', '@skillcap', 'gdh'));
|
|
273
|
+
if (packageBin) return packageBin;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const cacheRoots = unique([
|
|
277
|
+
process.env['npm_config_cache'],
|
|
278
|
+
process.env['NPM_CONFIG_CACHE'],
|
|
279
|
+
path.join(os.homedir(), '.npm'),
|
|
280
|
+
]);
|
|
281
|
+
for (const cacheRoot of cacheRoots) {
|
|
282
|
+
const npxRoot = cacheRoot ? path.join(cacheRoot, '_npx') : null;
|
|
283
|
+
if (!npxRoot) continue;
|
|
284
|
+
let entries = [];
|
|
285
|
+
try {
|
|
286
|
+
entries = fs.readdirSync(npxRoot, { withFileTypes: true });
|
|
287
|
+
} catch (_err) {
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
for (const entry of entries) {
|
|
291
|
+
if (!entry.isDirectory()) continue;
|
|
292
|
+
const packageBin = resolvePackageBin(path.join(npxRoot, entry.name, 'node_modules', '@skillcap', 'gdh'));
|
|
293
|
+
if (packageBin) return packageBin;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
return null;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function resolvePackageBin(packageDir) {
|
|
301
|
+
try {
|
|
302
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(packageDir, 'package.json'), 'utf8'));
|
|
303
|
+
if (!pkg || pkg.version !== PINNED_VERSION) return null;
|
|
304
|
+
const binField = pkg.bin;
|
|
305
|
+
let relativeBin = null;
|
|
306
|
+
if (typeof binField === 'string') relativeBin = binField;
|
|
307
|
+
else if (binField && typeof binField.gdh === 'string') relativeBin = binField.gdh;
|
|
308
|
+
if (!relativeBin) return null;
|
|
309
|
+
const candidate = path.join(packageDir, relativeBin);
|
|
310
|
+
return isExecutableFile(candidate) ? candidate : null;
|
|
311
|
+
} catch (_err) {
|
|
312
|
+
return null;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function isExecutableFile(value) {
|
|
317
|
+
if (typeof value !== 'string' || value.trim() === '') return false;
|
|
318
|
+
try {
|
|
319
|
+
return fs.statSync(value).isFile();
|
|
320
|
+
} catch (_err) {
|
|
321
|
+
return false;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function parentDirs(start, limit) {
|
|
326
|
+
const dirs = [];
|
|
327
|
+
let dir = path.resolve(start || '.');
|
|
328
|
+
for (let i = 0; i < limit; i += 1) {
|
|
329
|
+
const parent = path.dirname(dir);
|
|
330
|
+
if (parent === dir) break;
|
|
331
|
+
dirs.push(parent);
|
|
332
|
+
dir = parent;
|
|
333
|
+
}
|
|
334
|
+
return dirs;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// Stop is deliberately silent. It never blocks, never emits additionalContext,
|
|
338
|
+
// and never substitutes for final validation evidence. It may opportunistically
|
|
339
|
+
// prime `.gd` diagnostics for the next session.
|
|
354
340
|
function handleStop(input, targetRoot) {
|
|
355
341
|
if (!hasAuthoringWorkInTarget(targetRoot)) return allow();
|
|
356
342
|
|
|
357
343
|
// Discover the authoring file set from the working tree under targetRoot.
|
|
358
344
|
// We feed this into the embedded reader so it can resolve broker entries
|
|
359
|
-
// by file:// URI. If
|
|
360
|
-
//
|
|
361
|
-
// reason `file_not_in_snapshot` — same effect as no broker primed.
|
|
345
|
+
// by file:// URI. If discovery fails (git unavailable, etc.) Stop remains
|
|
346
|
+
// silent.
|
|
362
347
|
const authoringFiles = listAuthoringFilesInTarget(targetRoot);
|
|
363
348
|
|
|
364
349
|
let output;
|
|
@@ -368,59 +353,28 @@ function handleStop(input, targetRoot) {
|
|
|
368
353
|
output = '[pending]\nReasons: embedded_read_threw';
|
|
369
354
|
}
|
|
370
355
|
|
|
371
|
-
const truncatedSuffix = " Collect final validation evidence before claiming code-validity.";
|
|
372
|
-
function stopContext(message) {
|
|
373
|
-
const text = String(message || '').trim();
|
|
374
|
-
const ceiling = MAX_STOP_CONTEXT_CHARS;
|
|
375
|
-
if (text.length <= ceiling) return context(text);
|
|
376
|
-
const headroom = ceiling - truncatedSuffix.length;
|
|
377
|
-
return context(text.slice(0, headroom).trimEnd() + truncatedSuffix);
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
// Bracketed-token dispatch — same vocabulary as PostToolUse but EVERY branch
|
|
381
|
-
// routes to context() or allow(); decision: block is forbidden on Stop.
|
|
382
|
-
// Forward-compat [partial] / [timeout] / [failed] branches retained for
|
|
383
|
-
// potential future reader extensions; the embedded reader currently emits
|
|
384
|
-
// only [fresh] / [stale] / [pending].
|
|
385
356
|
if (/^\[fresh\]/m.test(output)) {
|
|
386
357
|
return allow();
|
|
387
358
|
}
|
|
388
359
|
if (/^\[failed\]/m.test(output) || /^\[partial\]/m.test(output) || /^\[stale\]/m.test(output)) {
|
|
389
|
-
return
|
|
360
|
+
return allow();
|
|
390
361
|
}
|
|
391
362
|
if (/^\[pending\]/m.test(output) || /^\[timeout\]/m.test(output)) {
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
// `gdh authoring check --mode final` (the relaxed contract — see RFC 0009
|
|
395
|
-
// addendum 2026-05-04). spawnDetachedRefresh is fire-and-forget; it never
|
|
396
|
-
// blocks hook return.
|
|
397
|
-
spawnDetachedRefresh(targetRoot, splitAuthoringFiles(authoringFiles).gd);
|
|
398
|
-
return stopContext(
|
|
399
|
-
'GDH session-end check could not prove final code-validity from the cached broker snapshot; continuing without blocking. ' +
|
|
400
|
-
"Collect final validation evidence before claiming code-validity. " +
|
|
401
|
-
compactOneLine(output) + ' ' + reasonHint(output, targetRoot, authoringFiles),
|
|
402
|
-
);
|
|
363
|
+
spawnDetachedRefresh(targetRoot, authoringFiles);
|
|
364
|
+
return allow();
|
|
403
365
|
}
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
return stopContext(
|
|
407
|
-
'GDH session-end check could not produce a final result; continuing without blocking. ' +
|
|
408
|
-
"Collect final validation evidence before claiming code-validity. " +
|
|
409
|
-
compactOneLine(output) + ' ' + reasonHint(output, targetRoot, authoringFiles),
|
|
410
|
-
);
|
|
366
|
+
spawnDetachedRefresh(targetRoot, authoringFiles);
|
|
367
|
+
return allow();
|
|
411
368
|
}
|
|
412
369
|
|
|
413
370
|
function runStopDiagnostics(targetRoot, authoringFiles) {
|
|
414
|
-
const
|
|
415
|
-
|
|
416
|
-
|
|
371
|
+
const gdFiles = unique(Array.isArray(authoringFiles) ? authoringFiles : [])
|
|
372
|
+
.filter(file => isExistingAuthoringValidationPath(targetRoot, file));
|
|
373
|
+
if (gdFiles.length > 0) {
|
|
374
|
+
const gdOutput = runEmbeddedDiagnosticsRead(targetRoot, gdFiles);
|
|
417
375
|
if (!/^\[fresh\]/m.test(gdOutput)) return gdOutput;
|
|
418
376
|
}
|
|
419
|
-
|
|
420
|
-
if (groups.resource.length > 0) return formatStatus('pending', ['tres_scoped_validation_ignored']);
|
|
421
|
-
if (groups.project.length > 0) return formatStatus('pending', ['project_godot_requires_final_authoring_check']);
|
|
422
|
-
if (groups.gd.length > 0) return '[fresh] 0 errors, 0 warnings, 0 info. Completion allowed.';
|
|
423
|
-
return formatStatus('pending', ['no_authoring_files_to_check']);
|
|
377
|
+
return '[fresh] 0 errors, 0 warnings, 0 info. Completion allowed.';
|
|
424
378
|
}
|
|
425
379
|
|
|
426
380
|
// Quick task 260504-ix2: collect authoring-relevant files in the working tree
|
|
@@ -428,9 +382,7 @@ function runStopDiagnostics(targetRoot, authoringFiles) {
|
|
|
428
382
|
// returns a boolean for the GF3 gate); this returns the list of files for the
|
|
429
383
|
// embedded broker-snapshot reader to look up by file:// URI. Order-stable.
|
|
430
384
|
//
|
|
431
|
-
// Fail-open: returns [] on any git failure
|
|
432
|
-
// `[pending]` reasons, which Stop dispatches to additionalContext — never
|
|
433
|
-
// block).
|
|
385
|
+
// Fail-open: returns [] on any git failure. Stop remains silent.
|
|
434
386
|
function listAuthoringFilesInTarget(targetRoot) {
|
|
435
387
|
let result;
|
|
436
388
|
try {
|
|
@@ -450,7 +402,7 @@ function listAuthoringFilesInTarget(targetRoot) {
|
|
|
450
402
|
const arrow = rest.indexOf(' -> ');
|
|
451
403
|
if (arrow !== -1) rest = rest.slice(arrow + 4);
|
|
452
404
|
if (rest.startsWith('"') && rest.endsWith('"')) rest = rest.slice(1, -1);
|
|
453
|
-
if (
|
|
405
|
+
if (isExistingAuthoringValidationPath(targetRoot, rest)) files.push(rest);
|
|
454
406
|
}
|
|
455
407
|
return unique(files);
|
|
456
408
|
}
|
|
@@ -730,10 +682,18 @@ function isInside(root, candidate) { const rel = path.relative(root, path.resolv
|
|
|
730
682
|
function canonicalPath(value) { try { return fs.realpathSync.native ? fs.realpathSync.native(value) : fs.realpathSync(value); } catch { return path.resolve(value); } }
|
|
731
683
|
|
|
732
684
|
function isAuthoringValidationPath(file) {
|
|
733
|
-
if (file === 'project.godot' || file.endsWith('/project.godot')) return true;
|
|
734
685
|
return AUTHORING_EXTENSIONS.has(path.extname(file).toLowerCase());
|
|
735
686
|
}
|
|
736
687
|
|
|
688
|
+
function isExistingAuthoringValidationPath(targetRoot, file) {
|
|
689
|
+
if (!isAuthoringValidationPath(file)) return false;
|
|
690
|
+
try {
|
|
691
|
+
return fs.statSync(path.resolve(targetRoot, file)).isFile();
|
|
692
|
+
} catch (_err) {
|
|
693
|
+
return false;
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
|
|
737
697
|
// GF3 (260504): session-end git-gate. Stop hook noops when zero authoring-file
|
|
738
698
|
// changes exist in the working tree under targetRoot. Fails closed (returns
|
|
739
699
|
// true) on any git failure so the freshness barrier never silently weakens.
|
|
@@ -766,7 +726,7 @@ function hasAuthoringWorkInTarget(targetRoot) {
|
|
|
766
726
|
if (rest.startsWith('"') && rest.endsWith('"')) {
|
|
767
727
|
rest = rest.slice(1, -1);
|
|
768
728
|
}
|
|
769
|
-
if (
|
|
729
|
+
if (isExistingAuthoringValidationPath(targetRoot, rest)) return true;
|
|
770
730
|
}
|
|
771
731
|
return false;
|
|
772
732
|
}
|
|
@@ -790,8 +750,55 @@ function readBoundedPositiveIntEnv(name, fallback, max) {
|
|
|
790
750
|
return Math.min(Math.floor(parsed), max);
|
|
791
751
|
}
|
|
792
752
|
|
|
793
|
-
function formatBlockingReason(output) {
|
|
794
|
-
|
|
753
|
+
function formatBlockingReason(output, targetRoot) {
|
|
754
|
+
const diagnostics = extractDiagnosticSummaries(output, targetRoot);
|
|
755
|
+
if (diagnostics.length > 0) {
|
|
756
|
+
const errorCount = diagnostics.filter(d => d.severity === 'error').length || diagnostics.length;
|
|
757
|
+
const fileCount = new Set(diagnostics.map(d => d.file)).size || 1;
|
|
758
|
+
const diagText = diagnostics
|
|
759
|
+
.slice(0, 8)
|
|
760
|
+
.map(d => `${d.file}:${d.line}:${d.column} ${d.severity} ${d.message}`)
|
|
761
|
+
.join('; ');
|
|
762
|
+
const suffix = diagnostics.length > 8 ? '; ...' : '';
|
|
763
|
+
return `GDH blocked ${errorCount} GDScript ${errorCount === 1 ? 'error' : 'errors'} in ${fileCount} ${fileCount === 1 ? 'file' : 'files'}: ${truncate(diagText + suffix, MAX_BLOCK_OUTPUT_CHARS)}. Fix these diagnostics before continuing.`;
|
|
764
|
+
}
|
|
765
|
+
return `GDH blocked GDScript diagnostics: ${truncate(output || 'No check output.', MAX_BLOCK_OUTPUT_CHARS)}. Fix these diagnostics before continuing.`;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
function extractDiagnosticSummaries(output, targetRoot) {
|
|
769
|
+
const diagnostics = [];
|
|
770
|
+
for (const raw of String(output || '').split('\n')) {
|
|
771
|
+
const line = raw.trim();
|
|
772
|
+
if (!line || line.startsWith('[')) continue;
|
|
773
|
+
const match = line.match(/^(.+):(\d+):(\d+)\s+\[(error|warning|info)\]\s+(.+)$/);
|
|
774
|
+
if (!match) continue;
|
|
775
|
+
const absoluteOrUri = match[1];
|
|
776
|
+
const file = diagnosticFileLabel(absoluteOrUri, targetRoot);
|
|
777
|
+
diagnostics.push({
|
|
778
|
+
file,
|
|
779
|
+
line: match[2],
|
|
780
|
+
column: match[3],
|
|
781
|
+
severity: match[4],
|
|
782
|
+
message: compactOneLine(match[5]),
|
|
783
|
+
});
|
|
784
|
+
}
|
|
785
|
+
return diagnostics;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
function diagnosticFileLabel(value, targetRoot) {
|
|
789
|
+
let filePath = value;
|
|
790
|
+
try {
|
|
791
|
+
if (String(value).startsWith('file:')) filePath = fileURLToPath(value);
|
|
792
|
+
} catch (_err) {
|
|
793
|
+
filePath = value;
|
|
794
|
+
}
|
|
795
|
+
if (path.isAbsolute(filePath)) {
|
|
796
|
+
const relative = path.relative(targetRoot, filePath);
|
|
797
|
+
if (!relative.startsWith('..') && !path.isAbsolute(relative)) {
|
|
798
|
+
return relative.split(path.sep).join('/');
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
return String(filePath).replace(/^res:\/\//, '');
|
|
795
802
|
}
|
|
796
803
|
|
|
797
804
|
function truncate(value, maxChars) {
|
|
@@ -829,18 +836,6 @@ function reasonHint(output, targetRoot, files) {
|
|
|
829
836
|
if (reasons.includes('lsp_diagnostics_timeout') || reasons.includes('lsp_connection_failed')) {
|
|
830
837
|
return `Godot LSP did not return diagnostics inside the hook budget; do not treat this as clean. ${scopedHint}`;
|
|
831
838
|
}
|
|
832
|
-
if (reasons.includes('scene_validation_not_confirmed')) {
|
|
833
|
-
return `Scene validation did not complete inside the hook budget; do not treat this as clean. ${scopedHint}`;
|
|
834
|
-
}
|
|
835
|
-
if (reasons.includes('scene_validation_requires_final_authoring_check')) {
|
|
836
|
-
return `Stop hooks do not run final scene validation. Collect final validation evidence before claiming code-validity. ${scopedHint}`;
|
|
837
|
-
}
|
|
838
|
-
if (reasons.includes('project_godot_requires_final_authoring_check')) {
|
|
839
|
-
return `project.godot is not covered by scoped hook diagnostics; collect final validation evidence before claiming code-validity.`;
|
|
840
|
-
}
|
|
841
|
-
if (reasons.includes('tres_scoped_validation_ignored')) {
|
|
842
|
-
return `.tres automatic scoped validation is ignored in this GDH version; do not wait on LSP/resource diagnostics for those files.`;
|
|
843
|
-
}
|
|
844
839
|
if (reasons.includes('broker_not_yet_primed') || reasons.includes('file_not_in_snapshot')) {
|
|
845
840
|
return `Broker did not contain this edit after bounded refresh; do not treat this as clean. Background refresh is running if needed. ${scopedHint}`;
|
|
846
841
|
}
|
|
@@ -853,18 +848,10 @@ function reasonHint(output, targetRoot, files) {
|
|
|
853
848
|
function fileTypeScopedHint(files) {
|
|
854
849
|
const inputFiles = Array.isArray(files) ? unique(files) : [];
|
|
855
850
|
const gdFiles = inputFiles.filter(f => path.extname(f).toLowerCase() === '.gd');
|
|
856
|
-
const sceneFiles = inputFiles.filter(f => path.extname(f).toLowerCase() === '.tscn');
|
|
857
|
-
const resourceFiles = inputFiles.filter(f => path.extname(f).toLowerCase() === '.tres');
|
|
858
851
|
const parts = [];
|
|
859
852
|
if (gdFiles.length > 0) {
|
|
860
853
|
parts.push('For .gd: hook refresh runs automatically; wait for the next hook result instead of rerunning checks during the edit loop.');
|
|
861
854
|
}
|
|
862
|
-
if (sceneFiles.length > 0) {
|
|
863
|
-
parts.push('For .tscn: hook scoped scene validation already ran or timed out; collect final evidence only at handoff.');
|
|
864
|
-
}
|
|
865
|
-
if (resourceFiles.length > 0) {
|
|
866
|
-
parts.push('.tres automatic scoped validation is ignored in this GDH version; do not wait on LSP/resource diagnostics for those files.');
|
|
867
|
-
}
|
|
868
855
|
if (parts.length === 0) {
|
|
869
856
|
return 'Collect final validation evidence before claiming code-validity.';
|
|
870
857
|
}
|
|
@@ -899,24 +886,11 @@ function describeLspLock(targetRoot) {
|
|
|
899
886
|
}
|
|
900
887
|
return 'lock metadata unavailable (expected .gdh-state/authoring/lsp.lock or .gdh-state/lsp.lock).';
|
|
901
888
|
}
|
|
902
|
-
//
|
|
903
|
-
//
|
|
904
|
-
//
|
|
905
|
-
// Claude receives hookSpecificOutput.additionalContext on PostToolUse / PostToolBatch
|
|
906
|
-
// / FileChanged. Claude Stop is silent-allow because the Stop hook schema does NOT
|
|
907
|
-
// accept additionalContext (only decision/reason/continue/stopReason/suppressOutput/
|
|
908
|
-
// systemMessage). The previous gate emitted the wrong shape on every Claude Stop
|
|
909
|
-
// and Claude rejected it as "invalid stop hook JSON output" (quick task 260504-o6w
|
|
910
|
-
// bug B, verified live on TheBeacon 2026-05-04).
|
|
889
|
+
// PostToolUse / PostToolBatch / FileChanged can receive additionalContext.
|
|
890
|
+
// Stop is silent for every agent: Claude rejects hookSpecificOutput on Stop,
|
|
891
|
+
// and Codex Stop context proved noisy/stale in TheBeacon dogfooding.
|
|
911
892
|
function context(additionalContext) {
|
|
912
|
-
|
|
913
|
-
// (only decision/reason/continue/stopReason/suppressOutput/systemMessage). Emitting
|
|
914
|
-
// the wrong shape causes Claude to reject the hook with "invalid stop hook JSON
|
|
915
|
-
// output" — verified locally 2026-05-04 (quick task 260504-o6w bug B). Drop to
|
|
916
|
-
// silent-allow on Claude Stop. Claude PostToolUse / PostToolBatch DO accept
|
|
917
|
-
// additionalContext, so those branches still flow through. Codex accepts
|
|
918
|
-
// additionalContext on every event.
|
|
919
|
-
if (AGENT !== 'codex' && CURRENT_EVENT === 'Stop') return allow();
|
|
893
|
+
if (CURRENT_EVENT === 'Stop') return allow();
|
|
920
894
|
const payload = { hookSpecificOutput: { hookEventName: CURRENT_EVENT, additionalContext } };
|
|
921
895
|
process.stdout.write(`${JSON.stringify(payload)}\n`);
|
|
922
896
|
process.exit(0);
|
|
@@ -11,13 +11,13 @@
|
|
|
11
11
|
}
|
|
12
12
|
},
|
|
13
13
|
"dependencies": {
|
|
14
|
-
"@gdh/authoring": "0.26.
|
|
15
|
-
"@gdh/core": "0.26.
|
|
16
|
-
"@gdh/docs": "0.26.
|
|
17
|
-
"@gdh/observability": "0.26.
|
|
18
|
-
"@gdh/runtime": "0.26.
|
|
19
|
-
"@gdh/scan": "0.26.
|
|
20
|
-
"@gdh/verify": "0.26.
|
|
14
|
+
"@gdh/authoring": "0.26.9",
|
|
15
|
+
"@gdh/core": "0.26.9",
|
|
16
|
+
"@gdh/docs": "0.26.9",
|
|
17
|
+
"@gdh/observability": "0.26.9",
|
|
18
|
+
"@gdh/runtime": "0.26.9",
|
|
19
|
+
"@gdh/scan": "0.26.9",
|
|
20
|
+
"@gdh/verify": "0.26.9"
|
|
21
21
|
},
|
|
22
|
-
"version": "0.26.
|
|
22
|
+
"version": "0.26.9"
|
|
23
23
|
}
|
|
@@ -15,16 +15,16 @@
|
|
|
15
15
|
},
|
|
16
16
|
"dependencies": {
|
|
17
17
|
"@clack/prompts": "^1.2.0",
|
|
18
|
-
"@gdh/adapters": "0.26.
|
|
19
|
-
"@gdh/authoring": "0.26.
|
|
20
|
-
"@gdh/core": "0.26.
|
|
21
|
-
"@gdh/docs": "0.26.
|
|
22
|
-
"@gdh/mcp": "0.26.
|
|
23
|
-
"@gdh/observability": "0.26.
|
|
24
|
-
"@gdh/runtime": "0.26.
|
|
25
|
-
"@gdh/scan": "0.26.
|
|
26
|
-
"@gdh/verify": "0.26.
|
|
18
|
+
"@gdh/adapters": "0.26.9",
|
|
19
|
+
"@gdh/authoring": "0.26.9",
|
|
20
|
+
"@gdh/core": "0.26.9",
|
|
21
|
+
"@gdh/docs": "0.26.9",
|
|
22
|
+
"@gdh/mcp": "0.26.9",
|
|
23
|
+
"@gdh/observability": "0.26.9",
|
|
24
|
+
"@gdh/runtime": "0.26.9",
|
|
25
|
+
"@gdh/scan": "0.26.9",
|
|
26
|
+
"@gdh/verify": "0.26.9",
|
|
27
27
|
"picocolors": "^1.1.1"
|
|
28
28
|
},
|
|
29
|
-
"version": "0.26.
|
|
29
|
+
"version": "0.26.9"
|
|
30
30
|
}
|