sawbuck 0.1.20 → 0.1.22
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/GLOSSARY.md +7 -6
- package/README.md +11 -0
- package/init.mjs +26 -2
- package/package.json +2 -1
- package/reflect.mjs +1 -1
- package/run.mjs +1 -1
- package/sawbuck.mjs +47 -6
- package/yank.mjs +87 -0
package/GLOSSARY.md
CHANGED
|
@@ -14,7 +14,7 @@ it is plain and scientific. The saddle is the brand and the architecture spine
|
|
|
14
14
|
(the name, `attach`, `fit`); it is NOT a vocabulary for every concept.
|
|
15
15
|
|
|
16
16
|
Why plain wins everywhere: an LLM has strong trained priors on words like
|
|
17
|
-
`regression`, `verified`, `
|
|
17
|
+
`regression`, `verified`, `gamed`. Metaphor jargon in the model's context forces
|
|
18
18
|
a gloss every session (token cost) and risks a misread (trajectory drift). Plain,
|
|
19
19
|
high-prior words serve the human reader and the agent equally, so we do not keep
|
|
20
20
|
two vocabularies.
|
|
@@ -28,7 +28,7 @@ two vocabularies.
|
|
|
28
28
|
| the verifier | **the check** (verb: *verify*) | runs the metrics on a change |
|
|
29
29
|
| a change that improved and holds | **verified** | |
|
|
30
30
|
| a change that made a guarded metric worse | **regression** | do-not-retry |
|
|
31
|
-
| a change that passed by
|
|
31
|
+
| a change that passed by faking the metric | **gamed** | the win came from editing the test, config, or grader, not the source (the engine verdict, and the word every Sawbuck surface uses) |
|
|
32
32
|
| the accumulated record of outcomes | **results** | `results.json` |
|
|
33
33
|
| what Sawbuck leaves you | **verified history** | the findings you can trust |
|
|
34
34
|
| a surfaced past outcome | **finding** | |
|
|
@@ -61,12 +61,13 @@ Findings are **leads, not conclusions**, verify against the diff before acting.
|
|
|
61
61
|
## Dispatch template
|
|
62
62
|
|
|
63
63
|
```
|
|
64
|
-
Sawbuck · {N} regressions charted · {N}
|
|
64
|
+
Sawbuck · {N} regressions charted · {N} gamed changes caught
|
|
65
65
|
🆕 just learned: {reason} ({file})
|
|
66
66
|
```
|
|
67
67
|
|
|
68
68
|
## Note on engine verdicts
|
|
69
69
|
|
|
70
|
-
The check engine
|
|
71
|
-
|
|
72
|
-
**
|
|
70
|
+
The check engine emits verdicts (`gamed`, `fail`, `pass`); the surface keeps **gamed**
|
|
71
|
+
as-is (it is vivid and used on every surface) and maps `fail` → **regression** and
|
|
72
|
+
`pass` → **verified**. ("Invalid" was an earlier softening of `gamed` that no surface
|
|
73
|
+
adopted; `gamed` is canonical.)
|
package/README.md
CHANGED
|
@@ -48,6 +48,17 @@ sawbuck: gamed recorded on test.js (the win came from editing the test, no
|
|
|
48
48
|
| `sawbuck recall` | related notes for your current change from extra memory providers you compose in (bring your own) |
|
|
49
49
|
| `sawbuck run --attempt '<agent cmd>'` | thin harness: load context, run one attempt through the keep-if-verified gate |
|
|
50
50
|
| `sawbuck trace` · `sawbuck doctor` · `detach --verify` | show the footprint; check the attachment resolves; prove a clean removal |
|
|
51
|
+
| `sawbuck where` · `sawbuck statusline` | list every repo it is attached to; a one-line status-line segment (🐴 watching / N caught) |
|
|
52
|
+
|
|
53
|
+
## Give your agent the contract
|
|
54
|
+
|
|
55
|
+
`sawbuck init` writes this block into your `AGENTS.md` for you (and `CLAUDE.md` / `.cursorrules` if present; `--no-agent-block` to skip), and `sawbuck attach --claude` enforces the same thing every turn via the hook. Here it is to paste yourself if you want:
|
|
56
|
+
|
|
57
|
+
This repo is under a Sawbuck contract: a change is done only when the win survives verification
|
|
58
|
+
from the source alone. Before finishing, run `sawbuck check`. If it reads `gamed`, the green came
|
|
59
|
+
from editing a test, config, or grader, not the source, so undo that and earn it in the source.
|
|
60
|
+
Run `sawbuck context` first to see what broke in these files before (reverts, weakened tests,
|
|
61
|
+
fragile spots). Never skip, weaken, or delete a test to go green.
|
|
51
62
|
|
|
52
63
|
Each repo needs a `promptwheel.config.json` saying what to measure (your tests, lint, coverage, or an eval).
|
|
53
64
|
|
package/init.mjs
CHANGED
|
@@ -32,7 +32,7 @@ const engineBin = (envVar, pkgSubpath, devPath) => {
|
|
|
32
32
|
for (const spec of [pkgSubpath, bare]) { try { return require.resolve(spec); } catch {} }
|
|
33
33
|
return devPath;
|
|
34
34
|
};
|
|
35
|
-
const PW = engineBin('PW_BIN', 'promptwheel/bin/promptwheel.mjs', join(HERE, '..', '
|
|
35
|
+
const PW = engineBin('PW_BIN', 'promptwheel/bin/promptwheel.mjs', join(HERE, '..', 'promptwheel', 'bin', 'promptwheel.mjs'));
|
|
36
36
|
const LB = engineBin('LB_BIN', '@promptwheel/logbook/bin/logbook.mjs', join(HERE, '..', 'logbook', 'bin', 'logbook.mjs'));
|
|
37
37
|
|
|
38
38
|
const argv = process.argv.slice(2);
|
|
@@ -44,6 +44,7 @@ const yes = has('--yes') || has('-y');
|
|
|
44
44
|
const forceWizard = has('--wizard');
|
|
45
45
|
const investigate = !has('--no-investigate');
|
|
46
46
|
const force = has('--force');
|
|
47
|
+
const noBlock = has('--no-agent-block');
|
|
47
48
|
const presetIdx = argv.indexOf('--preset');
|
|
48
49
|
const preset = presetIdx > -1 ? argv[presetIdx + 1] : null;
|
|
49
50
|
|
|
@@ -54,7 +55,9 @@ const prior = priorExisted ? readFileSync(cfgPath, 'utf8') : null;
|
|
|
54
55
|
const restore = () => { try { if (prior != null) writeFileSync(cfgPath, prior); else if (existsSync(cfgPath)) unlinkSync(cfgPath); } catch {} };
|
|
55
56
|
|
|
56
57
|
// 1) base contract from promptwheel init (stack-detected tests + antihack tripwires)
|
|
57
|
-
|
|
58
|
+
// --no-agent-block: suppress promptwheel's own AGENTS.md gate block here; sawbuck writes its own
|
|
59
|
+
// sawbuck-flavored contract block below, so the user does not get two overlapping agent guides.
|
|
60
|
+
const pwArgs = ['init', '--force', '--no-agent-block'];
|
|
58
61
|
if (preset) pwArgs.push('--preset', preset);
|
|
59
62
|
try { execFileSync(process.execPath, [PW, ...pwArgs], { cwd: repo, stdio: 'ignore' }); }
|
|
60
63
|
catch { restore(); out('sawbuck init: could not generate the base contract (promptwheel init failed).'); process.exit(1); }
|
|
@@ -115,8 +118,29 @@ function describeMetric(m) {
|
|
|
115
118
|
|
|
116
119
|
function writeCfg() { writeFileSync(cfgPath, JSON.stringify(cfg, null, 2) + '\n'); }
|
|
117
120
|
|
|
121
|
+
// Write (or refresh) the agent-orienting contract block so a coding agent (or a loop not wired to the
|
|
122
|
+
// `attach --claude` hook) knows the rules. AGENTS.md (the cross-tool convention; created if absent) +
|
|
123
|
+
// CLAUDE.md / .cursorrules when present. Idempotent + safe: only the sentinel-delimited block is
|
|
124
|
+
// touched. `--no-agent-block` skips it.
|
|
125
|
+
function writeAgentBlock() {
|
|
126
|
+
const START = '<!-- sawbuck:contract -->', END = '<!-- /sawbuck:contract -->';
|
|
127
|
+
const block = `${START}\n## Sawbuck contract\n\nThis repo is under a Sawbuck contract: a change is done only when the win survives verification from the source alone. Before finishing, run \`sawbuck check\`. If it reads \`gamed\`, the green came from editing a test, config, or grader, not the source, so undo that and earn it in the source. Run \`sawbuck context\` first to see what broke in these files before (reverts, weakened tests, fragile spots). Never skip, weaken, or delete a test to go green.\n${END}\n`;
|
|
128
|
+
const targets = ['AGENTS.md', ...['CLAUDE.md', '.cursorrules'].filter((f) => existsSync(join(repo, f)))];
|
|
129
|
+
const done = [];
|
|
130
|
+
for (const f of targets) {
|
|
131
|
+
const p = join(repo, f);
|
|
132
|
+
let text = existsSync(p) ? readFileSync(p, 'utf8') : '';
|
|
133
|
+
const s = text.indexOf(START);
|
|
134
|
+
if (s > -1) { const e = text.indexOf(END, s); text = (e > -1 ? text.slice(0, s) + block + text.slice(e + END.length).replace(/^\n+/, '\n') : text.slice(0, s) + block); }
|
|
135
|
+
else { text = (text.trim() ? text.replace(/\n*$/, '\n\n') : '') + block; }
|
|
136
|
+
try { writeFileSync(p, text); done.push(f); } catch { /* best-effort */ }
|
|
137
|
+
}
|
|
138
|
+
return done;
|
|
139
|
+
}
|
|
140
|
+
|
|
118
141
|
function report() {
|
|
119
142
|
out('sawbuck: wrote promptwheel.config.json');
|
|
143
|
+
if (!noBlock) { const w = writeAgentBlock(); if (w.length) out(`sawbuck: wrote the agent guide (the contract rules) to ${w.join(', ')}`); }
|
|
120
144
|
out("\n the contract (what a win must satisfy):");
|
|
121
145
|
for (const m of cfg.metrics) out(' - ' + describeMetric(m));
|
|
122
146
|
if (evidence) { out('\n investigated this repo:'); for (const l of evidenceLines()) out(l); }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sawbuck",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.22",
|
|
4
4
|
"description": "Sign your agent to a contract so it does what you want: a deterministic, zero-LLM check that re-proves every win from the agent's source edits alone, so a green that came from editing the test is caught and reverted. A saddle for your coding harness.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
"debt.mjs",
|
|
29
29
|
"status.mjs",
|
|
30
30
|
"memory.mjs",
|
|
31
|
+
"yank.mjs",
|
|
31
32
|
"providers/obsidian.mjs",
|
|
32
33
|
"providers/mem0.mjs",
|
|
33
34
|
"providers/local.mjs",
|
package/reflect.mjs
CHANGED
|
@@ -34,7 +34,7 @@ const engineBin = (envVar, pkgSubpath, devPath) => {
|
|
|
34
34
|
for (const spec of [pkgSubpath, bare]) { try { return require.resolve(spec); } catch {} }
|
|
35
35
|
return devPath;
|
|
36
36
|
};
|
|
37
|
-
const PW = engineBin('PW_BIN', 'promptwheel/bin/promptwheel.mjs', join(HERE, '..', '
|
|
37
|
+
const PW = engineBin('PW_BIN', 'promptwheel/bin/promptwheel.mjs', join(HERE, '..', 'promptwheel', 'bin', 'promptwheel.mjs'));
|
|
38
38
|
|
|
39
39
|
const flag = (name, def) => { const i = process.argv.indexOf(name); return i > -1 ? process.argv[i + 1] : def; };
|
|
40
40
|
const repo = flag('--repo', process.cwd());
|
package/run.mjs
CHANGED
|
@@ -16,7 +16,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
16
16
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
17
17
|
const require = createRequire(import.meta.url);
|
|
18
18
|
const bin = (envVar, pkg, dev) => { if (process.env[envVar]) return process.env[envVar]; const bare = pkg.replace(/\/bin\/[^/]*$/, ''); for (const s of [pkg, bare]) { try { return require.resolve(s); } catch {} } return dev; };
|
|
19
|
-
const PW = bin('PW_BIN', 'promptwheel/bin/promptwheel.mjs', join(HERE, '..', '
|
|
19
|
+
const PW = bin('PW_BIN', 'promptwheel/bin/promptwheel.mjs', join(HERE, '..', 'promptwheel', 'bin', 'promptwheel.mjs'));
|
|
20
20
|
|
|
21
21
|
const arg = (n, d) => { const i = process.argv.indexOf(n); return i > -1 ? process.argv[i + 1] : d; };
|
|
22
22
|
const repo = arg('--repo', process.cwd());
|
package/sawbuck.mjs
CHANGED
|
@@ -19,6 +19,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
19
19
|
import { homedir } from 'node:os';
|
|
20
20
|
import { createRequire } from 'node:module';
|
|
21
21
|
import { recall as memRecall, conform as memConform } from './memory.mjs';
|
|
22
|
+
import { tally, resolveSessionPath } from './yank.mjs';
|
|
22
23
|
|
|
23
24
|
const SELF = fileURLToPath(import.meta.url);
|
|
24
25
|
const HERE = dirname(SELF);
|
|
@@ -27,7 +28,7 @@ const require = createRequire(import.meta.url);
|
|
|
27
28
|
// resolve the promptwheel engine bin: env override, installed package, then the dev sibling
|
|
28
29
|
const PW = process.env.PW_BIN
|
|
29
30
|
|| ['promptwheel/bin/promptwheel.mjs', 'promptwheel'].reduce((r, s) => { try { return r || require.resolve(s); } catch { return r; } }, '')
|
|
30
|
-
|| join(HERE, '..', '
|
|
31
|
+
|| join(HERE, '..', 'promptwheel', 'bin', 'promptwheel.mjs');
|
|
31
32
|
// run the engine and parse its JSON verdict (it prints JSON even when it exits nonzero)
|
|
32
33
|
function gateReport(repo, a) {
|
|
33
34
|
let o = '', code = 0;
|
|
@@ -63,10 +64,11 @@ try { version = JSON.parse(readFileSync(join(HERE, 'package.json'), 'utf8')).ver
|
|
|
63
64
|
const help = `sawbuck ${version}: a saddle for your repo
|
|
64
65
|
|
|
65
66
|
Usage:
|
|
66
|
-
sawbuck init [--yes] [--wizard] [--no-investigate]
|
|
67
|
+
sawbuck init [--yes] [--wizard] [--no-investigate] [--no-agent-block]
|
|
67
68
|
Generate the contract (promptwheel.config.json): a stack starter, tuned and
|
|
68
|
-
explained by what your git history shows actually breaks.
|
|
69
|
-
|
|
69
|
+
explained by what your git history shows actually breaks. Also writes a short
|
|
70
|
+
contract guide into AGENTS.md so your agent knows the rules (--no-agent-block
|
|
71
|
+
to skip). Wizard in a terminal, non-interactive in a pipe/CI/agent (or --yes).
|
|
70
72
|
sawbuck attach [repo] Wire Sawbuck into a repo (default: a git post-commit hook)
|
|
71
73
|
--claude instead wire project Claude Code hooks (writes .claude/settings.local.json)
|
|
72
74
|
--shared with --claude, write the tracked .claude/settings.json (team-shared, visible in git)
|
|
@@ -97,6 +99,10 @@ Usage:
|
|
|
97
99
|
shows only the debt a branch adds; --fail-on exits nonzero for a CI gate
|
|
98
100
|
sawbuck run --attempt '<cmd>' [--loops N]
|
|
99
101
|
Thin harness: load context, run one attempt through the keep-if-verified gate
|
|
102
|
+
sawbuck yank [--session <path>] [--json]
|
|
103
|
+
The steering meter: how much you had to steer (yank the reins) this session,
|
|
104
|
+
read from your Claude Code transcript. The check guards the turns you did not
|
|
105
|
+
steer — a miss there is the risk. Yank rate falling = the reins getting looser.
|
|
100
106
|
sawbuck --help | --version
|
|
101
107
|
|
|
102
108
|
Rides two engines: promptwheel (the check) + @promptwheel/logbook (memory).`;
|
|
@@ -125,8 +131,13 @@ switch (cmd) {
|
|
|
125
131
|
// zero-config (the engine auto-detects your test command), and say plainly whether the win was
|
|
126
132
|
// earned. No attach, no hook, no record. The try-it-first on-ramp.
|
|
127
133
|
const repo = repoArg();
|
|
128
|
-
|
|
134
|
+
// stderr ignored so a benign failing probe (e.g. rev-parse HEAD~1 on a one-commit repo) does not
|
|
135
|
+
// leak a scary "fatal:" line before sawbuck's own message.
|
|
136
|
+
const g = (...a) => { try { return execFileSync('git', ['-C', repo, ...a], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); } catch { return ''; } };
|
|
129
137
|
const dirty = g('status', '--porcelain').length > 0;
|
|
138
|
+
// nothing to verify yet: a clean tree with no previous commit to diff against (a fresh repo). Say so
|
|
139
|
+
// plainly instead of the generic "could not run" below, so the first run is not a dead end.
|
|
140
|
+
if (!dirty && !g('rev-parse', '--verify', 'HEAD~1')) { out('🐴 sawbuck: nothing to verify yet, the working tree is clean and this is the only commit. Make a change (or add one more commit), then `sawbuck check` re-proves the win from your source edits alone.'); process.exit(2); }
|
|
130
141
|
const where = dirty ? 'working' : 'last';
|
|
131
142
|
const { rep, code } = gateReport(repo, dirty ? ['run', '--working'] : ['run', '--base', 'HEAD~1', '--head', 'HEAD']);
|
|
132
143
|
if (!rep || !rep.verdict) { out('🐴 sawbuck: could not run the check here. Make sure this is a git repo with a commit, and a test command (or run `sawbuck init`).'); process.exit(2); }
|
|
@@ -258,11 +269,41 @@ switch (cmd) {
|
|
|
258
269
|
const armed = existsSync(join(repo, 'promptwheel.config.json')) || existsSync(join(repo, 'package.json'));
|
|
259
270
|
const mem = existsSync(join(repo, 'LOGBOOK.md')) || existsSync(join(repo, 'events.jsonl'));
|
|
260
271
|
const seg = caught > 0 ? `🐴 sawbuck · ${caught} caught` : active ? `🐴 sawbuck · watching` : armed ? `🐴 sawbuck · ready` : mem ? `🐴 sawbuck · memory` : '';
|
|
261
|
-
|
|
272
|
+
const base = seg ? (last ? `${seg} · ${last}` : seg) : '';
|
|
273
|
+
// the live steering readout, when the wrapper forwards the session transcript path
|
|
274
|
+
let yk = '';
|
|
275
|
+
const ti = rest.findIndex((a) => a === '--transcript' || a === '--session');
|
|
276
|
+
if (ti > -1 && rest[ti + 1]) { try { const { yanks, turns } = tally(readFileSync(rest[ti + 1], 'utf8')); if (turns) yk = `🤠 ${yanks}/${turns} yanks`; } catch {} }
|
|
277
|
+
// live steering % from the reins engine's file (OFFLINE read; reins does the LLM work, never sawbuck)
|
|
278
|
+
let st = '';
|
|
279
|
+
try { const sj = JSON.parse(readFileSync(join(repo, '.sawbuck', 'steer.json'), 'utf8')); if (sj && sj.scored) st = `🤠 steer ${Math.round((sj.steeringRate || 0) * 100)}%`; } catch {}
|
|
280
|
+
const outp = [base, yk, st].filter(Boolean).join(' · ');
|
|
281
|
+
if (outp) process.stdout.write(outp);
|
|
262
282
|
}
|
|
263
283
|
} catch {}
|
|
264
284
|
break;
|
|
265
285
|
}
|
|
286
|
+
case 'yank': {
|
|
287
|
+
// the live steering meter (the reins hook): how much did you have to steer this session?
|
|
288
|
+
// A MIRROR read from the Claude Code transcript; the check guards the turns you did NOT steer.
|
|
289
|
+
const si = rest.findIndex((a) => a === '--session' || a === '--transcript');
|
|
290
|
+
const sess = resolveSessionPath(si > -1 ? rest[si + 1] : null, process.cwd());
|
|
291
|
+
if (!sess) { out('🐴 sawbuck: no session transcript found to read (pass --session <path.jsonl>).'); process.exit(2); }
|
|
292
|
+
let text = ''; try { text = readFileSync(sess, 'utf8'); } catch { out('🐴 sawbuck: could not read the session transcript.'); process.exit(2); }
|
|
293
|
+
const { turns, yanks, rate, recent } = tally(text);
|
|
294
|
+
if (flags.has('--json')) { out(JSON.stringify({ turns, yanks, rate, recent })); break; }
|
|
295
|
+
const pct = Math.round(rate * 100);
|
|
296
|
+
out('🐴 reins: steering this session');
|
|
297
|
+
if (yanks === 0) {
|
|
298
|
+
out(` 0 hard yanks across ${turns} turns — you did not have to pull the reins hard.`);
|
|
299
|
+
} else {
|
|
300
|
+
out(` ${yanks} hard yank${yanks === 1 ? '' : 's'} across ${turns} turns (${pct}%) — times you pulled me back hard.`);
|
|
301
|
+
if (turns - yanks > 0) out(` the check guards the other ${turns - yanks} turns you trusted me with — a miss there is the risk.`);
|
|
302
|
+
if (recent.length) out(` recent: ${recent.map((r) => '"' + r + '"').join(' · ')}`);
|
|
303
|
+
}
|
|
304
|
+
out(' (terse course-corrections only; the full steer rate, incl. discursive redirects, is the harvest layer.)');
|
|
305
|
+
break;
|
|
306
|
+
}
|
|
266
307
|
default: die(`unknown command '${cmd}'\n\n${help}`);
|
|
267
308
|
}
|
|
268
309
|
|
package/yank.mjs
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// yank.mjs — the live steering meter (the "reins" hook). Reads the current Claude
|
|
3
|
+
// Code session transcript and counts YANKS: turns where you corrected or redirected
|
|
4
|
+
// the agent. Deterministic, zero-dep, with a transparent definition shown in the
|
|
5
|
+
// output. It is a MIRROR — a number about how much you had to steer this session —
|
|
6
|
+
// and it pairs with the check: the check guards the turns you did NOT steer, where
|
|
7
|
+
// a miss is the real risk. The yank rate falling over time is the reins getting
|
|
8
|
+
// looser (the agent learning your cues).
|
|
9
|
+
//
|
|
10
|
+
// A yank is counted CONSERVATIVELY (precision over recall): a human turn, after the
|
|
11
|
+
// agent has acted, that OPENS by correcting or redirecting ("no", "don't",
|
|
12
|
+
// "actually", "stop", "undo", "instead", ...). Discursive steers buried mid-
|
|
13
|
+
// paragraph are deliberately NOT counted here — that is the LLM-harvest layer's job;
|
|
14
|
+
// this stays cheap, instant, and honest (an undercount with a clear definition beats
|
|
15
|
+
// an inflated guess).
|
|
16
|
+
|
|
17
|
+
import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs';
|
|
18
|
+
import { join } from 'node:path';
|
|
19
|
+
import { homedir } from 'node:os';
|
|
20
|
+
|
|
21
|
+
// leading correction/redirect markers at the START of the turn (case-insensitive)
|
|
22
|
+
const LEAD = /^\s*["'(]?\s*(no\b|no,|nope\b|don'?t\b|do not\b|actually\b|wait\b|stop\b|undo\b|revert\b|instead\b|nah\b|hold on|rather\b|not (that|like|quite|what|really)|that'?s (not|wrong)|hmm,? ?no|why (did|are) you|you (shouldn'?t|weren'?t))/i;
|
|
23
|
+
|
|
24
|
+
function humanText(o) {
|
|
25
|
+
if (!o || o.type !== 'user' || o.isMeta) return null;
|
|
26
|
+
if (o.userType && o.userType !== 'external') return null;
|
|
27
|
+
const c = o.message && o.message.content;
|
|
28
|
+
if (typeof c === 'string') return c;
|
|
29
|
+
if (Array.isArray(c)) {
|
|
30
|
+
if (c.some((b) => b && b.type === 'tool_result')) return null; // tool output, not you
|
|
31
|
+
const t = c.filter((b) => b && b.type === 'text').map((b) => b.text || '').join(' ');
|
|
32
|
+
return t || null;
|
|
33
|
+
}
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
const isNoise = (t) => {
|
|
37
|
+
const s = t.trim();
|
|
38
|
+
if (s.includes('<task-notification>') || s.startsWith('<task-id>')) return true; // injected
|
|
39
|
+
if (s.replace(/\[Image #\d+\]/g, '').trim().length < 3) return true; // image-only
|
|
40
|
+
return false;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
// tally(transcriptText) -> { turns, yanks, rate, recent } over YOUR typed turns.
|
|
44
|
+
export function tally(text) {
|
|
45
|
+
let turns = 0, yanks = 0, seenHuman = false, prevAssistantActed = false;
|
|
46
|
+
const recent = [];
|
|
47
|
+
for (const line of String(text).split('\n')) {
|
|
48
|
+
if (!line.trim()) continue;
|
|
49
|
+
let o; try { o = JSON.parse(line); } catch { continue; }
|
|
50
|
+
if (o.type === 'assistant') { prevAssistantActed = true; continue; }
|
|
51
|
+
const t = humanText(o);
|
|
52
|
+
if (t === null || isNoise(t)) continue;
|
|
53
|
+
const opener = !seenHuman; seenHuman = true;
|
|
54
|
+
turns++;
|
|
55
|
+
if (!opener && prevAssistantActed && LEAD.test(t)) {
|
|
56
|
+
yanks++;
|
|
57
|
+
recent.push(t.replace(/\s+/g, ' ').trim().slice(0, 48));
|
|
58
|
+
}
|
|
59
|
+
prevAssistantActed = false;
|
|
60
|
+
}
|
|
61
|
+
return { turns, yanks, rate: turns ? yanks / turns : 0, recent: recent.slice(-5) };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Resolve the newest session transcript for a cwd. Claude Code stores transcripts
|
|
65
|
+
// under ~/.claude/projects/<encoded-cwd>/<id>.jsonl (path separators -> dashes).
|
|
66
|
+
// Best-effort: prefer the dir matching this cwd, else the newest across all. Never throws.
|
|
67
|
+
export function resolveSessionPath(explicit, cwd = process.cwd()) {
|
|
68
|
+
if (explicit) return existsSync(explicit) ? explicit : null;
|
|
69
|
+
const root = join(homedir(), '.claude', 'projects');
|
|
70
|
+
if (!existsSync(root)) return null;
|
|
71
|
+
const key = cwd.replace(/[^A-Za-z0-9]/g, '-');
|
|
72
|
+
let dirs = [];
|
|
73
|
+
try { dirs = readdirSync(root).map((d) => join(root, d)).filter((p) => { try { return statSync(p).isDirectory(); } catch { return false; } }); }
|
|
74
|
+
catch { return null; }
|
|
75
|
+
const matched = dirs.filter((p) => p.endsWith('/' + key));
|
|
76
|
+
const pool = matched.length ? matched : dirs;
|
|
77
|
+
let best = null, bestT = 0;
|
|
78
|
+
for (const full of pool) {
|
|
79
|
+
let files = []; try { files = readdirSync(full); } catch { continue; }
|
|
80
|
+
for (const f of files) {
|
|
81
|
+
if (!f.endsWith('.jsonl')) continue;
|
|
82
|
+
const p = join(full, f);
|
|
83
|
+
try { const st = statSync(p); if (st.mtimeMs > bestT) { bestT = st.mtimeMs; best = p; } } catch {}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return best;
|
|
87
|
+
}
|