sawbuck 0.1.36 โ†’ 0.1.38

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/contract-cmd.mjs CHANGED
@@ -3,6 +3,7 @@
3
3
  // here, adds your tests and tripwires if there is none, then the supply-chain protections and the architecture
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
+ import { ensureExcluded } from './exclude.mjs';
6
7
  import { execFileSync } from 'node:child_process';
7
8
  import { readFileSync, writeFileSync, existsSync, mkdtempSync, rmSync, cpSync, mkdirSync } from 'node:fs';
8
9
  import { join, dirname, resolve } from 'node:path';
@@ -48,6 +49,7 @@ try {
48
49
  const next = text.replace(/^\.promptwheel\/?[ \t]*$/gm, '.promptwheel/*\n!.promptwheel/packs/');
49
50
  if (next !== text) writeFileSync(f, next);
50
51
  }
52
+ ensureExcluded(repo);
51
53
  const locked = existsSync(join(repo, 'promptwheel.lock.json'));
52
54
  if (!locked) run([join(pack, 'lock.mjs'), 'write', repo], repo);
53
55
  const added = (cfg.mandates.length - before.m) + (cfg.metrics.length - before.g);
package/exclude.mjs ADDED
@@ -0,0 +1,24 @@
1
+ // Keep sawbuck's own record out of git status, before anything is written: signed files are the person's to
2
+ // commit, the record is not, and an untracked .sawbuck/ would trip the rule that protects it.
3
+ import { execFileSync } from 'node:child_process';
4
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
5
+ import { resolve, dirname } from 'node:path';
6
+ const sh = (r, ...a) => { try { return execFileSync('git', ['-C', r, ...a], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); } catch { return ''; } };
7
+
8
+ export function ensureExcluded(r) {
9
+ try {
10
+ // the repository's own exclude file, wherever its git dir is (a worktree or submodule keeps it elsewhere)
11
+ const rel = sh(r, 'rev-parse', '--git-path', 'info/exclude');
12
+ if (!rel) return;
13
+ const f = resolve(r, rel);
14
+ // the record dirs + the logbook's root ledger cache (events.jsonl). Rewrite the block to
15
+ // the CURRENT set rather than bail on presence, so a grown set retrofits existing repos.
16
+ const WANT = '# >>> sawbuck >>>\n.sawbuck/\n.promptwheel/*\n!.promptwheel/packs/\nevents.jsonl\nannotations.jsonl\nannotation-reviews.jsonl\ndecision-cards.jsonl\nLOGBOOK.md\nJOURNEY.md\n.claude/settings.local.json\n# <<< sawbuck <<<\n';
17
+ const body = existsSync(f) ? readFileSync(f, 'utf8') : '';
18
+ const stripped = body.replace(/# >>> sawbuck >>>[\s\S]*?# <<< sawbuck <<<\n?/g, '');
19
+ const next = (stripped && !stripped.endsWith('\n') ? stripped + '\n' : stripped) + WANT;
20
+ if (next === body) return;
21
+ mkdirSync(dirname(f), { recursive: true });
22
+ writeFileSync(f, next);
23
+ } catch {}
24
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sawbuck",
3
- "version": "0.1.36",
3
+ "version": "0.1.38",
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",
@@ -39,6 +39,7 @@
39
39
  "files": [
40
40
  "sawbuck.mjs",
41
41
  "rewrites.mjs",
42
+ "exclude.mjs",
42
43
  "contract-cmd.mjs",
43
44
  "bins.mjs",
44
45
  "contract.mjs",
package/sawbuck-hook.mjs CHANGED
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ import { ensureExcluded } from './exclude.mjs';
2
3
  // sawbuck-hook.mjs: the Sawbuck Claude Code Stop-hook entry.
3
4
  // Each turn: if a new commit landed, run reflect on it; then render the dispatch
4
5
  // on a fresh catch OR every N turns (config). State in .sawbuck/state.json.
@@ -124,7 +125,16 @@ if (stop && !process.env.SAWBUCK_NO_ENFORCE) {
124
125
  const committedHit = hits.some((h) => h.files.some((f) => committedSince.includes(f)));
125
126
  const accept = committedHit ? ' To accept this change on purpose, change the mandate in the contract or revert the path.' : '';
126
127
  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' });
128
+ // one catch per prompt, however many times the turn is sent back. Kept beside rec.latest, never in it:
129
+ // a block must not replace a gamed verdict that is still standing.
130
+ if (st.mandateCountedPrompt !== promptId) {
131
+ st.mandateCountedPrompt = promptId;
132
+ const r = readRec() || { regressions: 0, gamed: 0, latest: null };
133
+ r.mandates = (r.mandates || 0) + 1;
134
+ r.lastMandate = { name: hits[0].name, file: hits[0].files[0], at: now };
135
+ writeRec(r);
136
+ logActivity({ kind: 'mandate', file: hits[0].files[0], reason: `mandate ${hits[0].name} violated in the working tree`, by: by || 'hook' });
137
+ }
128
138
  if ((st.mandateBlocks || 0) < MAX_BLOCKS) {
129
139
  st.mandateBlocks = (st.mandateBlocks || 0) + 1;
130
140
  saveState();
@@ -193,20 +203,3 @@ saveState();
193
203
  // dirties the worktree, including under `attach --user`, which is global and cannot
194
204
  // set this up per-repo at attach time. Idempotent; markers match the CLI's so a
195
205
  // per-repo `detach` removes them.
196
- function ensureExcluded(r) {
197
- try {
198
- // the repository's own exclude file, wherever its git dir is (a worktree or submodule keeps it elsewhere)
199
- const rel = sh(r, 'rev-parse', '--git-path', 'info/exclude');
200
- if (!rel) return;
201
- const f = resolve(r, rel);
202
- // the record dirs + the logbook's root ledger cache (events.jsonl). Rewrite the block to
203
- // the CURRENT set rather than bail on presence, so a grown set retrofits existing repos.
204
- const WANT = '# >>> sawbuck >>>\n.sawbuck/\n.promptwheel/*\n!.promptwheel/packs/\nevents.jsonl\nannotations.jsonl\nannotation-reviews.jsonl\ndecision-cards.jsonl\nLOGBOOK.md\nJOURNEY.md\n.claude/settings.local.json\n# <<< sawbuck <<<\n';
205
- const body = existsSync(f) ? readFileSync(f, 'utf8') : '';
206
- const stripped = body.replace(/# >>> sawbuck >>>[\s\S]*?# <<< sawbuck <<<\n?/g, '');
207
- const next = (stripped && !stripped.endsWith('\n') ? stripped + '\n' : stripped) + WANT;
208
- if (next === body) return;
209
- mkdirSync(dirname(f), { recursive: true });
210
- writeFileSync(f, next);
211
- } catch {}
212
- }
package/sawbuck.mjs CHANGED
@@ -14,8 +14,9 @@
14
14
 
15
15
  import { execFileSync } from 'node:child_process';
16
16
  import { erasedBlock } from './rewrites.mjs';
17
+ import { ensureExcluded } from './exclude.mjs';
17
18
  import { readFileSync, writeFileSync, existsSync, chmodSync, mkdirSync, unlinkSync, rmdirSync, rmSync, readdirSync } from 'node:fs';
18
- import { join, dirname, resolve } from 'node:path';
19
+ import { basename, join, dirname, resolve } from 'node:path';
19
20
  import { fileURLToPath } from 'node:url';
20
21
  import { homedir } from 'node:os';
21
22
  import { recall as memRecall, conform as memConform } from './memory.mjs';
@@ -190,7 +191,7 @@ switch (cmd) {
190
191
  if (!lines.length) { out('sawbuck: no activity recorded yet (the check logs here on each commit/turn).'); break; }
191
192
  const ni = rest.indexOf('-n');
192
193
  const n = ni > -1 ? (parseInt(rest[ni + 1], 10) || 20) : 20;
193
- const icon = { verified: 'โœ“', gamed: '๐Ÿšฉ', regression: 'โœ—', inconclusive: 'โ‰ˆ', allowed: 'โœŽ' };
194
+ const icon = { verified: 'โœ“', gamed: '๐Ÿšฉ', regression: 'โœ—', inconclusive: 'โ‰ˆ', allowed: 'โœŽ', mandate: 'โ›”' };
194
195
  const recent = lines.slice(-n).reverse();
195
196
  out(`sawbuck activity (last ${recent.length}):`);
196
197
  for (const l of recent) {
@@ -244,6 +245,7 @@ switch (cmd) {
244
245
  if (flags.has('--hook') && !process.stdin.isTTY) { try { hookEvent = String(JSON.parse(readFileSync(0, 'utf8') || '{}').hook_event_name || ''); } catch {} }
245
246
  if (hookEvent === 'UserPromptSubmit' && top) {
246
247
  try {
248
+ ensureExcluded(repo); // before the first write: under user-level hooks nothing else has done it yet
247
249
  const stDir = join(repo, '.sawbuck'); const stPath = join(stDir, 'state.json');
248
250
  let st = {}; try { st = JSON.parse(readFileSync(stPath, 'utf8')); } catch {}
249
251
  const now = new Date().toISOString();
@@ -344,10 +346,12 @@ switch (cmd) {
344
346
  case 'statusline': {
345
347
  // compact status-line segment for the cwd repo. Never throws; fast.
346
348
  try {
347
- const repo = process.cwd();
348
- if (existsSync(join(repo, '.git'))) {
349
+ const repo = gitTop(process.cwd());
350
+ if (!repo) process.stdout.write('๐Ÿด sawbuck ยท no repo here');
351
+ if (repo) {
352
+ const name = basename(repo);
349
353
  let caught = 0;
350
- try { const r = JSON.parse(readFileSync(join(repo, '.sawbuck', 'results.json'), 'utf8')); caught = (r.regressions || 0) + (r.gamed || 0); } catch {}
354
+ try { const r = JSON.parse(readFileSync(join(repo, '.sawbuck', 'results.json'), 'utf8')); caught = (r.regressions || 0) + (r.gamed || 0) + (r.mandates || 0); } catch {}
351
355
  // the last verdict from the activity feed (includes verified passes), so you see it working each turn
352
356
  let last = '';
353
357
  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 +359,22 @@ switch (cmd) {
355
359
  const active = existsSync(join(repo, '.sawbuck', 'state.json'));
356
360
  const armed = existsSync(join(repo, 'promptwheel.config.json')) || existsSync(join(repo, 'package.json'));
357
361
  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) : '';
362
+ const label = caught > 0 ? `${caught} caught` : active ? 'watching' : armed ? 'ready' : mem ? 'memory' : '';
363
+ const seg = label ? `๐Ÿด sawbuck ยท ${label}` : '';
364
+ const named = label ? `๐Ÿด sawbuck ยท ${name} ยท ${label}` : ''; // which repo it detected, in the default line
365
+ const base = named ? (last ? `${named} ยท ${last}` : named) : '';
360
366
  // live steering % from the reins engine's file (OFFLINE read; reins does the LLM work, never sawbuck)
361
367
  let st = '';
362
368
  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
369
  // 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
370
+ // (tokens {status} {sawbuck} {repo} {verdict} {steer} plus any literal text); empty tokens collapse
365
371
  // so you never get a dangling separator. Unset = the default composition. Mix in other
366
372
  // tools (git branch, model, your own adapters) by composing in your statusline script.
367
373
  const tpl = process.env.SAWBUCK_STATUSLINE;
368
374
  let outp;
369
375
  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] || '')
376
+ const tok = { status: base, sawbuck: seg, repo: label ? name : '', verdict: last, steer: st };
377
+ outp = tpl.replace(/\{(status|sawbuck|repo|verdict|steer)\}/g, (_, k) => tok[k] || '')
372
378
  .replace(/\s*ยท\s*ยท\s*/g, ' ยท ').replace(/^\s*ยท\s*/, '').replace(/\s*ยท\s*$/, '').replace(/\s{2,}/g, ' ').trim();
373
379
  } else {
374
380
  outp = [base, st].filter(Boolean).join(' ยท ');
@@ -522,6 +528,12 @@ export function nodeCmd(execPath = process.execPath, platform = process.platform
522
528
  }
523
529
  function wireClaude(repo, opts) {
524
530
  const { user, shared } = opts;
531
+ // user-level hooks already cover every repo; a second set would run each check twice and spend the
532
+ // send-back budget twice as fast. --shared is a deliberate team file, so it is written anyway.
533
+ if (!user && !shared) {
534
+ const uf = claudeFile(repo, { user: true }).f;
535
+ try { if (readFileSync(uf, 'utf8').includes('sawbuck.mjs')) { out(`sawbuck: already watching here through your user-level hooks (${uf}); nothing to add`); return; } } catch {}
536
+ }
525
537
  const { dir, f } = claudeFile(repo, opts);
526
538
  const dirExisted = existsSync(dir);
527
539
  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` : '',