sawbuck 0.1.12 → 0.1.13

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 CHANGED
@@ -32,7 +32,9 @@ two vocabularies.
32
32
  | what Sawbuck leaves you | **verified history** | the findings you can trust |
33
33
  | a surfaced past outcome | **finding** | |
34
34
  | mounting Sawbuck to a repo | **attach** / **cinch** | `sawbuck attach <repo>` |
35
- | reading the repo's shape | **fit** | history + config |
35
+ | reading the repo's shape | **fit** | history + config; a concept, not a command (it is what `attach` reads to decide what it can measure) |
36
+ | code still silenced in HEAD now | **debt** (suppression debt) | disabled/weakened tests + lint/type suppressions still live; `sawbuck debt` ranks it by severity and file fragility |
37
+ | the one-view accumulation | **inventory** | `sawbuck status`: what it has caught over time + the debt accruing now + whether it is armed here |
36
38
 
37
39
  ## The one humility rule
38
40
 
package/README.md CHANGED
@@ -31,6 +31,7 @@ sawbuck: gamed recorded on test.js (the win came from editing the test, no
31
31
 
32
32
  - **Verifies the win.** `check` re-runs your metric from the source diff alone. A faked green (a deleted, weakened, or skipped test; a mocked grader; an edited golden file) reads `gamed`. Deterministic, zero-LLM, $0, and it cannot be obfuscated against because it never reads the trajectory (89% recall on a labeled gaming benchmark).
33
33
  - **Remembers what broke.** `context` and `rank` surface the relevant history (reverts, fragile areas, test-weakening) for the files you are touching: a ranked trail guide, before you act.
34
+ - **Shows you everything.** `status` is the inventory: what it has caught for you over time, the debt accruing now, and whether it is armed here, in one view. On-demand, so it is quiet until you go looking.
34
35
  - **Rides on, does not replace.** Sawbuck is the verify + memory layer. Your agent and your loop stay yours.
35
36
  - **Clean on and off.** `attach` touches nothing in your worktree (the hook lives in `.git/`, the record is excluded locally); `detach` is a true inverse. `trace` shows every byte it placed, and `detach --verify` proves the repo came back unchanged.
36
37
 
@@ -43,6 +44,7 @@ sawbuck: gamed recorded on test.js (the win came from editing the test, no
43
44
  | `sawbuck check [--base R --head R]` | verify a commit; record a regression or a gamed win |
44
45
  | `sawbuck context` · `sawbuck rank` | the ranked relevant history for the current change |
45
46
  | `sawbuck debt` | the whole-repo live suppression debt: what is still silenced in HEAD now, disabled tests first, heaviest files ranked |
47
+ | `sawbuck status` · `sawbuck log` | the inventory (caught over time + debt now + armed state), in one view; and the recent per-check feed |
46
48
  | `sawbuck run --attempt '<agent cmd>'` | thin harness: load context, run one attempt through the keep-if-verified gate |
47
49
  | `sawbuck trace` · `sawbuck doctor` · `detach --verify` | show the footprint; check the attachment resolves; prove a clean removal |
48
50
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sawbuck",
3
- "version": "0.1.12",
3
+ "version": "0.1.13",
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",
@@ -26,6 +26,7 @@
26
26
  "sawbuck-hook.mjs",
27
27
  "rank.mjs",
28
28
  "debt.mjs",
29
+ "status.mjs",
29
30
  "run.mjs",
30
31
  "README.md",
31
32
  "GLOSSARY.md",
package/sawbuck.mjs CHANGED
@@ -78,7 +78,8 @@ Usage:
78
78
  sawbuck doctor [repo] Check attachments are healthy (hook still resolves to a live sawbuck); --all for every repo
79
79
  sawbuck where List every repo Sawbuck is attached to
80
80
  sawbuck statusline One-line segment for your status line (🐴 watching | N caught)
81
- sawbuck status [repo] Show the results receipt (what the check has caught)
81
+ sawbuck status [repo] The inventory: everything it has accumulated here, in one view, caught
82
+ over time + debt accruing now + whether it is armed. --json for the feed
82
83
  sawbuck log [repo] [-n N] Recent activity, newest first: every check (verified / gamed / regression)
83
84
  sawbuck check [repo] Try it: verify your latest change right now (working tree if dirty, else the
84
85
  last commit). Zero-config, no attach, no hook. The fastest way to see it work.
@@ -108,10 +109,10 @@ switch (cmd) {
108
109
  break;
109
110
  }
110
111
  case 'status': {
111
- let o = '';
112
- try { o = execFileSync(process.execPath, [join(HERE, 'dispatch.mjs'), '--repo', repoArg(), '--force'], { encoding: 'utf8' }); }
113
- catch (e) { o = (e.stdout || '').toString(); }
114
- out(o.trim() ? o : 'sawbuck: nothing recorded yet.');
112
+ // the inventory: one view unioning what the check has caught over time, what debt is accruing
113
+ // now, and whether it is armed here. The "show me everything" face.
114
+ try { execFileSync(process.execPath, [join(HERE, 'status.mjs'), '--repo', repoArg(), ...rest.filter((a) => a.startsWith('--'))], { stdio: 'inherit' }); }
115
+ catch (e) { process.exit(e.status || 1); }
115
116
  break;
116
117
  }
117
118
  case 'check': {
package/status.mjs ADDED
@@ -0,0 +1,139 @@
1
+ #!/usr/bin/env node
2
+ // status.mjs, the inventory: one on-demand view of everything Sawbuck has accumulated for a repo.
3
+ // Unions the three faces that are otherwise surfaced piecemeal: what it has CAUGHT for you over
4
+ // time (the outcome record), what debt is ACCRUING right now (the live suppression ledger), and
5
+ // whether it is ARMED here (what it can measure). The quiet per-turn faces stay quiet; this is the
6
+ // "show me everything" face you run when you go looking.
7
+ //
8
+ // Read-only, zero-dep, bounded. The debt scan is best-effort (a pointer if it cannot run in time).
9
+ // Usage: node status.mjs [--repo P] [--json]
10
+
11
+ import { execFileSync } from 'node:child_process';
12
+ import { readFileSync, existsSync } from 'node:fs';
13
+ import { join, dirname, resolve, basename } from 'node:path';
14
+ import { fileURLToPath } from 'node:url';
15
+
16
+ const HERE = dirname(fileURLToPath(import.meta.url));
17
+ const flag = (n, d) => { const i = process.argv.indexOf(n); return i > -1 ? (process.argv[i + 1] ?? true) : d; };
18
+ const repo = resolve(flag('--repo', process.cwd()));
19
+ const asJson = process.argv.includes('--json');
20
+
21
+ const readJSON = (p) => { try { return JSON.parse(readFileSync(p, 'utf8')); } catch { return null; } };
22
+ const readLines = (p) => { try { return readFileSync(p, 'utf8').split('\n').filter(Boolean); } catch { return []; } };
23
+ const git = (...a) => { try { return execFileSync('git', ['-C', repo, ...a], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); } catch { return ''; } };
24
+
25
+ const ICON = { verified: '✓', gamed: '🚩', regression: '✗', inconclusive: '≈', allowed: '✎' };
26
+
27
+ // --- 1) the outcome record: what the check has done here -------------------
28
+ // results.json holds the all-time catch tally (gamed + regression, never trimmed); activity.jsonl is
29
+ // the recent per-check feed (verified included, capped ~200). We read both and keep each honest.
30
+ const results = readJSON(join(repo, '.sawbuck', 'results.json')) || {};
31
+ const feed = readLines(join(repo, '.sawbuck', 'activity.jsonl')).map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
32
+ const tally = { verified: 0, gamed: 0, regression: 0, inconclusive: 0, allowed: 0 };
33
+ for (const e of feed) if (e && tally[e.kind] != null) tally[e.kind]++;
34
+ const seen = feed.length;
35
+ const catchesAllTime = (results.regressions || 0) + (results.gamed || 0);
36
+ const latest = results.latest || (feed.length ? feed[feed.length - 1] : null);
37
+ const since = feed.length ? (feed[0].at || '').slice(0, 10) : null;
38
+
39
+ // --- 2) the armed state: what it can measure here (mirror of statusline) ---
40
+ const isGit = !!existsSync(join(repo, '.git'));
41
+ const hasConfig = existsSync(join(repo, 'promptwheel.config.json'));
42
+ const watching = existsSync(join(repo, '.sawbuck', 'state.json'));
43
+ const armed = hasConfig || existsSync(join(repo, 'package.json'));
44
+ const mem = existsSync(join(repo, 'LOGBOOK.md')) || existsSync(join(repo, 'events.jsonl'));
45
+ const state = watching ? 'watching' : armed ? 'ready' : mem ? 'memory' : 'unarmed';
46
+
47
+ // --- 3) the live debt ledger: best-effort, bounded -------------------------
48
+ // A full-repo HEAD grep + gating; fast on normal repos, can be heavy on a giant one. Bounded so
49
+ // status never hangs; on timeout/failure it degrades to a pointer at `sawbuck debt`.
50
+ let debt = null, debtErr = null;
51
+ if (isGit) {
52
+ const ms = +(process.env.SAWBUCK_STATUS_DEBT_MS) || 20000;
53
+ try {
54
+ const o = execFileSync(process.execPath, [join(HERE, 'debt.mjs'), '--repo', repo, '--json'], { encoding: 'utf8', timeout: ms, stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 64 * 1024 * 1024 });
55
+ debt = JSON.parse(o.slice(o.indexOf('{')));
56
+ } catch (e) { debtErr = e.killed ? 'timeout' : 'unavailable'; }
57
+ }
58
+
59
+ // --- JSON face -------------------------------------------------------------
60
+ if (asJson) {
61
+ console.log(JSON.stringify({
62
+ repo: basename(repo),
63
+ state,
64
+ armed,
65
+ git: isGit,
66
+ config: hasConfig,
67
+ outcomes: { seen, caughtAllTime: catchesAllTime, gamedAllTime: results.gamed || 0, regressionsAllTime: results.regressions || 0, recent: tally, latest, since },
68
+ debt: debt ? { total: debt.total, testSkips: debt.testSkips, undocumented: debt.undocumented, filesOnFragileCode: debt.filesOnFragileCode, top: (debt.files || []).slice(0, 5) } : null,
69
+ debtError: debtErr,
70
+ }, null, 2));
71
+ process.exit(0);
72
+ }
73
+
74
+ // --- human face ------------------------------------------------------------
75
+ const out = [];
76
+ const stateLabel = { watching: 'watching (wired in)', ready: 'ready (checkable, not wired yet)', memory: 'memory only (history, no check configured)', unarmed: 'not a measurable repo yet' }[state];
77
+ out.push(`🐴 sawbuck · ${basename(repo)} · ${stateLabel}`);
78
+
79
+ // Outcomes
80
+ if (seen || catchesAllTime) {
81
+ out.push('');
82
+ out.push(` Caught for you${seen ? ` · last ${seen} check${seen === 1 ? '' : 's'}` : ''}`);
83
+ if (seen) {
84
+ const parts = [
85
+ tally.verified ? `${ICON.verified} ${tally.verified} verified` : '',
86
+ tally.gamed ? `${ICON.gamed} ${tally.gamed} gamed` : '',
87
+ tally.regression ? `${ICON.regression} ${tally.regression} regression${tally.regression === 1 ? '' : 's'}` : '',
88
+ tally.inconclusive ? `${ICON.inconclusive} ${tally.inconclusive} inconclusive` : '',
89
+ tally.allowed ? `${ICON.allowed} ${tally.allowed} allowed` : '',
90
+ ].filter(Boolean).join(' ');
91
+ out.push(` ${parts || 'no checks recorded yet'}`);
92
+ }
93
+ // the all-time catch count outlasts the 200-line window, so surface it when it is larger
94
+ if (catchesAllTime && catchesAllTime > (tally.gamed + tally.regression)) {
95
+ out.push(` ${catchesAllTime} caught here all-time (${results.gamed || 0} gamed, ${results.regressions || 0} regression${(results.regressions || 0) === 1 ? '' : 's'})`);
96
+ }
97
+ if (latest) {
98
+ const k = latest.kind || '';
99
+ const when = (latest.at || '').slice(0, 10);
100
+ const why = String(latest.reason || '').replace(/^sawbuck:\s*/i, '').slice(0, 90);
101
+ out.push(` latest: ${ICON[k] || '·'} ${k}${latest.file ? ` · ${latest.file}` : ''}${why ? ` · ${why}` : ''}${when ? ` (${when})` : ''}`);
102
+ }
103
+ } else {
104
+ out.push('');
105
+ out.push(` Caught for you · nothing recorded yet (the check logs here on each commit/turn)`);
106
+ }
107
+
108
+ // Debt
109
+ out.push('');
110
+ if (debt && debt.total) {
111
+ out.push(` Debt accruing now`);
112
+ out.push(` ${debt.total} silenced in HEAD · ${debt.testSkips} disabled test${debt.testSkips === 1 ? '' : 's'} · ${debt.undocumented} undocumented (no code/reason)`);
113
+ for (const f of (debt.files || []).slice(0, 3)) {
114
+ const bits = [f.testSkips ? `${f.testSkips} test` : '', f.suppressions ? `${f.suppressions} lint/type${f.undocumented ? `, ${f.undocumented} bare` : ''}` : ''].filter(Boolean).join(' + ');
115
+ const frag = [f.reverts ? `${f.reverts} revert${f.reverts === 1 ? '' : 's'}` : '', f.hot ? 'fix-hotspot' : ''].filter(Boolean).join(', ');
116
+ out.push(` ${f.reverts || f.hot ? '🔥 ' : ' '}${f.file} · ${f.live} (${bits})${frag ? ` · ${frag}` : ''} · risk ${f.risk}`);
117
+ }
118
+ out.push(` → sawbuck debt for the full ledger`);
119
+ } else if (debt) {
120
+ out.push(` Debt accruing now · none live in HEAD (nothing silenced in src/test/config). A clean tree.`);
121
+ } else if (debtErr === 'timeout') {
122
+ out.push(` Debt accruing now · scan did not finish in time here (a large repo); run \`sawbuck debt\` directly.`);
123
+ } else if (isGit) {
124
+ out.push(` Debt accruing now · could not read the ledger; run \`sawbuck debt\` for the detail.`);
125
+ }
126
+
127
+ // Measuring / next action
128
+ out.push('');
129
+ if (!isGit) {
130
+ out.push(` Not a git repo here, so there is nothing to verify or remember. cd into your project.`);
131
+ } else if (state === 'watching') {
132
+ out.push(` Measuring here · ${hasConfig ? '✓ wired in with a contract (promptwheel.config.json)' : '✓ wired in (zero-config auto-detect)'}. It verifies each commit/turn automatically.`);
133
+ } else if (state === 'ready') {
134
+ out.push(` Measuring here · checkable but not wired yet. Try it now: \`sawbuck check\` • make it automatic: \`sawbuck attach --claude\``);
135
+ } else {
136
+ out.push(` Measuring here · ${hasConfig ? 'a contract exists' : 'no test command detected yet'}. \`sawbuck init\` to set up the check, or \`sawbuck context\` for the history alone.`);
137
+ }
138
+
139
+ console.log(out.join('\n'));