@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.
- package/dist/bin/harness-mcp.js +1 -1
- package/dist/bin/harness.js +93 -2
- package/dist/{chunk-AZT567I6.js → chunk-5I3W4J5X.js} +38 -11
- package/dist/{chunk-RX7TUMBR.js → chunk-TBGIHPNA.js} +2 -0
- package/dist/hooks/adoption-tracker.js +189 -0
- package/dist/hooks/block-no-verify.js +50 -0
- package/dist/hooks/cost-tracker.js +66 -0
- package/dist/hooks/pre-compact-state.js +115 -0
- package/dist/hooks/profiles.ts +48 -0
- package/dist/hooks/protect-config.js +75 -0
- package/dist/hooks/quality-gate.js +131 -0
- package/dist/hooks/sentinel-post.js +159 -0
- package/dist/hooks/sentinel-pre.js +244 -0
- package/dist/hooks/telemetry-reporter.js +248 -0
- package/dist/index.js +2 -2
- package/dist/{mcp-2553PNUC.js → mcp-W3FLXSFF.js} +1 -1
- package/package.json +1 -1
package/dist/bin/harness-mcp.js
CHANGED
package/dist/bin/harness.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import {
|
|
3
3
|
createProgram,
|
|
4
4
|
printFirstRunWelcome
|
|
5
|
-
} from "../chunk-
|
|
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-
|
|
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-
|
|
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
|
|
3115
|
-
|
|
3116
|
-
|
|
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(
|
|
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: [
|
|
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
|
|
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-
|
|
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
|
|
6332
|
+
const projectName = await promptRaw(" Project name: ");
|
|
6306
6333
|
if (projectName) identity.project = projectName;
|
|
6307
|
-
const teamName = await
|
|
6334
|
+
const teamName = await promptRaw(" Team name: ");
|
|
6308
6335
|
if (teamName) identity.team = teamName;
|
|
6309
|
-
const alias = await
|
|
6336
|
+
const alias = await promptRaw(" Alias: ");
|
|
6310
6337
|
if (alias) identity.alias = alias;
|
|
6311
6338
|
}
|
|
6312
6339
|
return identity;
|
|
@@ -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();
|