@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.
@@ -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', '.tscn', '.tres']);
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(isAuthoringValidationPath);
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(isAuthoringValidationPath);
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 groups = splitAuthoringFiles(authoringFiles);
178
- let output = '';
179
- let gdFresh = false;
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
- if (groups.gd.length > 0 && !gdFresh) {
218
- spawnDetachedRefresh(targetRoot, groups.gd);
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
- if (groups.gd.length > 0) {
227
- return output;
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
- return formatStatus('pending', ['no_authoring_files_to_check']);
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 args = ['-y', `@skillcap/gdh@${PINNED_VERSION}`, 'authoring', 'diagnostics', 'refresh', '--target', targetRoot];
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('npx', args, {
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 args = ['-y', `@skillcap/gdh@${PINNED_VERSION}`, 'authoring', 'diagnostics', 'refresh', '--target', targetRoot];
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
- 'npx',
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
- // Phase 82 / LSP-03 + Quick task 260504-ix2 — Stop-hook contract relaxation.
330
- //
331
- // Original Phase 82 contract: Stop synchronously invoked
332
- // `gdh authoring check --mode final`, which calls
333
- // `getManagedLspStatus(launch_if_needed)` and could spawn Godot LSP from the
334
- // Stop event. Real-world dogfooding (2026-05-04 session) showed this path
335
- // always timed out due to per-invocation `npx -y @skillcap/gdh@PINNED` cost
336
- // (~9.3 s on the operator's measured machine). The hook ate the full
337
- // `STOP_TIMEOUT_MS` budget on the npx bootstrap path before producing any
338
- // output.
339
- //
340
- // Updated contract (RFC 0009 addendum): Stop reads the existing broker
341
- // snapshot via the embedded dependency-free reader; it does NOT synchronously
342
- // launch LSP. Cold/missing-broker case returns `additionalContext` telling the
343
- // agent to collect final validation evidence before claiming code-validity AND
344
- // spawns detached refresh so the next session's PostToolUse events have a
345
- // primed broker. This trades the rare "fresh session, no warm broker" case
346
- // (where Stop would have produced final diagnostics synchronously) for the
347
- // common case of sub-100ms hook return on every Stop.
348
- //
349
- // Hard invariants preserved:
350
- // - GF3 (260504): zero authoring-files-in-target → noop (git gate, runs first)
351
- // - Pitfall 3: NEVER returns decision: block (Stop hook must not block session end)
352
- // - Pitfall 5: Claude additionalContext lives in hookSpecificOutput
353
- // - Pitfall 6: additionalContext bounded to MAX_STOP_CONTEXT_CHARS = 8000
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 the discovery fails (git unavailable, etc.) we fall
360
- // through with an empty list, which routes to a `[pending]` status with
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 stopContext(`GDH session-end check found unresolved diagnostics: ${compactOneLine(output)} ${reasonHint(output, targetRoot, authoringFiles)}`);
360
+ return allow();
390
361
  }
391
362
  if (/^\[pending\]/m.test(output) || /^\[timeout\]/m.test(output)) {
392
- // Cold/missing broker: spawn detached refresh so the NEXT session benefits.
393
- // The current Stop returns additionalContext recommending manual
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
- // Unknown status: degrade to additionalContext (NEVER block).
405
- spawnDetachedRefresh(targetRoot, splitAuthoringFiles(authoringFiles).gd);
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 groups = splitAuthoringFiles(authoringFiles);
415
- if (groups.gd.length > 0) {
416
- const gdOutput = runEmbeddedDiagnosticsRead(targetRoot, groups.gd);
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
- if (groups.scene.length > 0) return formatStatus('pending', ['scene_validation_requires_final_authoring_check']);
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 (the embedded reader will then emit
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 (isAuthoringValidationPath(rest)) files.push(rest);
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 (isAuthoringValidationPath(rest)) return true;
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
- return `GDH post-edit authoring check found blocking diagnostics.\n\n${truncate(output || 'No check output.', MAX_BLOCK_OUTPUT_CHARS)}`;
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
- // Codex always receives hookSpecificOutput.additionalContext (any event Codex's
903
- // hook contract accepts it on PreToolUse / PostToolUse / Stop / etc.).
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
- // Claude: Stop hook schema does NOT accept hookSpecificOutput.additionalContext
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.8",
15
- "@gdh/core": "0.26.8",
16
- "@gdh/docs": "0.26.8",
17
- "@gdh/observability": "0.26.8",
18
- "@gdh/runtime": "0.26.8",
19
- "@gdh/scan": "0.26.8",
20
- "@gdh/verify": "0.26.8"
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.8"
22
+ "version": "0.26.9"
23
23
  }
@@ -14,7 +14,7 @@
14
14
  "test": "vitest run"
15
15
  },
16
16
  "dependencies": {
17
- "@gdh/core": "0.26.8"
17
+ "@gdh/core": "0.26.9"
18
18
  },
19
- "version": "0.26.8"
19
+ "version": "0.26.9"
20
20
  }
@@ -15,16 +15,16 @@
15
15
  },
16
16
  "dependencies": {
17
17
  "@clack/prompts": "^1.2.0",
18
- "@gdh/adapters": "0.26.8",
19
- "@gdh/authoring": "0.26.8",
20
- "@gdh/core": "0.26.8",
21
- "@gdh/docs": "0.26.8",
22
- "@gdh/mcp": "0.26.8",
23
- "@gdh/observability": "0.26.8",
24
- "@gdh/runtime": "0.26.8",
25
- "@gdh/scan": "0.26.8",
26
- "@gdh/verify": "0.26.8",
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.8"
29
+ "version": "0.26.9"
30
30
  }