sawbuck 0.1.36 โ†’ 0.1.37

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sawbuck",
3
- "version": "0.1.36",
3
+ "version": "0.1.37",
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 support in beta.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
package/sawbuck-hook.mjs CHANGED
@@ -124,7 +124,16 @@ if (stop && !process.env.SAWBUCK_NO_ENFORCE) {
124
124
  const committedHit = hits.some((h) => h.files.some((f) => committedSince.includes(f)));
125
125
  const accept = committedHit ? ' To accept this change on purpose, change the mandate in the contract or revert the path.' : '';
126
126
  const upgrade = base ? '' : ' (No prompt baseline is recorded in this repo, so edits a person made before the prompt cannot be told apart from yours: ask them to run sawbuck attach --claude again, which upgrades the hooks.)';
127
- logActivity({ kind: 'mandate', file: hits[0].files[0], reason: `mandate ${hits[0].name} violated in the working tree`, by: by || 'hook' });
127
+ // one catch per prompt, however many times the turn is sent back. Kept beside rec.latest, never in it:
128
+ // a block must not replace a gamed verdict that is still standing.
129
+ if (st.mandateCountedPrompt !== promptId) {
130
+ st.mandateCountedPrompt = promptId;
131
+ const r = readRec() || { regressions: 0, gamed: 0, latest: null };
132
+ r.mandates = (r.mandates || 0) + 1;
133
+ r.lastMandate = { name: hits[0].name, file: hits[0].files[0], at: now };
134
+ writeRec(r);
135
+ logActivity({ kind: 'mandate', file: hits[0].files[0], reason: `mandate ${hits[0].name} violated in the working tree`, by: by || 'hook' });
136
+ }
128
137
  if ((st.mandateBlocks || 0) < MAX_BLOCKS) {
129
138
  st.mandateBlocks = (st.mandateBlocks || 0) + 1;
130
139
  saveState();
package/sawbuck.mjs CHANGED
@@ -15,7 +15,7 @@
15
15
  import { execFileSync } from 'node:child_process';
16
16
  import { erasedBlock } from './rewrites.mjs';
17
17
  import { readFileSync, writeFileSync, existsSync, chmodSync, mkdirSync, unlinkSync, rmdirSync, rmSync, readdirSync } from 'node:fs';
18
- import { join, dirname, resolve } from 'node:path';
18
+ import { basename, join, dirname, resolve } from 'node:path';
19
19
  import { fileURLToPath } from 'node:url';
20
20
  import { homedir } from 'node:os';
21
21
  import { recall as memRecall, conform as memConform } from './memory.mjs';
@@ -190,7 +190,7 @@ switch (cmd) {
190
190
  if (!lines.length) { out('sawbuck: no activity recorded yet (the check logs here on each commit/turn).'); break; }
191
191
  const ni = rest.indexOf('-n');
192
192
  const n = ni > -1 ? (parseInt(rest[ni + 1], 10) || 20) : 20;
193
- const icon = { verified: 'โœ“', gamed: '๐Ÿšฉ', regression: 'โœ—', inconclusive: 'โ‰ˆ', allowed: 'โœŽ' };
193
+ const icon = { verified: 'โœ“', gamed: '๐Ÿšฉ', regression: 'โœ—', inconclusive: 'โ‰ˆ', allowed: 'โœŽ', mandate: 'โ›”' };
194
194
  const recent = lines.slice(-n).reverse();
195
195
  out(`sawbuck activity (last ${recent.length}):`);
196
196
  for (const l of recent) {
@@ -344,10 +344,12 @@ switch (cmd) {
344
344
  case 'statusline': {
345
345
  // compact status-line segment for the cwd repo. Never throws; fast.
346
346
  try {
347
- const repo = process.cwd();
348
- if (existsSync(join(repo, '.git'))) {
347
+ const repo = gitTop(process.cwd());
348
+ if (!repo) process.stdout.write('๐Ÿด sawbuck ยท no repo here');
349
+ if (repo) {
350
+ const name = basename(repo);
349
351
  let caught = 0;
350
- try { const r = JSON.parse(readFileSync(join(repo, '.sawbuck', 'results.json'), 'utf8')); caught = (r.regressions || 0) + (r.gamed || 0); } catch {}
352
+ try { const r = JSON.parse(readFileSync(join(repo, '.sawbuck', 'results.json'), 'utf8')); caught = (r.regressions || 0) + (r.gamed || 0) + (r.mandates || 0); } catch {}
351
353
  // the last verdict from the activity feed (includes verified passes), so you see it working each turn
352
354
  let last = '';
353
355
  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 {}
@@ -355,20 +357,22 @@ switch (cmd) {
355
357
  const active = existsSync(join(repo, '.sawbuck', 'state.json'));
356
358
  const armed = existsSync(join(repo, 'promptwheel.config.json')) || existsSync(join(repo, 'package.json'));
357
359
  const mem = existsSync(join(repo, 'LOGBOOK.md')) || existsSync(join(repo, 'events.jsonl'));
358
- const seg = caught > 0 ? `๐Ÿด sawbuck ยท ${caught} caught` : active ? `๐Ÿด sawbuck ยท watching` : armed ? `๐Ÿด sawbuck ยท ready` : mem ? `๐Ÿด sawbuck ยท memory` : '';
359
- const base = seg ? (last ? `${seg} ยท ${last}` : seg) : '';
360
+ const label = caught > 0 ? `${caught} caught` : active ? 'watching' : armed ? 'ready' : mem ? 'memory' : '';
361
+ const seg = label ? `๐Ÿด sawbuck ยท ${label}` : '';
362
+ const named = label ? `๐Ÿด sawbuck ยท ${name} ยท ${label}` : ''; // which repo it detected, in the default line
363
+ const base = named ? (last ? `${named} ยท ${last}` : named) : '';
360
364
  // live steering % from the reins engine's file (OFFLINE read; reins does the LLM work, never sawbuck)
361
365
  let st = '';
362
366
  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 {}
363
367
  // configurable template: SAWBUCK_STATUSLINE picks which segments show and in what order
364
- // (tokens {status} {sawbuck} {verdict} {steer} plus any literal text); empty tokens collapse
368
+ // (tokens {status} {sawbuck} {repo} {verdict} {steer} plus any literal text); empty tokens collapse
365
369
  // so you never get a dangling separator. Unset = the default composition. Mix in other
366
370
  // tools (git branch, model, your own adapters) by composing in your statusline script.
367
371
  const tpl = process.env.SAWBUCK_STATUSLINE;
368
372
  let outp;
369
373
  if (tpl && tpl.trim()) {
370
- const tok = { status: base, sawbuck: seg, verdict: last, steer: st };
371
- outp = tpl.replace(/\{(status|sawbuck|verdict|steer)\}/g, (_, k) => tok[k] || '')
374
+ const tok = { status: base, sawbuck: seg, repo: label ? name : '', verdict: last, steer: st };
375
+ outp = tpl.replace(/\{(status|sawbuck|repo|verdict|steer)\}/g, (_, k) => tok[k] || '')
372
376
  .replace(/\s*ยท\s*ยท\s*/g, ' ยท ').replace(/^\s*ยท\s*/, '').replace(/\s*ยท\s*$/, '').replace(/\s{2,}/g, ' ').trim();
373
377
  } else {
374
378
  outp = [base, st].filter(Boolean).join(' ยท ');
@@ -522,6 +526,12 @@ export function nodeCmd(execPath = process.execPath, platform = process.platform
522
526
  }
523
527
  function wireClaude(repo, opts) {
524
528
  const { user, shared } = opts;
529
+ // user-level hooks already cover every repo; a second set would run each check twice and spend the
530
+ // send-back budget twice as fast. --shared is a deliberate team file, so it is written anyway.
531
+ if (!user && !shared) {
532
+ const uf = claudeFile(repo, { user: true }).f;
533
+ try { if (readFileSync(uf, 'utf8').includes('sawbuck.mjs')) { out(`sawbuck: already watching here through your user-level hooks (${uf}); nothing to add`); return; } } catch {}
534
+ }
525
535
  const { dir, f } = claudeFile(repo, opts);
526
536
  const dirExisted = existsSync(dir);
527
537
  const fileExisted = existsSync(f);
package/status.mjs CHANGED
@@ -22,17 +22,17 @@ const readJSON = (p) => { try { return JSON.parse(readFileSync(p, 'utf8')); } ca
22
22
  const readLines = (p) => { try { return readFileSync(p, 'utf8').split('\n').filter(Boolean); } catch { return []; } };
23
23
  const git = (...a) => { try { return execFileSync('git', ['-C', repo, ...a], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); } catch { return ''; } };
24
24
 
25
- const ICON = { verified: 'โœ“', gamed: '๐Ÿšฉ', regression: 'โœ—', inconclusive: 'โ‰ˆ', allowed: 'โœŽ' };
25
+ const ICON = { verified: 'โœ“', gamed: '๐Ÿšฉ', regression: 'โœ—', inconclusive: 'โ‰ˆ', allowed: 'โœŽ', mandate: 'โ›”' };
26
26
 
27
27
  // --- 1) the outcome record: what the check has done here -------------------
28
28
  // results.json holds the all-time catch tally (gamed + regression, never trimmed); activity.jsonl is
29
29
  // the recent per-check feed (verified included, capped ~200). We read both and keep each honest.
30
30
  const results = readJSON(join(repo, '.sawbuck', 'results.json')) || {};
31
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 };
32
+ const tally = { verified: 0, gamed: 0, regression: 0, inconclusive: 0, allowed: 0, mandate: 0 };
33
33
  for (const e of feed) if (e && tally[e.kind] != null) tally[e.kind]++;
34
34
  const seen = feed.length;
35
- const catchesAllTime = (results.regressions || 0) + (results.gamed || 0);
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
38
  // the steer rate, if the reins judge has been scoring turns here (an OFFLINE read of its file; the
@@ -89,6 +89,7 @@ if (seen || catchesAllTime) {
89
89
  const parts = [
90
90
  tally.verified ? `${ICON.verified} ${tally.verified} verified` : '',
91
91
  tally.gamed ? `${ICON.gamed} ${tally.gamed} gamed` : '',
92
+ tally.mandate ? `${ICON.mandate} ${tally.mandate} protected path${tally.mandate === 1 ? '' : 's'}` : '',
92
93
  tally.regression ? `${ICON.regression} ${tally.regression} regression${tally.regression === 1 ? '' : 's'}` : '',
93
94
  tally.inconclusive ? `${ICON.inconclusive} ${tally.inconclusive} inconclusive` : '',
94
95
  tally.allowed ? `${ICON.allowed} ${tally.allowed} allowed` : '',