@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.
Files changed (27) hide show
  1. package/INSTALL-BUNDLE.json +1 -1
  2. package/RELEASE-SPAN-UPDATE-CONTRACTS.json +115 -0
  3. package/node_modules/@gdh/adapters/dist/authoring-hook-render.d.ts +13 -0
  4. package/node_modules/@gdh/adapters/dist/authoring-hook-render.d.ts.map +1 -1
  5. package/node_modules/@gdh/adapters/dist/authoring-hook-render.js +1 -0
  6. package/node_modules/@gdh/adapters/dist/authoring-hook-render.js.map +1 -1
  7. package/node_modules/@gdh/adapters/dist/authoring-hook-state-path.d.ts +17 -0
  8. package/node_modules/@gdh/adapters/dist/authoring-hook-state-path.d.ts.map +1 -0
  9. package/node_modules/@gdh/adapters/dist/authoring-hook-state-path.js +23 -0
  10. package/node_modules/@gdh/adapters/dist/authoring-hook-state-path.js.map +1 -0
  11. package/node_modules/@gdh/adapters/dist/index.d.ts.map +1 -1
  12. package/node_modules/@gdh/adapters/dist/index.js +36 -0
  13. package/node_modules/@gdh/adapters/dist/index.js.map +1 -1
  14. package/node_modules/@gdh/adapters/dist/templates/authoring-hook.js.tpl +407 -112
  15. package/node_modules/@gdh/adapters/package.json +8 -8
  16. package/node_modules/@gdh/authoring/package.json +2 -2
  17. package/node_modules/@gdh/cli/package.json +10 -10
  18. package/node_modules/@gdh/core/dist/index.d.ts +1 -1
  19. package/node_modules/@gdh/core/dist/index.js +1 -1
  20. package/node_modules/@gdh/core/package.json +1 -1
  21. package/node_modules/@gdh/docs/package.json +2 -2
  22. package/node_modules/@gdh/mcp/package.json +8 -8
  23. package/node_modules/@gdh/observability/package.json +2 -2
  24. package/node_modules/@gdh/runtime/package.json +2 -2
  25. package/node_modules/@gdh/scan/package.json +3 -3
  26. package/node_modules/@gdh/verify/package.json +7 -7
  27. 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 warmup BEFORE the bounded check. The verb
47
- // is idempotent (lsp.lock single-attempt; in-flight warmups return "warming"
48
- // without re-spawning) so spamming on every edit is safe. Hook still returns
49
- // within CHECK_TIMEOUT_MS regardless of warmup outcome (Pitfall 1: detached +
50
- // stdio:'ignore' + .unref() means the parent never blocks on the child).
51
- spawnDetachedWarmup(targetRoot);
52
- const changedArgs = authoring.flatMap((file) => ['--changed', file]);
53
- // Run one compact fast check. Post-edit checks reuse a running LSP if present,
54
- // trust only content-matched broker diagnostics, and avoid cold Godot startup.
55
- const result = runGdh(targetRoot, ['authoring', 'check', '--target', targetRoot, '--mode', 'post-edit', '--format', 'compact', ...changedArgs], {
56
- timeoutMs: CHECK_TIMEOUT_MS,
57
- });
58
- if (result.timedOut) {
59
- return context(`GDH post-edit authoring check timed out after ${CHECK_TIMEOUT_MS}ms; continuing without blocking. Run \`gdh authoring check --mode final\` before claiming code-validity.`);
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
- if (/^\[failed\]/m.test(result.output) || /^\[partial\]/m.test(result.output)) {
65
- return block(formatBlockingReason(result.output));
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(result.output)) {
85
+ if (/^\[fresh\]/m.test(output)) {
68
86
  return allow();
69
87
  }
70
- if (/^\[pending\]/m.test(result.output) || /^\[stale\]/m.test(result.output)) {
71
- return context(`GDH post-edit authoring check could not prove this edit quickly; continuing without blocking. ${compactOneLine(result.output || 'Run final authoring validation before claiming code-validity.')}`);
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 (!result.ok) {
77
- return context(`GDH post-edit authoring check failed before producing diagnostics; continuing without blocking. ${compactOneLine(result.error || result.output || 'Run final authoring validation before claiming code-validity.')}`);
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: spawnDetachedWarmup reuses Phase 82's lsp.lock-serialized helper. If a
121
- // per-edit warmup is already in flight when this fires, the lock primitive
122
- // returns warming without spawning a second Godot.
123
- spawnDetachedWarmup(targetRoot);
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
- if (result.timedOut) {
135
- return context(`GDH PostToolBatch authoring check timed out after ${CHECK_TIMEOUT_MS}ms; continuing without blocking. Run \`gdh authoring check --mode final\` before claiming code-validity.`);
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
- if (/^\[failed\]/m.test(result.output) || /^\[partial\]/m.test(result.output)) {
141
- return block(formatBlockingReason(result.output));
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(result.output)) {
161
+ if (/^\[fresh\]/m.test(output)) {
144
162
  return allow();
145
163
  }
146
- if (/^\[pending\]/m.test(result.output) || /^\[stale\]/m.test(result.output)) {
147
- return context(`GDH PostToolBatch authoring check could not prove this batch quickly; continuing without blocking. ${compactOneLine(result.output || 'Run final authoring validation before claiming code-validity.')}`);
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 (!result.ok) {
153
- return context(`GDH PostToolBatch authoring check failed before producing diagnostics; continuing without blocking. ${compactOneLine(result.error || result.output || 'Run final authoring validation before claiming code-validity.')}`);
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
- function runGdh(targetRoot, args, options = {}) {
159
- const result = spawnSync('npx', ['-y', `@skillcap/gdh@${PINNED_VERSION}`, ...args], {
160
- cwd: targetRoot,
161
- encoding: 'utf8',
162
- windowsHide: true,
163
- timeout: options.timeoutMs || CHECK_TIMEOUT_MS,
164
- });
165
- const output = `${result.stdout || ''}${result.stderr || ''}`.trim();
166
- const error = result.error ? String(result.error.message || result.error) : '';
167
- return {
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
- // Phase 82 / LSP-02. Fire-and-forget warmup spawn from the post-edit hook. The
179
- // hook returns within CHECK_TIMEOUT_MS regardless of whether warmup has finished
180
- // Godot startup typically takes 2-5 s, much longer than the 2500 ms cap.
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 spawnDetachedWarmup(targetRoot) {
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
- ['-y', `@skillcap/gdh@${PINNED_VERSION}`, 'lsp', 'warmup', '--target', targetRoot],
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 — warmup is fire-and-forget. The bounded check below still runs.
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. Session-end ("Stop") freshness barrier.
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
- // Hard contract:
208
- // - Runs gdh authoring check --mode final with STOP_TIMEOUT_MS bound (60 s default)
209
- // - Reports unresolved diagnostics as additionalContext, NEVER decision: block (Pitfall 3)
210
- // - Degrades to context() on subprocess timeout, error, or Godot crash
211
- // - additionalContext bounded to MAX_STOP_CONTEXT_CHARS = 8000 (Pitfall 6)
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
- // Stop hook waits for in-flight warmup naturally: --mode final calls
214
- // getManagedLspStatus(launch_if_needed) which acquires lsp.lock; if warmup is
215
- // holding the lock, the 5 s LSP_STATE_LOCK_ACQUIRE_TIMEOUT_MS retry loop inside
216
- // the lock primitive handles the wait (Pitfall 9 well within 60 s).
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
- const result = runGdh(
220
- targetRoot,
221
- ['authoring', 'check', '--target', targetRoot, '--mode', 'final', '--format', 'compact'],
222
- { timeoutMs: STOP_TIMEOUT_MS },
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
- if (result.timedOut) {
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
- if (/^\[fresh\]/m.test(result.output)) {
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(result.output) || /^\[partial\]/m.test(result.output) || /^\[stale\]/m.test(result.output)) {
244
- return stopContext(`GDH session-end check found unresolved diagnostics: ${compactOneLine(result.output)}`);
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(result.output) || /^\[timeout\]/m.test(result.output)) {
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 did not produce a final result before time bound; continuing without blocking. ' +
249
- compactOneLine(result.output || 'Run final authoring validation before claiming code-validity.'),
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 / runtime error: degrade to additionalContext (NEVER block).
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
- compactOneLine(result.output || result.error || 'Run final authoring validation before claiming code-validity.'),
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
- function context(additionalContext) {
386
- // Codex always receives hookSpecificOutput.additionalContext (any event).
387
- // Claude receives hookSpecificOutput.additionalContext on Stop (Phase 82 / LSP-03) and
388
- // on PostToolBatch (Phase 83 / LSP-09 — coalesced batch result). Without the third
389
- // branch below, [pending]/[stale]/[timeout] PostToolBatch results would silently drop
390
- // additionalContext on Claude Pitfall 1.
391
- if (AGENT !== 'codex') {
392
- if (CURRENT_EVENT !== 'Stop' && CURRENT_EVENT !== 'PostToolBatch') return allow();
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);