@sawbuck/reins 0.3.0 → 0.4.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.
package/README.md CHANGED
@@ -21,6 +21,13 @@ $ reins
21
21
  the check guards the 7 turns you didn't steer - a miss there is the risk.
22
22
  ```
23
23
 
24
+ ## Harnesses
25
+
26
+ reins reads **Claude Code** transcripts today. The parser lives in `scan.mjs`; everything
27
+ downstream works on abstract `{ prior, user }` turn-pairs, so another harness (Codex, Cursor)
28
+ is a parallel module that emits the same pairs plus its own session resolver, not a rewrite.
29
+ `pairs()` is the seam.
30
+
24
31
  ## Backend (SAWBUCK_JUDGE)
25
32
 
26
33
  The judge model is a socket. Set `SAWBUCK_JUDGE` (default `ollama:qwen2.5:7b`):
package/bin/reins.mjs CHANGED
@@ -4,14 +4,22 @@
4
4
  // the seismograph + distribution. The check guards the turns you did not steer.
5
5
 
6
6
  import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
7
- import { join, dirname } from "node:path";
8
- import { scan, resolveSessionPath, pairs } from "../scan.mjs";
7
+ import { join, dirname, relative } from "node:path";
8
+ import * as claudeHarness from "../scan.mjs";
9
+ import * as codexHarness from "../codex.mjs";
10
+ import { scan } from "../scan.mjs";
9
11
  import { score, backend, harvestScore } from "../judge.mjs";
10
- import { harvest, resolveSessions, aggregateCues } from "../wizard.mjs";
12
+ import { harvest, aggregateCues } from "../wizard.mjs";
13
+
14
+ // show a path relative to the cwd when it lives under it (clean output, no leaked home dir);
15
+ // fall back to the absolute path for anything outside the tree.
16
+ const rel = (p) => { const r = relative(process.cwd(), p); return r && !r.startsWith("..") ? r : p; };
11
17
 
12
18
  const argv = process.argv.slice(2);
13
19
  const flag = (n) => { const i = argv.indexOf(n); return i > -1 ? argv[i + 1] : null; };
14
20
  const has = (n) => argv.includes(n);
21
+ // harness adapter: whose transcripts to read. --harness codex (or SAWBUCK_HARNESS=codex); default Claude Code.
22
+ const H = (flag("--harness") || process.env.SAWBUCK_HARNESS) === "codex" ? codexHarness : claudeHarness;
15
23
 
16
24
  // ---- `reins wizard` / `reins harvest`: read your old chats, rank the cues you keep giving ----
17
25
  if (argv[0] === "wizard" || argv[0] === "harvest") {
@@ -38,7 +46,7 @@ It reads every turn-pair with a model. On the local ollama default that is free;
38
46
  const maxPairs = parseInt(flag("--max"), 10) || 400;
39
47
  const sinceDays = parseInt(flag("--since"), 10) || 0;
40
48
  const sinceMs = sinceDays > 0 ? Date.now() - sinceDays * 86400000 : 0;
41
- const sessions = resolveSessions({ limit: sessionsN, sinceMs });
49
+ const sessions = H.resolveSessions({ limit: sessionsN, sinceMs });
42
50
  if (!sessions.length) { process.stderr.write("reins wizard: no Claude Code sessions found under ~/.claude/projects\n"); process.exit(2); }
43
51
  const hosted = !b.startsWith("ollama:");
44
52
  if (hosted && !has("--yes")) {
@@ -46,7 +54,7 @@ It reads every turn-pair with a model. On the local ollama default that is free;
46
54
  process.exit(2);
47
55
  }
48
56
  process.stderr.write(`reins wizard: harvesting up to ${maxPairs} turn-pairs from ${sessions.length} session(s) via ${b}${hosted ? " (hosted, paid)" : " (local, free)"}...\n`);
49
- const rw = await harvest(harvestScore, { sessions, maxPairs });
57
+ const rw = await harvest(harvestScore, { sessions, maxPairs, pairsFn: H.pairs });
50
58
  const wf = flag("--write");
51
59
  const out = wf && !wf.startsWith("--") ? wf : join(process.cwd(), ".sawbuck", "cues.json");
52
60
  if (has("--write") || !has("--json")) {
@@ -56,14 +64,16 @@ It reads every turn-pair with a model. On the local ollama default that is free;
56
64
  if (has("--json")) { process.stdout.write(JSON.stringify({ backend: b, ...rw }, null, 2) + "\n"); process.exit(0); }
57
65
  const pct = rw.scored ? Math.round((rw.steers / rw.scored) * 100) : 0;
58
66
  process.stdout.write(`\n reins · wizard (judge: ${b})\n`);
59
- process.stdout.write(` read ${rw.pairs} turn-pairs from ${rw.sessionsRead} session(s) · ${rw.steers} steers (${pct}%)\n\n`);
67
+ process.stdout.write(` read ${rw.pairs} turn-pairs from ${rw.sessionsRead} session(s) · ${rw.steers} steers (${pct}%)\n`);
68
+ if (rw.axes && rw.axes.length) process.stdout.write(` you steer most on: ${rw.axes.slice(0, 5).map((a) => `${a.axis} (${a.count})`).join(" · ")}\n`);
69
+ process.stdout.write("\n");
60
70
  const top = rw.cues.filter((c) => c.count >= 2 || c.gen > 0).slice(0, 12);
61
- if (!top.length) { process.stdout.write(" no repeated cues surfaced yet - harvest more sessions (--sessions, --since).\n\n"); process.exit(0); }
71
+ if (!top.length) { process.stdout.write(" no repeated specific cues yet (the axis fingerprint above is the durable signal) - harvest more with --sessions / --since.\n\n"); process.exit(0); }
62
72
  process.stdout.write(" your top cues (candidates from your history, review before trusting):\n");
63
73
  for (const c of top) {
64
74
  process.stdout.write(` ${("[" + c.axis + "]").padEnd(13)} ${(c.cue || "(unnamed)").padEnd(34)} · ${c.sessions} sess · gen ${c.genShare} · ${c.count}x\n`);
65
75
  }
66
- process.stdout.write(`\n wrote ${out}\n these are LEADS. Promote the ones that are really you; the check still guards the rest.\n\n`);
76
+ process.stdout.write(`\n wrote ${rel(out)}\n these are LEADS. Promote the ones that are really you; the check still guards the rest.\n\n`);
67
77
  process.exit(0);
68
78
  }
69
79
 
@@ -88,28 +98,33 @@ Free on the local ollama default. Seed from your whole history once with: reins
88
98
  }
89
99
  if (flag("--judge")) process.env.SAWBUCK_JUDGE = flag("--judge");
90
100
  const b = backend();
91
- const sess = resolveSessionPath(flag("--session"));
101
+ const sess = H.resolveSessionPath(flag("--session"));
92
102
  if (!sess) { process.stderr.write("reins cues: no session transcript found\n"); process.exit(2); }
93
103
  let ctext = ""; try { ctext = readFileSync(sess, "utf8"); } catch { process.stderr.write("reins cues: cannot read " + sess + "\n"); process.exit(2); }
94
- const allC = pairs(ctext);
104
+ const allC = H.pairs(ctext);
95
105
  const cout = join(process.cwd(), ".sawbuck", "cues.json");
96
106
  let prior = { session: "", lastPairs: 0, items: [] };
97
107
  if (has("--append")) { try { const p = JSON.parse(readFileSync(cout, "utf8")); if (p && Array.isArray(p.items)) prior = { session: p.session || "", lastPairs: p.lastPairs || 0, items: p.items }; } catch { /* fresh */ } }
98
- const startIdx = has("--append") && prior.session === sess ? prior.lastPairs : 0; // same session = resume; new session = score all its pairs
108
+ const startIdx = has("--append") && prior.session === sess ? Math.min(prior.lastPairs, allC.length) : 0; // same session = resume; new/changed session = score from its start
99
109
  let fresh = allC.slice(startIdx);
100
- if (!has("--append")) { const m = parseInt(flag("--max"), 10) || 400; if (fresh.length > m) fresh = fresh.slice(-m); }
110
+ // bounded per call: --append scores at most PER new pairs (oldest-first, so a backlog drains over
111
+ // turns); a full scan caps at --max newest. Keeps the per-turn background call cheap.
112
+ const PER = has("--append") ? 40 : (parseInt(flag("--max"), 10) || 400);
113
+ if (fresh.length > PER) fresh = has("--append") ? fresh.slice(0, PER) : fresh.slice(-PER);
114
+ const consumed = startIdx + fresh.length;
101
115
  const items = has("--append") ? prior.items.slice() : [];
102
116
  const sid = sess.split("/").pop();
103
117
  for (const { prior: a, user: u } of fresh) { let r = null; try { r = await harvestScore(a, u); } catch {} items.push({ session: sid, result: r }); }
104
118
  const capped = items.slice(-1500);
105
119
  const agg = aggregateCues(capped);
106
- const rec = { at: new Date().toISOString(), backend: b, session: sess, lastPairs: allC.length, pairs: capped.length, ...agg, items: capped };
120
+ const rec = { at: new Date().toISOString(), backend: b, session: sess, lastPairs: has("--append") ? consumed : allC.length, pairs: capped.length, ...agg, items: capped };
107
121
  try { mkdirSync(dirname(cout), { recursive: true }); writeFileSync(cout, JSON.stringify(rec, null, 2) + "\n"); } catch { process.stderr.write("reins cues: could not write " + cout + "\n"); }
108
122
  if (has("--json")) { const { items: _omit, ...pub } = rec; process.stdout.write(JSON.stringify(pub, null, 2) + "\n"); process.exit(0); }
109
123
  const top = agg.cues.filter((c) => c.count >= 2 || c.gen > 0).slice(0, 10);
110
- process.stdout.write(`\n reins · cues (judge: ${b})\n ${agg.steers} steers over ${agg.scored} scored turns · ${top.length} cues\n`);
111
- for (const c of top) process.stdout.write(` ${("[" + c.axis + "]").padEnd(12)} ${(c.cue || "(unnamed)").padEnd(32)} · ${c.sessions} sess · ${c.count}x\n`);
112
- process.stdout.write(` wrote ${cout}\n\n`);
124
+ process.stdout.write(`\n reins · cues (judge: ${b})\n ${agg.steers} steers over ${agg.scored} scored turns\n`);
125
+ if (agg.axes && agg.axes.length) process.stdout.write(` you steer most on: ${agg.axes.slice(0, 4).map((a) => `${a.axis} (${a.count})`).join(" · ")}\n`);
126
+ if (top.length) { process.stdout.write(` candidate cues (review before trusting):\n`); for (const c of top) process.stdout.write(` ${("[" + c.axis + "]").padEnd(12)} ${(c.cue || "(unnamed)").padEnd(32)} · ${c.count}x\n`); }
127
+ process.stdout.write(` wrote ${rel(cout)}\n\n`);
113
128
  process.exit(0);
114
129
  }
115
130
 
@@ -128,13 +143,16 @@ Usage: reins [--session <path.jsonl>] [--judge <backend>] [-n N] [--json] [--wri
128
143
  --label <term> the word for the stat on the share card (default "steer rate";
129
144
  e.g. "yank rate" for a playful channel - keep it neutral for LinkedIn)
130
145
 
146
+ --harness <h> whose transcripts to read: claude (default) | codex (reads ~/.codex/sessions)
147
+
148
+ Commands: 'reins wizard' (harvest history into cues), 'reins cues' (the live cue inventory). --help on each.
131
149
  Backend is SAWBUCK_JUDGE (default ollama:qwen2.5:7b). Local = free + private.
132
150
  `);
133
151
  process.exit(0);
134
152
  }
135
153
 
136
154
  if (flag("--judge")) process.env.SAWBUCK_JUDGE = flag("--judge");
137
- const sess = resolveSessionPath(flag("--session"));
155
+ const sess = H.resolveSessionPath(flag("--session"));
138
156
  if (!sess) { process.stderr.write("reins: no session transcript found (pass --session <path.jsonl>)\n"); process.exit(2); }
139
157
 
140
158
  let text = "";
@@ -151,9 +169,14 @@ if (has("--append")) {
151
169
  const p = JSON.parse(readFileSync(out, "utf8"));
152
170
  if (p && p.lastPairs != null) prior = { lastPairs: p.lastPairs, scored: p.scored || 0, steering: p.steering || 0, wsum: p._wsum || 0, distribution: p.distribution || prior.distribution };
153
171
  } catch { /* no prior tally = fresh */ }
154
- const all = pairs(text);
172
+ const all = H.pairs(text);
155
173
  let added = 0;
156
- for (const { prior: a, user: u } of all.slice(prior.lastPairs)) {
174
+ // bounded per call (graceful): at most PER new pairs this run, so the first --append on a long
175
+ // session never blocks on hundreds of judge calls at once; a backlog drains over the next turns.
176
+ const PER = 40;
177
+ const start = Math.min(prior.lastPairs, all.length);
178
+ const consumed = Math.min(start + PER, all.length);
179
+ for (const { prior: a, user: u } of all.slice(start, consumed)) {
157
180
  let s = null; try { s = await score(a, u); } catch {}
158
181
  if (!s) continue;
159
182
  prior.distribution[s.pressure] = (prior.distribution[s.pressure] || 0) + 1;
@@ -161,7 +184,7 @@ if (has("--append")) {
161
184
  if (s.pressure >= 1) prior.steering++;
162
185
  }
163
186
  const rate = prior.scored ? prior.steering / prior.scored : 0;
164
- const rec = { at: new Date().toISOString(), backend: backend(), lastPairs: all.length, turns: all.length, scored: prior.scored, steering: prior.steering, steeringRate: rate, meanPressure: prior.scored ? prior.wsum / prior.scored : 0, distribution: prior.distribution, _wsum: prior.wsum };
187
+ const rec = { at: new Date().toISOString(), backend: backend(), lastPairs: consumed, turns: all.length, scored: prior.scored, steering: prior.steering, steeringRate: rate, meanPressure: prior.scored ? prior.wsum / prior.scored : 0, distribution: prior.distribution, _wsum: prior.wsum };
165
188
  try { mkdirSync(dirname(out), { recursive: true }); writeFileSync(out, JSON.stringify(rec) + "\n"); } catch { process.stderr.write("reins: could not write " + out + "\n"); }
166
189
  if (has("--json")) process.stdout.write(JSON.stringify(rec) + "\n");
167
190
  else process.stdout.write(`reins: +${added} new turn(s), steer rate ${Math.round(rate * 100)}% over ${prior.scored} turns\n`);
@@ -169,7 +192,7 @@ if (has("--append")) {
169
192
  }
170
193
 
171
194
  const n = parseInt(flag("-n"), 10) || 0;
172
- const r = await scan(text, score, { limit: n });
195
+ const r = await scan(text, score, { limit: n, pairsFn: H.pairs });
173
196
 
174
197
  // --write [path]: persist the summary so an OFFLINE reader (the sawbuck statusline)
175
198
  // can display the live steering number without ever calling a model itself.
@@ -201,10 +224,10 @@ if (has("--share")) {
201
224
  ${r.seismograph}
202
225
  · let it ride - nudge + redirect # hard pull
203
226
 
204
- I steered ${r.steering} of ${r.scored} turns (${pct}%) — ${grip}, ${texture}.
227
+ I steered ${r.steering} of ${r.scored} turns (${pct}%): ${grip}, ${texture}.
205
228
  The ${r.scored - r.steering} I let ride, the check had my back.
206
229
 
207
- — measured with reins · sawbuck
230
+ measured with reins · sawbuck
208
231
  `);
209
232
  process.exit(0);
210
233
  }
package/codex.mjs ADDED
@@ -0,0 +1,99 @@
1
+ // codex.mjs - the OpenAI Codex transcript adapter. The HARNESS sibling of scan.mjs: it reads
2
+ // Codex's rollout logs and emits the SAME { prior, user } turn-pairs, so the judge (judge.mjs),
3
+ // the cue aggregation (wizard.mjs), and the CLI work on Codex sessions unchanged. Exports the
4
+ // same interface as scan.mjs (pairs / resolveSessionPath / resolveSessions) so the CLI swaps
5
+ // adapters by --harness. Format verified against codex-cli 0.147.0 on disk + the openai/codex
6
+ // source (rollout envelope { timestamp, type, payload }, serde snake_case tags).
7
+ //
8
+ // sessions: $CODEX_HOME/sessions/YYYY/MM/DD/rollout-<iso>-<thread>.jsonl (+ legacy flat layout)
9
+ // turns: prefer type=="event_msg" payload.type in {user_message, agent_message} (clean,
10
+ // already scaffolding-free); fall back to type=="response_item" message role
11
+ // user/assistant for older sessions, filtering the injected <environment_context> /
12
+ // AGENTS.md / permissions / collaboration_mode pseudo-user turns.
13
+
14
+ import { readFileSync, readdirSync, statSync, existsSync } from "node:fs";
15
+ import { join } from "node:path";
16
+ import { homedir } from "node:os";
17
+
18
+ const codexHome = () => process.env.CODEX_HOME || join(homedir(), ".codex");
19
+
20
+ // role:user response_items are polluted with injected scaffolding - not real user turns.
21
+ const SCAFFOLD = /^\s*<(environment_context|permissions|collaboration_mode|user_instructions|system|tools?)\b/i;
22
+ const isNoise = (t) => { const s = String(t).trim(); return s.length < 3 || SCAFFOLD.test(s); };
23
+
24
+ function textOf(content) {
25
+ if (typeof content === "string") return content;
26
+ if (Array.isArray(content)) return content.filter((b) => b && (b.type === "input_text" || b.type === "output_text" || b.type === "text") && b.text).map((b) => b.text).join(" ");
27
+ return "";
28
+ }
29
+
30
+ // Codex rollout jsonl -> ordered { role, text } turns, from whichever layer the file has.
31
+ function turnsFromRollout(text) {
32
+ const objs = [];
33
+ for (const line of String(text).split("\n")) { const l = line.trim(); if (!l) continue; try { objs.push(JSON.parse(l)); } catch {} }
34
+ // newer builds carry the clean event_msg layer; if present, use it EXCLUSIVELY (the response_item
35
+ // layer duplicates each turn and carries scaffolding, so mixing them double-counts).
36
+ const hasEvent = objs.some((o) => o && o.type === "event_msg" && o.payload && (o.payload.type === "user_message" || o.payload.type === "agent_message"));
37
+ const turns = [];
38
+ for (const o of objs) {
39
+ if (!o || !o.payload) continue;
40
+ if (hasEvent) {
41
+ if (o.type !== "event_msg") continue;
42
+ if (o.payload.type === "user_message") turns.push({ role: "user", text: o.payload.message });
43
+ else if (o.payload.type === "agent_message") turns.push({ role: "assistant", text: o.payload.message });
44
+ } else {
45
+ if (o.type !== "response_item" || o.payload.type !== "message") continue;
46
+ const r = o.payload.role;
47
+ if (r === "user" || r === "assistant") turns.push({ role: r, text: textOf(o.payload.content) });
48
+ }
49
+ }
50
+ return turns;
51
+ }
52
+
53
+ // Same pairing contract as scan.mjs: every non-opener user turn that followed an agent turn.
54
+ export function pairs(text) {
55
+ const out = [];
56
+ let prev = "", seen = false;
57
+ for (const { role, text: t } of turnsFromRollout(text)) {
58
+ const s = String(t == null ? "" : t);
59
+ if (role === "assistant") { if (s.trim()) prev = s.slice(0, 600); continue; }
60
+ if (isNoise(s)) continue; // skip injected scaffolding / empties
61
+ if (!seen) { seen = true; continue; } // opener = a request, not a steer
62
+ if (prev) out.push({ prior: prev, user: s.replace(/\s+/g, " ").slice(0, 800) });
63
+ prev = "";
64
+ }
65
+ return out;
66
+ }
67
+
68
+ // recursively list rollout-*.jsonl under $CODEX_HOME/sessions (dated tree + legacy flat)
69
+ function listRollouts() {
70
+ const root = join(codexHome(), "sessions");
71
+ if (!existsSync(root)) return [];
72
+ const found = [];
73
+ const walk = (dir, depth) => {
74
+ if (depth > 5) return;
75
+ let ents = []; try { ents = readdirSync(dir, { withFileTypes: true }); } catch { return; }
76
+ for (const e of ents) {
77
+ const p = join(dir, e.name);
78
+ if (e.isDirectory()) walk(p, depth + 1);
79
+ else if (e.isFile() && /^rollout-.*\.jsonl$/.test(e.name)) { try { found.push({ path: p, mtime: statSync(p).mtimeMs }); } catch {} }
80
+ }
81
+ };
82
+ walk(root, 0);
83
+ return found;
84
+ }
85
+
86
+ export function resolveSessionPath(explicit) {
87
+ if (explicit) return existsSync(explicit) ? explicit : null;
88
+ const f = listRollouts();
89
+ if (!f.length) return null;
90
+ f.sort((a, b) => b.mtime - a.mtime);
91
+ return f[0].path;
92
+ }
93
+
94
+ export function resolveSessions({ limit = 0, sinceMs = 0 } = {}) {
95
+ let f = listRollouts();
96
+ if (sinceMs) f = f.filter((x) => x.mtime >= sinceMs);
97
+ f.sort((a, b) => b.mtime - a.mtime);
98
+ return (limit > 0 ? f.slice(0, limit) : f).map((x) => x.path);
99
+ }
package/judge.mjs CHANGED
@@ -95,15 +95,25 @@ async function complete(promptStr, schema) {
95
95
  throw new Error("unknown SAWBUCK_JUDGE backend: " + b + " (use ollama:<model> | haiku | anthropic:<model> | openai:<model>)");
96
96
  }
97
97
 
98
- async function ollama(model, promptStr, schema) {
99
- const host = process.env.OLLAMA_HOST || "http://localhost:11434";
98
+ async function ollamaCall(host, model, promptStr, format) {
99
+ const body = { model, prompt: promptStr, stream: false, options: { temperature: 0, num_predict: 800 } };
100
+ if (format) body.format = format;
100
101
  const res = await fetch(host + "/api/generate", {
101
102
  method: "POST", headers: { "Content-Type": "application/json" },
102
- body: JSON.stringify({ model, prompt: promptStr, stream: false, format: schema, options: { temperature: 0, num_predict: 800 } }),
103
+ body: JSON.stringify(body),
103
104
  });
104
105
  const j = await res.json();
105
106
  return j.response || "";
106
107
  }
108
+ async function ollama(model, promptStr, schema) {
109
+ const host = process.env.OLLAMA_HOST || "http://localhost:11434";
110
+ // Grammar-constrained first: clean JSON on most models (qwen2.5, llama). But reasoning models
111
+ // (gpt-oss, deepseek-r1) spend their budget in a hidden channel and return an EMPTY response
112
+ // under a format grammar - so on empty we retry free-form; the parser lifts JSON from raw text.
113
+ let out = await ollamaCall(host, model, promptStr, schema);
114
+ if (!out.trim()) out = await ollamaCall(host, model, promptStr, null);
115
+ return out;
116
+ }
107
117
  async function anthropic(model, promptStr) {
108
118
  const key = process.env.ANTHROPIC_API_KEY;
109
119
  if (!key) throw new Error("ANTHROPIC_API_KEY not set (SAWBUCK_JUDGE=haiku needs it)");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sawbuck/reins",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
4
  "description": "Reins: measure how much you steer your AI coding agent. A per-turn steering-pressure judge over your session transcript, pluggable backend (Haiku / local ollama / any OpenAI-compatible). The LLM half of the reins steering layer; keeps sawbuck's core offline.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -14,6 +14,7 @@
14
14
  "bin/reins.mjs",
15
15
  "judge.mjs",
16
16
  "scan.mjs",
17
+ "codex.mjs",
17
18
  "wizard.mjs",
18
19
  "README.md",
19
20
  "LICENSE",
package/scan.mjs CHANGED
@@ -2,6 +2,14 @@
2
2
  // reading: score each of your turns (given the agent action it followed) and
3
3
  // aggregate into a distribution + a seismograph. The scorer is injected so the
4
4
  // aggregation is testable without a live model.
5
+ //
6
+ // HARNESS ADAPTER SEAM. This file is the CLAUDE CODE adapter: humanText/assistantText/
7
+ // noise parse Claude Code's .jsonl schema, and resolveSessions finds them under
8
+ // ~/.claude/projects. Everything downstream (the judge in judge.mjs, the cue aggregation
9
+ // in wizard.mjs, the CLI) operates ONLY on the abstract { prior, user } turn-pairs that
10
+ // pairs() emits and knows nothing about Claude Code. So supporting another harness (Codex,
11
+ // Cursor, ...) is a PARALLEL module that produces the same { prior, user } pairs + its own
12
+ // session resolver, not a refactor. pairs() is the seam; keep it harness-agnostic.
5
13
 
6
14
  import { readFileSync, readdirSync, statSync, existsSync } from "node:fs";
7
15
  import { join } from "node:path";
@@ -53,8 +61,8 @@ export function pairs(text) {
53
61
  const GLYPH = { 0: ".", 1: "-", 2: "+", 3: "#" };
54
62
 
55
63
  // scan(text, scoreFn) -> aggregate. scoreFn(prior, user) -> {pressure, axis, nudge} | null.
56
- export async function scan(text, scoreFn, { limit = 0 } = {}) {
57
- let ps = pairs(text);
64
+ export async function scan(text, scoreFn, { limit = 0, pairsFn = pairs } = {}) {
65
+ let ps = pairsFn(text);
58
66
  if (limit > 0 && ps.length > limit) ps = ps.slice(-limit); // most recent N
59
67
  const dist = { 0: 0, 1: 0, 2: 0, 3: 0 };
60
68
  let scored = 0, fails = 0, wsum = 0, spark = "";
package/wizard.mjs CHANGED
@@ -15,12 +15,16 @@ import { pairs, resolveSessions } from "./scan.mjs";
15
15
  // one-session spike never outranks a cue you give across many sessions - the study's lesson).
16
16
  export function aggregateCues(items) {
17
17
  const clusters = new Map();
18
+ const axisTally = new Map(); // the robust signal: WHICH axes you steer on (research: the fingerprint
19
+ // replicates across judges; exact cue text does not, so it rarely clusters)
18
20
  let scored = 0, steers = 0, fails = 0;
19
21
  for (const { session, result } of items) {
20
22
  if (!result) { fails++; continue; }
21
23
  scored++;
22
24
  if (!result.steer) continue;
23
25
  steers++;
26
+ const ax = result.axis || "other";
27
+ axisTally.set(ax, (axisTally.get(ax) || 0) + 1);
24
28
  const key = (result.axis || "other") + "|" + String(result.cue || "").toLowerCase().replace(/\s+/g, " ").trim().slice(0, 40);
25
29
  const c = clusters.get(key) || { axis: result.axis || "other", cue: result.cue || "", count: 0, gen: 0, sessions: new Set(), mag: { refine: 0, redirect: 0, "hard-stop": 0 } };
26
30
  c.count++;
@@ -34,17 +38,18 @@ export function aggregateCues(items) {
34
38
  const cues = [...clusters.values()]
35
39
  .map((c) => ({ axis: c.axis, cue: c.cue, count: c.count, gen: c.gen, genShare: c.count ? +(c.gen / c.count).toFixed(2) : 0, sessions: c.sessions.size, magnitude: c.mag }))
36
40
  .sort((a, b) => (b.sessions - a.sessions) || (b.gen - a.gen) || (b.count - a.count));
37
- return { scored, steers, fails, steerRate: scored ? +(steers / scored).toFixed(3) : 0, cues };
41
+ const axes = [...axisTally.entries()].map(([axis, count]) => ({ axis, count })).sort((a, b) => b.count - a.count);
42
+ return { scored, steers, fails, steerRate: scored ? +(steers / scored).toFixed(3) : 0, axes, cues };
38
43
  }
39
44
 
40
45
  // harvest(scoreFn, { sessions, maxPairs }) -> aggregateCues over the pairs in those session
41
46
  // files, newest first, capped at maxPairs (cost control). scoreFn(prior, user) -> result | null.
42
- export async function harvest(scoreFn, { sessions = [], maxPairs = 400 } = {}) {
47
+ export async function harvest(scoreFn, { sessions = [], maxPairs = 400, pairsFn = pairs } = {}) {
43
48
  const items = [];
44
49
  const gathered = [];
45
50
  for (const path of sessions) {
46
51
  let text = ""; try { text = readFileSync(path, "utf8"); } catch { continue; }
47
- for (const p of pairs(text)) {
52
+ for (const p of pairsFn(text)) {
48
53
  gathered.push({ ...p, session: path });
49
54
  if (gathered.length >= maxPairs) break;
50
55
  }