harnesstrim 0.0.4 → 0.0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 HarnessTrim contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -2,7 +2,7 @@
2
2
  //
3
3
  // Pi fires `tool_result` after a tool finishes and before the result reaches the model;
4
4
  // handlers chain like middleware and may return a patch ({ content, details, isError }).
5
- // This extension reduces string `content` for noisy output (test runners, git diffs, ...)
5
+ // This extension reduces text chunks in structured tool results (test runners, git diffs, ...)
6
6
  // by shelling out to `harnesstrim reduce`, so it is self-contained (no workspace imports)
7
7
  // and loads from `~/.pi/agent/extensions/` or `<project>/.pi/extensions/`.
8
8
  //
@@ -12,15 +12,19 @@
12
12
  // HARNESSTRIM_MINLENGTH=<chars> (default 400)
13
13
  import { spawnSync } from "node:child_process";
14
14
 
15
+ type TextContent = { type: "text"; text: string };
16
+ type ToolContent = TextContent | { type: string; [key: string]: unknown };
17
+
15
18
  interface ToolResultEvent {
16
- content?: unknown;
19
+ content?: ToolContent[];
17
20
  isError?: boolean;
18
21
  }
19
22
  interface ExtensionAPI {
20
23
  on(event: string, handler: (event: ToolResultEvent, ctx: unknown) => unknown): void;
21
24
  }
22
25
 
23
- const env = globalThis.process?.env ?? {};
26
+ const runtime = globalThis as typeof globalThis & { process?: NodeJS.Process };
27
+ const env = runtime.process?.env ?? {};
24
28
  const MODE = env.HARNESSTRIM_MODE ?? "dryrun";
25
29
  const MIN_LENGTH = Number(env.HARNESSTRIM_MINLENGTH ?? "400") || 400;
26
30
  const MARKER = "[harnesstrim";
@@ -44,19 +48,28 @@ function reduceViaCli(text: string): string | null {
44
48
  export default function harnesstrim(pi: ExtensionAPI): void {
45
49
  if (MODE === "off") return;
46
50
  pi.on("tool_result", async (event) => {
47
- const content = event.content;
48
- if (typeof content !== "string" || content.length < MIN_LENGTH) return;
49
- if (content.includes(MARKER)) return; // already reduced — avoid double work
50
-
51
- const reduced = reduceViaCli(content);
52
- if (!reduced || reduced.length >= content.length) return;
53
-
54
- if (MODE === "dryrun") {
55
- globalThis.process?.stderr?.write(
56
- `[harnesstrim] dryrun tool_result: ${content.length} -> ${reduced.length} chars\n`
57
- );
58
- return; // dryrun: observe only
59
- }
60
- return { content: reduced }; // active: patch the tool result the model sees
51
+ if (!Array.isArray(event.content)) return;
52
+
53
+ let changed = false;
54
+ const content = event.content.map((chunk) => {
55
+ if (chunk.type !== "text" || typeof chunk.text !== "string") return chunk;
56
+ const text = chunk.text;
57
+ if (text.length < MIN_LENGTH || text.includes(MARKER)) return chunk;
58
+
59
+ const reduced = reduceViaCli(text);
60
+ if (!reduced || reduced.length >= text.length) return chunk;
61
+
62
+ if (MODE === "dryrun") {
63
+ runtime.process?.stderr?.write(
64
+ `[harnesstrim] dryrun tool_result: ${text.length} -> ${reduced.length} chars\n`
65
+ );
66
+ return chunk;
67
+ }
68
+
69
+ changed = true;
70
+ return { ...chunk, text: reduced };
71
+ });
72
+
73
+ return changed ? { content } : undefined;
61
74
  });
62
75
  }
package/dist/cli.mjs CHANGED
@@ -581,6 +581,75 @@ ${input.slice(response.index)}`;
581
581
  }
582
582
  });
583
583
 
584
+ // ../core/src/reducers/lint-output-slim.ts
585
+ function isLintLine(line) {
586
+ return LINT_LINE_RE.test(line);
587
+ }
588
+ var MARKER_PREFIX7, LINT_LINE_RE, MAX_RULES_IN_MARKER, lintOutputSlim;
589
+ var init_lint_output_slim = __esm({
590
+ "../core/src/reducers/lint-output-slim.ts"() {
591
+ "use strict";
592
+ MARKER_PREFIX7 = "[harnesstrim:lint-output-slim]";
593
+ LINT_LINE_RE = /^[\w.\/\\-]+:\d+:\d+\s+(warning|error)\s+([\w@.\/-]+)/;
594
+ MAX_RULES_IN_MARKER = 8;
595
+ lintOutputSlim = {
596
+ name: "lint-output-slim",
597
+ reduce(input) {
598
+ const lines = input.split(/\r?\n/);
599
+ const out = [];
600
+ let droppedTotal = 0;
601
+ let i = 0;
602
+ while (i < lines.length) {
603
+ const line = lines[i];
604
+ if (!isLintLine(line)) {
605
+ out.push(line);
606
+ i++;
607
+ continue;
608
+ }
609
+ const counts = [];
610
+ const byKey = /* @__PURE__ */ new Map();
611
+ let runEnd = i;
612
+ while (runEnd < lines.length && isLintLine(lines[runEnd])) {
613
+ const m = LINT_LINE_RE.exec(lines[runEnd]);
614
+ const severity = m[1] === "error" ? "error" : "warning";
615
+ const rule = m[2];
616
+ const key = `${severity}:${rule}`;
617
+ const existing = byKey.get(key);
618
+ if (existing) {
619
+ existing.count++;
620
+ } else {
621
+ const entry = { severity, rule, count: 1 };
622
+ byKey.set(key, entry);
623
+ counts.push(entry);
624
+ }
625
+ runEnd++;
626
+ }
627
+ const runLength = runEnd - i;
628
+ if (runLength >= 2) {
629
+ const parts = counts.slice(0, MAX_RULES_IN_MARKER).map(
630
+ (c) => `${c.rule} \xD7${c.count}`
631
+ );
632
+ const truncated = counts.length > MAX_RULES_IN_MARKER;
633
+ const suffix = truncated ? `, +${counts.length - MAX_RULES_IN_MARKER} more rule(s)` : "";
634
+ const severities = counts.some((c) => c.severity === "error") && counts.some((c) => c.severity === "warning") ? "error(s) and warning(s)" : counts.some((c) => c.severity === "error") ? "error(s)" : "warning(s)";
635
+ out.push(`${MARKER_PREFIX7} omitted ${runLength} lint line(s) (${severities}: ${parts.join(", ")}${suffix})`);
636
+ droppedTotal += runLength;
637
+ } else {
638
+ for (let j = i; j < runEnd; j++) out.push(lines[j]);
639
+ }
640
+ i = runEnd;
641
+ }
642
+ const output = out.join("\n");
643
+ return {
644
+ output,
645
+ changed: droppedTotal > 0,
646
+ note: droppedTotal > 0 ? `dropped ${droppedTotal} lint noise line(s)` : void 0
647
+ };
648
+ }
649
+ };
650
+ }
651
+ });
652
+
584
653
  // ../core/src/reducers/index.ts
585
654
  var init_reducers = __esm({
586
655
  "../core/src/reducers/index.ts"() {
@@ -592,6 +661,7 @@ var init_reducers = __esm({
592
661
  init_json_output_slim();
593
662
  init_file_listing_slim();
594
663
  init_cron_output_slim();
664
+ init_lint_output_slim();
595
665
  }
596
666
  });
597
667
 
@@ -600,6 +670,7 @@ function pickReducer(text) {
600
670
  if (GIT_DIFF_RE.test(text)) return gitDiffSlim;
601
671
  if (TEST_OUTPUT_RE.test(text)) return testOutputSlim;
602
672
  if (CRON_OUTPUT_RE.test(text) && text.length >= 400) return cronOutputSlim;
673
+ if (LINT_OUTPUT_RE.test(text) && text.length >= 400) return lintOutputSlim;
603
674
  if (JSON_RE.test(text) && text.length >= 400) return jsonOutputSlim;
604
675
  if (FILE_LISTING_RE.test(text) && text.length >= 400) return fileListingSlim;
605
676
  if (LONG_TEXT_RE.test(text) && text.length >= 1e3) return genericTextSlim;
@@ -619,7 +690,7 @@ function reduceAuto(text, minLength = DEFAULT_MIN_LENGTH) {
619
690
  }
620
691
  return { ...result, reducer: reducer.name };
621
692
  }
622
- var DEFAULT_MIN_LENGTH, GIT_DIFF_RE, TEST_OUTPUT_RE, JSON_RE, FILE_LISTING_RE, CRON_OUTPUT_RE, LONG_TEXT_RE;
693
+ var DEFAULT_MIN_LENGTH, GIT_DIFF_RE, TEST_OUTPUT_RE, JSON_RE, FILE_LISTING_RE, CRON_OUTPUT_RE, LINT_OUTPUT_RE, LONG_TEXT_RE;
623
694
  var init_dispatch = __esm({
624
695
  "../core/src/dispatch.ts"() {
625
696
  "use strict";
@@ -629,12 +700,14 @@ var init_dispatch = __esm({
629
700
  init_json_output_slim();
630
701
  init_file_listing_slim();
631
702
  init_cron_output_slim();
703
+ init_lint_output_slim();
632
704
  DEFAULT_MIN_LENGTH = 400;
633
705
  GIT_DIFF_RE = /^diff --git /m;
634
706
  TEST_OUTPUT_RE = /\b\d+\s+(passed|failed)\b|^(PASS|FAIL)\s|::\w.*\b(PASSED|FAILED)\b|=+\s*(FAILURES|short test summary)/im;
635
707
  JSON_RE = /^\s*[\[{]/m;
636
708
  FILE_LISTING_RE = /(?:^total\s+\d+|^[\-bcdlsp][\-r][\-w][\-xs\-][\-r][\-w][\-xs\-][\-r][\-w][\-xs\-]|^\.\/(?:\.|[^.\s])|^\s*(?:├──|└──|│\s+)|^[\w.\/\-]+\.[a-zA-Z]{1,4}:\d+\|)/m;
637
709
  CRON_OUTPUT_RE = /^# Cron Job:.*\n[\s\S]*^## Prompt\s*$[\s\S]*^## Response\s*$/m;
710
+ LINT_OUTPUT_RE = /^[\w.\/\\-]+:\d+:\d+\s+(?:warning|error)\s+[\w@.\/-]+\s/m;
638
711
  LONG_TEXT_RE = /^#{1,4}\s.*\n(?:(?!^#{1,4}\s|^diff --git |^```).*\n){5,}/m;
639
712
  }
640
713
  });
@@ -1166,8 +1239,8 @@ var init_parseUtil = __esm({
1166
1239
  init_errors();
1167
1240
  init_en();
1168
1241
  makeIssue = (params) => {
1169
- const { data, path: path15, errorMaps, issueData } = params;
1170
- const fullPath = [...path15, ...issueData.path || []];
1242
+ const { data, path: path16, errorMaps, issueData } = params;
1243
+ const fullPath = [...path16, ...issueData.path || []];
1171
1244
  const fullIssue = {
1172
1245
  ...issueData,
1173
1246
  path: fullPath
@@ -1475,11 +1548,11 @@ var init_types2 = __esm({
1475
1548
  init_parseUtil();
1476
1549
  init_util();
1477
1550
  ParseInputLazyPath = class {
1478
- constructor(parent, value, path15, key) {
1551
+ constructor(parent, value, path16, key) {
1479
1552
  this._cachedPath = [];
1480
1553
  this.parent = parent;
1481
1554
  this.data = value;
1482
- this._path = path15;
1555
+ this._path = path16;
1483
1556
  this._key = key;
1484
1557
  }
1485
1558
  get path() {
@@ -5060,10 +5133,10 @@ function assignProp(target, prop, value) {
5060
5133
  configurable: true
5061
5134
  });
5062
5135
  }
5063
- function getElementAtPath(obj, path15) {
5064
- if (!path15)
5136
+ function getElementAtPath(obj, path16) {
5137
+ if (!path16)
5065
5138
  return obj;
5066
- return path15.reduce((acc, key) => acc?.[key], obj);
5139
+ return path16.reduce((acc, key) => acc?.[key], obj);
5067
5140
  }
5068
5141
  function promiseAllObject(promisesObj) {
5069
5142
  const keys = Object.keys(promisesObj);
@@ -5312,11 +5385,11 @@ function aborted(x, startIndex = 0) {
5312
5385
  }
5313
5386
  return false;
5314
5387
  }
5315
- function prefixIssues(path15, issues) {
5388
+ function prefixIssues(path16, issues) {
5316
5389
  return issues.map((iss) => {
5317
5390
  var _a;
5318
5391
  (_a = iss).path ?? (_a.path = []);
5319
- iss.path.unshift(path15);
5392
+ iss.path.unshift(path16);
5320
5393
  return iss;
5321
5394
  });
5322
5395
  }
@@ -17146,8 +17219,8 @@ var require_utils = __commonJS({
17146
17219
  }
17147
17220
  return ind;
17148
17221
  }
17149
- function removeDotSegments(path15) {
17150
- let input = path15;
17222
+ function removeDotSegments(path16) {
17223
+ let input = path16;
17151
17224
  const output = [];
17152
17225
  let nextSlash = -1;
17153
17226
  let len = 0;
@@ -17399,8 +17472,8 @@ var require_schemes = __commonJS({
17399
17472
  wsComponent.secure = void 0;
17400
17473
  }
17401
17474
  if (wsComponent.resourceName) {
17402
- const [path15, query] = wsComponent.resourceName.split("?");
17403
- wsComponent.path = path15 && path15 !== "/" ? path15 : void 0;
17475
+ const [path16, query] = wsComponent.resourceName.split("?");
17476
+ wsComponent.path = path16 && path16 !== "/" ? path16 : void 0;
17404
17477
  wsComponent.query = query;
17405
17478
  wsComponent.resourceName = void 0;
17406
17479
  }
@@ -20793,12 +20866,12 @@ var require_dist = __commonJS({
20793
20866
  throw new Error(`Unknown format "${name}"`);
20794
20867
  return f;
20795
20868
  };
20796
- function addFormats(ajv, list, fs11, exportName) {
20869
+ function addFormats(ajv, list, fs12, exportName) {
20797
20870
  var _a;
20798
20871
  var _b;
20799
20872
  (_a = (_b = ajv.opts.code).formats) !== null && _a !== void 0 ? _a : _b.formats = (0, codegen_1._)`require("ajv-formats/dist/formats").${exportName}`;
20800
20873
  for (const f of list)
20801
- ajv.addFormat(f, fs11[f]);
20874
+ ajv.addFormat(f, fs12[f]);
20802
20875
  }
20803
20876
  module.exports = exports = formatsPlugin;
20804
20877
  Object.defineProperty(exports, "__esModule", { value: true });
@@ -22554,15 +22627,39 @@ var init_stdio2 = __esm({
22554
22627
  // ../mcp/src/server.ts
22555
22628
  var server_exports = {};
22556
22629
  __export(server_exports, {
22630
+ createFileSink: () => createFileSink,
22557
22631
  createServer: () => createServer,
22558
22632
  runReduceTool: () => runReduceTool,
22559
22633
  startStdioServer: () => startStdioServer
22560
22634
  });
22561
- function runReduceTool(text, minLength) {
22635
+ import fs10 from "node:fs";
22636
+ import path14 from "node:path";
22637
+ function createFileSink(metricsPath) {
22638
+ return (event) => {
22639
+ try {
22640
+ const p = path14.resolve(metricsPath);
22641
+ fs10.mkdirSync(path14.dirname(p), { recursive: true });
22642
+ fs10.appendFileSync(p, JSON.stringify(event) + "\n");
22643
+ } catch {
22644
+ }
22645
+ };
22646
+ }
22647
+ function runReduceTool(text, minLength, sink = noopSink) {
22562
22648
  const result = reduceAuto(text, minLength);
22649
+ if (result.changed) {
22650
+ sink({
22651
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
22652
+ harness: "mcp",
22653
+ tool: "reduce",
22654
+ reducer: result.reducer,
22655
+ beforeChars: text.length,
22656
+ afterChars: result.output.length
22657
+ });
22658
+ }
22563
22659
  return { content: [{ type: "text", text: result.output }] };
22564
22660
  }
22565
- function createServer() {
22661
+ function createServer(options = {}) {
22662
+ const sink = options.metricsPath ? createFileSink(options.metricsPath) : noopSink;
22566
22663
  const server = new McpServer({ name: "harnesstrim", version: "0.0.1" });
22567
22664
  server.registerTool(
22568
22665
  "reduce",
@@ -22574,16 +22671,17 @@ function createServer() {
22574
22671
  minLength: external_exports.number().optional().describe("Skip reduction for inputs shorter than this many characters (default 400)")
22575
22672
  }
22576
22673
  },
22577
- async ({ text, minLength }) => runReduceTool(text, minLength)
22674
+ async ({ text, minLength }) => runReduceTool(text, minLength, sink)
22578
22675
  );
22579
22676
  return server;
22580
22677
  }
22581
- async function startStdioServer() {
22582
- const server = createServer();
22678
+ async function startStdioServer(options = {}) {
22679
+ const metricsPath = options.metricsPath ?? process.env.HARNESSTRIM_TELEMETRY_PATH;
22680
+ const server = createServer({ metricsPath });
22583
22681
  const transport = new StdioServerTransport();
22584
22682
  await server.connect(transport);
22585
22683
  }
22586
- var REDUCE_DESCRIPTION;
22684
+ var noopSink, REDUCE_DESCRIPTION;
22587
22685
  var init_server3 = __esm({
22588
22686
  "../mcp/src/server.ts"() {
22589
22687
  "use strict";
@@ -22591,6 +22689,8 @@ var init_server3 = __esm({
22591
22689
  init_stdio2();
22592
22690
  init_zod();
22593
22691
  init_src();
22692
+ noopSink = () => {
22693
+ };
22594
22694
  REDUCE_DESCRIPTION = "Slim noisy text to its signal: keeps failures, errors, assertions and summaries while dropping passing-test noise and generated-file (lockfile/dist) diffs. Pass test-runner output or a git diff and use the returned text instead of the raw output. Deterministic and idempotent; returns the input unchanged if no reducer matches or it is too short.";
22595
22695
  }
22596
22696
  });
@@ -22598,9 +22698,9 @@ var init_server3 = __esm({
22598
22698
  // src/cli.ts
22599
22699
  init_src();
22600
22700
  import { parseArgs } from "node:util";
22601
- import fs10 from "node:fs";
22602
- import path14 from "node:path";
22603
- import os2 from "node:os";
22701
+ import fs11 from "node:fs";
22702
+ import path15 from "node:path";
22703
+ import os4 from "node:os";
22604
22704
 
22605
22705
  // src/doctor.ts
22606
22706
  import fs from "node:fs";
@@ -22870,7 +22970,9 @@ function runInstallOpencode(dir, apply, presetName, installDeps = true) {
22870
22970
  const npm = process.platform === "win32" ? "npm.cmd" : "npm";
22871
22971
  const res = spawnSync(npm, ["install", "--silent"], {
22872
22972
  cwd: path2.dirname(packageJsonPath),
22873
- encoding: "utf8"
22973
+ encoding: "utf8",
22974
+ input: "",
22975
+ timeout: 12e4
22874
22976
  });
22875
22977
  if (res.error || res.status !== 0) {
22876
22978
  depsInstalled = false;
@@ -22895,6 +22997,7 @@ function runInstallOpencode(dir, apply, presetName, installDeps = true) {
22895
22997
 
22896
22998
  // src/install-codex.ts
22897
22999
  import fs5 from "node:fs";
23000
+ import os from "node:os";
22898
23001
  import path6 from "node:path";
22899
23002
 
22900
23003
  // ../adapter-codex/src/index.ts
@@ -22966,9 +23069,31 @@ function hasHarnessTrimHook(document) {
22966
23069
  const post = hooks?.PostToolUse;
22967
23070
  if (!Array.isArray(post)) return false;
22968
23071
  return post.some(
22969
- (entry) => Array.isArray(entry?.hooks) && entry.hooks.some((hook) => typeof hook?.command === "string" && hook.command.includes("harnesstrim hook codex"))
23072
+ (entry) => Array.isArray(entry?.hooks) && entry.hooks.some(
23073
+ (hook) => typeof hook?.command === "string" && hook.command.includes("harnesstrim") && hook.command.includes("hook codex")
23074
+ )
22970
23075
  );
22971
23076
  }
23077
+ function hasExactHarnessTrimHookCommand(document, command) {
23078
+ const hooks = document.hooks;
23079
+ const post = hooks?.PostToolUse;
23080
+ return Array.isArray(post) && post.some(
23081
+ (entry) => Array.isArray(entry?.hooks) && entry.hooks.some((hook) => hook?.command === command)
23082
+ );
23083
+ }
23084
+ function replaceHarnessTrimHookCommand(document, command) {
23085
+ const hooks = document.hooks;
23086
+ const post = hooks?.PostToolUse;
23087
+ if (!Array.isArray(post)) return;
23088
+ for (const entry of post) {
23089
+ if (!Array.isArray(entry?.hooks)) continue;
23090
+ for (const hook of entry.hooks) {
23091
+ if (typeof hook?.command === "string" && hook.command.includes("harnesstrim") && hook.command.includes("hook codex")) {
23092
+ hook.command = command;
23093
+ }
23094
+ }
23095
+ }
23096
+ }
22972
23097
  function planCodexHookInstall(input) {
22973
23098
  let document = {};
22974
23099
  let action;
@@ -22985,14 +23110,21 @@ function planCodexHookInstall(input) {
22985
23110
  throw new Error(".codex/hooks.json must contain a JSON object; refusing to overwrite it.");
22986
23111
  }
22987
23112
  document = parsed;
22988
- action = hasHarnessTrimHook(document) ? "present" : "patch";
23113
+ action = hasHarnessTrimHook(document) && (!input.hookCommand || hasExactHarnessTrimHookCommand(document, input.hookCommand)) ? "present" : "patch";
22989
23114
  }
22990
23115
  if (action === "present") {
22991
23116
  return { hooksFile: path3.join(input.projectDir, ".codex", "hooks.json"), action, nextHooks: document };
22992
23117
  }
23118
+ if (hasHarnessTrimHook(document)) {
23119
+ replaceHarnessTrimHookCommand(document, input.hookCommand ?? CODEX_HOOK_COMMAND);
23120
+ return { hooksFile: path3.join(input.projectDir, ".codex", "hooks.json"), action, nextHooks: document };
23121
+ }
22993
23122
  const hooks = { ...document.hooks ?? {} };
22994
23123
  const post = Array.isArray(hooks.PostToolUse) ? [...hooks.PostToolUse] : [];
22995
- post.push({ matcher: CODEX_HOOK_MATCHER, hooks: [{ type: "command", command: CODEX_HOOK_COMMAND }] });
23124
+ post.push({
23125
+ matcher: CODEX_HOOK_MATCHER,
23126
+ hooks: [{ type: "command", command: input.hookCommand ?? CODEX_HOOK_COMMAND }]
23127
+ });
22996
23128
  hooks.PostToolUse = post;
22997
23129
  return {
22998
23130
  hooksFile: path3.join(input.projectDir, ".codex", "hooks.json"),
@@ -23067,6 +23199,13 @@ function existingSkillNames(dest) {
23067
23199
  }
23068
23200
 
23069
23201
  // src/install-codex.ts
23202
+ function resolveCodexHookCommand() {
23203
+ if (process.platform !== "win32") return void 0;
23204
+ const pnpmHome = process.env.PNPM_HOME ?? path6.join(process.env.LOCALAPPDATA ?? path6.join(os.homedir(), "AppData", "Local"), "pnpm");
23205
+ const shim = path6.join(pnpmHome, "harnesstrim.CMD");
23206
+ if (!fs5.existsSync(shim)) return void 0;
23207
+ return `"${shim}" hook codex --metrics .harnesstrim/metrics.jsonl`;
23208
+ }
23070
23209
  function readHooksJson(hooksPath) {
23071
23210
  try {
23072
23211
  return fs5.readFileSync(hooksPath, "utf8");
@@ -23100,7 +23239,7 @@ function runInstallCodex(dir, apply, hook = false) {
23100
23239
  });
23101
23240
  const hooksPath = path6.join(dir, ".codex", "hooks.json");
23102
23241
  const hooksJsonContent = hook ? readHooksJson(hooksPath) : null;
23103
- const hookPlan = hook ? planCodexHookInstall({ projectDir: dir, hooksJsonContent }) : null;
23242
+ const hookPlan = hook ? planCodexHookInstall({ projectDir: dir, hooksJsonContent, hookCommand: resolveCodexHookCommand() }) : null;
23104
23243
  const copied = [];
23105
23244
  let applied = false;
23106
23245
  if (apply) {
@@ -23125,7 +23264,8 @@ function runInstallCodexGlobalHook(codexHome, apply) {
23125
23264
  // The planner expects the directory that contains .codex; for a user-level config
23126
23265
  // the Codex home is itself that directory, so add its parent and use a normal path.
23127
23266
  projectDir: path6.dirname(codexHome),
23128
- hooksJsonContent: readHooksJson(hooksPath)
23267
+ hooksJsonContent: readHooksJson(hooksPath),
23268
+ hookCommand: resolveCodexHookCommand()
23129
23269
  });
23130
23270
  return { hookPlan, applied: applyHookPlan(hookPlan, apply) };
23131
23271
  }
@@ -23187,6 +23327,19 @@ function extractOutputText2(p) {
23187
23327
  import path7 from "node:path";
23188
23328
  var HOOK_COMMAND = "harnesstrim hook claude";
23189
23329
  var HOOK_MATCHER = "Bash";
23330
+ var HARNESSTRIM_MARKER2 = "harnesstrim:begin";
23331
+ var REDUCE_INSTRUCTION_SNIPPET2 = `<!-- ${HARNESSTRIM_MARKER2} -->
23332
+ ## Token economy (HarnessTrim)
23333
+
23334
+ When a shell command produces long, noisy output \u2014 test runners, \`git diff\`, build logs,
23335
+ large file dumps \u2014 pipe it through the reducer so only the signal enters context:
23336
+
23337
+ <your command> 2>&1 | harnesstrim reduce --metrics .harnesstrim/metrics.jsonl
23338
+
23339
+ This keeps failures, errors, assertions, and summaries while dropping passing-test noise and
23340
+ generated-file (lockfile/dist) diffs, and records what was saved. Prefer the installed skills
23341
+ for output, review, and scaffolding discipline.
23342
+ <!-- harnesstrim:end -->`;
23190
23343
  function hasHarnessTrimHook2(settings) {
23191
23344
  const hooks = settings.hooks;
23192
23345
  const post = hooks?.PostToolUse;
@@ -23218,12 +23371,16 @@ function planClaudeInstall(input) {
23218
23371
  action = hasHarnessTrimHook2(settings) ? "present" : "patch";
23219
23372
  }
23220
23373
  const nextSettings = action === "present" ? settings : addHook(settings);
23374
+ const instructionsAction = input.claudeMdContent === null ? "create" : input.claudeMdContent.includes(HARNESSTRIM_MARKER2) ? "present" : "append";
23221
23375
  return {
23222
23376
  skillsDest,
23223
23377
  skills,
23224
23378
  settingsFile: path7.join(input.projectDir, ".claude", "settings.json"),
23225
23379
  settingsAction: action,
23226
- nextSettings
23380
+ nextSettings,
23381
+ instructionsFile: path7.join(input.projectDir, "CLAUDE.md"),
23382
+ instructionsAction,
23383
+ instructionsSnippet: REDUCE_INSTRUCTION_SNIPPET2
23227
23384
  };
23228
23385
  }
23229
23386
  function addHook(settings) {
@@ -23248,11 +23405,19 @@ function runInstallClaude(dir, apply) {
23248
23405
  } catch {
23249
23406
  settingsJsonContent = null;
23250
23407
  }
23408
+ const claudeMdPath = path8.join(dir, "CLAUDE.md");
23409
+ let claudeMdContent = null;
23410
+ try {
23411
+ claudeMdContent = fs6.readFileSync(claudeMdPath, "utf8");
23412
+ } catch {
23413
+ claudeMdContent = null;
23414
+ }
23251
23415
  const plan = planClaudeInstall({
23252
23416
  projectDir: dir,
23253
23417
  skillsSourceDir,
23254
23418
  skillNames,
23255
23419
  settingsJsonContent,
23420
+ claudeMdContent,
23256
23421
  existingSkillNames: existingSkillNames(skillsDest)
23257
23422
  });
23258
23423
  const copied = [];
@@ -23267,6 +23432,11 @@ function runInstallClaude(dir, apply) {
23267
23432
  fs6.mkdirSync(path8.dirname(plan.settingsFile), { recursive: true });
23268
23433
  fs6.writeFileSync(plan.settingsFile, JSON.stringify(plan.nextSettings, null, 2) + "\n");
23269
23434
  }
23435
+ if (plan.instructionsAction === "create") {
23436
+ fs6.writeFileSync(plan.instructionsFile, plan.instructionsSnippet + "\n");
23437
+ } else if (plan.instructionsAction === "append") {
23438
+ fs6.appendFileSync(plan.instructionsFile, "\n\n" + plan.instructionsSnippet + "\n");
23439
+ }
23270
23440
  applied = true;
23271
23441
  }
23272
23442
  return { plan, applied, copied };
@@ -23275,12 +23445,13 @@ function runInstallClaude(dir, apply) {
23275
23445
  // src/install-pi.ts
23276
23446
  import fs7 from "node:fs";
23277
23447
  import path10 from "node:path";
23448
+ import os2 from "node:os";
23278
23449
 
23279
23450
  // ../adapter-pi/src/index.ts
23280
23451
  import path9 from "node:path";
23281
23452
  var PI_EXTENSION_NAME = "harnesstrim";
23282
23453
  function planPiInstall(input) {
23283
- const extensionDest = path9.join(input.installDir, ".pi", "extensions", PI_EXTENSION_NAME);
23454
+ const extensionDest = input.scope === "user" ? path9.join(input.installDir, ".pi", "agent", "extensions", PI_EXTENSION_NAME) : path9.join(input.installDir, ".pi", "extensions", PI_EXTENSION_NAME);
23284
23455
  return {
23285
23456
  extensionDest,
23286
23457
  extensionSource: input.extensionSourceDir,
@@ -23315,16 +23486,18 @@ function markerPresent(dest) {
23315
23486
  }
23316
23487
  function runInstallPi(installDir, apply) {
23317
23488
  const extensionSourceDir = resolvePiExtensionSourceDir();
23318
- const dest = path10.join(installDir, ".pi", "extensions", "harnesstrim");
23489
+ const scope = path10.resolve(installDir) === path10.resolve(os2.homedir()) ? "user" : "project";
23490
+ const dest = scope === "user" ? path10.join(installDir, ".pi", "agent", "extensions", "harnesstrim") : path10.join(installDir, ".pi", "extensions", "harnesstrim");
23319
23491
  const plan = planPiInstall({
23320
23492
  installDir,
23321
23493
  extensionSourceDir,
23322
23494
  extensionDirExists: dirExists(dest),
23323
- markerPresent: markerPresent(dest)
23495
+ markerPresent: markerPresent(dest),
23496
+ scope
23324
23497
  });
23325
23498
  const copiedFiles = [];
23326
23499
  let applied = false;
23327
- if (apply && !plan.alreadyInstalled) {
23500
+ if (apply) {
23328
23501
  fs7.mkdirSync(dest, { recursive: true });
23329
23502
  for (const entry of fs7.readdirSync(extensionSourceDir, { withFileTypes: true })) {
23330
23503
  if (!entry.isDirectory()) {
@@ -23428,8 +23601,8 @@ function runInstallHermes(installDir, apply) {
23428
23601
  init_src();
23429
23602
  import fs9 from "node:fs";
23430
23603
  import path13 from "node:path";
23431
- import os from "node:os";
23432
- var HERMES_METRICS_PATH = path13.join(os.homedir(), ".hermes", "harnesstrim-metrics.jsonl");
23604
+ import os3 from "node:os";
23605
+ var HERMES_METRICS_PATH = path13.join(os3.homedir(), ".hermes", "harnesstrim-metrics.jsonl");
23433
23606
  var LOCAL_METRICS_PATH = ".harnesstrim/metrics.jsonl";
23434
23607
  var DEFAULT_METRICS_PATH = fs9.existsSync(HERMES_METRICS_PATH) ? HERMES_METRICS_PATH : LOCAL_METRICS_PATH;
23435
23608
  function loadMetrics(filePath) {
@@ -23445,9 +23618,46 @@ function loadMetrics(filePath) {
23445
23618
  return { path: filePath, found: true, summary: summarize(parseTrimEvents(raw)) };
23446
23619
  }
23447
23620
 
23621
+ // package.json
23622
+ var package_default = {
23623
+ name: "harnesstrim",
23624
+ version: "0.0.6",
23625
+ description: "HarnessTrim CLI: doctor (diagnose token waste), install adapters, run benchmarks.",
23626
+ license: "MIT",
23627
+ type: "module",
23628
+ bin: {
23629
+ harnesstrim: "./dist/cli.mjs"
23630
+ },
23631
+ files: [
23632
+ "dist",
23633
+ "assets"
23634
+ ],
23635
+ publishConfig: {
23636
+ access: "public"
23637
+ },
23638
+ devDependencies: {
23639
+ "@harnesstrim/adapter-claude": "workspace:*",
23640
+ "@harnesstrim/adapter-codex": "workspace:*",
23641
+ "@harnesstrim/adapter-hermes": "workspace:*",
23642
+ "@harnesstrim/adapter-pi": "workspace:*",
23643
+ "@harnesstrim/benchmarks": "workspace:*",
23644
+ "@harnesstrim/core": "workspace:*",
23645
+ "@harnesstrim/mcp": "workspace:*",
23646
+ esbuild: "^0.25.0"
23647
+ },
23648
+ scripts: {
23649
+ build: "node build.mjs",
23650
+ prepare: "node build.mjs",
23651
+ prepack: "node build.mjs",
23652
+ test: 'node --test "src/**/*.test.ts"',
23653
+ typecheck: "tsc -p tsconfig.json"
23654
+ }
23655
+ };
23656
+
23448
23657
  // src/reduce.ts
23449
23658
  init_src();
23450
23659
  async function readStdin() {
23660
+ if (process.stdin.isTTY) return "";
23451
23661
  const chunks = [];
23452
23662
  for await (const chunk of process.stdin) {
23453
23663
  chunks.push(chunk);
@@ -23619,7 +23829,19 @@ function renderClaudeInstall(result, apply) {
23619
23829
  }
23620
23830
  }
23621
23831
  lines.push("");
23622
- lines.push("Note: the hook command is `harnesstrim hook claude` \u2014 ensure harnesstrim is on PATH.");
23832
+ if (plan.instructionsAction === "present") {
23833
+ lines.push(`${plan.instructionsFile}: reduce-pipe instruction already present (no change).`);
23834
+ } else {
23835
+ lines.push(
23836
+ `${plan.instructionsFile}: reduce-pipe instruction ${apply ? plan.instructionsAction === "create" ? "created" : "appended" : "would be added"}.`
23837
+ );
23838
+ }
23839
+ lines.push("");
23840
+ lines.push("Note: `harnesstrim` must be on PATH (used by both the hook and the reduce pipe).");
23841
+ lines.push(
23842
+ "The CLAUDE.md instruction is the effective path today: current Claude Code versions don't apply"
23843
+ );
23844
+ lines.push("the hook's updatedToolOutput, so piping through `harnesstrim reduce` is what saves tokens.");
23623
23845
  if (!apply) lines.push("Dry run \u2014 nothing written. Re-run with `--apply`.");
23624
23846
  return lines.join("\n");
23625
23847
  }
@@ -23723,10 +23945,13 @@ Usage:
23723
23945
  harnesstrim preset list List policy presets
23724
23946
  harnesstrim preset show <name> Show a preset in detail
23725
23947
  harnesstrim metrics [path] Summarize adapter telemetry (JSONL)
23726
- harnesstrim reduce [--stats] Slim stdin -> stdout (pipe noisy command output)
23727
- harnesstrim mcp Start the MCP server (stdio) exposing a reduce tool
23948
+ harnesstrim reduce [--stats] [--metrics <path>]
23949
+ Slim stdin -> stdout (pipe noisy command output);
23950
+ --metrics records a TrimEvent per reduction
23951
+ harnesstrim mcp [--metrics <path>] Start the MCP server (stdio) exposing a reduce tool;
23952
+ --metrics records a TrimEvent per reduction
23728
23953
  harnesstrim bench Run the Tier A reducer micro-benchmark
23729
- harnesstrim --help Show this help
23954
+ harnesstrim --version Print the installed version
23730
23955
 
23731
23956
  Notes:
23732
23957
  - install is dry-run by default; nothing is written without --apply.
@@ -23738,6 +23963,7 @@ async function main(argv) {
23738
23963
  allowPositionals: true,
23739
23964
  options: {
23740
23965
  help: { type: "boolean", short: "h" },
23966
+ version: { type: "boolean", short: "v" },
23741
23967
  apply: { type: "boolean" },
23742
23968
  preset: { type: "string" },
23743
23969
  stats: { type: "boolean" },
@@ -23749,6 +23975,10 @@ async function main(argv) {
23749
23975
  }
23750
23976
  });
23751
23977
  const [command, ...rest] = positionals;
23978
+ if (values.version) {
23979
+ console.log(package_default.version);
23980
+ return 0;
23981
+ }
23752
23982
  if (values.help || !command) {
23753
23983
  console.log(HELP);
23754
23984
  return 0;
@@ -23761,7 +23991,7 @@ async function main(argv) {
23761
23991
  }
23762
23992
  case "install": {
23763
23993
  const target = rest[0];
23764
- const dir = rest[1] ?? (target === "hermes" ? os2.homedir() : process.cwd());
23994
+ const dir = rest[1] ?? (target === "hermes" ? os4.homedir() : process.cwd());
23765
23995
  const apply = values.apply === true;
23766
23996
  if (target === "opencode") {
23767
23997
  const result = runInstallOpencode(dir, apply, values.preset);
@@ -23774,7 +24004,7 @@ async function main(argv) {
23774
24004
  console.error("`harnesstrim install codex --global` requires `--hook`.");
23775
24005
  return 1;
23776
24006
  }
23777
- console.log(renderCodexGlobalHookInstall(runInstallCodexGlobalHook(path14.join(os2.homedir(), ".codex"), apply), apply));
24007
+ console.log(renderCodexGlobalHookInstall(runInstallCodexGlobalHook(path15.join(os4.homedir(), ".codex"), apply), apply));
23778
24008
  return 0;
23779
24009
  }
23780
24010
  console.log(renderCodexInstall(runInstallCodex(dir, apply, values.hook === true), apply));
@@ -23806,9 +24036,9 @@ async function main(argv) {
23806
24036
  process.stdout.write(response);
23807
24037
  if (values.metrics && event) {
23808
24038
  try {
23809
- const p = path14.resolve(values.metrics);
23810
- fs10.mkdirSync(path14.dirname(p), { recursive: true });
23811
- fs10.appendFileSync(
24039
+ const p = path15.resolve(values.metrics);
24040
+ fs11.mkdirSync(path15.dirname(p), { recursive: true });
24041
+ fs11.appendFileSync(
23812
24042
  p,
23813
24043
  JSON.stringify({ ts: (/* @__PURE__ */ new Date()).toISOString(), harness: which, ...event }) + "\n"
23814
24044
  );
@@ -23817,7 +24047,7 @@ async function main(argv) {
23817
24047
  }
23818
24048
  if (values.log) {
23819
24049
  try {
23820
- fs10.appendFileSync(
24050
+ fs11.appendFileSync(
23821
24051
  values.log,
23822
24052
  JSON.stringify({ inputChars: input.length, changed: response !== "{}", responseChars: response.length }) + "\n"
23823
24053
  );
@@ -23846,8 +24076,8 @@ async function main(argv) {
23846
24076
  return 1;
23847
24077
  }
23848
24078
  case "metrics": {
23849
- const path15 = rest[0] ?? DEFAULT_METRICS_PATH;
23850
- console.log(renderMetrics(loadMetrics(path15)));
24079
+ const path16 = rest[0] ?? DEFAULT_METRICS_PATH;
24080
+ console.log(renderMetrics(loadMetrics(path16)));
23851
24081
  return 0;
23852
24082
  }
23853
24083
  case "reduce": {
@@ -23860,6 +24090,24 @@ async function main(argv) {
23860
24090
  const input = await readStdin();
23861
24091
  const result = reducePipe(input, minLength);
23862
24092
  process.stdout.write(result.output);
24093
+ if (values.metrics && result.changed) {
24094
+ try {
24095
+ const p = path15.resolve(values.metrics);
24096
+ fs11.mkdirSync(path15.dirname(p), { recursive: true });
24097
+ fs11.appendFileSync(
24098
+ p,
24099
+ JSON.stringify({
24100
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
24101
+ harness: "pipe",
24102
+ tool: "reduce",
24103
+ reducer: result.reducer,
24104
+ beforeChars: result.beforeChars,
24105
+ afterChars: result.afterChars
24106
+ }) + "\n"
24107
+ );
24108
+ } catch {
24109
+ }
24110
+ }
23863
24111
  if (values.stats) {
23864
24112
  const note = result.changed ? `${result.reducer}: ${result.beforeChars} -> ${result.afterChars} chars` : "no reduction (no reducer matched or below min-length)";
23865
24113
  console.error(`[harnesstrim reduce] ${note}`);
@@ -23868,7 +24116,7 @@ async function main(argv) {
23868
24116
  }
23869
24117
  case "mcp": {
23870
24118
  const { startStdioServer: startStdioServer2 } = await Promise.resolve().then(() => (init_server3(), server_exports));
23871
- await startStdioServer2();
24119
+ await startStdioServer2(values.metrics ? { metricsPath: values.metrics } : {});
23872
24120
  await new Promise(() => {
23873
24121
  });
23874
24122
  return 0;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "harnesstrim",
3
- "version": "0.0.4",
3
+ "version": "0.0.6",
4
4
  "description": "HarnessTrim CLI: doctor (diagnose token waste), install adapters, run benchmarks.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -15,20 +15,18 @@
15
15
  "access": "public"
16
16
  },
17
17
  "devDependencies": {
18
- "@harnesstrim/adapter-claude": "workspace:*",
19
- "@harnesstrim/adapter-codex": "workspace:*",
20
- "@harnesstrim/adapter-hermes": "workspace:*",
21
- "@harnesstrim/adapter-pi": "workspace:*",
22
- "@harnesstrim/benchmarks": "workspace:*",
23
- "@harnesstrim/core": "workspace:*",
24
- "@harnesstrim/mcp": "workspace:*",
25
- "esbuild": "^0.25.0"
18
+ "esbuild": "^0.25.0",
19
+ "@harnesstrim/adapter-claude": "0.0.1",
20
+ "@harnesstrim/adapter-hermes": "0.0.1",
21
+ "@harnesstrim/core": "0.0.2",
22
+ "@harnesstrim/adapter-codex": "0.0.1",
23
+ "@harnesstrim/benchmarks": "0.0.1",
24
+ "@harnesstrim/mcp": "0.0.1",
25
+ "@harnesstrim/adapter-pi": "0.0.1"
26
26
  },
27
27
  "scripts": {
28
28
  "build": "node build.mjs",
29
- "prepare": "node build.mjs",
30
- "prepack": "node build.mjs",
31
29
  "test": "node --test \"src/**/*.test.ts\"",
32
30
  "typecheck": "tsc -p tsconfig.json"
33
31
  }
34
- }
32
+ }