@hegemonart/get-design-done 1.27.1 → 1.27.6

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.
@@ -105,6 +105,76 @@ interface RuntimeDetectModule {
105
105
  }
106
106
  const runtimeDetect = nodeRequire('../scripts/lib/runtime-detect.cjs') as RuntimeDetectModule;
107
107
 
108
+ // Plan 27.5-01: bandit production-integration shim. Hides pull /
109
+ // pullWithDelegate choice from the hook; reads adaptive_mode + frontmatter
110
+ // tier_override under the same gating discipline as Phase 23.5 D-07 and
111
+ // Phase 27.5 D-05.
112
+ interface BanditIntegrationModule {
113
+ consultBandit(args: {
114
+ agent: string;
115
+ bin: string;
116
+ delegate: string;
117
+ agentFrontmatter: { tier_override?: string; default_tier?: string };
118
+ adaptiveMode?: 'static' | 'hedge' | 'full';
119
+ baseDir?: string;
120
+ posteriorPath?: string;
121
+ }): {
122
+ tier: 'haiku' | 'sonnet' | 'opus';
123
+ decision_log: {
124
+ source:
125
+ | 'frontmatter'
126
+ | 'tier_override_bypass'
127
+ | 'bandit_pull'
128
+ | 'bandit_pull_with_delegate';
129
+ samples?: Record<string, number> | Record<string, Record<string, number>>;
130
+ delegate?: string;
131
+ adaptive_mode: 'static' | 'hedge' | 'full';
132
+ reason?: string;
133
+ };
134
+ };
135
+ recordOutcome(args: unknown): void;
136
+ DELEGATE_NONE: 'none';
137
+ }
138
+ const banditIntegration = nodeRequire(
139
+ '../scripts/lib/bandit-router/integration.cjs',
140
+ ) as BanditIntegrationModule;
141
+
142
+ // Plan 27.5-02: adaptive-mode module surfaces the single gating predicate.
143
+ interface AdaptiveModeModule {
144
+ getMode(opts?: {
145
+ baseDir?: string;
146
+ budgetPath?: string;
147
+ quiet?: boolean;
148
+ }): 'static' | 'hedge' | 'full';
149
+ isBanditEnabled(opts?: { baseDir?: string; budgetPath?: string }): boolean;
150
+ }
151
+ const adaptiveMode = nodeRequire(
152
+ '../scripts/lib/adaptive-mode.cjs',
153
+ ) as AdaptiveModeModule;
154
+
155
+ // Plan 27.5-02: bin selection helper for bandit (agent, bin) addressing.
156
+ // budget-enforcer doesn't currently surface glob_count; default to 'medium'
157
+ // as a safe per-agent partition until a future plan wires the real count.
158
+ interface BanditRouterCoreModule {
159
+ binForGlobCount(n: number): 'tiny' | 'small' | 'medium' | 'large';
160
+ DEFAULT_DELEGATES: readonly string[];
161
+ }
162
+ const banditRouterCore = nodeRequire(
163
+ '../scripts/lib/bandit-router.cjs',
164
+ ) as BanditRouterCoreModule;
165
+
166
+ // Plan 27.5-02: tier-resolver translates bandit tier → concrete model.
167
+ interface TierResolverModule {
168
+ resolve(
169
+ runtime: string,
170
+ tier: string,
171
+ opts?: { silent?: boolean },
172
+ ): string | null;
173
+ }
174
+ const tierResolver = nodeRequire(
175
+ '../scripts/lib/tier-resolver.cjs',
176
+ ) as TierResolverModule;
177
+
108
178
  // ── Types ───────────────────────────────────────────────────────────────────
109
179
 
110
180
  /**
@@ -618,6 +688,50 @@ function emitCostRecorded(
618
688
  }
619
689
  }
620
690
 
691
+ /**
692
+ * Plan 27.5-02 / D-03: emit `bandit.tier_selected` event when the bandit
693
+ * is consulted (regardless of whether it overrode the prior tier). The
694
+ * event captures the prior tier, the bandit's pick, the sampled posterior
695
+ * (when applicable), the delegate dimension, and the runtime tag so
696
+ * Phase 11 reflector (27.5-04) and `/gdd:bandit-status` (27.5-05) can
697
+ * reconstruct decision history without re-reading the posterior file.
698
+ *
699
+ * Fail-open like every other emit in this hook.
700
+ */
701
+ function emitBanditTierSelected(
702
+ payload: {
703
+ agent: string;
704
+ bin: string;
705
+ prior_tier: string;
706
+ selected_tier: 'haiku' | 'sonnet' | 'opus';
707
+ source:
708
+ | 'frontmatter'
709
+ | 'tier_override_bypass'
710
+ | 'bandit_pull'
711
+ | 'bandit_pull_with_delegate';
712
+ delegate: string;
713
+ adaptive_mode: 'static' | 'hedge' | 'full';
714
+ samples?: unknown;
715
+ runtime: string;
716
+ model_id: string | null;
717
+ reason?: string;
718
+ },
719
+ cycle?: string,
720
+ ): void {
721
+ const ev = {
722
+ type: 'bandit.tier_selected',
723
+ timestamp: new Date().toISOString(),
724
+ sessionId: getSessionId(),
725
+ ...(cycle !== undefined && cycle !== 'unknown' ? { cycle } : {}),
726
+ payload,
727
+ };
728
+ try {
729
+ appendEvent(ev as unknown as HookFiredEvent);
730
+ } catch {
731
+ // Fail open.
732
+ }
733
+ }
734
+
621
735
  // ── main ────────────────────────────────────────────────────────────────────
622
736
 
623
737
  async function readStdin(): Promise<string> {
@@ -905,12 +1019,142 @@ export async function main(): Promise<void> {
905
1019
  ? routerDecision.runtime
906
1020
  : runtimeDetect.detect()) ?? 'claude';
907
1021
 
1022
+ // ── Plan 27.5-02 — bandit consultation ────────────────────────────────────
1023
+ //
1024
+ // D-01 / D-02 / D-03 / D-07: per-spawn after `resolved_models` is computed,
1025
+ // before the SDK call. Skip conditions (all silent — no event, no override):
1026
+ // - adaptive_mode !== 'full' (D-07)
1027
+ // - toolInput._tier_downgraded === true (80% downgrade fired upstream —
1028
+ // bandit must not undo budget)
1029
+ //
1030
+ // When bandit fires, override resolved_models[agent] through tier-resolver
1031
+ // so downstream consumers see the bandit's pick as the actual model.
1032
+ // model_tier_overrides[agent] is preserved (D-03 back-compat).
1033
+ const currentMode = adaptiveMode.getMode({ quiet: true });
1034
+ const priorTier = resolvedTier; // captured before bandit override
1035
+ // Mutable references for the cost/telemetry path; bandit may rewrite.
1036
+ let effectiveTier: string = resolvedTier;
1037
+ let effectiveModelId: string | null = resolvedModelId;
1038
+
1039
+ if (currentMode === 'full' && toolInput._tier_downgraded !== true) {
1040
+ // Bin defaults to 'medium' — budget-enforcer doesn't currently surface
1041
+ // glob_count; future plan can wire it. Per-agent bandit arms still
1042
+ // converge correctly under a fixed bin (Phase 23.5 D-08). The function
1043
+ // call below makes the integration point explicit for future plans.
1044
+ void banditRouterCore.binForGlobCount(0);
1045
+ const bin = 'medium';
1046
+
1047
+ // Source the frontmatter view from the in-flight toolInput. The hook
1048
+ // reads frontmatter indirectly: _default_tier carries the agent's
1049
+ // declared default-tier, _tier_override (if any) carries an explicit
1050
+ // override the router emitted. For bandit purposes, _tier_override
1051
+ // means "operator has already taken control" — the shim returns
1052
+ // source='tier_override_bypass' (no posterior side effect).
1053
+ const agentFrontmatter: {
1054
+ tier_override?: string;
1055
+ default_tier?: string;
1056
+ } = {};
1057
+ if (
1058
+ typeof toolInput._tier_override === 'string' &&
1059
+ toolInput._tier_override.length > 0
1060
+ ) {
1061
+ agentFrontmatter.tier_override = toolInput._tier_override;
1062
+ }
1063
+ if (
1064
+ typeof toolInput._default_tier === 'string' &&
1065
+ toolInput._default_tier.length > 0
1066
+ ) {
1067
+ agentFrontmatter.default_tier = toolInput._default_tier;
1068
+ }
1069
+
1070
+ // Delegate dimension: budget-enforcer doesn't currently see the
1071
+ // agent's delegate_to: frontmatter (session-runner does). For 27.5-02
1072
+ // we always consult the local-call slice (delegate='none'); 27.5-03
1073
+ // wires delegate=<peer> for the recordOutcome side.
1074
+ const banditDelegate = banditIntegration.DELEGATE_NONE;
1075
+
1076
+ let banditResult: ReturnType<
1077
+ BanditIntegrationModule['consultBandit']
1078
+ > | null = null;
1079
+ try {
1080
+ banditResult = banditIntegration.consultBandit({
1081
+ agent,
1082
+ bin,
1083
+ delegate: banditDelegate,
1084
+ agentFrontmatter,
1085
+ adaptiveMode: currentMode,
1086
+ });
1087
+ } catch {
1088
+ // Fail open — never let a bandit error block a spawn.
1089
+ }
1090
+
1091
+ if (banditResult !== null) {
1092
+ // Translate the bandit tier into a concrete model. The tier-resolver
1093
+ // emits its own fallback events (tier_resolution_fallback /
1094
+ // tier_resolution_failed) when the runtime row is incomplete, so we
1095
+ // don't need to re-emit those here.
1096
+ const banditModel = tierResolver.resolve(
1097
+ runtimeId,
1098
+ banditResult.tier,
1099
+ { silent: true },
1100
+ );
1101
+
1102
+ // Apply override only when:
1103
+ // 1. bandit actually picked a different tier than priorTier
1104
+ // (no-op write avoided)
1105
+ // 2. tier-resolver returned a non-null model (fall back to
1106
+ // existing resolvedModelId on null)
1107
+ // 3. source is 'bandit_pull' or 'bandit_pull_with_delegate'
1108
+ // (frontmatter/bypass paths don't override resolved_models)
1109
+ if (
1110
+ banditResult.tier !== priorTier &&
1111
+ banditModel !== null &&
1112
+ (banditResult.decision_log.source === 'bandit_pull' ||
1113
+ banditResult.decision_log.source === 'bandit_pull_with_delegate')
1114
+ ) {
1115
+ // Override resolved_models[agent] without touching
1116
+ // model_tier_overrides[agent] (D-03 back-compat).
1117
+ if (routerDecision !== undefined) {
1118
+ const rm = routerDecision.resolved_models ?? {};
1119
+ rm[agent] = banditModel;
1120
+ routerDecision.resolved_models = rm;
1121
+ }
1122
+ // Also stamp _tier_override on toolInput so downstream readers
1123
+ // see the bandit's pick.
1124
+ toolInput._tier_override = banditResult.tier;
1125
+ effectiveTier = banditResult.tier;
1126
+ effectiveModelId = banditModel;
1127
+ }
1128
+
1129
+ // Emit one bandit.tier_selected event regardless of override outcome
1130
+ // (the event captures the decision, not the override side effect).
1131
+ emitBanditTierSelected(
1132
+ {
1133
+ agent,
1134
+ bin,
1135
+ prior_tier: priorTier,
1136
+ selected_tier: banditResult.tier,
1137
+ source: banditResult.decision_log.source,
1138
+ delegate: banditResult.decision_log.delegate ?? banditDelegate,
1139
+ adaptive_mode: banditResult.decision_log.adaptive_mode,
1140
+ samples: banditResult.decision_log.samples,
1141
+ runtime: runtimeId,
1142
+ model_id: effectiveModelId ?? resolvedModelId,
1143
+ ...(banditResult.decision_log.reason !== undefined
1144
+ ? { reason: banditResult.decision_log.reason }
1145
+ : {}),
1146
+ },
1147
+ cycle,
1148
+ );
1149
+ }
1150
+ }
1151
+
908
1152
  // Compute runtime-aware cost via the shared backend. Failures return
909
1153
  // null cost; we emit the event regardless so the cost-aggregator sees
910
1154
  // the lookup attempt (Phase 22 events.jsonl tagging).
911
1155
  const costLookup = budgetBackend.computeCost({
912
- model_id: resolvedModelId,
913
- tier: resolvedTier,
1156
+ model_id: effectiveModelId,
1157
+ tier: effectiveTier,
914
1158
  runtime: runtimeId,
915
1159
  tokens_in: Number(toolInput._tokens_in_est ?? 0),
916
1160
  tokens_out: Number(toolInput._tokens_out_est ?? 0),
@@ -920,8 +1164,8 @@ export async function main(): Promise<void> {
920
1164
  {
921
1165
  runtime: runtimeId,
922
1166
  agent,
923
- model_id: resolvedModelId ?? costLookup.model,
924
- tier: costLookup.tier ?? resolvedTier,
1167
+ model_id: effectiveModelId ?? costLookup.model,
1168
+ tier: costLookup.tier ?? effectiveTier,
925
1169
  tokens_in: Number(toolInput._tokens_in_est ?? 0),
926
1170
  tokens_out: Number(toolInput._tokens_out_est ?? 0),
927
1171
  cost_usd: costLookup.cost_usd,
@@ -932,7 +1176,7 @@ export async function main(): Promise<void> {
932
1176
  // Branch E: standard spawn-allowed (includes tier-downgraded path).
933
1177
  writeTelemetry({
934
1178
  agent,
935
- tier: resolvedTier,
1179
+ tier: effectiveTier,
936
1180
  tokens_in: Number(toolInput._tokens_in_est ?? 0),
937
1181
  tokens_out: Number(toolInput._tokens_out_est ?? 0),
938
1182
  cache_hit: false,
@@ -0,0 +1,334 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * hooks/gdd-precompact-snapshot.js — Plan 27.6-05
4
+ *
5
+ * Claude Code PreCompact hook. Immediately before context compaction,
6
+ * writes an atomic snapshot of STATE.md sections + last-N event-chain
7
+ * entries + last-N decisions to `.design/snapshots/<ts>.json`.
8
+ *
9
+ * Phase 27.6 D-08: atomic .tmp + rename via scripts/lib/lockfile.cjs.
10
+ * - Lockfile serializes concurrent PreCompact writers.
11
+ * - .tmp + rename guarantees no partial file ever appears at target path
12
+ * (a SIGKILL between writeFileSync and renameSync leaves an orphan
13
+ * .tmp file, never a corrupted snapshot).
14
+ *
15
+ * Phase 27.6 D-10: harness-aware — Codex has no PreCompact, so on
16
+ * harness=codex this is a one-line stderr no-op (Phase 45 dep for
17
+ * full pre-large-context-action interception).
18
+ *
19
+ * Silent-on-failure: tolerable errors exit 0 with stderr breadcrumb.
20
+ * Emits `snapshot.written` event via lazy appendEvent (best-effort).
21
+ */
22
+
23
+ 'use strict';
24
+
25
+ const fs = require('node:fs');
26
+ const path = require('node:path');
27
+
28
+ const SNAPSHOT_DIR = path.resolve(process.cwd(), '.design', 'snapshots');
29
+ const STATE_MD_PATH = path.resolve(process.cwd(), '.design', 'STATE.md');
30
+ const EVENTS_PATH = path.resolve(process.cwd(), '.design', 'telemetry', 'events.jsonl');
31
+ const RETENTION_COUNT = 10;
32
+ const EVENTS_TAIL_COUNT = 50;
33
+ const DECISIONS_TAIL_COUNT = 10;
34
+ const SCHEMA_VERSION = '1.0.0';
35
+
36
+ // ---------------------------------------------------------------------------
37
+ // Harness detection (D-10)
38
+ // ---------------------------------------------------------------------------
39
+
40
+ function detectHarness() {
41
+ const explicit = (process.env.CLAUDE_HARNESS || process.env.GDD_HARNESS || '')
42
+ .toLowerCase()
43
+ .trim();
44
+ if (explicit === 'codex' || explicit === 'codex-cli') return 'codex';
45
+ // Default — Claude Code (only harness that emits PreCompact today).
46
+ return 'claude-code';
47
+ }
48
+
49
+ // ---------------------------------------------------------------------------
50
+ // Lazy event-stream emit (best-effort — never blocks the hook)
51
+ // ---------------------------------------------------------------------------
52
+
53
+ function getAppendEvent() {
54
+ try {
55
+ const m = require('../scripts/lib/event-stream');
56
+ if (m && typeof m.appendEvent === 'function') return m.appendEvent;
57
+ } catch {
58
+ /* swallow — event-stream is optional infrastructure */
59
+ }
60
+ return function noopAppend(_ev) {
61
+ /* no-op */
62
+ };
63
+ }
64
+
65
+ // ---------------------------------------------------------------------------
66
+ // STATE.md tolerant parser — extracts frontmatter + decisions + blockers
67
+ // ---------------------------------------------------------------------------
68
+
69
+ function readStateSections() {
70
+ if (!fs.existsSync(STATE_MD_PATH)) {
71
+ return { frontmatter: {}, decisions: [], blockers: [], session: '' };
72
+ }
73
+ let body;
74
+ try {
75
+ body = fs.readFileSync(STATE_MD_PATH, 'utf8');
76
+ } catch {
77
+ return { frontmatter: {}, decisions: [], blockers: [], session: '' };
78
+ }
79
+
80
+ // Extract YAML frontmatter (between leading '---' delimiters)
81
+ const frontmatter = {};
82
+ const fmMatch = body.match(/^---\n([\s\S]*?)\n---\n/);
83
+ if (fmMatch) {
84
+ for (const line of fmMatch[1].split('\n')) {
85
+ const m = line.match(/^(\w+):\s*(.+)$/);
86
+ if (m) frontmatter[m[1]] = m[2].trim();
87
+ }
88
+ }
89
+
90
+ // Decisions: extract D-XX entries from a '<decisions>' or '## Decisions' section
91
+ const decisions = [];
92
+ const decisionsMatch = body.match(
93
+ /(?:<decisions>|## Decisions)([\s\S]*?)(?:<\/decisions>|^##\s|\Z)/m,
94
+ );
95
+ if (decisionsMatch) {
96
+ const dRe = /D-\d+:[^\n]+/g;
97
+ let m2;
98
+ while ((m2 = dRe.exec(decisionsMatch[1])) !== null) {
99
+ decisions.push(m2[0].trim());
100
+ }
101
+ }
102
+
103
+ // Blockers: similar to decisions
104
+ const blockers = [];
105
+ const blockersMatch = body.match(
106
+ /(?:<blockers>|## Blockers)([\s\S]*?)(?:<\/blockers>|^##\s|\Z)/m,
107
+ );
108
+ if (blockersMatch) {
109
+ const bRe = /B-\d+:[^\n]+/g;
110
+ let m3;
111
+ while ((m3 = bRe.exec(blockersMatch[1])) !== null) {
112
+ blockers.push(m3[0].trim());
113
+ }
114
+ }
115
+
116
+ // Session prefix (first ~500 chars after '## Session' or '<session>')
117
+ const sessionMatch = body.match(/(?:## Session|<session>)([\s\S]{0,500})/);
118
+ const session = sessionMatch ? sessionMatch[1].trim().slice(0, 500) : '';
119
+
120
+ return { frontmatter, decisions, blockers, session };
121
+ }
122
+
123
+ // ---------------------------------------------------------------------------
124
+ // Events tail reader — JSONL-tolerant (malformed lines are skipped)
125
+ // ---------------------------------------------------------------------------
126
+
127
+ function readEventsTail(count) {
128
+ if (!fs.existsSync(EVENTS_PATH)) return [];
129
+ let body;
130
+ try {
131
+ body = fs.readFileSync(EVENTS_PATH, 'utf8');
132
+ } catch {
133
+ return [];
134
+ }
135
+ const events = [];
136
+ for (const line of body.split(/\r?\n/)) {
137
+ const trimmed = line.trim();
138
+ if (trimmed.length === 0) continue;
139
+ try {
140
+ events.push(JSON.parse(trimmed));
141
+ } catch {
142
+ /* tolerate malformed line — T-27.6.05-05 mitigation */
143
+ }
144
+ }
145
+ return events.slice(-count);
146
+ }
147
+
148
+ // ---------------------------------------------------------------------------
149
+ // Retention prune — LRU by mtime, keep last RETENTION_COUNT (D-08)
150
+ // ---------------------------------------------------------------------------
151
+
152
+ function pruneSnapshots() {
153
+ let files;
154
+ try {
155
+ files = fs.readdirSync(SNAPSHOT_DIR);
156
+ } catch {
157
+ return;
158
+ }
159
+ const jsonFiles = files
160
+ .filter((f) => f.endsWith('.json') && f !== 'last-recap.json')
161
+ .map((f) => ({ name: f, full: path.join(SNAPSHOT_DIR, f), mtime: 0 }));
162
+
163
+ for (const entry of jsonFiles) {
164
+ try {
165
+ entry.mtime = fs.statSync(entry.full).mtimeMs;
166
+ } catch {
167
+ /* swallow */
168
+ }
169
+ }
170
+
171
+ jsonFiles.sort((a, b) => a.mtime - b.mtime);
172
+ while (jsonFiles.length > RETENTION_COUNT) {
173
+ const oldest = jsonFiles.shift();
174
+ try {
175
+ fs.unlinkSync(oldest.full);
176
+ } catch {
177
+ /* swallow — race with another writer; LRU eventually wins */
178
+ }
179
+ }
180
+ }
181
+
182
+ // ---------------------------------------------------------------------------
183
+ // Main — atomic write with lockfile serialization
184
+ // ---------------------------------------------------------------------------
185
+
186
+ async function main() {
187
+ const harness = detectHarness();
188
+ if (harness === 'codex') {
189
+ // D-10: Codex has no PreCompact event; emit notice + exit. Phase 45 dep
190
+ // for full `pre-large-context-action` interception.
191
+ process.stderr.write(
192
+ '[gdd-precompact-snapshot] this harness does not emit PreCompact; snapshots disabled\n',
193
+ );
194
+ process.exit(0);
195
+ }
196
+
197
+ // Drain stdin (Claude Code may pipe a hook event JSON; we don't need it
198
+ // but draining avoids EPIPE on the parent's writer side).
199
+ try {
200
+ if (!process.stdin.isTTY) {
201
+ // Best-effort, non-blocking — we have nothing time-sensitive in stdin.
202
+ process.stdin.on('error', () => {
203
+ /* swallow */
204
+ });
205
+ process.stdin.resume();
206
+ }
207
+ } catch {
208
+ /* swallow */
209
+ }
210
+
211
+ const ts = new Date().toISOString().replace(/[:.]/g, '-');
212
+ const snapshotPath = path.join(SNAPSHOT_DIR, ts + '.json');
213
+ const tmpPath = snapshotPath + '.tmp';
214
+
215
+ // Ensure snapshot dir exists (mkdir -p semantics).
216
+ try {
217
+ fs.mkdirSync(SNAPSHOT_DIR, { recursive: true });
218
+ } catch {
219
+ /* swallow — write will fail loudly below if truly missing */
220
+ }
221
+
222
+ // Acquire lockfile on the target path (T-27.6.05-02 mitigation).
223
+ // The lock file lives at <snapshotPath>.lock and serializes concurrent
224
+ // PreCompact writers; the second writer either waits or fails-silent.
225
+ let release = null;
226
+ try {
227
+ const lockfile = require('../scripts/lib/lockfile.cjs');
228
+ release = await lockfile.acquire(snapshotPath, {
229
+ staleMs: 60_000,
230
+ maxWaitMs: 10_000,
231
+ pollMs: 50,
232
+ });
233
+ } catch (err) {
234
+ process.stderr.write(
235
+ '[gdd-precompact-snapshot] lock acquire failed: ' +
236
+ (err && err.message ? err.message : String(err)) +
237
+ '\n',
238
+ );
239
+ process.exit(0);
240
+ }
241
+
242
+ try {
243
+ const sections = readStateSections();
244
+ const events = readEventsTail(EVENTS_TAIL_COUNT);
245
+ const decisions = sections.decisions.slice(-DECISIONS_TAIL_COUNT);
246
+ const cycleId =
247
+ sections.frontmatter && sections.frontmatter.milestone
248
+ ? sections.frontmatter.milestone
249
+ : 'unknown';
250
+
251
+ const snapshot = {
252
+ schema_version: SCHEMA_VERSION,
253
+ timestamp: new Date().toISOString(),
254
+ cycle_id: cycleId,
255
+ state_md_sections: sections,
256
+ last_n_events: events,
257
+ last_n_decisions: decisions,
258
+ };
259
+
260
+ const body = JSON.stringify(snapshot, null, 2);
261
+
262
+ // Atomic write: .tmp + rename (T-27.6.05-01 mitigation).
263
+ // A SIGKILL between writeFileSync and renameSync leaves <snapshotPath>.tmp
264
+ // orphaned but NEVER a partial file at <snapshotPath> itself.
265
+ try {
266
+ fs.writeFileSync(tmpPath, body, 'utf8');
267
+ fs.renameSync(tmpPath, snapshotPath);
268
+ } catch (err) {
269
+ process.stderr.write(
270
+ '[gdd-precompact-snapshot] atomic write failed: ' +
271
+ (err && err.message ? err.message : String(err)) +
272
+ '\n',
273
+ );
274
+ try {
275
+ fs.unlinkSync(tmpPath);
276
+ } catch {
277
+ /* swallow orphan cleanup */
278
+ }
279
+ process.exit(0);
280
+ }
281
+
282
+ // Retention prune (T-27.6.05-04 DoS mitigation).
283
+ pruneSnapshots();
284
+
285
+ // Best-effort event emit.
286
+ const appendEvent = getAppendEvent();
287
+ try {
288
+ appendEvent({
289
+ type: 'snapshot.written',
290
+ timestamp: new Date().toISOString(),
291
+ sessionId: process.env.GDD_SESSION_ID || 'precompact-hook',
292
+ payload: {
293
+ path: snapshotPath,
294
+ size_bytes: Buffer.byteLength(body, 'utf8'),
295
+ events_count: events.length,
296
+ decisions_count: decisions.length,
297
+ harness,
298
+ },
299
+ });
300
+ } catch {
301
+ /* swallow — telemetry never blocks */
302
+ }
303
+
304
+ // Emit non-blocking continue verdict on stdout (matches other hooks).
305
+ try {
306
+ process.stdout.write(JSON.stringify({ continue: true, suppressOutput: true }));
307
+ } catch {
308
+ /* swallow */
309
+ }
310
+
311
+ process.exit(0);
312
+ } finally {
313
+ if (release) {
314
+ try {
315
+ await release();
316
+ } catch {
317
+ /* swallow — stale-detection reclaims */
318
+ }
319
+ }
320
+ }
321
+ }
322
+
323
+ main().catch((err) => {
324
+ try {
325
+ process.stderr.write(
326
+ '[gdd-precompact-snapshot] uncaught: ' +
327
+ (err && err.message ? err.message : String(err)) +
328
+ '\n',
329
+ );
330
+ } catch {
331
+ /* swallow */
332
+ }
333
+ process.exit(0);
334
+ });