@harness-engineering/cli 1.25.0 → 1.25.2

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.
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  startServer
4
- } from "../chunk-RX7TUMBR.js";
4
+ } from "../chunk-TBGIHPNA.js";
5
5
  import "../chunk-FES2YEQU.js";
6
6
  import "../chunk-UV3BZMGT.js";
7
7
  import "../chunk-F23H3U5U.js";
@@ -2,7 +2,7 @@
2
2
  import {
3
3
  createProgram,
4
4
  printFirstRunWelcome
5
- } from "../chunk-AZT567I6.js";
5
+ } from "../chunk-5I3W4J5X.js";
6
6
  import "../chunk-5BQ5BOJL.js";
7
7
  import "../chunk-EHRZMOQ2.js";
8
8
  import "../chunk-XTITAVUR.js";
@@ -19,7 +19,7 @@ import "../chunk-P7PANON5.js";
19
19
  import "../chunk-4NK7Z3BP.js";
20
20
  import {
21
21
  dispatchSkillsFromGit
22
- } from "../chunk-RX7TUMBR.js";
22
+ } from "../chunk-TBGIHPNA.js";
23
23
  import "../chunk-FES2YEQU.js";
24
24
  import "../chunk-UV3BZMGT.js";
25
25
  import "../chunk-F23H3U5U.js";
@@ -113,6 +113,96 @@ ${message}
113
113
  }
114
114
  }
115
115
 
116
+ // src/bin/command-telemetry.ts
117
+ import { existsSync, readFileSync, mkdirSync, appendFileSync, writeFileSync } from "fs";
118
+ import { join, parse as parsePath, resolve } from "path";
119
+ import { spawn } from "child_process";
120
+ var EXCLUDED_COMMANDS = /* @__PURE__ */ new Set(["help", "completion"]);
121
+ function findProjectRoot(cwd) {
122
+ let dir = resolve(cwd);
123
+ const { root } = parsePath(dir);
124
+ while (dir !== root) {
125
+ if (existsSync(join(dir, "harness.config.json"))) return dir;
126
+ dir = resolve(dir, "..");
127
+ }
128
+ return cwd;
129
+ }
130
+ var commandName = "";
131
+ var startTime = 0;
132
+ var recorded = false;
133
+ function installCommandTelemetry(program, cwd) {
134
+ if (typeof program.hook !== "function") return;
135
+ const projectRoot = findProjectRoot(cwd);
136
+ flushTelemetryBackground(projectRoot);
137
+ program.hook("preAction", (thisCommand) => {
138
+ commandName = resolveCommandName(thisCommand);
139
+ startTime = Date.now();
140
+ });
141
+ process.on("exit", (code) => {
142
+ if (recorded || !commandName || EXCLUDED_COMMANDS.has(commandName)) return;
143
+ recorded = true;
144
+ const duration = Date.now() - startTime;
145
+ const outcome = code === 0 ? "completed" : "failed";
146
+ writeCommandRecordSync(projectRoot, commandName, duration, outcome);
147
+ });
148
+ }
149
+ function resolveCommandName(cmd) {
150
+ const parts = [];
151
+ let current = cmd;
152
+ while (current) {
153
+ const name = current.name();
154
+ if (name && name !== "harness") {
155
+ parts.unshift(name);
156
+ }
157
+ current = current.parent;
158
+ }
159
+ return parts.length > 0 ? `cli/${parts.join(".")}` : "";
160
+ }
161
+ function writeCommandRecordSync(cwd, command, duration, outcome) {
162
+ try {
163
+ const metricsDir = join(cwd, ".harness", "metrics");
164
+ mkdirSync(metricsDir, { recursive: true });
165
+ const record = {
166
+ skill: command,
167
+ session: `cli-${Date.now()}`,
168
+ startedAt: new Date(Date.now() - duration).toISOString(),
169
+ duration,
170
+ outcome,
171
+ phasesReached: []
172
+ };
173
+ const adoptionFile = join(metricsDir, "adoption.jsonl");
174
+ appendFileSync(adoptionFile, JSON.stringify(record) + "\n");
175
+ } catch {
176
+ }
177
+ }
178
+ function flushTelemetryBackground(cwd) {
179
+ try {
180
+ const adoptionFile = join(cwd, ".harness", "metrics", "adoption.jsonl");
181
+ if (!existsSync(adoptionFile)) return;
182
+ const configPath = join(cwd, "harness.config.json");
183
+ if (existsSync(configPath)) {
184
+ try {
185
+ const config = JSON.parse(readFileSync(configPath, "utf-8"));
186
+ if (config?.telemetry?.enabled === false) return;
187
+ } catch {
188
+ }
189
+ }
190
+ if (process.env.DO_NOT_TRACK === "1") return;
191
+ if (process.env.HARNESS_TELEMETRY_OPTOUT === "1") return;
192
+ const reporterPath = join(cwd, ".harness", "hooks", "telemetry-reporter.js");
193
+ if (!existsSync(reporterPath)) return;
194
+ const child = spawn(process.execPath, [reporterPath], {
195
+ cwd,
196
+ stdio: ["pipe", "ignore", "ignore"],
197
+ detached: true
198
+ });
199
+ child.stdin?.write("{}");
200
+ child.stdin?.end();
201
+ child.unref();
202
+ } catch {
203
+ }
204
+ }
205
+
116
206
  // src/skill/dispatch-session.ts
117
207
  import { execSync } from "child_process";
118
208
  import fs from "fs";
@@ -211,6 +301,7 @@ async function main() {
211
301
  () => ({ dispatched: false })
212
302
  );
213
303
  const program = createProgram();
304
+ installCommandTelemetry(program, process.cwd());
214
305
  try {
215
306
  await program.parseAsync(process.argv);
216
307
  } catch (error) {
@@ -56,7 +56,7 @@ import {
56
56
  loadOrRebuildIndex,
57
57
  persistToolingConfig,
58
58
  recommend
59
- } from "./chunk-RX7TUMBR.js";
59
+ } from "./chunk-TBGIHPNA.js";
60
60
  import {
61
61
  findConfigFile,
62
62
  resolveConfig
@@ -3111,11 +3111,21 @@ var __filename2 = fileURLToPath2(import.meta.url);
3111
3111
  var __dirname2 = path20.dirname(__filename2);
3112
3112
  var VALID_PROFILES = ["minimal", "standard", "strict"];
3113
3113
  function resolveHookSourceDir() {
3114
- const candidate = path20.resolve(__dirname2, "..", "..", "hooks");
3115
- if (fs8.existsSync(candidate)) {
3116
- return candidate;
3114
+ const candidates = [
3115
+ // Dev layout: src/commands/hooks/ → ../../hooks/
3116
+ path20.resolve(__dirname2, "..", "..", "hooks"),
3117
+ // Bundled layout: dist/ → ./hooks/ (copied by copy-assets.mjs)
3118
+ path20.resolve(__dirname2, "hooks")
3119
+ ];
3120
+ for (const candidate of candidates) {
3121
+ if (fs8.existsSync(candidate)) {
3122
+ return candidate;
3123
+ }
3117
3124
  }
3118
- throw new Error(`Cannot locate hook scripts directory. Expected at: ${candidate}`);
3125
+ throw new Error(
3126
+ `Cannot locate hook scripts directory. Searched:
3127
+ ${candidates.map((c) => ` - ${c}`).join("\n")}`
3128
+ );
3119
3129
  }
3120
3130
  function buildSettingsHooks(profile) {
3121
3131
  const activeHookNames = PROFILES[profile];
@@ -3127,7 +3137,12 @@ function buildSettingsHooks(profile) {
3127
3137
  }
3128
3138
  hooks[script.event].push({
3129
3139
  matcher: script.matcher,
3130
- hooks: [{ type: "command", command: `node .harness/hooks/${script.name}.js` }]
3140
+ hooks: [
3141
+ {
3142
+ type: "command",
3143
+ command: `node "$(git rev-parse --show-toplevel)/.harness/hooks/${script.name}.js"`
3144
+ }
3145
+ ]
3131
3146
  });
3132
3147
  }
3133
3148
  return hooks;
@@ -3345,7 +3360,7 @@ function readJson(p) {
3345
3360
  }
3346
3361
  function registerHook(s, ev, matcher, name) {
3347
3362
  if (!s.hooks[ev]) s.hooks[ev] = [];
3348
- const cmd = `node .harness/hooks/${name}.js`;
3363
+ const cmd = `node "$(git rev-parse --show-toplevel)/.harness/hooks/${name}.js"`;
3349
3364
  if (!s.hooks[ev].some((e) => e.hooks?.some((h) => h.command === cmd))) {
3350
3365
  s.hooks[ev].push({ matcher, hooks: [{ type: "command", command: cmd }] });
3351
3366
  }
@@ -5408,7 +5423,7 @@ function createMcpCommand() {
5408
5423
  parseBudget
5409
5424
  ).action(async (opts) => {
5410
5425
  const [{ startServer: startServer2, getToolDefinitions: getToolDefinitions2 }, { selectTier }] = await Promise.all([
5411
- import("./mcp-2553PNUC.js"),
5426
+ import("./mcp-W3FLXSFF.js"),
5412
5427
  import("./tool-tiers-7QGZ3FKY.js")
5413
5428
  ]);
5414
5429
  if (opts.tools && opts.tools.length > 0) {
@@ -6262,6 +6277,18 @@ function prompt(question) {
6262
6277
  });
6263
6278
  });
6264
6279
  }
6280
+ function promptRaw(question) {
6281
+ const rl = readline.createInterface({
6282
+ input: process.stdin,
6283
+ output: process.stdout
6284
+ });
6285
+ return new Promise((resolve35) => {
6286
+ rl.question(question, (answer) => {
6287
+ rl.close();
6288
+ resolve35(answer.trim());
6289
+ });
6290
+ });
6291
+ }
6265
6292
  function isNo(answer) {
6266
6293
  return answer === "n" || answer === "no";
6267
6294
  }
@@ -6302,11 +6329,11 @@ async function promptIdentity() {
6302
6329
  const answer = await prompt(" Set identity fields? (y/N) ");
6303
6330
  const identity = {};
6304
6331
  if (answer === "y" || answer === "yes") {
6305
- const projectName = await prompt(" Project name: ");
6332
+ const projectName = await promptRaw(" Project name: ");
6306
6333
  if (projectName) identity.project = projectName;
6307
- const teamName = await prompt(" Team name: ");
6334
+ const teamName = await promptRaw(" Team name: ");
6308
6335
  if (teamName) identity.team = teamName;
6309
- const alias = await prompt(" Alias: ");
6336
+ const alias = await promptRaw(" Alias: ");
6310
6337
  if (alias) identity.alias = alias;
6311
6338
  }
6312
6339
  return identity;
@@ -564,6 +564,8 @@ function ensureHarnessGitignore(targetDir) {
564
564
  const content = `# Runtime artifacts (generated, ephemeral, session-scoped)
565
565
  graph/
566
566
  debug/
567
+ hooks/
568
+ security/
567
569
  sessions/
568
570
  state.json
569
571
  state/
@@ -0,0 +1,189 @@
1
+ #!/usr/bin/env node
2
+ // adoption-tracker.js — Stop:* hook
3
+ // Reads .harness/events.jsonl, reconstructs skill invocations,
4
+ // appends SkillInvocationRecord entries to .harness/metrics/adoption.jsonl.
5
+ // Exit codes: 0 = allow (always, log-only hook)
6
+
7
+ import { readFileSync, mkdirSync, appendFileSync, existsSync } from 'node:fs';
8
+ import { join } from 'node:path';
9
+ import process from 'node:process';
10
+
11
+ /** Read and parse a JSON file, returning null on any error. */
12
+ function readJsonSafe(filePath) {
13
+ try {
14
+ return JSON.parse(readFileSync(filePath, 'utf-8'));
15
+ } catch {
16
+ return null;
17
+ }
18
+ }
19
+
20
+ /** Check if adoption tracking is enabled in harness.config.json. */
21
+ function isAdoptionEnabled(cwd) {
22
+ const config = readJsonSafe(join(cwd, 'harness.config.json'));
23
+ if (!config) return true; // default: enabled
24
+ if (config.adoption && config.adoption.enabled === false) return false;
25
+ return true;
26
+ }
27
+
28
+ /** Parse events.jsonl into an array of event objects. Skips malformed lines. */
29
+ function parseEventsFile(eventsPath) {
30
+ let raw;
31
+ try {
32
+ raw = readFileSync(eventsPath, 'utf-8');
33
+ } catch {
34
+ return [];
35
+ }
36
+
37
+ const events = [];
38
+ const lines = raw.split('\n');
39
+ for (const line of lines) {
40
+ const trimmed = line.trim();
41
+ if (!trimmed) continue;
42
+ try {
43
+ const parsed = JSON.parse(trimmed);
44
+ if (parsed.skill && parsed.type && parsed.timestamp) {
45
+ events.push(parsed);
46
+ }
47
+ } catch {
48
+ // Skip malformed lines
49
+ }
50
+ }
51
+ return events;
52
+ }
53
+
54
+ /** Relevant event types for adoption tracking. */
55
+ const RELEVANT_TYPES = new Set(['phase_transition', 'gate_result', 'handoff', 'error']);
56
+
57
+ /** Derive outcome from a skill's events. */
58
+ function deriveOutcome(events) {
59
+ const hasHandoff = events.some((e) => e.type === 'handoff');
60
+ const hasError = events.some((e) => e.type === 'error');
61
+
62
+ // Check for final phase (VALIDATE is the conventional final phase)
63
+ const phases = events
64
+ .filter((e) => e.type === 'phase_transition')
65
+ .map((e) => (e.data && e.data.to) || '')
66
+ .filter(Boolean);
67
+ const hasFinalPhase = phases.some(
68
+ (p) => p.toLowerCase() === 'validate' || p.toLowerCase() === 'complete'
69
+ );
70
+
71
+ if (hasHandoff || hasFinalPhase) return 'completed';
72
+ if (hasError) return 'failed';
73
+ return 'abandoned';
74
+ }
75
+
76
+ /** Derive phases reached from phase_transition events. */
77
+ function derivePhasesReached(events) {
78
+ const phases = [];
79
+ const seen = new Set();
80
+ for (const event of events) {
81
+ if (event.type === 'phase_transition' && event.data && event.data.to) {
82
+ const phase = event.data.to;
83
+ if (!seen.has(phase)) {
84
+ seen.add(phase);
85
+ phases.push(phase);
86
+ }
87
+ }
88
+ }
89
+ return phases;
90
+ }
91
+
92
+ /** Derive duration in ms from first to last event timestamp. */
93
+ function deriveDuration(events) {
94
+ if (events.length < 2) return 0;
95
+ const timestamps = events.map((e) => new Date(e.timestamp).getTime()).filter((t) => !isNaN(t));
96
+ if (timestamps.length < 2) return 0;
97
+ const min = Math.min(...timestamps);
98
+ const max = Math.max(...timestamps);
99
+ return max - min;
100
+ }
101
+
102
+ function main() {
103
+ let raw = '';
104
+ try {
105
+ raw = readFileSync(0, 'utf-8');
106
+ } catch {
107
+ process.exit(0);
108
+ }
109
+
110
+ if (!raw.trim()) {
111
+ process.exit(0);
112
+ }
113
+
114
+ let input;
115
+ try {
116
+ input = JSON.parse(raw);
117
+ } catch {
118
+ process.stderr.write('[adoption-tracker] Could not parse stdin — skipping\n');
119
+ process.exit(0);
120
+ }
121
+
122
+ try {
123
+ const cwd = process.cwd();
124
+
125
+ // Check config
126
+ if (!isAdoptionEnabled(cwd)) {
127
+ process.stderr.write('[adoption-tracker] Adoption tracking disabled — skipping\n');
128
+ process.exit(0);
129
+ }
130
+
131
+ // Read events.jsonl
132
+ const eventsPath = join(cwd, '.harness', 'events.jsonl');
133
+ if (!existsSync(eventsPath)) {
134
+ process.stderr.write('[adoption-tracker] No events.jsonl found — skipping\n');
135
+ process.exit(0);
136
+ }
137
+
138
+ const allEvents = parseEventsFile(eventsPath);
139
+ // Filter to relevant event types
140
+ const relevantEvents = allEvents.filter((e) => RELEVANT_TYPES.has(e.type));
141
+ if (relevantEvents.length === 0) {
142
+ process.stderr.write('[adoption-tracker] No relevant skill events — skipping\n');
143
+ process.exit(0);
144
+ }
145
+
146
+ // Group events by skill
147
+ const skillGroups = new Map();
148
+ for (const event of relevantEvents) {
149
+ if (!skillGroups.has(event.skill)) {
150
+ skillGroups.set(event.skill, []);
151
+ }
152
+ skillGroups.get(event.skill).push(event);
153
+ }
154
+
155
+ // Reconstruct invocation records
156
+ const sessionId = input.session_id ?? 'unknown';
157
+ const metricsDir = join(cwd, '.harness', 'metrics');
158
+ mkdirSync(metricsDir, { recursive: true });
159
+ const adoptionFile = join(metricsDir, 'adoption.jsonl');
160
+
161
+ let written = 0;
162
+ for (const [skill, events] of skillGroups) {
163
+ // Use all events for this skill (including non-relevant) for timing
164
+ const allSkillEvents = allEvents.filter((e) => e.skill === skill);
165
+
166
+ const record = {
167
+ skill,
168
+ session: sessionId,
169
+ startedAt: allSkillEvents[0]?.timestamp ?? events[0].timestamp,
170
+ duration: deriveDuration(allSkillEvents.length > 0 ? allSkillEvents : events),
171
+ outcome: deriveOutcome(events),
172
+ phasesReached: derivePhasesReached(events),
173
+ };
174
+
175
+ appendFileSync(adoptionFile, JSON.stringify(record) + '\n');
176
+ written++;
177
+ }
178
+
179
+ process.stderr.write(
180
+ `[adoption-tracker] Wrote ${written} adoption record(s) for session ${sessionId}\n`
181
+ );
182
+ process.exit(0);
183
+ } catch (err) {
184
+ process.stderr.write(`[adoption-tracker] Failed: ${err.message}\n`);
185
+ process.exit(0);
186
+ }
187
+ }
188
+
189
+ main();
@@ -0,0 +1,50 @@
1
+ #!/usr/bin/env node
2
+ // block-no-verify.js — PreToolUse:Bash hook
3
+ // Blocks git commands that use --no-verify to skip hooks.
4
+ // Exit codes: 0 = allow, 2 = block
5
+
6
+ import { readFileSync } from 'node:fs';
7
+ import process from 'node:process';
8
+
9
+ function main() {
10
+ let raw = '';
11
+ try {
12
+ raw = readFileSync(0, 'utf-8');
13
+ } catch {
14
+ // No stdin or read error — fail open
15
+ process.exit(0);
16
+ }
17
+
18
+ if (!raw.trim()) {
19
+ process.exit(0);
20
+ }
21
+
22
+ let input;
23
+ try {
24
+ input = JSON.parse(raw);
25
+ } catch {
26
+ // Malformed JSON — fail open
27
+ process.exit(0);
28
+ }
29
+
30
+ try {
31
+ const command = input?.tool_input?.command ?? '';
32
+ if (typeof command !== 'string') {
33
+ process.exit(0);
34
+ }
35
+
36
+ if (/--no-verify/.test(command) || /\bgit\b.*\bcommit\b.*\s-n\b/.test(command)) {
37
+ process.stderr.write(
38
+ 'BLOCKED: --no-verify flag detected. Hooks must not be bypassed.\n'
39
+ );
40
+ process.exit(2);
41
+ }
42
+
43
+ process.exit(0);
44
+ } catch {
45
+ // Unexpected error — fail open
46
+ process.exit(0);
47
+ }
48
+ }
49
+
50
+ main();
@@ -0,0 +1,66 @@
1
+ #!/usr/bin/env node
2
+ // cost-tracker.js — Stop:* hook
3
+ // Appends token usage to .harness/metrics/costs.jsonl.
4
+ // Exit codes: 0 = allow (always, log-only hook)
5
+
6
+ import { readFileSync, mkdirSync, appendFileSync } from 'node:fs';
7
+ import { join } from 'node:path';
8
+ import process from 'node:process';
9
+
10
+ function main() {
11
+ let raw = '';
12
+ try {
13
+ raw = readFileSync(0, 'utf-8');
14
+ } catch {
15
+ process.exit(0);
16
+ }
17
+
18
+ if (!raw.trim()) {
19
+ process.exit(0);
20
+ }
21
+
22
+ let input;
23
+ try {
24
+ input = JSON.parse(raw);
25
+ } catch {
26
+ process.stderr.write('[cost-tracker] Could not parse stdin — skipping\n');
27
+ process.exit(0);
28
+ }
29
+
30
+ try {
31
+ const cwd = process.cwd();
32
+ const metricsDir = join(cwd, '.harness', 'metrics');
33
+
34
+ mkdirSync(metricsDir, { recursive: true });
35
+
36
+ const entry = {
37
+ timestamp: new Date().toISOString(),
38
+ session_id: input.session_id ?? null,
39
+ token_usage: input.token_usage ?? null,
40
+ model: input.model ?? null,
41
+ };
42
+
43
+ // Pass through cache token fields (prefer camelCase input, fall back to snake_case)
44
+ if (input.cacheCreationTokens != null) {
45
+ entry.cacheCreationTokens = input.cacheCreationTokens;
46
+ } else if (input.cache_creation_tokens != null) {
47
+ entry.cacheCreationTokens = input.cache_creation_tokens;
48
+ }
49
+ if (input.cacheReadTokens != null) {
50
+ entry.cacheReadTokens = input.cacheReadTokens;
51
+ } else if (input.cache_read_tokens != null) {
52
+ entry.cacheReadTokens = input.cache_read_tokens;
53
+ }
54
+
55
+ const costsFile = join(metricsDir, 'costs.jsonl');
56
+ appendFileSync(costsFile, JSON.stringify(entry) + '\n');
57
+
58
+ process.stderr.write(`[cost-tracker] Logged cost entry for session ${entry.session_id}\n`);
59
+ process.exit(0);
60
+ } catch (err) {
61
+ process.stderr.write(`[cost-tracker] Failed to log costs: ${err.message}\n`);
62
+ process.exit(0);
63
+ }
64
+ }
65
+
66
+ main();
@@ -0,0 +1,115 @@
1
+ #!/usr/bin/env node
2
+ // pre-compact-state.js — PreCompact:* hook
3
+ // Saves a compact session summary before context compaction.
4
+ // Reads from .harness/state.json and .harness/sessions/ to gather context.
5
+ // Writes to .harness/state/pre-compact-summary.json (overwrites on each run).
6
+ // Fail-open: parse errors and unexpected exceptions log to stderr and exit 0.
7
+ // Exit codes: 0 = allow (always, log-only hook)
8
+
9
+ import { readFileSync, mkdirSync, writeFileSync, readdirSync, statSync } from 'node:fs';
10
+ import { join } from 'node:path';
11
+ import process from 'node:process';
12
+
13
+ function readJsonSafe(filePath) {
14
+ try {
15
+ return JSON.parse(readFileSync(filePath, 'utf-8'));
16
+ } catch {
17
+ return null;
18
+ }
19
+ }
20
+
21
+ function findActiveSession(sessionsDir) {
22
+ try {
23
+ const entries = readdirSync(sessionsDir, { withFileTypes: true });
24
+ // Look for the most recently modified session with an autopilot-state.json
25
+ let latest = null;
26
+ let latestMtime = 0;
27
+ for (const entry of entries) {
28
+ if (!entry.isDirectory()) continue;
29
+ const statePath = join(sessionsDir, entry.name, 'autopilot-state.json');
30
+ try {
31
+ const stat = statSync(statePath);
32
+ if (stat.mtimeMs > latestMtime) {
33
+ latestMtime = stat.mtimeMs;
34
+ latest = { dir: entry.name, state: readJsonSafe(statePath) };
35
+ }
36
+ } catch {
37
+ // No autopilot-state.json in this session
38
+ }
39
+ }
40
+ return latest;
41
+ } catch {
42
+ return null;
43
+ }
44
+ }
45
+
46
+ function main() {
47
+ let raw = '';
48
+ try {
49
+ raw = readFileSync(0, 'utf-8');
50
+ } catch {
51
+ process.stderr.write('[pre-compact-state] Could not read stdin — allowing (fail-open)\n');
52
+ process.exit(0);
53
+ }
54
+
55
+ if (!raw.trim()) {
56
+ process.stderr.write('[pre-compact-state] Empty stdin — allowing (fail-open)\n');
57
+ process.exit(0);
58
+ }
59
+
60
+ try {
61
+ JSON.parse(raw); // validate stdin is JSON
62
+ } catch {
63
+ process.stderr.write('[pre-compact-state] Could not parse stdin — allowing (fail-open)\n');
64
+ process.exit(0);
65
+ }
66
+
67
+ try {
68
+ const cwd = process.cwd();
69
+ const harnessDir = join(cwd, '.harness');
70
+ const stateDir = join(harnessDir, 'state');
71
+
72
+ // Read harness state
73
+ const state = readJsonSafe(join(harnessDir, 'state.json'));
74
+
75
+ // Find active session
76
+ const session = findActiveSession(join(harnessDir, 'sessions'));
77
+
78
+ // Extract recent decisions (last 5)
79
+ const decisions = state?.decisions ?? [];
80
+ const recentDecisions = decisions.slice(-5).map((d) =>
81
+ typeof d === 'string' ? d : (d?.decision ?? d?.summary ?? JSON.stringify(d))
82
+ );
83
+
84
+ // Extract open questions / blockers
85
+ const openQuestions = state?.blockers ?? [];
86
+
87
+ // Determine current phase from session state
88
+ const currentPhase = session?.state?.currentState
89
+ ?? (state?.position?.phase ?? null);
90
+
91
+ // Build summary
92
+ const summary = {
93
+ timestamp: new Date().toISOString(),
94
+ sessionId: session?.dir ?? null,
95
+ activeStream: session?.state?.currentState ?? null,
96
+ recentDecisions,
97
+ openQuestions,
98
+ currentPhase,
99
+ };
100
+
101
+ mkdirSync(stateDir, { recursive: true });
102
+ writeFileSync(
103
+ join(stateDir, 'pre-compact-summary.json'),
104
+ JSON.stringify(summary, null, 2) + '\n'
105
+ );
106
+
107
+ process.stderr.write('[pre-compact-state] Saved pre-compact summary\n');
108
+ process.exit(0);
109
+ } catch (err) {
110
+ process.stderr.write(`[pre-compact-state] Failed to save summary: ${String(err?.message ?? err)}\n`);
111
+ process.exit(0);
112
+ }
113
+ }
114
+
115
+ main();