@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.
- package/INSTALL-BUNDLE.json +1 -1
- package/RELEASE-SPAN-UPDATE-CONTRACTS.json +123 -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/index.d.ts.map +1 -1
- package/node_modules/@gdh/adapters/dist/index.js +71 -1
- package/node_modules/@gdh/adapters/dist/index.js.map +1 -1
- package/node_modules/@gdh/adapters/dist/self-update-mechanics.d.ts +4 -2
- package/node_modules/@gdh/adapters/dist/self-update-mechanics.d.ts.map +1 -1
- package/node_modules/@gdh/adapters/dist/self-update-mechanics.js +4 -2
- package/node_modules/@gdh/adapters/dist/self-update-mechanics.js.map +1 -1
- package/node_modules/@gdh/adapters/dist/skill-rendering.js +4 -4
- package/node_modules/@gdh/adapters/dist/skill-rendering.js.map +1 -1
- package/node_modules/@gdh/adapters/dist/templates/authoring-hook.js.tpl +200 -230
- package/node_modules/@gdh/adapters/package.json +8 -8
- package/node_modules/@gdh/authoring/package.json +2 -2
- package/node_modules/@gdh/cli/dist/migrate.js +1 -1
- package/node_modules/@gdh/cli/dist/migrate.js.map +1 -1
- package/node_modules/@gdh/cli/package.json +10 -10
- package/node_modules/@gdh/core/dist/index.d.ts +8 -4
- package/node_modules/@gdh/core/dist/index.d.ts.map +1 -1
- package/node_modules/@gdh/core/dist/index.js +8 -4
- package/node_modules/@gdh/core/dist/index.js.map +1 -1
- package/node_modules/@gdh/core/dist/migrations/entries/s2c2_to_s2c3_rules_schema_v2_to_v3.d.ts.map +1 -1
- package/node_modules/@gdh/core/dist/migrations/entries/s2c2_to_s2c3_rules_schema_v2_to_v3.js +8 -4
- package/node_modules/@gdh/core/dist/migrations/entries/s2c2_to_s2c3_rules_schema_v2_to_v3.js.map +1 -1
- package/node_modules/@gdh/core/package.json +1 -1
- package/node_modules/@gdh/docs/dist/agent-contract.js +1 -1
- package/node_modules/@gdh/docs/dist/agent-contract.js.map +1 -1
- package/node_modules/@gdh/docs/dist/rules.js +3 -3
- package/node_modules/@gdh/docs/dist/rules.js.map +1 -1
- package/node_modules/@gdh/docs/dist/templates/guidance/authoring-and-validation.md.tpl +8 -6
- 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,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(
|
|
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(
|
|
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 || '
|
|
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 || '
|
|
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(
|
|
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(
|
|
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 || '
|
|
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 || '
|
|
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
|
|
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 = ' 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
|
|
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
|
-
'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
|
-
|
|
405
|
-
|
|
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
|
|
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,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
|
-
|
|
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;
|
|
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(
|
|
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)}
|
|
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 `
|
|
845
|
+
return `Scoped hook validation did not prove this edit clean. ${scopedHint}`;
|
|
851
846
|
}
|
|
852
847
|
|
|
853
|
-
function fileTypeScopedHint(
|
|
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(
|
|
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
|
|
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
|
-
//
|
|
907
|
-
//
|
|
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
|
-
|
|
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);
|