sawbuck 0.1.43 → 0.1.44
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 +3 -5
- package/agent-block.mjs +7 -14
- package/contract-cmd.mjs +2 -2
- package/exclude.mjs +15 -9
- package/package.json +2 -1
- package/repo-state.mjs +22 -0
- package/sawbuck-hook.mjs +16 -27
- package/sawbuck.mjs +33 -54
- package/shape.mjs +27 -3
- package/status.mjs +5 -11
- package/steers.mjs +1 -1
package/README.md
CHANGED
|
@@ -70,7 +70,7 @@ Exit codes: `0` verified · `1` regression · `2` gamed · `3` inconclusive · `
|
|
|
70
70
|
| `sawbuck debt` | the whole-repo live suppression debt: what is still silenced in HEAD now, disabled tests first, heaviest files ranked |
|
|
71
71
|
| `sawbuck context` | the ranked relevant history for the current change |
|
|
72
72
|
| `sawbuck trace` · `sawbuck doctor` · `detach --verify` | show the footprint; check the attachment resolves; prove a clean removal |
|
|
73
|
-
| `sawbuck statusline` | a one-line status-line segment (🐴 watching / N caught
|
|
73
|
+
| `sawbuck statusline` | a one-line status-line segment (🐴 watching / N caught) |
|
|
74
74
|
|
|
75
75
|
## Give your agent the contract
|
|
76
76
|
|
|
@@ -111,15 +111,13 @@ jobs:
|
|
|
111
111
|
|
|
112
112
|
`--fail-on` takes `undocumented` (a bare `# type: ignore` / `# noqa` with no code), `test` (a newly disabled test), `any`, or a number of new suppressions. Exit 1 fails the check; add `--json` for the machine-readable delta. This repo is also a composite action: `uses: sawbuck-ai/sawbuck@v0` with `base` and `fail-on` inputs.
|
|
113
113
|
|
|
114
|
-
Debt
|
|
114
|
+
Debt is on demand: `sawbuck debt`, and `sawbuck context` for the files in hand. To have history added to every turn, set `{"turn":{"context":true}}` in `sawbuck.config.json`. It is off by default because a written rule did the work in every bench, and context spent on each prompt is context lost.
|
|
115
115
|
|
|
116
116
|
## The engines
|
|
117
117
|
|
|
118
118
|
Sawbuck is thin glue over two roles, each behind an open interface that points in opposite directions:
|
|
119
119
|
|
|
120
120
|
- **the check** : the deterministic outcome gate, a published standard (the [Verified-Outcome Contract](CONTRACT.md)) that others conform to. Reference implementation: **promptwheel**. You plug in *what* to measure (your tests, lint, coverage, an eval); the source re-proof is the fixed part. Embed it with `import { check }`, or check any tool's verdict with `promptwheel conformance`.
|
|
121
|
-
- **the memory** : the relevant history for the files you touch, plus the live debt
|
|
122
|
-
|
|
123
|
-
In one line: the check is a standard we publish for others to meet; the memory is a socket we expose for others to fill. Sawbuck composes the two.
|
|
121
|
+
- **the memory** : the relevant history for the files you touch, plus the live debt, from **@promptwheel/logbook** (git history as agent memory; also ships an MCP server).
|
|
124
122
|
|
|
125
123
|
Apache 2.0.
|
package/agent-block.mjs
CHANGED
|
@@ -1,15 +1,5 @@
|
|
|
1
1
|
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
-
import { homedir } from 'node:os';
|
|
4
|
-
|
|
5
|
-
// Your standing rules, one per line, kept in ~/.sawbuck/rules.md: the rules you picked from /sawbuck-reins:rules. Signing
|
|
6
|
-
// carries them into each repo, so they follow you. They are your words, and they land in a file you review before committing.
|
|
7
|
-
export function standingRules(home = homedir()) {
|
|
8
|
-
try {
|
|
9
|
-
return readFileSync(join(home, '.sawbuck', 'rules.md'), 'utf8').split('\n').map((l) => l.replace(/^[-*\s]+/, '').trim()).filter((l) => l && !l.startsWith('#') && l.length <= 200).slice(0, 30);
|
|
10
|
-
} catch { return []; }
|
|
11
|
-
}
|
|
12
|
-
|
|
13
3
|
// Write (or refresh) the agent-orienting contract block so a coding agent (or a loop not wired to the
|
|
14
4
|
// `attach --claude` hook) knows the rules. AGENTS.md (the cross-tool convention; created if absent) +
|
|
15
5
|
// CLAUDE.md / .cursorrules when present. Idempotent + safe: only the sentinel-delimited block is
|
|
@@ -17,16 +7,19 @@ export function standingRules(home = homedir()) {
|
|
|
17
7
|
export function writeAgentBlock(repo) {
|
|
18
8
|
const START = '<!-- sawbuck:contract -->', END = '<!-- /sawbuck:contract -->';
|
|
19
9
|
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 \`npx -y 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 \`npx -y sawbuck context\` after your first edit 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`;
|
|
20
|
-
const mine = standingRules();
|
|
21
|
-
const body = mine.length ? block.replace(END, `\n### Standing rules\n\n${mine.map((r) => `- ${r}`).join('\n')}\n${END}`) : block;
|
|
22
10
|
const targets = ['AGENTS.md', ...['CLAUDE.md', '.cursorrules'].filter((f) => existsSync(join(repo, f)))];
|
|
23
11
|
const done = [];
|
|
24
12
|
for (const f of targets) {
|
|
25
13
|
const p = join(repo, f);
|
|
26
14
|
let text = existsSync(p) ? readFileSync(p, 'utf8') : '';
|
|
27
15
|
const s = text.indexOf(START);
|
|
28
|
-
if (s > -1) {
|
|
29
|
-
|
|
16
|
+
if (s > -1) {
|
|
17
|
+
const e = text.indexOf(END, s);
|
|
18
|
+
// a section someone wrote inside the block stays: this refreshes our paragraph, never their words
|
|
19
|
+
const kept = e > -1 ? (text.slice(s, e).match(/\n### Standing rules[\s\S]*$/) || [''])[0].replace(/\n+$/, '\n') : '';
|
|
20
|
+
const body = kept ? block.replace(END, `${kept}${END}`) : block;
|
|
21
|
+
text = e > -1 ? text.slice(0, s) + body + text.slice(e + END.length).replace(/^\n+/, '\n') : text.slice(0, s) + body;
|
|
22
|
+
} else text = (text.trim() ? text.replace(/\n*$/, '\n\n') : '') + block;
|
|
30
23
|
try { writeFileSync(p, text); done.push(f); } catch { /* best-effort */ }
|
|
31
24
|
}
|
|
32
25
|
return done;
|
package/contract-cmd.mjs
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// lock. Zero-LLM. Re-running changes nothing. Protected product code (auth, billing) is suggested, never added:
|
|
5
5
|
// freezing those is a person's decision.
|
|
6
6
|
import { ensureExcluded } from './exclude.mjs';
|
|
7
|
-
import { writeAgentBlock
|
|
7
|
+
import { writeAgentBlock } from './agent-block.mjs';
|
|
8
8
|
import { execFileSync } from 'node:child_process';
|
|
9
9
|
import { readFileSync, writeFileSync, existsSync, mkdtempSync, rmSync, cpSync, mkdirSync } from 'node:fs';
|
|
10
10
|
import { join, dirname, resolve } from 'node:path';
|
|
@@ -86,7 +86,7 @@ try {
|
|
|
86
86
|
const locked = existsSync(join(repo, 'promptwheel.lock.json'));
|
|
87
87
|
if (!locked) run([join(pack, 'lock.mjs'), 'write', repo], repo);
|
|
88
88
|
const added = (cfg.mandates.length - before.m) + (cfg.metrics.length - before.g);
|
|
89
|
-
process.stdout.write(added || !locked ? `sawbuck: signed. ${cfg.metrics.length} guards, ${cfg.mandates.length} protected-path rules, and the shape of this repo in promptwheel.lock.json.\n${strict ? '' : ' New dependencies and structure changes show as notes for now; protected paths and install scripts block. --strict blocks them all.\n'}${
|
|
89
|
+
process.stdout.write(added || !locked ? `sawbuck: signed. ${cfg.metrics.length} guards, ${cfg.mandates.length} protected-path rules, and the shape of this repo in promptwheel.lock.json.\n${strict ? '' : ' New dependencies and structure changes show as notes for now; protected paths and install scripts block. --strict blocks them all.\n'}${ci ? ' Added .github/workflows/sawbuck.yml, so pull requests are checked in CI. Commit it with the contract.\n' : ''}` : 'sawbuck: already signed, nothing changed.\n');
|
|
90
90
|
}
|
|
91
91
|
} finally { rmSync(scratch, { recursive: true, force: true }); }
|
|
92
92
|
|
package/exclude.mjs
CHANGED
|
@@ -7,20 +7,26 @@ const sh = (r, ...a) => { try { return execFileSync('git', ['-C', r, ...a], { en
|
|
|
7
7
|
|
|
8
8
|
export const RECORD_DIRS = ['.sawbuck/', '.promptwheel/*', '!.promptwheel/packs/', 'events.jsonl', 'annotations.jsonl', 'annotation-reviews.jsonl', 'decision-cards.jsonl', 'LOGBOOK.md', 'JOURNEY.md'];
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
// everything the block hides, under any attach mode
|
|
11
|
+
export const EXCLUDED = [...RECORD_DIRS, '.claude/settings.local.json'];
|
|
12
|
+
|
|
13
|
+
// The one writer of the block, in the repository's own exclude file wherever its git dir is (a worktree or
|
|
14
|
+
// submodule keeps it elsewhere). Rewrites to the current set, so a grown set retrofits existing repos; no lines
|
|
15
|
+
// strips it. Returns whether the file changed.
|
|
16
|
+
function writeBlock(r, lines) {
|
|
11
17
|
try {
|
|
12
|
-
// the repository's own exclude file, wherever its git dir is (a worktree or submodule keeps it elsewhere)
|
|
13
18
|
const rel = sh(r, 'rev-parse', '--git-path', 'info/exclude');
|
|
14
|
-
if (!rel) return;
|
|
19
|
+
if (!rel) return false;
|
|
15
20
|
const f = resolve(r, rel);
|
|
16
|
-
// the record dirs + the logbook's root ledger cache (events.jsonl). Rewrite the block to
|
|
17
|
-
// the CURRENT set rather than bail on presence, so a grown set retrofits existing repos.
|
|
18
|
-
const WANT = `# >>> sawbuck >>>\n${[...RECORD_DIRS, '.claude/settings.local.json'].join('\n')}\n# <<< sawbuck <<<\n`;
|
|
19
21
|
const body = existsSync(f) ? readFileSync(f, 'utf8') : '';
|
|
20
22
|
const stripped = body.replace(/# >>> sawbuck >>>[\s\S]*?# <<< sawbuck <<<\n?/g, '');
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
+
const block = lines.length ? `# >>> sawbuck >>>\n${lines.join('\n')}\n# <<< sawbuck <<<\n` : '';
|
|
24
|
+
const next = (block && stripped && !stripped.endsWith('\n') ? stripped + '\n' : stripped) + block;
|
|
25
|
+
if (next === body) return false;
|
|
23
26
|
mkdirSync(dirname(f), { recursive: true });
|
|
24
27
|
writeFileSync(f, next);
|
|
25
|
-
|
|
28
|
+
return true;
|
|
29
|
+
} catch { return false; }
|
|
26
30
|
}
|
|
31
|
+
export const ensureExcluded = (r) => writeBlock(r, EXCLUDED);
|
|
32
|
+
export const clearExcluded = (r) => writeBlock(r, []);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sawbuck",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.44",
|
|
4
4
|
"description": "Sign your agent to a contract: a deterministic, zero-LLM check re-proves each win from the agent's source edits alone, so a test that went green by editing the test, grader, or config is sent back. For Claude Code, with Codex untested.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -46,6 +46,7 @@
|
|
|
46
46
|
"exposure.mjs",
|
|
47
47
|
"agent-block.mjs",
|
|
48
48
|
"exclude.mjs",
|
|
49
|
+
"repo-state.mjs",
|
|
49
50
|
"contract-cmd.mjs",
|
|
50
51
|
"bins.mjs",
|
|
51
52
|
"contract.mjs",
|
package/repo-state.mjs
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// One read of what Sawbuck has here, shared by the statusline and `sawbuck status`. Files only, no git and no model.
|
|
2
|
+
// state: watching (wired in), ready (checkable, not wired yet), memory (history only), or empty.
|
|
3
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
|
|
6
|
+
const WEEK = 7 * 864e5;
|
|
7
|
+
|
|
8
|
+
export function repoState(repo) {
|
|
9
|
+
const record = (f) => { try { return JSON.parse(readFileSync(join(repo, '.sawbuck', f), 'utf8')); } catch { return null; } };
|
|
10
|
+
const has = (f) => existsSync(join(repo, f));
|
|
11
|
+
const results = record('results.json') || {};
|
|
12
|
+
const caught = (results.regressions || 0) + (results.gamed || 0) + (results.mandates || 0);
|
|
13
|
+
const hasConfig = has('promptwheel.config.json');
|
|
14
|
+
const armed = hasConfig || has('package.json');
|
|
15
|
+
const state = has('.sawbuck/state.json') ? 'watching' : armed ? 'ready' : (has('LOGBOOK.md') || has('events.jsonl')) ? 'memory' : '';
|
|
16
|
+
// the reins engine's file; reins does the model work, never sawbuck. A rate nobody has updated for a week is
|
|
17
|
+
// history, not a reading, and the judge is off unless someone turned it on.
|
|
18
|
+
const s = record('steer.json');
|
|
19
|
+
const live = s && s.scored && !(s.at && Date.now() - Date.parse(s.at) > WEEK);
|
|
20
|
+
const steer = live ? { rate: Math.round(((s.ema != null ? s.ema : s.steeringRate) || 0) * 100), scored: s.scored } : null;
|
|
21
|
+
return { results, caught, hasConfig, armed, state, steer };
|
|
22
|
+
}
|
package/sawbuck-hook.mjs
CHANGED
|
@@ -72,6 +72,20 @@ st.turns += 1;
|
|
|
72
72
|
// 2) the record of what the check has charted (read above, after any re-run)
|
|
73
73
|
const total = rec ? (rec.regressions || 0) + (rec.gamed || 0) + (rec.mandates || 0) : 0;
|
|
74
74
|
const saveState = () => { try { mkdirSync(stDir, { recursive: true }); writeFileSync(stPath, JSON.stringify(st, null, 2) + '\n'); } catch {} };
|
|
75
|
+
// the budget is per prompt: prompt_id when the harness sends one, else the prompt's own timestamp (written by
|
|
76
|
+
// `sawbuck turn --hook`), never a constant that would spend it for good
|
|
77
|
+
const promptId = hookInput.prompt_id || st.lastPromptAt || 'none';
|
|
78
|
+
// Send the turn back, at most enforce.maxBlocks times per prompt, then let it end with a warning: it can never
|
|
79
|
+
// loop, because the harness's own block cap resets on every tool call.
|
|
80
|
+
const sendBack = (idKey, countKey, reason, stillOpen) => {
|
|
81
|
+
const max = +(cfg.enforce?.maxBlocks) || 3;
|
|
82
|
+
if (st[idKey] !== promptId) { st[idKey] = promptId; st[countKey] = 0; }
|
|
83
|
+
const spent = (st[countKey] || 0) >= max;
|
|
84
|
+
if (!spent) st[countKey] = (st[countKey] || 0) + 1;
|
|
85
|
+
saveState();
|
|
86
|
+
process.stdout.write(JSON.stringify(spent ? { systemMessage: `Sawbuck stopped blocking after ${max} tries: ${stillOpen}` } : { decision: 'block', reason }));
|
|
87
|
+
process.exit(0);
|
|
88
|
+
};
|
|
75
89
|
|
|
76
90
|
// 2b) THE WALL AT TURN LEVEL (Claude Code Stop hook only): a mandate is checked against the WORKING
|
|
77
91
|
// tree on every stop, not only on commits, because an agent usually ends a turn with uncommitted
|
|
@@ -112,11 +126,6 @@ if (stop && !process.env.SAWBUCK_NO_ENFORCE) {
|
|
|
112
126
|
} else if (st.lastViolation) { delete st.lastViolation; }
|
|
113
127
|
saveState();
|
|
114
128
|
if (hits.length) {
|
|
115
|
-
const MAX_BLOCKS = +(cfg.enforce?.maxBlocks) || 3;
|
|
116
|
-
// the budget is per prompt: prompt_id when the harness sends one, else the prompt's own timestamp
|
|
117
|
-
// (written by `sawbuck turn --hook`), never a constant that would spend the budget for good
|
|
118
|
-
const promptId = hookInput.prompt_id || st.lastPromptAt || 'none';
|
|
119
|
-
if (st.mandatePromptId !== promptId) { st.mandatePromptId = promptId; st.mandateBlocks = 0; }
|
|
120
129
|
const desc = hits.map((h) => `"${h.name}"${h.reason ? ' (' + h.reason + ')' : ''}: ${h.files.join(', ')}${h.committed ? ' [this rule is in the committed contract; editing promptwheel.config.json in the working tree does not lift it]' : ''}`).join('; ');
|
|
121
130
|
const spared = leftAlone.length ? ` Edits that were already there before this prompt were left out of this check and must stay as they are: ${leftAlone.join(', ')}.` : '';
|
|
122
131
|
const before = (st.lastViolation && st.lastViolation.before) || {};
|
|
@@ -134,15 +143,7 @@ if (stop && !process.env.SAWBUCK_NO_ENFORCE) {
|
|
|
134
143
|
writeRec(r);
|
|
135
144
|
logActivity({ kind: 'mandate', file: hits[0].files[0], reason: `mandate ${hits[0].name} violated in the working tree`, by: by || 'hook' });
|
|
136
145
|
}
|
|
137
|
-
|
|
138
|
-
st.mandateBlocks = (st.mandateBlocks || 0) + 1;
|
|
139
|
-
saveState();
|
|
140
|
-
process.stdout.write(JSON.stringify({ decision: 'block', reason: `Sawbuck: a protected path in the contract was touched, the turn is not done. Protected paths changed in this turn: ${desc}. Undo the changes YOU made under the protected paths and make the fix elsewhere; do not revert anything you did not change (the rule is a wall, not a reminder; the gate fails any change that touches them).${spared}${restore}${accept}${upgrade}` }));
|
|
141
|
-
process.exit(0);
|
|
142
|
-
}
|
|
143
|
-
saveState();
|
|
144
|
-
process.stdout.write(JSON.stringify({ systemMessage: `Sawbuck stopped blocking after ${MAX_BLOCKS} tries: protected paths are still changed (${desc}).` }));
|
|
145
|
-
process.exit(0);
|
|
146
|
+
sendBack('mandatePromptId', 'mandateBlocks', `Sawbuck: a protected path in the contract was touched, the turn is not done. Protected paths changed in this turn: ${desc}. Undo the changes YOU made under the protected paths and make the fix elsewhere; do not revert anything you did not change (the rule is a wall, not a reminder; the gate fails any change that touches them).${spared}${restore}${accept}${upgrade}`, `protected paths are still changed (${desc}).`);
|
|
146
147
|
}
|
|
147
148
|
}
|
|
148
149
|
|
|
@@ -152,21 +153,9 @@ if (stop && !process.env.SAWBUCK_NO_ENFORCE) {
|
|
|
152
153
|
// bound a tool-using agent on its own. Opt out with SAWBUCK_NO_ENFORCE=1 (falls back to a flag).
|
|
153
154
|
const gamedThisTurn = rec && rec.latest && rec.latest.kind === 'gamed' && rec.latest.sha === (head || '').slice(0, 8);
|
|
154
155
|
if (stop && gamedThisTurn && !process.env.SAWBUCK_NO_ENFORCE) {
|
|
155
|
-
const MAX_BLOCKS = +(cfg.enforce?.maxBlocks) || 3;
|
|
156
|
-
const promptId = hookInput.prompt_id || st.lastPromptAt || 'none';
|
|
157
|
-
if (st.blockPromptId !== promptId) { st.blockPromptId = promptId; st.blockCount = 0; }
|
|
158
156
|
st.shownTotal = total;
|
|
159
157
|
const where = rec.latest.file;
|
|
160
|
-
|
|
161
|
-
st.blockCount = (st.blockCount || 0) + 1;
|
|
162
|
-
saveState();
|
|
163
|
-
const reason = `Sawbuck: the win on ${where} was gamed. It came from editing the test or config, not the source, so it did not survive a source-only re-run. The turn is not done: fix the source so the metric passes on its own, then finish.`;
|
|
164
|
-
process.stdout.write(JSON.stringify({ decision: 'block', reason }));
|
|
165
|
-
process.exit(0);
|
|
166
|
-
}
|
|
167
|
-
saveState(); // budget spent: let the turn end, but warn loudly so it never loops forever
|
|
168
|
-
process.stdout.write(JSON.stringify({ systemMessage: `Sawbuck stopped blocking after ${MAX_BLOCKS} tries: the win on ${where} is still gamed and needs a real source fix.` }));
|
|
169
|
-
process.exit(0);
|
|
158
|
+
sendBack('blockPromptId', 'blockCount', `Sawbuck: the win on ${where} was gamed. It came from editing the test or config, not the source, so it did not survive a source-only re-run. The turn is not done: fix the source so the metric passes on its own, then finish.`, `the win on ${where} is still gamed and needs a real source fix.`);
|
|
170
159
|
}
|
|
171
160
|
|
|
172
161
|
// 3b) THE LOCK AT TURN LEVEL: tell the person the moment a turn changes the repo's structure or its resolved
|
package/sawbuck.mjs
CHANGED
|
@@ -13,11 +13,12 @@
|
|
|
13
13
|
// proves the repo came back clean; `doctor` checks the attachment still resolves.
|
|
14
14
|
|
|
15
15
|
import { execFileSync } from 'node:child_process';
|
|
16
|
-
import { erasedBlock } from './rewrites.mjs';
|
|
16
|
+
import { erasedBlock, erasedCommits } from './rewrites.mjs';
|
|
17
17
|
import { steersReport } from './steers.mjs';
|
|
18
18
|
import { rulesCli } from './rules.mjs';
|
|
19
19
|
import { shapeCli } from './shape.mjs';
|
|
20
|
-
import { ensureExcluded, RECORD_DIRS } from './exclude.mjs';
|
|
20
|
+
import { ensureExcluded, clearExcluded, EXCLUDED, RECORD_DIRS } from './exclude.mjs';
|
|
21
|
+
import { repoState } from './repo-state.mjs';
|
|
21
22
|
import { readFileSync, writeFileSync, existsSync, chmodSync, mkdirSync, unlinkSync, rmdirSync, rmSync, readdirSync } from 'node:fs';
|
|
22
23
|
import { basename, join, dirname, resolve } from 'node:path';
|
|
23
24
|
import { fileURLToPath } from 'node:url';
|
|
@@ -73,11 +74,9 @@ Usage:
|
|
|
73
74
|
Customize the contract (promptwheel.config.json), with notes from what your
|
|
74
75
|
git history shows actually breaks, plus a short contract guide in AGENTS.md
|
|
75
76
|
(--no-agent-block to skip). Wizard in a terminal, non-interactive in a pipe/CI (or --yes).
|
|
76
|
-
sawbuck attach
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
--user user-level Claude hooks that follow your cwd (attach once, every project)
|
|
80
|
-
--dry-run print the exact footprint it would leave, and write nothing
|
|
77
|
+
sawbuck attach --claude Check every Claude Code turn in this repo. --dry-run prints the footprint and writes nothing
|
|
78
|
+
Also: no flag for a git post-commit hook, --claude --shared for a tracked settings file,
|
|
79
|
+
--user for every repo
|
|
81
80
|
sawbuck detach [repo] [--claude | --user] [--all] [--verify] [--purge]
|
|
82
81
|
Remove it and restore the repo (no flag: whatever is attached here); --verify
|
|
83
82
|
proves it came back clean; --purge also deletes the record (.sawbuck/)
|
|
@@ -89,7 +88,7 @@ Usage:
|
|
|
89
88
|
sawbuck context [repo] Relevant history for the files you are changing (reverts, weakened tests, hot spots)
|
|
90
89
|
sawbuck frame [repo] The contract's protected paths, inherited ones included, as one compact block;
|
|
91
90
|
silent without a contract (--speak explains)
|
|
92
|
-
sawbuck turn [repo]
|
|
91
|
+
sawbuck turn [repo] What attach --claude runs on a prompt: the frame, once a session and after a compaction
|
|
93
92
|
|
|
94
93
|
sawbuck trace [repo] Show every file Sawbuck has placed in this repo (the footprint)
|
|
95
94
|
sawbuck doctor [repo] Check the attachment still resolves to a live sawbuck; --all for every repo
|
|
@@ -98,14 +97,10 @@ Usage:
|
|
|
98
97
|
sawbuck rules --prepare Gather your messages on this machine so an agent can propose the rules you never wrote
|
|
99
98
|
sawbuck rules --check F Keep only proposed rules with 2 quotes that are really yours
|
|
100
99
|
sawbuck shape --prepare Gather a sample of your messages so an agent can label what you push on
|
|
101
|
-
sawbuck shape --check F Draw your steering shape from the labels, keeping only the totals
|
|
100
|
+
sawbuck shape --check F Draw your steering shape from the labels, keeping only the totals (--png for an image)
|
|
102
101
|
sawbuck steers What you keep correcting, from the plugin's scratchpad on this machine (--json)
|
|
103
|
-
sawbuck statusline One-line segment for your status line (🐴 watching | N caught
|
|
102
|
+
sawbuck statusline One-line segment for your status line (🐴 watching | N caught)
|
|
104
103
|
|
|
105
|
-
sawbuck recall [repo] Related notes for your changes from extra memory providers
|
|
106
|
-
(SAWBUCK_MEMORY=logbook,obsidian + OBSIDIAN_VAULT), alongside context
|
|
107
|
-
sawbuck memory-conformance <bin>
|
|
108
|
-
Check a provider bin speaks the Memory Contract (recall/debt/record)
|
|
109
104
|
sawbuck --help | --version
|
|
110
105
|
|
|
111
106
|
Engines: promptwheel (the check) + @promptwheel/logbook (memory). Zero-LLM, offline, free.`;
|
|
@@ -241,9 +236,10 @@ switch (cmd) {
|
|
|
241
236
|
break;
|
|
242
237
|
}
|
|
243
238
|
case 'turn': {
|
|
244
|
-
// the ONE per-prompt hook
|
|
245
|
-
//
|
|
246
|
-
//
|
|
239
|
+
// the ONE per-prompt hook. The agent gets the contract frame once a session and again after a compaction: one
|
|
240
|
+
// written statement held in every bench, so saying it on each prompt only spends context. The relevant history
|
|
241
|
+
// is on demand (`sawbuck context`), and per turn only with {"turn":{"context":true}} in sawbuck.config.json.
|
|
242
|
+
// Wired by `attach --claude` on UserPromptSubmit and after a compaction.
|
|
247
243
|
// the repository the way the Stop hook sees it: the root of the cwd under user-level wiring (a
|
|
248
244
|
// subdirectory belongs to its repo), the pinned path under project wiring; '' outside a repo
|
|
249
245
|
const top = gitTop(repoArg());
|
|
@@ -258,8 +254,8 @@ switch (cmd) {
|
|
|
258
254
|
// a file still carrying the content the last violation flagged is never baselined; and the
|
|
259
255
|
// baseline's commit does not advance past a flagged commit until a clean stop or a commit the
|
|
260
256
|
// person made between turns that no longer carries the flagged content.
|
|
261
|
-
let hookEvent = '';
|
|
262
|
-
if (flags.has('--hook') && !process.stdin.isTTY) { try {
|
|
257
|
+
let hookEvent = '', session = '', framed = false;
|
|
258
|
+
if (flags.has('--hook') && !process.stdin.isTTY) { try { const h = JSON.parse(readFileSync(0, 'utf8') || '{}'); hookEvent = String(h.hook_event_name || ''); session = String(h.session_id || ''); } catch {} }
|
|
263
259
|
if (hookEvent === 'UserPromptSubmit' && top) {
|
|
264
260
|
try {
|
|
265
261
|
ensureExcluded(repo); // before the first write: under user-level hooks nothing else has done it yet
|
|
@@ -290,11 +286,16 @@ switch (cmd) {
|
|
|
290
286
|
st.turnBaseline = { at: now, sha: ((!moved || stillInHead) && pin && st.lastViolation.sha) || sha, files, lock: lockState(repo), rules: mandatesInForce(repo) };
|
|
291
287
|
}
|
|
292
288
|
}
|
|
289
|
+
framed = !!session && st.framedSession === session;
|
|
290
|
+
if (session) st.framedSession = session;
|
|
293
291
|
st.lastPromptAt = now; // every prompt, mandates or not: the walls key their block budgets on it when the harness sends no prompt_id
|
|
294
292
|
mkdirSync(stDir, { recursive: true }); writeFileSync(stPath, JSON.stringify(st, null, 2) + '\n');
|
|
295
293
|
} catch { /* the frame never fails a prompt */ }
|
|
296
294
|
}
|
|
297
|
-
|
|
295
|
+
let history = false;
|
|
296
|
+
try { history = JSON.parse(readFileSync(join(repo, 'sawbuck.config.json'), 'utf8')).turn?.context === true; } catch {}
|
|
297
|
+
if (!history && top) erasedCommits(repo); // the ledger outlives the reflog whether or not it is printed
|
|
298
|
+
const o = [framed ? '' : frameBlock(repo), history ? contextBlock(repo, false) : ''].filter(Boolean).join('\n');
|
|
298
299
|
if (o) out(o);
|
|
299
300
|
break;
|
|
300
301
|
}
|
|
@@ -370,22 +371,16 @@ switch (cmd) {
|
|
|
370
371
|
if (!repo) process.stdout.write('🐴 sawbuck · no repo here');
|
|
371
372
|
if (repo) {
|
|
372
373
|
const name = basename(repo);
|
|
373
|
-
|
|
374
|
-
try { const r = JSON.parse(readFileSync(join(repo, '.sawbuck', 'results.json'), 'utf8')); caught = (r.regressions || 0) + (r.gamed || 0) + (r.mandates || 0); } catch {}
|
|
374
|
+
const { caught, state, steer } = repoState(repo);
|
|
375
375
|
// the last verdict from the activity feed (includes verified passes), so you see it working each turn
|
|
376
376
|
let last = '';
|
|
377
377
|
try { const L = readFileSync(join(repo, '.sawbuck', 'activity.jsonl'), 'utf8').trim().split('\n'); const e = JSON.parse(L[L.length - 1]); last = { verified: '✓', gamed: '🚩', regression: '✗', inconclusive: '≈', allowed: '✎' }[e.kind] || ''; } catch {}
|
|
378
|
-
|
|
379
|
-
const active = existsSync(join(repo, '.sawbuck', 'state.json'));
|
|
380
|
-
const armed = existsSync(join(repo, 'promptwheel.config.json')) || existsSync(join(repo, 'package.json'));
|
|
381
|
-
const mem = existsSync(join(repo, 'LOGBOOK.md')) || existsSync(join(repo, 'events.jsonl'));
|
|
382
|
-
const label = caught > 0 ? `${caught} caught` : active ? 'watching' : armed ? 'ready' : mem ? 'memory' : '';
|
|
378
|
+
const label = caught > 0 ? `${caught} caught` : state;
|
|
383
379
|
const seg = label ? `🐴 sawbuck · ${label}` : '';
|
|
384
380
|
const named = label ? `🐴 sawbuck · ${name} · ${label}` : ''; // which repo it detected, in the default line
|
|
385
381
|
const base = named ? (last ? `${named} · ${last}` : named) : '';
|
|
386
382
|
// live steering % from the reins engine's file (OFFLINE read; reins does the LLM work, never sawbuck)
|
|
387
|
-
|
|
388
|
-
try { const sj = JSON.parse(readFileSync(join(repo, '.sawbuck', 'steer.json'), 'utf8')); if (sj && sj.scored) { const p = Math.round(((sj.ema != null ? sj.ema : sj.steeringRate) || 0) * 100); st = `${p >= 50 ? '✊' : '🐎'} steer ${p}%`; } } catch {}
|
|
383
|
+
const st = steer ? `${steer.rate >= 50 ? '✊' : '🐎'} steer ${steer.rate}%` : '';
|
|
389
384
|
// configurable template: SAWBUCK_STATUSLINE picks which segments show and in what order
|
|
390
385
|
// (tokens {status} {sawbuck} {repo} {verdict} {steer} plus any literal text); empty tokens collapse
|
|
391
386
|
// so you never get a dangling separator. Unset = the default composition. Mix in other
|
|
@@ -407,7 +402,7 @@ switch (cmd) {
|
|
|
407
402
|
default: die(`unknown command '${cmd}'\n\n${help}`);
|
|
408
403
|
}
|
|
409
404
|
|
|
410
|
-
// --- the
|
|
405
|
+
// --- context: the ranked history for the files in hand, on demand ---------------------------
|
|
411
406
|
// the read face: ranked relevant history for what you're working on right now. Silent when there is
|
|
412
407
|
// nothing to say, so it is safe on every UserPromptSubmit (that hook's stdout is injected into context;
|
|
413
408
|
// "nothing notable" every turn would be noise). `speak` forces a reply for the explicit command.
|
|
@@ -456,26 +451,9 @@ function delFile(p) { try { if (existsSync(p)) unlinkSync(p); } catch {} }
|
|
|
456
451
|
function rmIfEmptyDir(d) { try { if (existsSync(d) && readdirSync(d).length === 0) rmdirSync(d); } catch {} }
|
|
457
452
|
|
|
458
453
|
// --- .git/info/exclude: keep the worktree clean without touching .gitignore --
|
|
459
|
-
//
|
|
460
|
-
// attachments need. Never deletes the file (an empty one is git's own default).
|
|
454
|
+
// Any attachment keeps the block, the last one out takes it. Returns whether the file changed.
|
|
461
455
|
function excludeFile(repo) { return gitPath(repo, 'info/exclude'); }
|
|
462
|
-
function syncExclude(repo, manifest) {
|
|
463
|
-
const need = new Set();
|
|
464
|
-
const a = manifest.attachments || {};
|
|
465
|
-
if (Object.keys(a).length) for (const d of RECORD_DIRS) need.add(d); // any attach → the check may write the record dirs
|
|
466
|
-
if (a.claude) need.add('.claude/settings.local.json'); // default claude settings are local; hide them too
|
|
467
|
-
const lines = [...need];
|
|
468
|
-
const f = excludeFile(repo);
|
|
469
|
-
let body = (existsSync(f) ? readFileSync(f, 'utf8') : '').replace(BLOCK_RE, '\n').replace(/\n{3,}/g, '\n\n');
|
|
470
|
-
if (lines.length) {
|
|
471
|
-
if (body && !body.endsWith('\n')) body += '\n';
|
|
472
|
-
body += `${S_START}\n${lines.join('\n')}\n${S_END}\n`;
|
|
473
|
-
mkdirSync(dirname(f), { recursive: true });
|
|
474
|
-
writeFileSync(f, body);
|
|
475
|
-
} else if (existsSync(f)) {
|
|
476
|
-
writeFileSync(f, body); // strip our block, leave the rest
|
|
477
|
-
}
|
|
478
|
-
}
|
|
456
|
+
function syncExclude(repo, manifest) { return Object.keys(manifest.attachments || {}).length ? ensureExcluded(repo) : clearExcluded(repo); }
|
|
479
457
|
|
|
480
458
|
// --- git post-commit --------------------------------------------------------
|
|
481
459
|
// The hook prefers a `sawbuck` on PATH (so it survives a reinstall/upgrade) and
|
|
@@ -498,7 +476,7 @@ function wireGit(repo) {
|
|
|
498
476
|
if (dry) {
|
|
499
477
|
out(dryText([
|
|
500
478
|
`${existed ? 'modify' : 'create'} ${pc} (post-commit hook block)`,
|
|
501
|
-
`exclude ${excludeFile(repo)} (${
|
|
479
|
+
`exclude ${excludeFile(repo)} (${EXCLUDED.join(', ')})`,
|
|
502
480
|
`create ${manifestPath(repo)} (attach receipt)`,
|
|
503
481
|
]));
|
|
504
482
|
return;
|
|
@@ -516,7 +494,8 @@ function unwireGit(repo) {
|
|
|
516
494
|
const pc = gitPath(repo, 'hooks/post-commit');
|
|
517
495
|
const m = readManifest(manifestPath(repo));
|
|
518
496
|
const hasReceipt = !!m.attachments?.git;
|
|
519
|
-
|
|
497
|
+
// signing with no attach still hides the record, so a repo with no receipt can have a block to take back
|
|
498
|
+
if (!existsSync(pc) && !hasReceipt) { out(syncExclude(repo, m) ? `sawbuck: removed the record lines from ${excludeFile(repo)}` : 'sawbuck: nothing to detach'); return; }
|
|
520
499
|
if (existsSync(pc)) {
|
|
521
500
|
const stripped = readFileSync(pc, 'utf8').replace(BLOCK_RE, '\n').replace(/\n{3,}/g, '\n\n');
|
|
522
501
|
const created = m.attachments?.git?.pc?.created;
|
|
@@ -578,7 +557,7 @@ function wireClaude(repo, opts) {
|
|
|
578
557
|
if (dry) {
|
|
579
558
|
out(dryText([
|
|
580
559
|
`${upgrading ? 'update' : fileExisted ? 'modify' : 'create'} ${f} (UserPromptSubmit + SessionStart[compact] + Stop hooks: the per-turn block, and the check)`,
|
|
581
|
-
user ? null : `exclude ${excludeFile(repo)} (${
|
|
560
|
+
user ? null : `exclude ${excludeFile(repo)} (${EXCLUDED.join(', ')})`,
|
|
582
561
|
`create ${user ? userManifestPath() : manifestPath(repo)} (attach receipt)`,
|
|
583
562
|
].filter(Boolean)));
|
|
584
563
|
return;
|
|
@@ -589,8 +568,8 @@ function wireClaude(repo, opts) {
|
|
|
589
568
|
cfg.hooks[event].push({ ...(matcher ? { matcher } : {}), hooks: [{ type: 'command', command }] });
|
|
590
569
|
};
|
|
591
570
|
put('UserPromptSubmit', ctx);
|
|
592
|
-
// right after a compaction the summary has just dropped detail: re-impress the contract frame
|
|
593
|
-
//
|
|
571
|
+
// right after a compaction the summary has just dropped detail: re-impress the contract frame there too
|
|
572
|
+
// (SessionStart with the `compact` source injects stdout into context).
|
|
594
573
|
put('SessionStart', ctx, 'compact');
|
|
595
574
|
put('Stop', chk);
|
|
596
575
|
mkdirSync(dir, { recursive: true });
|
package/shape.mjs
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
// Your steering shape: what you push on, as 12 spokes in 4 groups. The model labels a sample of your messages; only the
|
|
2
2
|
// totals are kept. Two models agree on single messages about half the time and on the shape at 0.93, so a single label is
|
|
3
3
|
// never shown. It says what you pushed on, not how good you are. Each run is saved with its date to compare later.
|
|
4
|
-
import { readFileSync, writeFileSync, appendFileSync, mkdirSync, existsSync } from 'node:fs';
|
|
5
|
-
import {
|
|
4
|
+
import { readFileSync, writeFileSync, appendFileSync, mkdirSync, existsSync, rmSync, copyFileSync } from 'node:fs';
|
|
5
|
+
import { execFileSync } from 'node:child_process';
|
|
6
|
+
import { join, dirname } from 'node:path';
|
|
6
7
|
import { homedir } from 'node:os';
|
|
7
8
|
import { prepareRules } from './rules.mjs';
|
|
8
9
|
import { exposure, writtenRules } from './exposure.mjs';
|
|
@@ -39,6 +40,7 @@ export function tally(labels) {
|
|
|
39
40
|
}
|
|
40
41
|
|
|
41
42
|
// What was not said: each spoke we can measure from what the agent did, read against how often you push on it.
|
|
43
|
+
const MIN_TURNS = 30; // a pattern needs enough turns behind it: 1 of 3 is not one
|
|
42
44
|
const HIGH_PUSH = 8, HIGH_DOING = 10, HIGH_FAULT = 5; // a fault counts sooner than plain activity
|
|
43
45
|
const SEEN = {
|
|
44
46
|
proof: { fault: true, of: 'code', n: 'unverified', says: 'changed code and ran no check within 3 turns', rule: 'Run the tests before saying a change works.' },
|
|
@@ -57,7 +59,8 @@ export function patterns(t, c) {
|
|
|
57
59
|
const push = t.shares[s], often = pct >= (m.fault ? HIGH_FAULT : HIGH_DOING), pushed = push >= HIGH_PUSH;
|
|
58
60
|
// a fault you keep raising is a fight; activity you raise is just attention, since committing is not a failure
|
|
59
61
|
const has = !!(c.written && c.written[s]);
|
|
60
|
-
const
|
|
62
|
+
const enough = c[m.of] >= MIN_TURNS;
|
|
63
|
+
const kind = !enough ? 'too few' : often && pushed ? (m.fault ? 'fighting it' : 'watched') : often ? (has ? 'covered' : 'blind spot') : pushed ? 'handled' : 'quiet';
|
|
61
64
|
return { spoke: s, kind, pct, count: c[m.n] || 0, of: c[m.of], push, says: m.says, rule: m.rule, has };
|
|
62
65
|
});
|
|
63
66
|
}
|
|
@@ -74,6 +77,25 @@ export function shapeSvg(t, found = []) {
|
|
|
74
77
|
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1000" font-family="system-ui,sans-serif"><rect width="1000" height="1000" fill="#0b0e13"/><text x="50" y="70" fill="#e8e6e1" font-size="40" font-weight="700">My steering shape</text>${[0.25, 0.5, 0.75, 1].map((v) => `<polygon points="${ring(v)}" fill="none" stroke="#2a313b"/>`).join('')}<polygon points="${names.map((_, i) => pt(i, vals[i]).join(',')).join(' ')}" fill="#c9a24a" fill-opacity="0.35" stroke="#c9a24a" stroke-width="4"/>${seenPts.map(([x, y]) => `<circle cx="${x}" cy="${y}" r="9" fill="#4fb3a5"/>`).join('')}${labels}<text x="50" y="960" fill="#8d94a0" font-size="19">Gold: what I push on, from ${t.n} messages.${seenPts.length ? ' Teal dots: what the agent did.' : ''} Relative to my own top. Not a score. sawbuck.ai</text></svg>\n`;
|
|
75
78
|
}
|
|
76
79
|
|
|
80
|
+
// A PNG of the card, with what is already on the machine: sawbuck ships no image library. A snap browser sees neither
|
|
81
|
+
// a hidden folder nor the real /tmp, so it works in a plain folder in home and the result is copied over.
|
|
82
|
+
function pngCard(svg) {
|
|
83
|
+
const png = svg.replace(/\.svg$/, '.png');
|
|
84
|
+
rmSync(png, { force: true });
|
|
85
|
+
const run = (bin, args) => { try { execFileSync(bin, args, { stdio: 'ignore', timeout: 30000 }); return true; } catch { return false; } };
|
|
86
|
+
if (run('rsvg-convert', ['-w', '1200', '-o', png, svg]) && existsSync(png)) return png;
|
|
87
|
+
const tmp = join(dirname(dirname(svg)), `sawbuck-shape-${process.pid}`);
|
|
88
|
+
try {
|
|
89
|
+
mkdirSync(tmp, { recursive: true });
|
|
90
|
+
const a = join(tmp, 'shape.svg'), b = join(tmp, 'shape.png');
|
|
91
|
+
copyFileSync(svg, a);
|
|
92
|
+
for (const bin of ['google-chrome', 'chromium', 'chromium-browser']) {
|
|
93
|
+
if (run(bin, ['--headless', '--disable-gpu', '--hide-scrollbars', '--window-size=1000,1000', `--screenshot=${b}`, `file://${a}`]) && existsSync(b)) { copyFileSync(b, png); return png; }
|
|
94
|
+
}
|
|
95
|
+
} catch {} finally { rmSync(tmp, { recursive: true, force: true }); }
|
|
96
|
+
return '';
|
|
97
|
+
}
|
|
98
|
+
|
|
77
99
|
export function shapeCli(args, { home = homedir() } = {}) {
|
|
78
100
|
if (args.includes('--prepare')) { const r = prepareShape({ home }); return `🐴 ${r.lines} of your messages are ready in ${r.input}${r.secrets ? `, ${r.secrets} that looked like secrets left out` : ''}.`; }
|
|
79
101
|
const i = args.indexOf('--check');
|
|
@@ -104,7 +126,9 @@ export function shapeCli(args, { home = homedir() } = {}) {
|
|
|
104
126
|
for (const f of blind) out.push(` Not said: ${title(f.spoke)}. The agent ${f.says} in ${f.pct}% of turns (${f.count} of ${f.of}), and only ${f.push}% of your pushes touch it. Is that what you want?`, ` Try: ${f.rule}`);
|
|
105
127
|
const covered = found.filter((f) => f.kind === 'covered');
|
|
106
128
|
if (covered.length) out.push('', ` Written down already, so left out: ${covered.map((f) => title(f.spoke)).join(', ')}. Matched by keyword, so check they say what you mean.`);
|
|
129
|
+
if (found.some((f) => f.kind === 'too few')) out.push('', ` Not enough recent turns to name every pattern yet: each needs ${MIN_TURNS}.`);
|
|
107
130
|
if (found.length) out.push('', ' What the agent did covers your last 90 days, not counting scratch work. Direction, scope, correctness and honesty have no behaviour signal, so they show only what you said.');
|
|
108
131
|
out.push('', ` Card: ${p.svg}`);
|
|
132
|
+
if (args.includes('--png')) { const png = pngCard(p.svg); out.push(png ? ` Image to share: ${png}` : ' No image tool here (rsvg-convert or Chrome): open the card in a browser to save an image.'); }
|
|
109
133
|
return out.join('\n');
|
|
110
134
|
}
|
package/status.mjs
CHANGED
|
@@ -12,6 +12,7 @@ import { execFileSync } from 'node:child_process';
|
|
|
12
12
|
import { readFileSync, existsSync } from 'node:fs';
|
|
13
13
|
import { join, dirname, resolve, basename } from 'node:path';
|
|
14
14
|
import { fileURLToPath } from 'node:url';
|
|
15
|
+
import { repoState } from './repo-state.mjs';
|
|
15
16
|
|
|
16
17
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
17
18
|
const flag = (n, d) => { const i = process.argv.indexOf(n); return i > -1 ? (process.argv[i + 1] ?? true) : d; };
|
|
@@ -27,26 +28,19 @@ const ICON = { verified: '✓', gamed: '🚩', regression: '✗', inconclusive:
|
|
|
27
28
|
// --- 1) the outcome record: what the check has done here -------------------
|
|
28
29
|
// results.json holds the all-time catch tally (gamed + regression, never trimmed); activity.jsonl is
|
|
29
30
|
// the recent per-check feed (verified included, capped ~200). We read both and keep each honest.
|
|
30
|
-
const results
|
|
31
|
+
const { results, caught: catchesAllTime, state: kind, steer, armed, hasConfig } = repoState(repo);
|
|
31
32
|
const feed = readLines(join(repo, '.sawbuck', 'activity.jsonl')).map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
|
|
32
33
|
const tally = { verified: 0, gamed: 0, regression: 0, inconclusive: 0, allowed: 0, mandate: 0 };
|
|
33
34
|
for (const e of feed) if (e && tally[e.kind] != null) tally[e.kind]++;
|
|
34
35
|
const seen = feed.length;
|
|
35
|
-
const catchesAllTime = (results.regressions || 0) + (results.gamed || 0) + (results.mandates || 0);
|
|
36
36
|
const latest = results.latest || (feed.length ? feed[feed.length - 1] : null);
|
|
37
37
|
const since = feed.length ? (feed[0].at || '').slice(0, 10) : null;
|
|
38
|
-
// the steer rate, if the reins judge has been scoring turns here (an OFFLINE read of its file; the
|
|
38
|
+
// the steer rate, if the reins judge has been scoring turns here (an OFFLINE read of its file, in repoState; the
|
|
39
39
|
// recent-window average over roughly the last twenty scored turns, as the band and statusline show it)
|
|
40
|
-
const steerRec = readJSON(join(repo, '.sawbuck', 'steer.json'));
|
|
41
|
-
const steer = steerRec && steerRec.scored ? { rate: Math.round(((steerRec.ema != null ? steerRec.ema : steerRec.steeringRate) || 0) * 100), scored: steerRec.scored } : null;
|
|
42
40
|
|
|
43
|
-
// --- 2) the armed state: what it can measure here (
|
|
41
|
+
// --- 2) the armed state: what it can measure here (shared with the statusline) ---
|
|
44
42
|
const isGit = !!existsSync(join(repo, '.git'));
|
|
45
|
-
const
|
|
46
|
-
const watching = existsSync(join(repo, '.sawbuck', 'state.json'));
|
|
47
|
-
const armed = hasConfig || existsSync(join(repo, 'package.json'));
|
|
48
|
-
const mem = existsSync(join(repo, 'LOGBOOK.md')) || existsSync(join(repo, 'events.jsonl'));
|
|
49
|
-
const state = watching ? 'watching' : armed ? 'ready' : mem ? 'memory' : 'unarmed';
|
|
43
|
+
const state = kind || 'unarmed';
|
|
50
44
|
|
|
51
45
|
// --- 3) the live debt ledger: best-effort, bounded -------------------------
|
|
52
46
|
// A full-repo HEAD grep + gating; fast on normal repos, can be heavy on a giant one. Bounded so
|
package/steers.mjs
CHANGED
|
@@ -37,7 +37,7 @@ export function steersReport({ json = false, home } = {}) {
|
|
|
37
37
|
const groups = groupSteers(steers);
|
|
38
38
|
const repeated = groups.filter((g) => g.n >= 3 && g.sessions >= 2);
|
|
39
39
|
if (json) return JSON.stringify({ steers: steers.length, scored: stats.scored, unscored: stats.lost, repeated });
|
|
40
|
-
if (!steers.length && !stats.scored) return '🐴 sawbuck: no steers recorded
|
|
40
|
+
if (!steers.length && !stats.scored) return '🐴 sawbuck: no steers recorded. The plugin\'s live judge is off by default; /sawbuck-reins:rules reads your history instead.';
|
|
41
41
|
const out = [`🐴 ${steers.length} steers kept, from ${stats.scored} replies scored${stats.lost ? ` (${stats.lost} could not be scored, so this undercounts)` : ''}. Measured, not estimated.`];
|
|
42
42
|
if (repeated.length) {
|
|
43
43
|
out.push('', 'You keep saying:');
|