@skillcap/gdh 0.26.7 → 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.
Files changed (40) hide show
  1. package/INSTALL-BUNDLE.json +1 -1
  2. package/RELEASE-SPAN-UPDATE-CONTRACTS.json +123 -0
  3. package/node_modules/@gdh/adapters/dist/claude-settings-patch.d.ts.map +1 -1
  4. package/node_modules/@gdh/adapters/dist/claude-settings-patch.js +7 -6
  5. package/node_modules/@gdh/adapters/dist/claude-settings-patch.js.map +1 -1
  6. package/node_modules/@gdh/adapters/dist/index.d.ts.map +1 -1
  7. package/node_modules/@gdh/adapters/dist/index.js +71 -1
  8. package/node_modules/@gdh/adapters/dist/index.js.map +1 -1
  9. package/node_modules/@gdh/adapters/dist/self-update-mechanics.d.ts +4 -2
  10. package/node_modules/@gdh/adapters/dist/self-update-mechanics.d.ts.map +1 -1
  11. package/node_modules/@gdh/adapters/dist/self-update-mechanics.js +4 -2
  12. package/node_modules/@gdh/adapters/dist/self-update-mechanics.js.map +1 -1
  13. package/node_modules/@gdh/adapters/dist/skill-rendering.js +4 -4
  14. package/node_modules/@gdh/adapters/dist/skill-rendering.js.map +1 -1
  15. package/node_modules/@gdh/adapters/dist/templates/authoring-hook.js.tpl +200 -230
  16. package/node_modules/@gdh/adapters/package.json +8 -8
  17. package/node_modules/@gdh/authoring/package.json +2 -2
  18. package/node_modules/@gdh/cli/dist/migrate.js +1 -1
  19. package/node_modules/@gdh/cli/dist/migrate.js.map +1 -1
  20. package/node_modules/@gdh/cli/package.json +10 -10
  21. package/node_modules/@gdh/core/dist/index.d.ts +8 -4
  22. package/node_modules/@gdh/core/dist/index.d.ts.map +1 -1
  23. package/node_modules/@gdh/core/dist/index.js +8 -4
  24. package/node_modules/@gdh/core/dist/index.js.map +1 -1
  25. package/node_modules/@gdh/core/dist/migrations/entries/s2c2_to_s2c3_rules_schema_v2_to_v3.d.ts.map +1 -1
  26. package/node_modules/@gdh/core/dist/migrations/entries/s2c2_to_s2c3_rules_schema_v2_to_v3.js +8 -4
  27. package/node_modules/@gdh/core/dist/migrations/entries/s2c2_to_s2c3_rules_schema_v2_to_v3.js.map +1 -1
  28. package/node_modules/@gdh/core/package.json +1 -1
  29. package/node_modules/@gdh/docs/dist/agent-contract.js +1 -1
  30. package/node_modules/@gdh/docs/dist/agent-contract.js.map +1 -1
  31. package/node_modules/@gdh/docs/dist/rules.js +3 -3
  32. package/node_modules/@gdh/docs/dist/rules.js.map +1 -1
  33. package/node_modules/@gdh/docs/dist/templates/guidance/authoring-and-validation.md.tpl +8 -6
  34. package/node_modules/@gdh/docs/package.json +2 -2
  35. package/node_modules/@gdh/mcp/package.json +8 -8
  36. package/node_modules/@gdh/observability/package.json +2 -2
  37. package/node_modules/@gdh/runtime/package.json +2 -2
  38. package/node_modules/@gdh/scan/package.json +3 -3
  39. package/node_modules/@gdh/verify/package.json +7 -7
  40. 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', '.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,13 +44,13 @@ 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 {
58
51
  output = runPostEditDiagnostics(targetRoot, authoring);
59
52
  } catch (_err) {
60
- return context(`GDH post-edit hook embedded read threw; continuing without blocking. Run \`${gdhCommandPrefix()} authoring check --mode final --target ${JSON.stringify(targetRoot)}\` before claiming code-validity.`);
53
+ return context("GDH post-edit hook embedded read threw; continuing without blocking. Do not treat this edit as validated; collect final validation evidence before claiming code-validity.");
61
54
  }
62
55
  // Phase 81 / LSP-06 / D-01: dispatch on bracketed-token vocabulary.
63
56
  // Pitfall 5: anchor regex to line-start with /m flag so per-diagnostic
@@ -67,16 +60,16 @@ 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();
74
67
  }
75
68
  if (/^\[pending\]/m.test(output) || /^\[stale\]/m.test(output)) {
76
- return context(`GDH post-edit authoring check could not prove this edit quickly; continuing without blocking. ${compactOneLine(output || 'Run final authoring validation before claiming code-validity.')} ${reasonHint(output, targetRoot, authoring)}`);
69
+ return context(`GDH post-edit authoring check could not prove this edit quickly; continuing without blocking. ${compactOneLine(output || 'Collect final validation evidence before claiming code-validity.')} ${reasonHint(output, targetRoot, authoring)}`);
77
70
  }
78
71
  if (/^\[timeout\]/m.test(output)) {
79
- return context(`GDH post-edit authoring check returned a timeout status; continuing without blocking. ${compactOneLine(output || 'Run final authoring validation before claiming code-validity.')} ${reasonHint(output, targetRoot, authoring)}`);
72
+ return context(`GDH post-edit authoring check returned a timeout status; continuing without blocking. ${compactOneLine(output || 'Collect final validation evidence before claiming code-validity.')} ${reasonHint(output, targetRoot, authoring)}`);
80
73
  }
81
74
  return allow();
82
75
  }
@@ -116,14 +109,14 @@ 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;
123
116
  try {
124
117
  output = runPostEditDiagnostics(targetRoot, authoring);
125
118
  } catch (_err) {
126
- return context(`GDH PostToolBatch hook embedded read threw; continuing without blocking. Run \`${gdhCommandPrefix()} authoring check --mode final --target ${JSON.stringify(targetRoot)}\` before claiming code-validity.`);
119
+ return context("GDH PostToolBatch hook embedded read threw; continuing without blocking. Do not treat this batch as validated; collect final validation evidence before claiming code-validity.");
127
120
  }
128
121
 
129
122
  // Bracketed-token dispatch — IDENTICAL to handlePostEdit. Pitfall 5 from Phase 81
@@ -131,16 +124,16 @@ 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();
138
131
  }
139
132
  if (/^\[pending\]/m.test(output) || /^\[stale\]/m.test(output)) {
140
- return context(`GDH PostToolBatch authoring check could not prove this batch quickly; continuing without blocking. ${compactOneLine(output || 'Run final authoring validation before claiming code-validity.')} ${reasonHint(output, targetRoot, authoring)}`);
133
+ return context(`GDH PostToolBatch authoring check could not prove this batch quickly; continuing without blocking. ${compactOneLine(output || 'Collect final validation evidence before claiming code-validity.')} ${reasonHint(output, targetRoot, authoring)}`);
141
134
  }
142
135
  if (/^\[timeout\]/m.test(output)) {
143
- return context(`GDH PostToolBatch authoring check returned a timeout status; continuing without blocking. ${compactOneLine(output || 'Run final authoring validation before claiming code-validity.')} ${reasonHint(output, targetRoot, authoring)}`);
136
+ return context(`GDH PostToolBatch authoring check returned a timeout status; continuing without blocking. ${compactOneLine(output || 'Collect final validation evidence before claiming code-validity.')} ${reasonHint(output, targetRoot, authoring)}`);
144
137
  }
145
138
  return allow();
146
139
  }
@@ -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`
343
- // recommending the user run `gdh authoring check --mode final` manually AND
344
- // spawns detached warmup 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 = ' Run `gdh authoring check --mode final` for full output.';
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
- 'Run `gdh authoring check --mode final` manually 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
- 'Run `gdh authoring check --mode final` manually 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,14 +750,61 @@ 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) {
798
805
  const text = String(value || '').trim();
799
806
  if (text.length <= maxChars) return text;
800
- return `${text.slice(0, maxChars - 80).trimEnd()}\n... GDH hook output truncated; run \`gdh authoring check --mode post-edit\` for full diagnostics.`;
807
+ return `${text.slice(0, maxChars - 80).trimEnd()}\n... GDH hook output truncated; fix visible diagnostics first. Collect explicit validation evidence only for handoff or if the visible diagnostics are insufficient.`;
801
808
  }
802
809
 
803
810
  function compactOneLine(value) {
@@ -815,13 +822,13 @@ function reasonHint(output, targetRoot, files) {
815
822
  const reasons = match ? match[1].split(',').map(r => r.trim()).filter(Boolean) : [];
816
823
  // JSON.stringify embeds the path so spaces and quotes survive shell re-use.
817
824
  const targetArg = JSON.stringify(targetRoot);
818
- const scopedHint = fileTypeScopedHint(targetArg, files);
825
+ const scopedHint = fileTypeScopedHint(files);
819
826
  // First-match-wins ordering: more specific reasons before generic.
820
827
  if (reasons.includes('lsp_instance_identity_mismatch')) {
821
828
  return `Cross-worktree LSP from another checkout. Run \`${gdhCommandPrefix()} lsp prune --target ${targetArg}\` then retry the edit.`;
822
829
  }
823
830
  if (reasons.includes('lsp_state_lock_timeout')) {
824
- return `Authoring state lock busy; ${describeLspLock(targetRoot)} Retry the scoped command after the current authoring operation finishes. ${scopedHint}`;
831
+ return `Authoring state lock busy; ${describeLspLock(targetRoot)} Wait for the current authoring operation to finish before trusting diagnostics. ${scopedHint}`;
825
832
  }
826
833
  if (reasons.includes('lsp_instance_not_running')) {
827
834
  return `LSP not ready inside hook budget; foreground refresh attempted for .gd files and background refresh is running if needed. ${scopedHint}`;
@@ -829,44 +836,24 @@ 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. Run final authoring validation before claiming code-validity. ${scopedHint}`;
837
- }
838
- if (reasons.includes('project_godot_requires_final_authoring_check')) {
839
- return `project.godot is not covered by the LSP broker; run final authoring validation 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
  }
847
842
  if (reasons.includes('content_hash_mismatch') || reasons.includes('freshness_expired')) {
848
843
  return `Broker snapshot stale after bounded refresh; background refresh is running if needed. ${scopedHint}`;
849
844
  }
850
- return `Run scoped authoring validation before claiming code-validity. ${scopedHint}`;
845
+ return `Scoped hook validation did not prove this edit clean. ${scopedHint}`;
851
846
  }
852
847
 
853
- function fileTypeScopedHint(targetArg, files) {
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
- parts.push(`For .gd: \`${gdhCommandPrefix()} authoring diagnostics refresh --target ${targetArg}${changedArgs(gdFiles)}\``);
861
- }
862
- if (sceneFiles.length > 0) {
863
- parts.push(`For .tscn: \`${gdhCommandPrefix()} authoring check --mode post-edit --target ${targetArg}${changedArgs(sceneFiles)}\``);
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.');
853
+ parts.push('For .gd: hook refresh runs automatically; wait for the next hook result instead of rerunning checks during the edit loop.');
867
854
  }
868
855
  if (parts.length === 0) {
869
- return `Run \`${gdhCommandPrefix()} authoring check --mode final --target ${targetArg}\` before claiming code-validity.`;
856
+ return 'Collect final validation evidence before claiming code-validity.';
870
857
  }
871
858
  return parts.join(' ');
872
859
  }
@@ -875,10 +862,6 @@ function gdhCommandPrefix() {
875
862
  return `npx -y @skillcap/gdh@${PINNED_VERSION}`;
876
863
  }
877
864
 
878
- function changedArgs(files) {
879
- return files.map(f => ` --changed ${JSON.stringify(f)}`).join('');
880
- }
881
-
882
865
  function describeLspLock(targetRoot) {
883
866
  const stateRoot = resolveStateRoot(targetRoot) || path.join(targetRoot, '.gdh-state');
884
867
  const candidates = [
@@ -903,24 +886,11 @@ function describeLspLock(targetRoot) {
903
886
  }
904
887
  return 'lock metadata unavailable (expected .gdh-state/authoring/lsp.lock or .gdh-state/lsp.lock).';
905
888
  }
906
- // Codex always receives hookSpecificOutput.additionalContext (any event Codex's
907
- // hook contract accepts it on PreToolUse / PostToolUse / Stop / etc.).
908
- //
909
- // Claude receives hookSpecificOutput.additionalContext on PostToolUse / PostToolBatch
910
- // / FileChanged. Claude Stop is silent-allow because the Stop hook schema does NOT
911
- // accept additionalContext (only decision/reason/continue/stopReason/suppressOutput/
912
- // systemMessage). The previous gate emitted the wrong shape on every Claude Stop
913
- // and Claude rejected it as "invalid stop hook JSON output" (quick task 260504-o6w
914
- // 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.
915
892
  function context(additionalContext) {
916
- // Claude: Stop hook schema does NOT accept hookSpecificOutput.additionalContext
917
- // (only decision/reason/continue/stopReason/suppressOutput/systemMessage). Emitting
918
- // the wrong shape causes Claude to reject the hook with "invalid stop hook JSON
919
- // output" — verified locally 2026-05-04 (quick task 260504-o6w bug B). Drop to
920
- // silent-allow on Claude Stop. Claude PostToolUse / PostToolBatch DO accept
921
- // additionalContext, so those branches still flow through. Codex accepts
922
- // additionalContext on every event.
923
- if (AGENT !== 'codex' && CURRENT_EVENT === 'Stop') return allow();
893
+ if (CURRENT_EVENT === 'Stop') return allow();
924
894
  const payload = { hookSpecificOutput: { hookEventName: CURRENT_EVENT, additionalContext } };
925
895
  process.stdout.write(`${JSON.stringify(payload)}\n`);
926
896
  process.exit(0);