@harness-engineering/cli 1.25.0 → 1.25.1

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.
@@ -2,7 +2,7 @@
2
2
  import {
3
3
  createProgram,
4
4
  printFirstRunWelcome
5
- } from "../chunk-AZT567I6.js";
5
+ } from "../chunk-CJXVNDZZ.js";
6
6
  import "../chunk-5BQ5BOJL.js";
7
7
  import "../chunk-EHRZMOQ2.js";
8
8
  import "../chunk-XTITAVUR.js";
@@ -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];
@@ -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();
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Hook profile definitions.
3
+ *
4
+ * Profiles are additive: each higher tier includes all hooks from lower tiers.
5
+ * - minimal: safety floor (block-no-verify only)
6
+ * - standard: + protect-config, quality-gate, pre-compact-state (default)
7
+ * - strict: + cost-tracker, sentinel-pre, sentinel-post
8
+ */
9
+
10
+ export type HookProfile = 'minimal' | 'standard' | 'strict';
11
+
12
+ export interface HookScript {
13
+ /** Script filename without .js extension */
14
+ name: string;
15
+ /** Claude Code hook event */
16
+ event: 'PreToolUse' | 'PostToolUse' | 'PreCompact' | 'Stop';
17
+ /** Tool matcher pattern */
18
+ matcher: string;
19
+ /** Minimum profile tier that includes this hook */
20
+ minProfile: HookProfile;
21
+ }
22
+
23
+ export const HOOK_SCRIPTS: HookScript[] = [
24
+ { name: 'block-no-verify', event: 'PreToolUse', matcher: 'Bash', minProfile: 'minimal' },
25
+ { name: 'protect-config', event: 'PreToolUse', matcher: 'Write|Edit', minProfile: 'standard' },
26
+ { name: 'quality-gate', event: 'PostToolUse', matcher: 'Edit|Write', minProfile: 'standard' },
27
+ { name: 'pre-compact-state', event: 'PreCompact', matcher: '*', minProfile: 'standard' },
28
+ { name: 'adoption-tracker', event: 'Stop', matcher: '*', minProfile: 'standard' },
29
+ { name: 'telemetry-reporter', event: 'Stop', matcher: '*', minProfile: 'standard' },
30
+ { name: 'cost-tracker', event: 'Stop', matcher: '*', minProfile: 'strict' },
31
+ { name: 'sentinel-pre', event: 'PreToolUse', matcher: '*', minProfile: 'strict' },
32
+ { name: 'sentinel-post', event: 'PostToolUse', matcher: '*', minProfile: 'strict' },
33
+ ];
34
+
35
+ const PROFILE_ORDER: HookProfile[] = ['minimal', 'standard', 'strict'];
36
+
37
+ function hooksForProfile(profile: HookProfile): string[] {
38
+ const profileIndex = PROFILE_ORDER.indexOf(profile);
39
+ return HOOK_SCRIPTS.filter((h) => PROFILE_ORDER.indexOf(h.minProfile) <= profileIndex).map(
40
+ (h) => h.name
41
+ );
42
+ }
43
+
44
+ export const PROFILES: Record<HookProfile, string[]> = {
45
+ minimal: hooksForProfile('minimal'),
46
+ standard: hooksForProfile('standard'),
47
+ strict: hooksForProfile('strict'),
48
+ };
@@ -0,0 +1,75 @@
1
+ #!/usr/bin/env node
2
+ // protect-config.js — PreToolUse:Write/Edit hook
3
+ // Blocks modifications to linter/formatter config files.
4
+ // Fail-open: parse errors and unexpected exceptions log to stderr and exit 0.
5
+ // Exit codes: 0 = allow, 2 = block
6
+
7
+ import { readFileSync } from 'node:fs';
8
+ import { basename } from 'node:path';
9
+ import process from 'node:process';
10
+
11
+ // Protected config file patterns
12
+ const PROTECTED_PATTERNS = [
13
+ /^\.eslintrc/,
14
+ /^eslint\.config\./,
15
+ /^\.prettierrc/,
16
+ /^prettier\.config\./,
17
+ /^biome\.json$/,
18
+ /^biome\.jsonc$/,
19
+ /^\.ruff\.toml$/,
20
+ /^ruff\.toml$/,
21
+ /^\.stylelintrc/,
22
+ /^\.markdownlint/,
23
+ /^deno\.json$/,
24
+ ];
25
+
26
+ function isProtected(filePath) {
27
+ const base = basename(filePath);
28
+ return PROTECTED_PATTERNS.some((pattern) => pattern.test(base));
29
+ }
30
+
31
+ function main() {
32
+ let raw;
33
+ try {
34
+ raw = readFileSync(0, 'utf-8');
35
+ } catch {
36
+ process.stderr.write('[protect-config] Could not read stdin — allowing (fail-open)\n');
37
+ process.exit(0);
38
+ }
39
+
40
+ if (!raw.trim()) {
41
+ process.stderr.write('[protect-config] Empty stdin — allowing (fail-open)\n');
42
+ process.exit(0);
43
+ }
44
+
45
+ let input;
46
+ try {
47
+ input = JSON.parse(raw);
48
+ } catch {
49
+ process.stderr.write('[protect-config] Could not parse stdin JSON — allowing (fail-open)\n');
50
+ process.exit(0);
51
+ }
52
+
53
+ try {
54
+ const filePath = input?.tool_input?.file_path;
55
+
56
+ if (typeof filePath !== 'string' || !filePath) {
57
+ process.stderr.write('[protect-config] Missing file_path in tool input — allowing (fail-open)\n');
58
+ process.exit(0);
59
+ }
60
+
61
+ if (isProtected(filePath)) {
62
+ process.stderr.write(
63
+ `BLOCKED: Modification to protected config file: ${basename(filePath)}. Linter/formatter configs must not be weakened.\n`
64
+ );
65
+ process.exit(2);
66
+ }
67
+
68
+ process.exit(0);
69
+ } catch {
70
+ process.stderr.write('[protect-config] Unexpected error — allowing (fail-open)\n');
71
+ process.exit(0);
72
+ }
73
+ }
74
+
75
+ main();