@skillcap/gdh 0.26.2 → 0.26.4
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 +115 -0
- package/node_modules/@gdh/adapters/dist/authoring-hook-render.d.ts +13 -0
- package/node_modules/@gdh/adapters/dist/authoring-hook-render.d.ts.map +1 -1
- package/node_modules/@gdh/adapters/dist/authoring-hook-render.js +1 -0
- package/node_modules/@gdh/adapters/dist/authoring-hook-render.js.map +1 -1
- package/node_modules/@gdh/adapters/dist/authoring-hook-state-path.d.ts +17 -0
- package/node_modules/@gdh/adapters/dist/authoring-hook-state-path.d.ts.map +1 -0
- package/node_modules/@gdh/adapters/dist/authoring-hook-state-path.js +23 -0
- package/node_modules/@gdh/adapters/dist/authoring-hook-state-path.js.map +1 -0
- package/node_modules/@gdh/adapters/dist/index.d.ts.map +1 -1
- package/node_modules/@gdh/adapters/dist/index.js +36 -0
- package/node_modules/@gdh/adapters/dist/index.js.map +1 -1
- package/node_modules/@gdh/adapters/dist/templates/authoring-hook.js.tpl +407 -112
- package/node_modules/@gdh/adapters/package.json +8 -8
- package/node_modules/@gdh/authoring/package.json +2 -2
- package/node_modules/@gdh/cli/package.json +10 -10
- package/node_modules/@gdh/core/dist/index.d.ts +1 -1
- package/node_modules/@gdh/core/dist/index.js +1 -1
- package/node_modules/@gdh/core/package.json +1 -1
- 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
|
@@ -4,9 +4,18 @@
|
|
|
4
4
|
const PINNED_VERSION = {{pinnedVersionJson}};
|
|
5
5
|
const TARGET_RELATIVE_PATH = {{targetRelativePathJson}};
|
|
6
6
|
const AGENT = {{agentJson}};
|
|
7
|
+
// POSIX-normalised relative path from this hook script's directory (__dirname)
|
|
8
|
+
// to the integration root's `.gdh-state/` directory. Baked at install-plan time
|
|
9
|
+
// from `path.relative(hookDirAbsolute, path.join(integrationRootAbsolute, ".gdh-state"))`.
|
|
10
|
+
// The embedded broker-snapshot reader (Task 2) resolves
|
|
11
|
+
// `path.resolve(__dirname, STATE_RELATIVE_PATH_FROM_HOOK)` to find
|
|
12
|
+
// `authoring/lsp-instance.json` and `authoring/diagnostics-broker/snapshot.json`.
|
|
13
|
+
const STATE_RELATIVE_PATH_FROM_HOOK = {{stateRelativePathFromHookJson}};
|
|
7
14
|
|
|
8
15
|
const fs = require('fs');
|
|
9
16
|
const path = require('path');
|
|
17
|
+
const crypto = require('crypto');
|
|
18
|
+
const { pathToFileURL } = require('url');
|
|
10
19
|
const { spawnSync } = require('child_process');
|
|
11
20
|
|
|
12
21
|
const AUTHORING_EXTENSIONS = new Set(['.gd', '.tscn', '.tres']);
|
|
@@ -43,38 +52,44 @@ function handlePostEdit(input, targetRoot) {
|
|
|
43
52
|
const changed = collectChangedFiles(input, targetRoot);
|
|
44
53
|
const authoring = changed.filter(isAuthoringValidationPath);
|
|
45
54
|
if (authoring.length === 0) return allow();
|
|
46
|
-
// Phase 82 / LSP-02: fire-and-forget
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
//
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
55
|
+
// Phase 82 / LSP-02: fire-and-forget scoped diagnostics refresh BEFORE the
|
|
56
|
+
// bounded read. The verb calls `refreshAuthoringDiagnostics` which boots
|
|
57
|
+
// Godot LSP if needed, opens scoped files via LSP, drains diagnostics, and
|
|
58
|
+
// writes `diagnostics-broker/snapshot.json` + `primed` marker. Hook still
|
|
59
|
+
// returns within CHECK_TIMEOUT_MS regardless of refresh outcome (Pitfall 1:
|
|
60
|
+
// detached + stdio:'ignore' + .unref() means the parent never blocks on
|
|
61
|
+
// the child).
|
|
62
|
+
spawnDetachedRefresh(targetRoot, authoring);
|
|
63
|
+
// Quick task 260504-ix2: embedded broker-snapshot reader. Replaces the
|
|
64
|
+
// per-edit synchronous `npx -y @skillcap/gdh@PINNED authoring check ...`
|
|
65
|
+
// shellout (which was eating the full `CHECK_TIMEOUT_MS` budget on the npx
|
|
66
|
+
// bootstrap path even before any GDH code ran). The reader synthesises the
|
|
67
|
+
// same `[fresh]` / `[stale]` / `[pending]` bracketed-token vocabulary the
|
|
68
|
+
// existing dispatch already consumes, so the regex ladder below is unchanged.
|
|
69
|
+
let output;
|
|
70
|
+
try {
|
|
71
|
+
output = runEmbeddedDiagnosticsRead(targetRoot, authoring);
|
|
72
|
+
} catch (_err) {
|
|
73
|
+
return context('GDH post-edit hook embedded read threw; continuing without blocking. Run `gdh authoring check --mode final` before claiming code-validity.');
|
|
60
74
|
}
|
|
61
75
|
// Phase 81 / LSP-06 / D-01: dispatch on bracketed-token vocabulary.
|
|
62
76
|
// Pitfall 5: anchor regex to line-start with /m flag so per-diagnostic
|
|
63
77
|
// [error]/[warning]/[info] severity brackets do not match the status token.
|
|
64
|
-
|
|
65
|
-
|
|
78
|
+
// The embedded reader does not currently emit `[failed]` / `[partial]` /
|
|
79
|
+
// `[timeout]` (those statuses come from the legacy CLI compact formatter),
|
|
80
|
+
// but the dispatch retains them as forward-compat branches in case a future
|
|
81
|
+
// reader extension surfaces them.
|
|
82
|
+
if (/^\[failed\]/m.test(output) || /^\[partial\]/m.test(output)) {
|
|
83
|
+
return block(formatBlockingReason(output));
|
|
66
84
|
}
|
|
67
|
-
if (/^\[fresh\]/m.test(
|
|
85
|
+
if (/^\[fresh\]/m.test(output)) {
|
|
68
86
|
return allow();
|
|
69
87
|
}
|
|
70
|
-
if (/^\[pending\]/m.test(
|
|
71
|
-
return context(`GDH post-edit authoring check could not prove this edit quickly; continuing without blocking. ${compactOneLine(
|
|
72
|
-
}
|
|
73
|
-
if (/^\[timeout\]/m.test(result.output)) {
|
|
74
|
-
return context(`GDH post-edit authoring check returned a timeout status; continuing without blocking. ${compactOneLine(result.output || 'Run final authoring validation before claiming code-validity.')}`);
|
|
88
|
+
if (/^\[pending\]/m.test(output) || /^\[stale\]/m.test(output)) {
|
|
89
|
+
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)}`);
|
|
75
90
|
}
|
|
76
|
-
if (
|
|
77
|
-
return context(`GDH post-edit authoring check
|
|
91
|
+
if (/^\[timeout\]/m.test(output)) {
|
|
92
|
+
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)}`);
|
|
78
93
|
}
|
|
79
94
|
return allow();
|
|
80
95
|
}
|
|
@@ -117,78 +132,80 @@ function handlePostToolBatch(input, targetRoot) {
|
|
|
117
132
|
const authoring = unique(files).filter(isAuthoringValidationPath);
|
|
118
133
|
if (authoring.length === 0) return allow(); // Pitfall 2: no authoring files — no work
|
|
119
134
|
|
|
120
|
-
// SC2:
|
|
121
|
-
// per-edit
|
|
122
|
-
//
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
const changedArgs = authoring.flatMap((file) => ['--changed', file]);
|
|
126
|
-
// SC1: ONE coalesced check, not N. Same runGdh envelope as handlePostEdit —
|
|
127
|
-
// do NOT introduce a parallel CLI path.
|
|
128
|
-
const result = runGdh(
|
|
129
|
-
targetRoot,
|
|
130
|
-
['authoring', 'check', '--target', targetRoot, '--mode', 'post-edit', '--format', 'compact', ...changedArgs],
|
|
131
|
-
{ timeoutMs: CHECK_TIMEOUT_MS },
|
|
132
|
-
);
|
|
135
|
+
// SC2: spawnDetachedRefresh reuses the lsp.lock-serialized helper. If a
|
|
136
|
+
// per-edit refresh is already in flight when this fires, the broker side
|
|
137
|
+
// serializes via the lock primitive — no double-spawn of Godot.
|
|
138
|
+
spawnDetachedRefresh(targetRoot, authoring);
|
|
133
139
|
|
|
134
|
-
|
|
135
|
-
|
|
140
|
+
// Quick task 260504-ix2: embedded broker-snapshot reader replaces the
|
|
141
|
+
// synchronous `npx ... authoring check` shellout. The reader is invoked
|
|
142
|
+
// ONCE with the full deduplicated authoring set — the LSP-09 coalescing
|
|
143
|
+
// invariant is preserved: the loop in `runEmbeddedDiagnosticsRead` walks
|
|
144
|
+
// every file but returns on the first non-fresh status (first-failure-wins
|
|
145
|
+
// is the documented contract; a [stale] in any one file routes the batch
|
|
146
|
+
// through the drift dispatch branch).
|
|
147
|
+
let output;
|
|
148
|
+
try {
|
|
149
|
+
output = runEmbeddedDiagnosticsRead(targetRoot, authoring);
|
|
150
|
+
} catch (_err) {
|
|
151
|
+
return context('GDH PostToolBatch hook embedded read threw; continuing without blocking. Run `gdh authoring check --mode final` before claiming code-validity.');
|
|
136
152
|
}
|
|
153
|
+
|
|
137
154
|
// Bracketed-token dispatch — IDENTICAL to handlePostEdit. Pitfall 5 from Phase 81
|
|
138
155
|
// applies: anchor regex with /^.../m so per-diagnostic [error]/[warning]/[info]
|
|
139
|
-
// severity brackets do not match the line-leading status token.
|
|
140
|
-
|
|
141
|
-
|
|
156
|
+
// severity brackets do not match the line-leading status token. Forward-compat
|
|
157
|
+
// [partial] / [timeout] branches retained per quick-task 260504-ix2 plan note.
|
|
158
|
+
if (/^\[failed\]/m.test(output) || /^\[partial\]/m.test(output)) {
|
|
159
|
+
return block(formatBlockingReason(output));
|
|
142
160
|
}
|
|
143
|
-
if (/^\[fresh\]/m.test(
|
|
161
|
+
if (/^\[fresh\]/m.test(output)) {
|
|
144
162
|
return allow();
|
|
145
163
|
}
|
|
146
|
-
if (/^\[pending\]/m.test(
|
|
147
|
-
return context(`GDH PostToolBatch authoring check could not prove this batch quickly; continuing without blocking. ${compactOneLine(
|
|
148
|
-
}
|
|
149
|
-
if (/^\[timeout\]/m.test(result.output)) {
|
|
150
|
-
return context(`GDH PostToolBatch authoring check returned a timeout status; continuing without blocking. ${compactOneLine(result.output || 'Run final authoring validation before claiming code-validity.')}`);
|
|
164
|
+
if (/^\[pending\]/m.test(output) || /^\[stale\]/m.test(output)) {
|
|
165
|
+
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)}`);
|
|
151
166
|
}
|
|
152
|
-
if (
|
|
153
|
-
return context(`GDH PostToolBatch authoring check
|
|
167
|
+
if (/^\[timeout\]/m.test(output)) {
|
|
168
|
+
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)}`);
|
|
154
169
|
}
|
|
155
170
|
return allow();
|
|
156
171
|
}
|
|
157
172
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
ok: result.status === 0 && !result.error,
|
|
169
|
-
output,
|
|
170
|
-
error,
|
|
171
|
-
timedOut: Boolean(
|
|
172
|
-
(result.error && result.error.code === 'ETIMEDOUT') ||
|
|
173
|
-
result.signal === 'SIGTERM'
|
|
174
|
-
),
|
|
175
|
-
};
|
|
176
|
-
}
|
|
173
|
+
// Quick task 260504-ix2: `runGdh` deleted. The function previously wrapped
|
|
174
|
+
// `spawnSync('npx', ['-y', '@skillcap/gdh@PINNED', ...args])` for the three
|
|
175
|
+
// event handlers (handlePostEdit, handlePostToolBatch, handleStop). All three
|
|
176
|
+
// now call `runEmbeddedDiagnosticsRead` instead. `spawnDetachedRefresh` uses
|
|
177
|
+
// `child_process.spawn` directly (always did — it was never a `runGdh`
|
|
178
|
+
// caller), so removing the helper has no effect on the refresh path.
|
|
179
|
+
// Quick task 260504-o6w renamed `spawnDetachedWarmup` → `spawnDetachedRefresh`
|
|
180
|
+
// and switched the spawn argv from `lsp warmup` to `authoring diagnostics
|
|
181
|
+
// refresh --changed`. Detached spawn still uses `child_process.spawn` directly
|
|
182
|
+
// (always did).
|
|
177
183
|
|
|
178
|
-
//
|
|
179
|
-
// hook
|
|
180
|
-
//
|
|
184
|
+
// Quick task 260504-o6w. Fire-and-forget scoped diagnostics refresh from the
|
|
185
|
+
// post-edit hook. The CLI verb calls `refreshAuthoringDiagnostics` which boots
|
|
186
|
+
// Godot LSP if needed, opens scoped files via LSP, drains diagnostics, and
|
|
187
|
+
// writes `diagnostics-broker/snapshot.json` + `primed` marker — so this single
|
|
188
|
+
// fire-and-forget call covers BOTH warmup and broker priming. The previous
|
|
189
|
+
// `lsp warmup` argv only wrote `lsp-instance.json` and left the broker empty,
|
|
190
|
+
// leaving the embedded reader stuck on `[pending] broker_not_yet_primed`
|
|
191
|
+
// indefinitely (verified live on TheBeacon, 2026-05-04 dogfooding session 14).
|
|
181
192
|
//
|
|
182
193
|
// All three options are required (Pitfall 1):
|
|
183
194
|
// - detached: true — child gets its own process group, survives parent exit
|
|
184
195
|
// - stdio: 'ignore' — no fds tether the child to the parent's lifecycle
|
|
185
196
|
// - windowsHide: true — prevents a console flash on Windows
|
|
186
197
|
// .unref() is required so the Node event loop does not wait on the child.
|
|
187
|
-
function
|
|
198
|
+
function spawnDetachedRefresh(targetRoot, files) {
|
|
188
199
|
try {
|
|
200
|
+
const args = ['-y', `@skillcap/gdh@${PINNED_VERSION}`, 'authoring', 'diagnostics', 'refresh', '--target', targetRoot];
|
|
201
|
+
if (Array.isArray(files)) {
|
|
202
|
+
for (const f of files) {
|
|
203
|
+
args.push('--changed', f);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
189
206
|
const child = require('child_process').spawn(
|
|
190
207
|
'npx',
|
|
191
|
-
|
|
208
|
+
args,
|
|
192
209
|
{
|
|
193
210
|
cwd: targetRoot,
|
|
194
211
|
detached: true,
|
|
@@ -198,29 +215,52 @@ function spawnDetachedWarmup(targetRoot) {
|
|
|
198
215
|
);
|
|
199
216
|
child.unref();
|
|
200
217
|
} catch (_error) {
|
|
201
|
-
// Silent failure —
|
|
218
|
+
// Silent failure — refresh is fire-and-forget. The bounded read below still runs.
|
|
202
219
|
}
|
|
203
220
|
}
|
|
204
221
|
|
|
205
|
-
// Phase 82 / LSP-03
|
|
222
|
+
// Phase 82 / LSP-03 + Quick task 260504-ix2 — Stop-hook contract relaxation.
|
|
223
|
+
//
|
|
224
|
+
// Original Phase 82 contract: Stop synchronously invoked
|
|
225
|
+
// `gdh authoring check --mode final`, which calls
|
|
226
|
+
// `getManagedLspStatus(launch_if_needed)` and could spawn Godot LSP from the
|
|
227
|
+
// Stop event. Real-world dogfooding (2026-05-04 session) showed this path
|
|
228
|
+
// always timed out due to per-invocation `npx -y @skillcap/gdh@PINNED` cost
|
|
229
|
+
// (~9.3 s on the operator's measured machine). The hook ate the full
|
|
230
|
+
// `STOP_TIMEOUT_MS` budget on the npx bootstrap path before producing any
|
|
231
|
+
// output.
|
|
206
232
|
//
|
|
207
|
-
//
|
|
208
|
-
//
|
|
209
|
-
//
|
|
210
|
-
//
|
|
211
|
-
//
|
|
233
|
+
// Updated contract (RFC 0009 addendum): Stop reads the existing broker
|
|
234
|
+
// snapshot via the embedded dependency-free reader; it does NOT synchronously
|
|
235
|
+
// launch LSP. Cold/missing-broker case returns `additionalContext`
|
|
236
|
+
// recommending the user run `gdh authoring check --mode final` manually AND
|
|
237
|
+
// spawns detached warmup so the next session's PostToolUse events have a
|
|
238
|
+
// primed broker. This trades the rare "fresh session, no warm broker" case
|
|
239
|
+
// (where Stop would have produced final diagnostics synchronously) for the
|
|
240
|
+
// common case of sub-100ms hook return on every Stop.
|
|
212
241
|
//
|
|
213
|
-
//
|
|
214
|
-
//
|
|
215
|
-
//
|
|
216
|
-
//
|
|
242
|
+
// Hard invariants preserved:
|
|
243
|
+
// - GF3 (260504): zero authoring-files-in-target → noop (git gate, runs first)
|
|
244
|
+
// - Pitfall 3: NEVER returns decision: block (Stop hook must not block session end)
|
|
245
|
+
// - Pitfall 5: Claude additionalContext lives in hookSpecificOutput
|
|
246
|
+
// - Pitfall 6: additionalContext bounded to MAX_STOP_CONTEXT_CHARS = 8000
|
|
217
247
|
function handleStop(input, targetRoot) {
|
|
218
248
|
if (!hasAuthoringWorkInTarget(targetRoot)) return allow();
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
249
|
+
|
|
250
|
+
// Discover the authoring file set from the working tree under targetRoot.
|
|
251
|
+
// We feed this into the embedded reader so it can resolve broker entries
|
|
252
|
+
// by file:// URI. If the discovery fails (git unavailable, etc.) we fall
|
|
253
|
+
// through with an empty list, which routes to a `[pending]` status with
|
|
254
|
+
// reason `file_not_in_snapshot` — same effect as no broker primed.
|
|
255
|
+
const authoringFiles = listAuthoringFilesInTarget(targetRoot);
|
|
256
|
+
|
|
257
|
+
let output;
|
|
258
|
+
try {
|
|
259
|
+
output = runEmbeddedDiagnosticsRead(targetRoot, authoringFiles);
|
|
260
|
+
} catch (_err) {
|
|
261
|
+
output = '[pending]\nReasons: embedded_read_threw';
|
|
262
|
+
}
|
|
263
|
+
|
|
224
264
|
const truncatedSuffix = ' Run `gdh authoring check --mode final` for full output.';
|
|
225
265
|
function stopContext(message) {
|
|
226
266
|
const text = String(message || '').trim();
|
|
@@ -229,33 +269,254 @@ function handleStop(input, targetRoot) {
|
|
|
229
269
|
const headroom = ceiling - truncatedSuffix.length;
|
|
230
270
|
return context(text.slice(0, headroom).trimEnd() + truncatedSuffix);
|
|
231
271
|
}
|
|
232
|
-
|
|
233
|
-
return stopContext(
|
|
234
|
-
`GDH session-end check timed out after ${STOP_TIMEOUT_MS}ms; continuing without blocking.` +
|
|
235
|
-
' Run `gdh authoring check --mode final` manually before claiming code-validity.',
|
|
236
|
-
);
|
|
237
|
-
}
|
|
272
|
+
|
|
238
273
|
// Bracketed-token dispatch — same vocabulary as PostToolUse but EVERY branch
|
|
239
274
|
// routes to context() or allow(); decision: block is forbidden on Stop.
|
|
240
|
-
|
|
275
|
+
// Forward-compat [partial] / [timeout] / [failed] branches retained for
|
|
276
|
+
// potential future reader extensions; the embedded reader currently emits
|
|
277
|
+
// only [fresh] / [stale] / [pending].
|
|
278
|
+
if (/^\[fresh\]/m.test(output)) {
|
|
241
279
|
return allow();
|
|
242
280
|
}
|
|
243
|
-
if (/^\[failed\]/m.test(
|
|
244
|
-
return stopContext(`GDH session-end check found unresolved diagnostics: ${compactOneLine(
|
|
281
|
+
if (/^\[failed\]/m.test(output) || /^\[partial\]/m.test(output) || /^\[stale\]/m.test(output)) {
|
|
282
|
+
return stopContext(`GDH session-end check found unresolved diagnostics: ${compactOneLine(output)} ${reasonHint(output, targetRoot)}`);
|
|
245
283
|
}
|
|
246
|
-
if (/^\[pending\]/m.test(
|
|
284
|
+
if (/^\[pending\]/m.test(output) || /^\[timeout\]/m.test(output)) {
|
|
285
|
+
// Cold/missing broker: spawn detached refresh so the NEXT session benefits.
|
|
286
|
+
// The current Stop returns additionalContext recommending manual
|
|
287
|
+
// `gdh authoring check --mode final` (the relaxed contract — see RFC 0009
|
|
288
|
+
// addendum 2026-05-04). spawnDetachedRefresh is fire-and-forget; it never
|
|
289
|
+
// blocks hook return.
|
|
290
|
+
spawnDetachedRefresh(targetRoot, authoringFiles);
|
|
247
291
|
return stopContext(
|
|
248
|
-
'GDH session-end check
|
|
249
|
-
|
|
292
|
+
'GDH session-end check could not prove final code-validity from the cached broker snapshot; continuing without blocking. ' +
|
|
293
|
+
'Run `gdh authoring check --mode final` manually before claiming code-validity. ' +
|
|
294
|
+
compactOneLine(output) + ' ' + reasonHint(output, targetRoot),
|
|
250
295
|
);
|
|
251
296
|
}
|
|
252
|
-
// Unknown
|
|
297
|
+
// Unknown status: degrade to additionalContext (NEVER block).
|
|
298
|
+
spawnDetachedRefresh(targetRoot, authoringFiles);
|
|
253
299
|
return stopContext(
|
|
254
300
|
'GDH session-end check could not produce a final result; continuing without blocking. ' +
|
|
255
|
-
|
|
301
|
+
'Run `gdh authoring check --mode final` manually before claiming code-validity. ' +
|
|
302
|
+
compactOneLine(output) + ' ' + reasonHint(output, targetRoot),
|
|
256
303
|
);
|
|
257
304
|
}
|
|
258
305
|
|
|
306
|
+
// Quick task 260504-ix2: collect authoring-relevant files in the working tree
|
|
307
|
+
// under `targetRoot`. Sibling helper to `hasAuthoringWorkInTarget` (which
|
|
308
|
+
// returns a boolean for the GF3 gate); this returns the list of files for the
|
|
309
|
+
// embedded broker-snapshot reader to look up by file:// URI. Order-stable.
|
|
310
|
+
//
|
|
311
|
+
// Fail-open: returns [] on any git failure (the embedded reader will then emit
|
|
312
|
+
// `[pending]` reasons, which Stop dispatches to additionalContext — never
|
|
313
|
+
// block).
|
|
314
|
+
function listAuthoringFilesInTarget(targetRoot) {
|
|
315
|
+
let result;
|
|
316
|
+
try {
|
|
317
|
+
result = spawnSync('git', ['-C', targetRoot, 'status', '--porcelain', '--', '.'], {
|
|
318
|
+
encoding: 'utf8',
|
|
319
|
+
timeout: 1500,
|
|
320
|
+
windowsHide: true,
|
|
321
|
+
});
|
|
322
|
+
} catch (_error) {
|
|
323
|
+
return [];
|
|
324
|
+
}
|
|
325
|
+
if (!result || result.error || result.status !== 0 || result.signal) return [];
|
|
326
|
+
const files = [];
|
|
327
|
+
for (const raw of String(result.stdout || '').split('\n')) {
|
|
328
|
+
if (!raw || raw.length < 4) continue;
|
|
329
|
+
let rest = raw.slice(3);
|
|
330
|
+
const arrow = rest.indexOf(' -> ');
|
|
331
|
+
if (arrow !== -1) rest = rest.slice(arrow + 4);
|
|
332
|
+
if (rest.startsWith('"') && rest.endsWith('"')) rest = rest.slice(1, -1);
|
|
333
|
+
if (isAuthoringValidationPath(rest)) files.push(rest);
|
|
334
|
+
}
|
|
335
|
+
return unique(files);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Quick task 260504-ix2: dependency-free embedded broker-snapshot reader.
|
|
339
|
+
//
|
|
340
|
+
// Replaces the per-edit `npx -y @skillcap/gdh@PINNED authoring check ...`
|
|
341
|
+
// shellout (which paid the ~9.3 s npx bootstrap cost on every hook fire).
|
|
342
|
+
// Reads `<stateRoot>/authoring/lsp-instance.json` and
|
|
343
|
+
// `<stateRoot>/authoring/diagnostics-broker/snapshot.json` directly,
|
|
344
|
+
// validates LSP instance identity + per-file SHA-256 content hash +
|
|
345
|
+
// freshness window, and synthesises the same bracketed-token output the
|
|
346
|
+
// existing dispatch consumes (`[fresh]` / `[stale]` / `[pending]`).
|
|
347
|
+
//
|
|
348
|
+
// Reasons vocabulary (kept aligned with packages/authoring/src/lsp.ts):
|
|
349
|
+
// lsp_instance_not_running, lsp_instance_parse_error,
|
|
350
|
+
// broker_not_yet_primed, snapshot_parse_error, unsupported_broker_schema,
|
|
351
|
+
// lsp_instance_identity_mismatch, content_hash_mismatch, freshness_expired,
|
|
352
|
+
// file_not_in_snapshot, state_root_unresolved.
|
|
353
|
+
//
|
|
354
|
+
// Hash parity: the broker writer (`packages/authoring/src/diagnostics-broker.ts`
|
|
355
|
+
// `hashText`) and the cache validator (`packages/authoring/src/lsp.ts`
|
|
356
|
+
// `hashFilesByUri`) both read files via `fs.readFile(path, "utf8")` and
|
|
357
|
+
// SHA-256 the resulting string. The embedded reader MUST do the same so a
|
|
358
|
+
// fresh snapshot covering an unmodified file matches by content hash.
|
|
359
|
+
function runEmbeddedDiagnosticsRead(targetRoot, authoringFiles) {
|
|
360
|
+
const stateRoot = resolveStateRoot(targetRoot);
|
|
361
|
+
if (stateRoot === null) {
|
|
362
|
+
return formatStatus('pending', ['state_root_unresolved']);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const lspInstancePath = path.join(stateRoot, 'authoring', 'lsp-instance.json');
|
|
366
|
+
let lspInstance;
|
|
367
|
+
try {
|
|
368
|
+
const raw = fs.readFileSync(lspInstancePath, 'utf8');
|
|
369
|
+
lspInstance = JSON.parse(raw);
|
|
370
|
+
} catch (err) {
|
|
371
|
+
if (err && err.code === 'ENOENT') return formatStatus('pending', ['lsp_instance_not_running']);
|
|
372
|
+
return formatStatus('pending', ['lsp_instance_parse_error']);
|
|
373
|
+
}
|
|
374
|
+
const expectedInstanceId = lspInstance && lspInstance.instance && lspInstance.instance.instanceId;
|
|
375
|
+
if (typeof expectedInstanceId !== 'string' || expectedInstanceId.length === 0) {
|
|
376
|
+
return formatStatus('pending', ['lsp_instance_not_running']);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const snapshotPath = path.join(stateRoot, 'authoring', 'diagnostics-broker', 'snapshot.json');
|
|
380
|
+
let snapshot;
|
|
381
|
+
try {
|
|
382
|
+
const raw = fs.readFileSync(snapshotPath, 'utf8');
|
|
383
|
+
snapshot = JSON.parse(raw);
|
|
384
|
+
} catch (err) {
|
|
385
|
+
if (err && err.code === 'ENOENT') return formatStatus('pending', ['broker_not_yet_primed']);
|
|
386
|
+
return formatStatus('pending', ['snapshot_parse_error']);
|
|
387
|
+
}
|
|
388
|
+
if (!snapshot || snapshot.schemaVersion !== 1) {
|
|
389
|
+
return formatStatus('pending', ['unsupported_broker_schema']);
|
|
390
|
+
}
|
|
391
|
+
if (snapshot.lspInstanceId !== expectedInstanceId) {
|
|
392
|
+
return formatStatus('stale', ['lsp_instance_identity_mismatch']);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const filesByUri = new Map();
|
|
396
|
+
if (Array.isArray(snapshot.files)) {
|
|
397
|
+
for (const f of snapshot.files) if (f && typeof f.uri === 'string') filesByUri.set(f.uri, f);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// Match the constant in `packages/authoring/src/lsp.ts`
|
|
401
|
+
// (`DIAGNOSTICS_BROKER_DEFAULT_FRESHNESS_STALE_AFTER_MS`). Hard-coded here
|
|
402
|
+
// because the hook is dependency-free and cannot import @gdh/authoring.
|
|
403
|
+
const STALE_AFTER_MS = 60000;
|
|
404
|
+
const nowMs = Date.now();
|
|
405
|
+
const allDiagnostics = [];
|
|
406
|
+
|
|
407
|
+
for (const relPath of authoringFiles) {
|
|
408
|
+
// Match the broker writer's URI canonicalisation: it calls
|
|
409
|
+
// `fs.realpath` on each requested file before `pathToFileURL.href`,
|
|
410
|
+
// which collapses macOS `/var/folders` → `/private/var/folders`
|
|
411
|
+
// symlinks (and similar real-symlink layouts on Linux/Windows). The
|
|
412
|
+
// hook's `__dirname` already comes through canonicalised by Node's
|
|
413
|
+
// module loader, but the legacy `path.resolve` in the resolver does
|
|
414
|
+
// NOT walk symlinks. Use `canonicalPath` (already-defined helper) so
|
|
415
|
+
// the URIs match a snapshot captured by `hashFilesByUri`.
|
|
416
|
+
const absolutePath = canonicalPath(path.resolve(targetRoot, relPath));
|
|
417
|
+
const expectedUri = pathToFileURL(absolutePath).href;
|
|
418
|
+
const entry = filesByUri.get(expectedUri);
|
|
419
|
+
if (!entry) return formatStatus('pending', ['file_not_in_snapshot']);
|
|
420
|
+
|
|
421
|
+
if (typeof entry.contentHash !== 'string' || entry.contentHash.length === 0) {
|
|
422
|
+
// Pre-content-hash snapshot entries cannot prove current file content.
|
|
423
|
+
// Plan info-note: route through `[pending]` (not `[stale]`) — the broker
|
|
424
|
+
// is not yet fully primed for this file even if the snapshot exists.
|
|
425
|
+
return formatStatus('pending', ['broker_not_yet_primed']);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
let onDiskHash;
|
|
429
|
+
try {
|
|
430
|
+
const text = fs.readFileSync(absolutePath, 'utf8');
|
|
431
|
+
onDiskHash = crypto.createHash('sha256').update(text).digest('hex');
|
|
432
|
+
} catch (_err) {
|
|
433
|
+
// File deleted between hook trigger and read, or unreadable encoding.
|
|
434
|
+
// Fallthrough: treat as not-in-snapshot so we degrade to additionalContext.
|
|
435
|
+
return formatStatus('pending', ['file_not_in_snapshot']);
|
|
436
|
+
}
|
|
437
|
+
if (onDiskHash !== entry.contentHash) {
|
|
438
|
+
return formatStatus('stale', ['content_hash_mismatch']);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const updatedMs = Date.parse(entry.lastUpdatedAt);
|
|
442
|
+
if (!Number.isFinite(updatedMs) || (nowMs - updatedMs) > STALE_AFTER_MS) {
|
|
443
|
+
return formatStatus('stale', ['freshness_expired']);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
if (Array.isArray(entry.diagnostics)) {
|
|
447
|
+
for (const diag of entry.diagnostics) {
|
|
448
|
+
allDiagnostics.push({ uri: entry.uri, diag });
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
return formatFreshDiagnostics(allDiagnostics);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function formatStatus(token, reasons) {
|
|
457
|
+
return `[${token}]\nReasons: ${reasons.join(', ')}`;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function formatFreshDiagnostics(items) {
|
|
461
|
+
let errors = 0;
|
|
462
|
+
let warnings = 0;
|
|
463
|
+
let infoCount = 0;
|
|
464
|
+
const lines = [];
|
|
465
|
+
for (const item of items) {
|
|
466
|
+
const diag = item.diag;
|
|
467
|
+
const sev = (diag && typeof diag.severity === 'string') ? diag.severity : 'info';
|
|
468
|
+
if (sev === 'error') errors += 1;
|
|
469
|
+
else if (sev === 'warning') warnings += 1;
|
|
470
|
+
else infoCount += 1;
|
|
471
|
+
const line = (diag && diag.range && diag.range.start && Number.isFinite(diag.range.start.line)) ? diag.range.start.line + 1 : 0;
|
|
472
|
+
const col = (diag && diag.range && diag.range.start && Number.isFinite(diag.range.start.character)) ? diag.range.start.character + 1 : 0;
|
|
473
|
+
const msg = (diag && typeof diag.message === 'string') ? diag.message : '';
|
|
474
|
+
lines.push(`${item.uri}:${line}:${col} [${sev}] ${msg}`);
|
|
475
|
+
}
|
|
476
|
+
// Mirror packages/authoring/src/diagnostics-summary.ts: with the default
|
|
477
|
+
// "error" severity policy, ANY error diagnostic produces a `blocked` summary
|
|
478
|
+
// → CLI emits `[failed]` → hook dispatch routes to `block()`. The embedded
|
|
479
|
+
// reader must produce the same status token so the hook still blocks edits
|
|
480
|
+
// that introduce errors. Warnings and info do not alter the status token.
|
|
481
|
+
const statusToken = errors > 0 ? 'failed' : 'fresh';
|
|
482
|
+
const completion = errors > 0 ? 'blocked' : 'allowed';
|
|
483
|
+
const head = lines.length ? `${lines.join('\n')}\n` : '';
|
|
484
|
+
return `${head}[${statusToken}] ${errors} errors, ${warnings} warnings, ${infoCount} info. Completion ${completion}.`;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function resolveStateRoot(targetRoot) {
|
|
488
|
+
// 1. Baked render-time path — the canonical resolution for installed hooks.
|
|
489
|
+
try {
|
|
490
|
+
const baked = path.resolve(__dirname, STATE_RELATIVE_PATH_FROM_HOOK);
|
|
491
|
+
if (fs.existsSync(baked)) return baked;
|
|
492
|
+
} catch (_err) {
|
|
493
|
+
// Fall through to walk-up fallbacks if the baked path is malformed.
|
|
494
|
+
}
|
|
495
|
+
// 2. Walk up from __dirname looking for `.gdh/project.yaml`. Useful when the
|
|
496
|
+
// hook script has been moved or the bake-time relative path is wrong.
|
|
497
|
+
let dir = __dirname;
|
|
498
|
+
for (let i = 0; i < 10; i += 1) {
|
|
499
|
+
if (fs.existsSync(path.join(dir, '.gdh', 'project.yaml'))) {
|
|
500
|
+
return path.join(dir, '.gdh-state');
|
|
501
|
+
}
|
|
502
|
+
const parent = path.dirname(dir);
|
|
503
|
+
if (parent === dir) break;
|
|
504
|
+
dir = parent;
|
|
505
|
+
}
|
|
506
|
+
// 3. Walk up from targetRoot — covers root-launched cases where the hook
|
|
507
|
+
// might be loaded from outside the project tree.
|
|
508
|
+
dir = targetRoot;
|
|
509
|
+
for (let i = 0; i < 10; i += 1) {
|
|
510
|
+
if (fs.existsSync(path.join(dir, '.gdh', 'project.yaml'))) {
|
|
511
|
+
return path.join(dir, '.gdh-state');
|
|
512
|
+
}
|
|
513
|
+
const parent = path.dirname(dir);
|
|
514
|
+
if (parent === dir) break;
|
|
515
|
+
dir = parent;
|
|
516
|
+
}
|
|
517
|
+
return null;
|
|
518
|
+
}
|
|
519
|
+
|
|
259
520
|
function collectChangedFiles(input, targetRoot) {
|
|
260
521
|
const baseCwd = input && typeof input.cwd === 'string' ? input.cwd : process.cwd();
|
|
261
522
|
const files = [];
|
|
@@ -382,15 +643,49 @@ function compactOneLine(value) {
|
|
|
382
643
|
}
|
|
383
644
|
|
|
384
645
|
function allow() { process.exit(0); }
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
646
|
+
// Quick task 260504-o6w: agent-actionable hint derived from the Reasons: line in
|
|
647
|
+
// the embedded reader's output. The mapping covers all reason classes emitted by
|
|
648
|
+
// `formatStatus` so the agent receives a copy-paste-ready command rather than a
|
|
649
|
+
// generic "authoring check failed" message.
|
|
650
|
+
function reasonHint(output, targetRoot) {
|
|
651
|
+
const text = String(output || '');
|
|
652
|
+
const match = text.match(/^Reasons:\s*(.+)$/m);
|
|
653
|
+
const reasons = match ? match[1].split(',').map(r => r.trim()).filter(Boolean) : [];
|
|
654
|
+
// JSON.stringify embeds the path so spaces and quotes survive shell re-use.
|
|
655
|
+
const targetArg = JSON.stringify(targetRoot);
|
|
656
|
+
// First-match-wins ordering: more specific reasons before generic.
|
|
657
|
+
if (reasons.includes('lsp_instance_identity_mismatch')) {
|
|
658
|
+
return `Cross-worktree LSP from another checkout. Run \`gdh lsp prune --target ${targetArg}\` then retry the edit.`;
|
|
659
|
+
}
|
|
660
|
+
if (reasons.includes('lsp_instance_not_running')) {
|
|
661
|
+
return `LSP launching in background; next edit will report fresh diagnostics. Force-prime: \`gdh authoring diagnostics refresh --target ${targetArg}\``;
|
|
393
662
|
}
|
|
663
|
+
if (reasons.includes('broker_not_yet_primed') || reasons.includes('file_not_in_snapshot')) {
|
|
664
|
+
return `Broker priming in background; next edit will be fresh. Force-prime: \`gdh authoring diagnostics refresh --target ${targetArg}\``;
|
|
665
|
+
}
|
|
666
|
+
if (reasons.includes('content_hash_mismatch') || reasons.includes('freshness_expired')) {
|
|
667
|
+
return `Broker snapshot stale; background refresh running. Force-prime: \`gdh authoring diagnostics refresh --target ${targetArg}\``;
|
|
668
|
+
}
|
|
669
|
+
return `Run \`gdh authoring check --mode final --target ${targetArg}\` to prime broker before claiming code-validity.`;
|
|
670
|
+
}
|
|
671
|
+
// Codex always receives hookSpecificOutput.additionalContext (any event — Codex's
|
|
672
|
+
// hook contract accepts it on PreToolUse / PostToolUse / Stop / etc.).
|
|
673
|
+
//
|
|
674
|
+
// Claude receives hookSpecificOutput.additionalContext on PostToolUse / PostToolBatch
|
|
675
|
+
// / FileChanged. Claude Stop is silent-allow because the Stop hook schema does NOT
|
|
676
|
+
// accept additionalContext (only decision/reason/continue/stopReason/suppressOutput/
|
|
677
|
+
// systemMessage). The previous gate emitted the wrong shape on every Claude Stop
|
|
678
|
+
// and Claude rejected it as "invalid stop hook JSON output" (quick task 260504-o6w
|
|
679
|
+
// bug B, verified live on TheBeacon 2026-05-04).
|
|
680
|
+
function context(additionalContext) {
|
|
681
|
+
// Claude: Stop hook schema does NOT accept hookSpecificOutput.additionalContext
|
|
682
|
+
// (only decision/reason/continue/stopReason/suppressOutput/systemMessage). Emitting
|
|
683
|
+
// the wrong shape causes Claude to reject the hook with "invalid stop hook JSON
|
|
684
|
+
// output" — verified locally 2026-05-04 (quick task 260504-o6w bug B). Drop to
|
|
685
|
+
// silent-allow on Claude Stop. Claude PostToolUse / PostToolBatch DO accept
|
|
686
|
+
// additionalContext, so those branches still flow through. Codex accepts
|
|
687
|
+
// additionalContext on every event.
|
|
688
|
+
if (AGENT !== 'codex' && CURRENT_EVENT === 'Stop') return allow();
|
|
394
689
|
const payload = { hookSpecificOutput: { hookEventName: CURRENT_EVENT, additionalContext } };
|
|
395
690
|
process.stdout.write(`${JSON.stringify(payload)}\n`);
|
|
396
691
|
process.exit(0);
|