@skillcap/gdh 0.26.1 → 0.26.3

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 +370 -88
  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']);
@@ -49,32 +58,36 @@ function handlePostEdit(input, targetRoot) {
49
58
  // within CHECK_TIMEOUT_MS regardless of warmup outcome (Pitfall 1: detached +
50
59
  // stdio:'ignore' + .unref() means the parent never blocks on the child).
51
60
  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.`);
61
+ // Quick task 260504-ix2: embedded broker-snapshot reader. Replaces the
62
+ // per-edit synchronous `npx -y @skillcap/gdh@PINNED authoring check ...`
63
+ // shellout (which was eating the full `CHECK_TIMEOUT_MS` budget on the npx
64
+ // bootstrap path even before any GDH code ran). The reader synthesises the
65
+ // same `[fresh]` / `[stale]` / `[pending]` bracketed-token vocabulary the
66
+ // existing dispatch already consumes, so the regex ladder below is unchanged.
67
+ let output;
68
+ try {
69
+ output = runEmbeddedDiagnosticsRead(targetRoot, authoring);
70
+ } catch (_err) {
71
+ return context('GDH post-edit hook embedded read threw; continuing without blocking. Run `gdh authoring check --mode final` before claiming code-validity.');
60
72
  }
61
73
  // Phase 81 / LSP-06 / D-01: dispatch on bracketed-token vocabulary.
62
74
  // Pitfall 5: anchor regex to line-start with /m flag so per-diagnostic
63
75
  // [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));
76
+ // The embedded reader does not currently emit `[failed]` / `[partial]` /
77
+ // `[timeout]` (those statuses come from the legacy CLI compact formatter),
78
+ // but the dispatch retains them as forward-compat branches in case a future
79
+ // reader extension surfaces them.
80
+ if (/^\[failed\]/m.test(output) || /^\[partial\]/m.test(output)) {
81
+ return block(formatBlockingReason(output));
66
82
  }
67
- if (/^\[fresh\]/m.test(result.output)) {
83
+ if (/^\[fresh\]/m.test(output)) {
68
84
  return allow();
69
85
  }
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.')}`);
86
+ if (/^\[pending\]/m.test(output) || /^\[stale\]/m.test(output)) {
87
+ 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.')}`);
75
88
  }
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.')}`);
89
+ if (/^\[timeout\]/m.test(output)) {
90
+ return context(`GDH post-edit authoring check returned a timeout status; continuing without blocking. ${compactOneLine(output || 'Run final authoring validation before claiming code-validity.')}`);
78
91
  }
79
92
  return allow();
80
93
  }
@@ -122,58 +135,45 @@ function handlePostToolBatch(input, targetRoot) {
122
135
  // returns warming without spawning a second Godot.
123
136
  spawnDetachedWarmup(targetRoot);
124
137
 
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
- );
133
-
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.`);
138
+ // Quick task 260504-ix2: embedded broker-snapshot reader replaces the
139
+ // synchronous `npx ... authoring check` shellout. The reader is invoked
140
+ // ONCE with the full deduplicated authoring set — the LSP-09 coalescing
141
+ // invariant is preserved: the loop in `runEmbeddedDiagnosticsRead` walks
142
+ // every file but returns on the first non-fresh status (first-failure-wins
143
+ // is the documented contract; a [stale] in any one file routes the batch
144
+ // through the drift dispatch branch).
145
+ let output;
146
+ try {
147
+ output = runEmbeddedDiagnosticsRead(targetRoot, authoring);
148
+ } catch (_err) {
149
+ return context('GDH PostToolBatch hook embedded read threw; continuing without blocking. Run `gdh authoring check --mode final` before claiming code-validity.');
136
150
  }
151
+
137
152
  // Bracketed-token dispatch — IDENTICAL to handlePostEdit. Pitfall 5 from Phase 81
138
153
  // 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));
154
+ // severity brackets do not match the line-leading status token. Forward-compat
155
+ // [partial] / [timeout] branches retained per quick-task 260504-ix2 plan note.
156
+ if (/^\[failed\]/m.test(output) || /^\[partial\]/m.test(output)) {
157
+ return block(formatBlockingReason(output));
142
158
  }
143
- if (/^\[fresh\]/m.test(result.output)) {
159
+ if (/^\[fresh\]/m.test(output)) {
144
160
  return allow();
145
161
  }
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.')}`);
162
+ if (/^\[pending\]/m.test(output) || /^\[stale\]/m.test(output)) {
163
+ 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.')}`);
151
164
  }
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.')}`);
165
+ if (/^\[timeout\]/m.test(output)) {
166
+ return context(`GDH PostToolBatch authoring check returned a timeout status; continuing without blocking. ${compactOneLine(output || 'Run final authoring validation before claiming code-validity.')}`);
154
167
  }
155
168
  return allow();
156
169
  }
157
170
 
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
- }
171
+ // Quick task 260504-ix2: `runGdh` deleted. The function previously wrapped
172
+ // `spawnSync('npx', ['-y', '@skillcap/gdh@PINNED', ...args])` for the three
173
+ // event handlers (handlePostEdit, handlePostToolBatch, handleStop). All three
174
+ // now call `runEmbeddedDiagnosticsRead` instead. `spawnDetachedWarmup` uses
175
+ // `child_process.spawn` directly (always did — it was never a `runGdh`
176
+ // caller), so removing the helper has no effect on the warmup path.
177
177
 
178
178
  // Phase 82 / LSP-02. Fire-and-forget warmup spawn from the post-edit hook. The
179
179
  // hook returns within CHECK_TIMEOUT_MS regardless of whether warmup has finished
@@ -202,24 +202,48 @@ function spawnDetachedWarmup(targetRoot) {
202
202
  }
203
203
  }
204
204
 
205
- // Phase 82 / LSP-03. Session-end ("Stop") freshness barrier.
205
+ // Phase 82 / LSP-03 + Quick task 260504-ix2 Stop-hook contract relaxation.
206
+ //
207
+ // Original Phase 82 contract: Stop synchronously invoked
208
+ // `gdh authoring check --mode final`, which calls
209
+ // `getManagedLspStatus(launch_if_needed)` and could spawn Godot LSP from the
210
+ // Stop event. Real-world dogfooding (2026-05-04 session) showed this path
211
+ // always timed out due to per-invocation `npx -y @skillcap/gdh@PINNED` cost
212
+ // (~9.3 s on the operator's measured machine). The hook ate the full
213
+ // `STOP_TIMEOUT_MS` budget on the npx bootstrap path before producing any
214
+ // output.
206
215
  //
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)
216
+ // Updated contract (RFC 0009 addendum): Stop reads the existing broker
217
+ // snapshot via the embedded dependency-free reader; it does NOT synchronously
218
+ // launch LSP. Cold/missing-broker case returns `additionalContext`
219
+ // recommending the user run `gdh authoring check --mode final` manually AND
220
+ // spawns detached warmup so the next session's PostToolUse events have a
221
+ // primed broker. This trades the rare "fresh session, no warm broker" case
222
+ // (where Stop would have produced final diagnostics synchronously) for the
223
+ // common case of sub-100ms hook return on every Stop.
212
224
  //
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).
225
+ // Hard invariants preserved:
226
+ // - GF3 (260504): zero authoring-files-in-target noop (git gate, runs first)
227
+ // - Pitfall 3: NEVER returns decision: block (Stop hook must not block session end)
228
+ // - Pitfall 5: Claude additionalContext lives in hookSpecificOutput
229
+ // - Pitfall 6: additionalContext bounded to MAX_STOP_CONTEXT_CHARS = 8000
217
230
  function handleStop(input, targetRoot) {
218
- const result = runGdh(
219
- targetRoot,
220
- ['authoring', 'check', '--target', targetRoot, '--mode', 'final', '--format', 'compact'],
221
- { timeoutMs: STOP_TIMEOUT_MS },
222
- );
231
+ if (!hasAuthoringWorkInTarget(targetRoot)) return allow();
232
+
233
+ // Discover the authoring file set from the working tree under targetRoot.
234
+ // We feed this into the embedded reader so it can resolve broker entries
235
+ // by file:// URI. If the discovery fails (git unavailable, etc.) we fall
236
+ // through with an empty list, which routes to a `[pending]` status with
237
+ // reason `file_not_in_snapshot` — same effect as no broker primed.
238
+ const authoringFiles = listAuthoringFilesInTarget(targetRoot);
239
+
240
+ let output;
241
+ try {
242
+ output = runEmbeddedDiagnosticsRead(targetRoot, authoringFiles);
243
+ } catch (_err) {
244
+ output = '[pending]\nReasons: embedded_read_threw';
245
+ }
246
+
223
247
  const truncatedSuffix = ' Run `gdh authoring check --mode final` for full output.';
224
248
  function stopContext(message) {
225
249
  const text = String(message || '').trim();
@@ -228,33 +252,254 @@ function handleStop(input, targetRoot) {
228
252
  const headroom = ceiling - truncatedSuffix.length;
229
253
  return context(text.slice(0, headroom).trimEnd() + truncatedSuffix);
230
254
  }
231
- if (result.timedOut) {
232
- return stopContext(
233
- `GDH session-end check timed out after ${STOP_TIMEOUT_MS}ms; continuing without blocking.` +
234
- ' Run `gdh authoring check --mode final` manually before claiming code-validity.',
235
- );
236
- }
255
+
237
256
  // Bracketed-token dispatch — same vocabulary as PostToolUse but EVERY branch
238
257
  // routes to context() or allow(); decision: block is forbidden on Stop.
239
- if (/^\[fresh\]/m.test(result.output)) {
258
+ // Forward-compat [partial] / [timeout] / [failed] branches retained for
259
+ // potential future reader extensions; the embedded reader currently emits
260
+ // only [fresh] / [stale] / [pending].
261
+ if (/^\[fresh\]/m.test(output)) {
240
262
  return allow();
241
263
  }
242
- if (/^\[failed\]/m.test(result.output) || /^\[partial\]/m.test(result.output) || /^\[stale\]/m.test(result.output)) {
243
- return stopContext(`GDH session-end check found unresolved diagnostics: ${compactOneLine(result.output)}`);
264
+ if (/^\[failed\]/m.test(output) || /^\[partial\]/m.test(output) || /^\[stale\]/m.test(output)) {
265
+ return stopContext(`GDH session-end check found unresolved diagnostics: ${compactOneLine(output)}`);
244
266
  }
245
- if (/^\[pending\]/m.test(result.output) || /^\[timeout\]/m.test(result.output)) {
267
+ if (/^\[pending\]/m.test(output) || /^\[timeout\]/m.test(output)) {
268
+ // Cold/missing broker: spawn detached warmup so the NEXT session benefits.
269
+ // The current Stop returns additionalContext recommending manual
270
+ // `gdh authoring check --mode final` (the relaxed contract — see RFC 0009
271
+ // addendum 2026-05-04). spawnDetachedWarmup is fire-and-forget; it never
272
+ // blocks hook return.
273
+ spawnDetachedWarmup(targetRoot);
246
274
  return stopContext(
247
- 'GDH session-end check did not produce a final result before time bound; continuing without blocking. ' +
248
- compactOneLine(result.output || 'Run final authoring validation before claiming code-validity.'),
275
+ 'GDH session-end check could not prove final code-validity from the cached broker snapshot; continuing without blocking. ' +
276
+ 'Run `gdh authoring check --mode final` manually before claiming code-validity. ' +
277
+ compactOneLine(output),
249
278
  );
250
279
  }
251
- // Unknown / runtime error: degrade to additionalContext (NEVER block).
280
+ // Unknown status: degrade to additionalContext (NEVER block).
281
+ spawnDetachedWarmup(targetRoot);
252
282
  return stopContext(
253
283
  'GDH session-end check could not produce a final result; continuing without blocking. ' +
254
- compactOneLine(result.output || result.error || 'Run final authoring validation before claiming code-validity.'),
284
+ 'Run `gdh authoring check --mode final` manually before claiming code-validity. ' +
285
+ compactOneLine(output),
255
286
  );
256
287
  }
257
288
 
289
+ // Quick task 260504-ix2: collect authoring-relevant files in the working tree
290
+ // under `targetRoot`. Sibling helper to `hasAuthoringWorkInTarget` (which
291
+ // returns a boolean for the GF3 gate); this returns the list of files for the
292
+ // embedded broker-snapshot reader to look up by file:// URI. Order-stable.
293
+ //
294
+ // Fail-open: returns [] on any git failure (the embedded reader will then emit
295
+ // `[pending]` reasons, which Stop dispatches to additionalContext — never
296
+ // block).
297
+ function listAuthoringFilesInTarget(targetRoot) {
298
+ let result;
299
+ try {
300
+ result = spawnSync('git', ['-C', targetRoot, 'status', '--porcelain', '--', '.'], {
301
+ encoding: 'utf8',
302
+ timeout: 1500,
303
+ windowsHide: true,
304
+ });
305
+ } catch (_error) {
306
+ return [];
307
+ }
308
+ if (!result || result.error || result.status !== 0 || result.signal) return [];
309
+ const files = [];
310
+ for (const raw of String(result.stdout || '').split('\n')) {
311
+ if (!raw || raw.length < 4) continue;
312
+ let rest = raw.slice(3);
313
+ const arrow = rest.indexOf(' -> ');
314
+ if (arrow !== -1) rest = rest.slice(arrow + 4);
315
+ if (rest.startsWith('"') && rest.endsWith('"')) rest = rest.slice(1, -1);
316
+ if (isAuthoringValidationPath(rest)) files.push(rest);
317
+ }
318
+ return unique(files);
319
+ }
320
+
321
+ // Quick task 260504-ix2: dependency-free embedded broker-snapshot reader.
322
+ //
323
+ // Replaces the per-edit `npx -y @skillcap/gdh@PINNED authoring check ...`
324
+ // shellout (which paid the ~9.3 s npx bootstrap cost on every hook fire).
325
+ // Reads `<stateRoot>/authoring/lsp-instance.json` and
326
+ // `<stateRoot>/authoring/diagnostics-broker/snapshot.json` directly,
327
+ // validates LSP instance identity + per-file SHA-256 content hash +
328
+ // freshness window, and synthesises the same bracketed-token output the
329
+ // existing dispatch consumes (`[fresh]` / `[stale]` / `[pending]`).
330
+ //
331
+ // Reasons vocabulary (kept aligned with packages/authoring/src/lsp.ts):
332
+ // lsp_instance_not_running, lsp_instance_parse_error,
333
+ // broker_not_yet_primed, snapshot_parse_error, unsupported_broker_schema,
334
+ // lsp_instance_identity_mismatch, content_hash_mismatch, freshness_expired,
335
+ // file_not_in_snapshot, state_root_unresolved.
336
+ //
337
+ // Hash parity: the broker writer (`packages/authoring/src/diagnostics-broker.ts`
338
+ // `hashText`) and the cache validator (`packages/authoring/src/lsp.ts`
339
+ // `hashFilesByUri`) both read files via `fs.readFile(path, "utf8")` and
340
+ // SHA-256 the resulting string. The embedded reader MUST do the same so a
341
+ // fresh snapshot covering an unmodified file matches by content hash.
342
+ function runEmbeddedDiagnosticsRead(targetRoot, authoringFiles) {
343
+ const stateRoot = resolveStateRoot(targetRoot);
344
+ if (stateRoot === null) {
345
+ return formatStatus('pending', ['state_root_unresolved']);
346
+ }
347
+
348
+ const lspInstancePath = path.join(stateRoot, 'authoring', 'lsp-instance.json');
349
+ let lspInstance;
350
+ try {
351
+ const raw = fs.readFileSync(lspInstancePath, 'utf8');
352
+ lspInstance = JSON.parse(raw);
353
+ } catch (err) {
354
+ if (err && err.code === 'ENOENT') return formatStatus('pending', ['lsp_instance_not_running']);
355
+ return formatStatus('pending', ['lsp_instance_parse_error']);
356
+ }
357
+ const expectedInstanceId = lspInstance && lspInstance.instance && lspInstance.instance.instanceId;
358
+ if (typeof expectedInstanceId !== 'string' || expectedInstanceId.length === 0) {
359
+ return formatStatus('pending', ['lsp_instance_not_running']);
360
+ }
361
+
362
+ const snapshotPath = path.join(stateRoot, 'authoring', 'diagnostics-broker', 'snapshot.json');
363
+ let snapshot;
364
+ try {
365
+ const raw = fs.readFileSync(snapshotPath, 'utf8');
366
+ snapshot = JSON.parse(raw);
367
+ } catch (err) {
368
+ if (err && err.code === 'ENOENT') return formatStatus('pending', ['broker_not_yet_primed']);
369
+ return formatStatus('pending', ['snapshot_parse_error']);
370
+ }
371
+ if (!snapshot || snapshot.schemaVersion !== 1) {
372
+ return formatStatus('pending', ['unsupported_broker_schema']);
373
+ }
374
+ if (snapshot.lspInstanceId !== expectedInstanceId) {
375
+ return formatStatus('stale', ['lsp_instance_identity_mismatch']);
376
+ }
377
+
378
+ const filesByUri = new Map();
379
+ if (Array.isArray(snapshot.files)) {
380
+ for (const f of snapshot.files) if (f && typeof f.uri === 'string') filesByUri.set(f.uri, f);
381
+ }
382
+
383
+ // Match the constant in `packages/authoring/src/lsp.ts`
384
+ // (`DIAGNOSTICS_BROKER_DEFAULT_FRESHNESS_STALE_AFTER_MS`). Hard-coded here
385
+ // because the hook is dependency-free and cannot import @gdh/authoring.
386
+ const STALE_AFTER_MS = 60000;
387
+ const nowMs = Date.now();
388
+ const allDiagnostics = [];
389
+
390
+ for (const relPath of authoringFiles) {
391
+ // Match the broker writer's URI canonicalisation: it calls
392
+ // `fs.realpath` on each requested file before `pathToFileURL.href`,
393
+ // which collapses macOS `/var/folders` → `/private/var/folders`
394
+ // symlinks (and similar real-symlink layouts on Linux/Windows). The
395
+ // hook's `__dirname` already comes through canonicalised by Node's
396
+ // module loader, but the legacy `path.resolve` in the resolver does
397
+ // NOT walk symlinks. Use `canonicalPath` (already-defined helper) so
398
+ // the URIs match a snapshot captured by `hashFilesByUri`.
399
+ const absolutePath = canonicalPath(path.resolve(targetRoot, relPath));
400
+ const expectedUri = pathToFileURL(absolutePath).href;
401
+ const entry = filesByUri.get(expectedUri);
402
+ if (!entry) return formatStatus('pending', ['file_not_in_snapshot']);
403
+
404
+ if (typeof entry.contentHash !== 'string' || entry.contentHash.length === 0) {
405
+ // Pre-content-hash snapshot entries cannot prove current file content.
406
+ // Plan info-note: route through `[pending]` (not `[stale]`) — the broker
407
+ // is not yet fully primed for this file even if the snapshot exists.
408
+ return formatStatus('pending', ['broker_not_yet_primed']);
409
+ }
410
+
411
+ let onDiskHash;
412
+ try {
413
+ const text = fs.readFileSync(absolutePath, 'utf8');
414
+ onDiskHash = crypto.createHash('sha256').update(text).digest('hex');
415
+ } catch (_err) {
416
+ // File deleted between hook trigger and read, or unreadable encoding.
417
+ // Fallthrough: treat as not-in-snapshot so we degrade to additionalContext.
418
+ return formatStatus('pending', ['file_not_in_snapshot']);
419
+ }
420
+ if (onDiskHash !== entry.contentHash) {
421
+ return formatStatus('stale', ['content_hash_mismatch']);
422
+ }
423
+
424
+ const updatedMs = Date.parse(entry.lastUpdatedAt);
425
+ if (!Number.isFinite(updatedMs) || (nowMs - updatedMs) > STALE_AFTER_MS) {
426
+ return formatStatus('stale', ['freshness_expired']);
427
+ }
428
+
429
+ if (Array.isArray(entry.diagnostics)) {
430
+ for (const diag of entry.diagnostics) {
431
+ allDiagnostics.push({ uri: entry.uri, diag });
432
+ }
433
+ }
434
+ }
435
+
436
+ return formatFreshDiagnostics(allDiagnostics);
437
+ }
438
+
439
+ function formatStatus(token, reasons) {
440
+ return `[${token}]\nReasons: ${reasons.join(', ')}`;
441
+ }
442
+
443
+ function formatFreshDiagnostics(items) {
444
+ let errors = 0;
445
+ let warnings = 0;
446
+ let infoCount = 0;
447
+ const lines = [];
448
+ for (const item of items) {
449
+ const diag = item.diag;
450
+ const sev = (diag && typeof diag.severity === 'string') ? diag.severity : 'info';
451
+ if (sev === 'error') errors += 1;
452
+ else if (sev === 'warning') warnings += 1;
453
+ else infoCount += 1;
454
+ const line = (diag && diag.range && diag.range.start && Number.isFinite(diag.range.start.line)) ? diag.range.start.line + 1 : 0;
455
+ const col = (diag && diag.range && diag.range.start && Number.isFinite(diag.range.start.character)) ? diag.range.start.character + 1 : 0;
456
+ const msg = (diag && typeof diag.message === 'string') ? diag.message : '';
457
+ lines.push(`${item.uri}:${line}:${col} [${sev}] ${msg}`);
458
+ }
459
+ // Mirror packages/authoring/src/diagnostics-summary.ts: with the default
460
+ // "error" severity policy, ANY error diagnostic produces a `blocked` summary
461
+ // → CLI emits `[failed]` → hook dispatch routes to `block()`. The embedded
462
+ // reader must produce the same status token so the hook still blocks edits
463
+ // that introduce errors. Warnings and info do not alter the status token.
464
+ const statusToken = errors > 0 ? 'failed' : 'fresh';
465
+ const completion = errors > 0 ? 'blocked' : 'allowed';
466
+ const head = lines.length ? `${lines.join('\n')}\n` : '';
467
+ return `${head}[${statusToken}] ${errors} errors, ${warnings} warnings, ${infoCount} info. Completion ${completion}.`;
468
+ }
469
+
470
+ function resolveStateRoot(targetRoot) {
471
+ // 1. Baked render-time path — the canonical resolution for installed hooks.
472
+ try {
473
+ const baked = path.resolve(__dirname, STATE_RELATIVE_PATH_FROM_HOOK);
474
+ if (fs.existsSync(baked)) return baked;
475
+ } catch (_err) {
476
+ // Fall through to walk-up fallbacks if the baked path is malformed.
477
+ }
478
+ // 2. Walk up from __dirname looking for `.gdh/project.yaml`. Useful when the
479
+ // hook script has been moved or the bake-time relative path is wrong.
480
+ let dir = __dirname;
481
+ for (let i = 0; i < 10; i += 1) {
482
+ if (fs.existsSync(path.join(dir, '.gdh', 'project.yaml'))) {
483
+ return path.join(dir, '.gdh-state');
484
+ }
485
+ const parent = path.dirname(dir);
486
+ if (parent === dir) break;
487
+ dir = parent;
488
+ }
489
+ // 3. Walk up from targetRoot — covers root-launched cases where the hook
490
+ // might be loaded from outside the project tree.
491
+ dir = targetRoot;
492
+ for (let i = 0; i < 10; i += 1) {
493
+ if (fs.existsSync(path.join(dir, '.gdh', 'project.yaml'))) {
494
+ return path.join(dir, '.gdh-state');
495
+ }
496
+ const parent = path.dirname(dir);
497
+ if (parent === dir) break;
498
+ dir = parent;
499
+ }
500
+ return null;
501
+ }
502
+
258
503
  function collectChangedFiles(input, targetRoot) {
259
504
  const baseCwd = input && typeof input.cwd === 'string' ? input.cwd : process.cwd();
260
505
  const files = [];
@@ -310,6 +555,43 @@ function isAuthoringValidationPath(file) {
310
555
  return AUTHORING_EXTENSIONS.has(path.extname(file).toLowerCase());
311
556
  }
312
557
 
558
+ // GF3 (260504): session-end git-gate. Stop hook noops when zero authoring-file
559
+ // changes exist in the working tree under targetRoot. Fails closed (returns
560
+ // true) on any git failure so the freshness barrier never silently weakens.
561
+ //
562
+ // Honors GDH_AUTHORING_STOP_GIT_GATE=0 as an ops escape valve.
563
+ function hasAuthoringWorkInTarget(targetRoot) {
564
+ if (process.env['GDH_AUTHORING_STOP_GIT_GATE'] === '0') return true;
565
+ let result;
566
+ try {
567
+ result = spawnSync('git', ['-C', targetRoot, 'status', '--porcelain', '--', '.'], {
568
+ encoding: 'utf8',
569
+ timeout: 1500,
570
+ windowsHide: true,
571
+ });
572
+ } catch (_error) {
573
+ return true; // fail-closed: spawn threw (e.g. git missing on PATH)
574
+ }
575
+ if (!result || result.error || result.status !== 0 || result.signal) return true;
576
+ const lines = String(result.stdout || '').split('\n');
577
+ for (const raw of lines) {
578
+ if (!raw) continue;
579
+ // Porcelain v1: 2 status chars + space + path (or path -> path for renames).
580
+ // Skip lines shorter than the minimum "XY P" shape.
581
+ if (raw.length < 4) continue;
582
+ let rest = raw.slice(3);
583
+ // Renames / copies: "OLD -> NEW" — take NEW.
584
+ const arrow = rest.indexOf(' -> ');
585
+ if (arrow !== -1) rest = rest.slice(arrow + 4);
586
+ // Quoted paths (spaces / non-ASCII): strip surrounding quotes.
587
+ if (rest.startsWith('"') && rest.endsWith('"')) {
588
+ rest = rest.slice(1, -1);
589
+ }
590
+ if (isAuthoringValidationPath(rest)) return true;
591
+ }
592
+ return false;
593
+ }
594
+
313
595
  function resolveTargetRoot(input) {
314
596
  const base = path.resolve(__dirname, '..', '..');
315
597
  const fromScript = path.resolve(base, TARGET_RELATIVE_PATH || '.');
@@ -11,13 +11,13 @@
11
11
  }
12
12
  },
13
13
  "dependencies": {
14
- "@gdh/authoring": "0.26.1",
15
- "@gdh/core": "0.26.1",
16
- "@gdh/docs": "0.26.1",
17
- "@gdh/observability": "0.26.1",
18
- "@gdh/runtime": "0.26.1",
19
- "@gdh/scan": "0.26.1",
20
- "@gdh/verify": "0.26.1"
14
+ "@gdh/authoring": "0.26.3",
15
+ "@gdh/core": "0.26.3",
16
+ "@gdh/docs": "0.26.3",
17
+ "@gdh/observability": "0.26.3",
18
+ "@gdh/runtime": "0.26.3",
19
+ "@gdh/scan": "0.26.3",
20
+ "@gdh/verify": "0.26.3"
21
21
  },
22
- "version": "0.26.1"
22
+ "version": "0.26.3"
23
23
  }
@@ -14,7 +14,7 @@
14
14
  "test": "vitest run"
15
15
  },
16
16
  "dependencies": {
17
- "@gdh/core": "0.26.1"
17
+ "@gdh/core": "0.26.3"
18
18
  },
19
- "version": "0.26.1"
19
+ "version": "0.26.3"
20
20
  }
@@ -15,16 +15,16 @@
15
15
  },
16
16
  "dependencies": {
17
17
  "@clack/prompts": "^1.2.0",
18
- "@gdh/adapters": "0.26.1",
19
- "@gdh/authoring": "0.26.1",
20
- "@gdh/core": "0.26.1",
21
- "@gdh/docs": "0.26.1",
22
- "@gdh/mcp": "0.26.1",
23
- "@gdh/observability": "0.26.1",
24
- "@gdh/runtime": "0.26.1",
25
- "@gdh/scan": "0.26.1",
26
- "@gdh/verify": "0.26.1",
18
+ "@gdh/adapters": "0.26.3",
19
+ "@gdh/authoring": "0.26.3",
20
+ "@gdh/core": "0.26.3",
21
+ "@gdh/docs": "0.26.3",
22
+ "@gdh/mcp": "0.26.3",
23
+ "@gdh/observability": "0.26.3",
24
+ "@gdh/runtime": "0.26.3",
25
+ "@gdh/scan": "0.26.3",
26
+ "@gdh/verify": "0.26.3",
27
27
  "picocolors": "^1.1.1"
28
28
  },
29
- "version": "0.26.1"
29
+ "version": "0.26.3"
30
30
  }
@@ -55,7 +55,7 @@ export declare const GDH_AUTHORING_DOGFOOD_VERSION = 1;
55
55
  export declare const GDH_AUTHORING_SLICE_REPORT_VERSION = 1;
56
56
  export declare const GDH_MCP_MANIFEST_VERSION = 1;
57
57
  export declare const GDH_CURSOR_RULE_VERSION = 4;
58
- export declare const GDH_UPDATE_HOOK_VERSION = 10;
58
+ export declare const GDH_UPDATE_HOOK_VERSION = 12;
59
59
  export declare const GDH_RUNTIME_RECIPE_RUN_VERSION = 1;
60
60
  export declare const GDH_RUNTIME_RUN_BUNDLE_VERSION = 1;
61
61
  export declare const GDH_RUNTIME_CORPUS_ARTIFACT_VERSION = 1;
@@ -47,7 +47,7 @@ export const GDH_AUTHORING_DOGFOOD_VERSION = 1;
47
47
  export const GDH_AUTHORING_SLICE_REPORT_VERSION = 1;
48
48
  export const GDH_MCP_MANIFEST_VERSION = 1;
49
49
  export const GDH_CURSOR_RULE_VERSION = 4;
50
- export const GDH_UPDATE_HOOK_VERSION = 10;
50
+ export const GDH_UPDATE_HOOK_VERSION = 12;
51
51
  export const GDH_RUNTIME_RECIPE_RUN_VERSION = 1;
52
52
  export const GDH_RUNTIME_RUN_BUNDLE_VERSION = 1;
53
53
  export const GDH_RUNTIME_CORPUS_ARTIFACT_VERSION = 1;
@@ -10,5 +10,5 @@
10
10
  "import": "./dist/index.js"
11
11
  }
12
12
  },
13
- "version": "0.26.1"
13
+ "version": "0.26.3"
14
14
  }
@@ -11,8 +11,8 @@
11
11
  }
12
12
  },
13
13
  "dependencies": {
14
- "@gdh/core": "0.26.1",
14
+ "@gdh/core": "0.26.3",
15
15
  "yaml": "^2.8.3"
16
16
  },
17
- "version": "0.26.1"
17
+ "version": "0.26.3"
18
18
  }