@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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +95 -0
- package/SKILL.md +1 -0
- package/agents/design-reflector.md +52 -0
- package/agents/perf-analyzer.md +166 -0
- package/hooks/budget-enforcer.ts +249 -5
- package/hooks/gdd-precompact-snapshot.js +334 -0
- package/hooks/gdd-sessionstart-recap.js +281 -0
- package/hooks/hooks.json +18 -0
- package/package.json +2 -2
- package/reference/bandit-integration.md +163 -0
- package/reference/perf-budget.md +142 -0
- package/reference/registry.json +14 -0
- package/reference/retrieval-contract.md +16 -0
- package/scripts/lib/bandit-arbitrage.cjs +423 -0
- package/scripts/lib/bandit-router/integration.cjs +309 -0
- package/scripts/lib/cache/gdd-cache-manager.cjs +292 -0
- package/scripts/lib/discuss-parallel-runner/index.ts +5 -1
- package/scripts/lib/explore-parallel-runner/index.ts +5 -1
- package/scripts/lib/parallelism-engine/concurrency-tuner.cjs +259 -0
- package/scripts/lib/parallelism-engine/concurrency-tuner.d.cts +53 -0
- package/scripts/lib/perf-analyzer/cost-regression.cjs +299 -0
- package/scripts/lib/perf-analyzer/index.cjs +139 -0
- package/scripts/lib/prompt-dedup/index.cjs +161 -0
- package/scripts/lib/session-runner/index.ts +206 -0
- package/skills/bandit-status/SKILL.md +129 -0
- package/skills/peers/SKILL.md +27 -8
package/hooks/budget-enforcer.ts
CHANGED
|
@@ -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:
|
|
913
|
-
tier:
|
|
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:
|
|
924
|
-
tier: costLookup.tier ??
|
|
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:
|
|
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
|
+
});
|