sawbuck 0.1.13 → 0.1.20

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.md CHANGED
@@ -2,12 +2,18 @@
2
2
 
3
3
  *Put your coding agent under contract: a win counts only if it survives verification from the source alone, not because the agent said so. The terms below are not a promise the agent makes, they are a result anyone can re-prove.*
4
4
 
5
- **Status:** Draft v0.1 · **License:** MIT · **Reference implementation:** [Sawbuck](https://github.com/sawbuck-ai/sawbuck) (the saddle), [promptwheel](https://github.com/promptwheel-ai/promptwheel) (the check), [@promptwheel/logbook](https://github.com/promptwheel-ai/logbook) (the context record)
5
+ **Status:** Draft v0.1 · **License:** MIT · **Reference implementation:** [Sawbuck](https://github.com/sawbuck-ai/sawbuck) (the saddle), [promptwheel](https://github.com/promptwheel-ai/promptwheel) (the check), [@promptwheel/logbook](https://github.com/promptwheel-ai/logbook) (the default memory provider)
6
6
 
7
7
  This is an open interface for making an AI coding harness trustworthy. It defines how a loop's wins get verified, what a verdict looks like, what counts as gaming the goalposts, and how a repo's history is carried along. Any harness can emit or consume it without using this code. Sawbuck is a reference saddle that implements it; it is not the only possible one.
8
8
 
9
9
  The contract has one property that holds all of it together: a verdict is **deterministic and reproducible from `(base, head, config)` alone**. No LLM is in the path, and nothing is read from the agent's trajectory, so the result cannot be obfuscated against and does not change when you ask it twice.
10
10
 
11
+ A corollary worth stating as an invariant: **the gate is defined only by in-repo, versioned config.** External systems (a dashboard, an issue tracker, a memory provider) may *report on* a verdict, but they must never *define* the guardrail. A guardrail that lives outside the repo is one the check cannot see in the diff, so it cannot be fingerprinted, so loosening it leaves no trace to catch as gamed. Memory (the input) is a pluggable socket; the gate (the output) stays in-repo and fingerprinted.
12
+
13
+ > This contract covers the check. Sawbuck pairs it with the other half, **memory** (the relevant history for what you touch, plus the live debt), as the mirror idea: where the check is a standard others *conform to*, memory is a socket others *fill*. The default memory provider is @promptwheel/logbook, carried along so it works out of the box, but it is swappable; logbook is a default, not a requirement of the idea.
14
+ >
15
+ > Both halves nest with your folders, so policy and memory flow top-down and isolate across siblings. A parent `promptwheel.config.json` passes its guardrails down via `extends` (repos inherit the base gate; local metrics override by name), and memory inherits a parent folder's shared scope the same way (a repo sees itself and its ancestors, never a sibling). One model for both: the directory tree is the scope.
16
+
11
17
  ---
12
18
 
13
19
  ## 1. The reward contract (exit codes)
package/GLOSSARY.md CHANGED
@@ -1,9 +1,10 @@
1
1
  # Sawbuck: Glossary & Controlled Vocabulary
2
2
 
3
3
  Sawbuck is a saddle: an isolated layer you attach to a repo that fits its shape
4
- and responds when the repo moves. It reads the history (via Logbook) and a small
5
- config of what to measure, runs the check when a change lands, and surfaces the
6
- relevant past finding before the agent repeats a known mistake.
4
+ and responds when the repo moves. It reads the history (via its memory provider,
5
+ logbook by default) and a small config of what to measure, runs the check when a
6
+ change lands, and surfaces the relevant past finding before the agent repeats a
7
+ known mistake.
7
8
 
8
9
  ## The rule: one plain surface
9
10
 
@@ -23,7 +24,7 @@ two vocabularies.
23
24
  | concept | term | notes |
24
25
  |---|---|---|
25
26
  | the product | **Sawbuck** | the only brand word |
26
- | the history reader | **Logbook** | a lab logbook: plain and scientific |
27
+ | the memory (the role) | **memory**; default provider **Logbook** | the relevant history + live debt for what you touch. logbook (a lab logbook: plain, scientific) is a standalone product and the default fill; the role is a socket, swap your own via the Memory Contract |
27
28
  | the verifier | **the check** (verb: *verify*) | runs the metrics on a change |
28
29
  | a change that improved and holds | **verified** | |
29
30
  | a change that made a guarded metric worse | **regression** | do-not-retry |
@@ -36,6 +37,23 @@ two vocabularies.
36
37
  | 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
38
  | the one-view accumulation | **inventory** | `sawbuck status`: what it has caught over time + the debt accruing now + whether it is armed here |
38
39
 
40
+ ## The two interfaces (a socket and a standard)
41
+
42
+ Sawbuck composes two roles, and they are open in opposite directions. Keeping them
43
+ straight is what stops "Sawbuck, then logbook?" confusion: logbook is not a second
44
+ brand bolted on, it is the default fill for one of the two roles.
45
+
46
+ - **the check** is a *standard we publish* (the Verified-Outcome Contract, see
47
+ CONTRACT.md): other tools conform to it, or embed it. promptwheel is the reference
48
+ implementation. It is not swappable, because the un-gameable source re-proof is the
49
+ asset; what you plug in is *what to measure*.
50
+ - **the memory** is a *socket we expose*: you fill it with any provider. logbook is
51
+ the default, carried along so it works out of the box, and it is its own product you
52
+ can also run on its own.
53
+
54
+ In one line: the check is a standard for others to meet; the memory is a socket for
55
+ others to fill.
56
+
39
57
  ## The one humility rule
40
58
 
41
59
  Findings are **leads, not conclusions**, verify against the diff before acting.
package/README.md CHANGED
@@ -45,6 +45,7 @@ sawbuck: gamed recorded on test.js (the win came from editing the test, no
45
45
  | `sawbuck context` · `sawbuck rank` | the ranked relevant history for the current change |
46
46
  | `sawbuck debt` | the whole-repo live suppression debt: what is still silenced in HEAD now, disabled tests first, heaviest files ranked |
47
47
  | `sawbuck status` · `sawbuck log` | the inventory (caught over time + debt now + armed state), in one view; and the recent per-check feed |
48
+ | `sawbuck recall` | related notes for your current change from extra memory providers you compose in (bring your own) |
48
49
  | `sawbuck run --attempt '<agent cmd>'` | thin harness: load context, run one attempt through the keep-if-verified gate |
49
50
  | `sawbuck trace` · `sawbuck doctor` · `detach --verify` | show the footprint; check the attachment resolves; prove a clean removal |
50
51
 
@@ -73,9 +74,11 @@ Debt also rises on its own: once attached, the per-turn `context` hook surfaces
73
74
 
74
75
  ## The engines
75
76
 
76
- Sawbuck is thin glue over two tools it depends on:
77
+ Sawbuck is thin glue over two roles, each behind an open interface that points in opposite directions:
77
78
 
78
- - **promptwheel** : the check (the deterministic outcome gate).
79
- - **@promptwheel/logbook** : the memory (git history as agent memory; also ships an MCP server for on-demand lookups).
79
+ - **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 un-gameable source re-proof is the fixed part. Embed it with `import { check }`, or check any tool's verdict with `promptwheel conformance`.
80
+ - **the memory** : the relevant history for the files you touch, plus the live debt. A socket you can fill. Default provider: **@promptwheel/logbook** (git history as agent memory; also ships an MCP server). Sawbuck reads it through one adapter, so you can *compose in your own* memory: `SAWBUCK_MEMORY=logbook,obsidian` keeps logbook as the git-truth + debt provider and adds your notes, then `sawbuck recall`. Four reference providers ship in `providers/`: **obsidian** (local notes vault; set `OBSIDIAN_VAULT`), **local** (a plain JSON file, zero setup), **claude** (read-only: your project's `CLAUDE.md` / `.claude` memory, already there, nothing to configure), and **mem0** (semantic memory; the one that reaches the network, so it needs a `MEM0_API_KEY` or a `~/.mem0` key file, unlike the offline default; verified against Mem0's v3 API). Any bin that speaks the three contract verbs works; `sawbuck memory-conformance <bin>` checks one. Composed providers are **folder-scoped and isolated by default**: each repo sees only its own memory, never a sibling's. Drop a `.sawbuck-root` marker (or set `SAWBUCK_MEMORY_ROOT`) in a parent folder to let repos under it inherit that folder's shared memory, top down. logbook is the default, not a hard requirement of the idea.
81
+
82
+ 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.
80
83
 
81
84
  MIT.
package/debt.mjs CHANGED
@@ -7,20 +7,8 @@
7
7
  // complete lens for CURRENT debt; the historical diff scan under-counts it.
8
8
  // Zero-dep. Usage: node debt.mjs [--repo P] [--top N] [--since REF] [--json]
9
9
 
10
- import { execFileSync } from 'node:child_process';
11
- import { createRequire } from 'node:module';
12
- import { basename, join, dirname, resolve } from 'node:path';
13
- import { fileURLToPath, pathToFileURL } from 'node:url';
14
-
15
- const HERE = dirname(fileURLToPath(import.meta.url));
16
- const require = createRequire(import.meta.url);
17
- const engineBin = (envVar, pkgSubpath, devPath) => {
18
- if (process.env[envVar]) return process.env[envVar];
19
- const bare = pkgSubpath.replace(/\/bin\/[^/]*$/, '');
20
- for (const spec of [pkgSubpath, bare]) { try { return require.resolve(spec); } catch {} }
21
- return devPath;
22
- };
23
- const LB = engineBin('LB_BIN', '@promptwheel/logbook/bin/logbook.mjs', join(HERE, '..', 'logbook', 'bin', 'logbook.mjs'));
10
+ import { basename, resolve } from 'node:path';
11
+ import { events as memEvents, debt as memDebt } from './memory.mjs';
24
12
 
25
13
  const flag = (n, d) => { const i = process.argv.indexOf(n); return i > -1 ? (process.argv[i + 1] ?? true) : d; };
26
14
  const repo = resolve(flag('--repo', process.cwd()));
@@ -30,46 +18,24 @@ const failRaw = flag('--fail-on', null); // undocumented | test | any | <N>; bar
30
18
  const failMode = failRaw === true ? 'undocumented' : (failRaw ? String(failRaw).toLowerCase() : null);
31
19
  const asJson = process.argv.includes('--json');
32
20
 
33
- // A disabled/skipped/weakened TEST is a hidden-failure risk; a lint/type/error suppression is
34
- // usually mundane. Same split sawbuck's rank uses, so the two faces agree.
35
- const isTestSkip = (m) => /\bit\.skip\b|\btest\.skip\b|xit\(|xdescribe\(|\bdescribe\.skip\b|pytest\.mark\.skip|unittest\.skip|\bt\.Skip\b|@Disabled\b|@Ignore\b|^\[Ignore\b|^Skip\s*=|^#\[ignore|markTestSkipped|markTestIncomplete/.test(String(m).trim());
36
- // DOCUMENTED = a rule code or reason follows the directive (declared intent); BARE = just the
37
- // directive. Heuristic, a lead not a verdict, but bare is the suspect kind worth scrutinizing.
38
- const isDocumented = (c) => {
39
- c = String(c || '');
40
- if (/eslint-disable/.test(c)) return /eslint-disable(?:-next-line|-line)?\s+\S/.test(c); // a rule name follows
41
- if (/@ts-ignore/.test(c)) return false; // inherently undocumented
42
- if (/@ts-expect-error/.test(c)) return /@ts-expect-error\s+\S/.test(c);
43
- if (/noqa/i.test(c)) return /noqa:\s*\w/i.test(c); // # noqa: E501
44
- if (/type:\s*ignore/i.test(c)) return /ignore\s*\[/.test(c); // # type: ignore[code]
45
- return /\(\s*["']/.test(c) || /=\s*["']/.test(c); // annotation reason/code: @Disabled("..."), Skip="...", #[ignore = "..."]
46
- };
47
-
48
- // events give fragility + fall-back dates. Best-effort.
49
- let events = [];
50
- try { events = execFileSync(process.execPath, [LB, 'query', '--json'], { cwd: repo, encoding: 'utf8', maxBuffer: 256 * 1024 * 1024 }).split('\n').filter(Boolean).map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean); } catch {}
51
-
52
- let auditHead, SUPPRESS_PAT;
53
- try { ({ auditHead, SUPPRESS_PAT } = await import(pathToFileURL(LB).href)); } catch (e) { console.error(`sawbuck debt: cannot load logbook (${String(e.message).split('\n')[0]})`); process.exit(1); }
54
-
55
- let live;
56
- try { live = auditHead(repo, events); } catch (e) { console.error(`sawbuck debt: could not read the suppression ledger (${String(e.message).split('\n')[0]})`); process.exit(1); }
21
+ // events give fragility + fall-back dates (the memory provider's recall facts). Best-effort.
22
+ const events = memEvents(repo);
57
23
 
58
- // enrich each gated live suppression with its line content, so we can tell BARE from DOCUMENTED.
59
- // One extra HEAD grep; we only read content for lines auditHead already gated, so no gating drift.
60
- const grepAt = (ref) => { try { return execFileSync('git', ['-C', repo, 'grep', '-nP', SUPPRESS_PAT.source, ref], { encoding: 'utf8', maxBuffer: 512 * 1024 * 1024 }); } catch (e) { return (e.stdout || '').toString(); } };
61
- const parseGrep = (g, ref) => { const re = new RegExp('^' + ref.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + ':(.+?):(\\d+):(.*)$'); const m = []; for (const row of g.split('\n')) { const x = re.exec(row); if (x) m.push({ file: x[1], line: +x[2], content: x[3] }); } return m; };
62
- const headRows = SUPPRESS_PAT ? parseGrep(grepAt('HEAD'), 'HEAD') : [];
63
- const contentAt = new Map(headRows.map((r) => [r.file + ':' + r.line, r.content]));
64
- for (const it of live) { it.content = contentAt.get(it.file + ':' + it.line) || ''; it.documented = it.content ? isDocumented(it.content) : false; }
24
+ // the live suppression ledger via the memory adapter (the Memory Contract's `debt` capability). Each
25
+ // item arrives classified (documented/testSkip) and with its line content and blame date (`since`),
26
+ // so this file no longer imports the provider's pattern or gating. null = the provider FAILED
27
+ // (unmeasurable is NOT clean, refuse and exit nonzero); [] = a genuinely clean tree.
28
+ const live = memDebt(repo);
29
+ if (live === null) { console.error('sawbuck debt: could not read the suppression ledger (the memory provider failed)'); process.exit(1); }
65
30
  const bare = live.filter((it) => !it.documented && it.content).length;
66
31
 
67
32
  // --since REF: the DELTA. Suppressions in HEAD but not at REF = debt this branch/PR adds.
68
33
  if (since && typeof since === 'string') {
69
34
  const norm = (s) => String(s).trim().replace(/\s+/g, ' ');
70
- const base = new Set(parseGrep(grepAt(since), since).map((r) => r.file + '\u0000' + norm(r.content)));
35
+ // the delta base: the raw suppression set at REF (the adapter's ref mode), subtracted from HEAD.
36
+ const base = new Set((memDebt(repo, { ref: since }) || []).map((r) => r.file + '\u0000' + norm(r.content)));
71
37
  const added = live.filter((it) => it.content && !base.has(it.file + '\u0000' + norm(it.content)));
72
- const skips = added.filter((it) => isTestSkip(it.kind)).length, undoc = added.filter((it) => !it.documented).length;
38
+ const skips = added.filter((it) => it.testSkip).length, undoc = added.filter((it) => !it.documented).length;
73
39
  // the gate: fail (exit 1) when the branch adds the kind of debt --fail-on names.
74
40
  const fail = !failMode ? false
75
41
  : /^\d+$/.test(failMode) ? added.length >= +failMode
@@ -79,7 +45,7 @@ if (since && typeof since === 'string') {
79
45
  if (asJson) { console.log(JSON.stringify({ since, added: added.length, testSkips: skips, undocumented: undoc, failOn: failMode, fail, items: added.map((it) => ({ file: it.file, line: it.line, kind: String(it.kind).trim(), documented: it.documented })) }, null, 2)); process.exit(fail ? 1 : 0); }
80
46
  if (!added.length) { console.log(`🐴 sawbuck: no new suppression debt since ${since} (clean).`); process.exit(0); }
81
47
  const o = [`🐴 sawbuck: ${added.length} new suppression${added.length === 1 ? '' : 's'} since ${since} · ${skips} disabled test${skips === 1 ? '' : 's'} · ${undoc} undocumented (no code/reason)`];
82
- for (const it of added.slice(0, Math.max(top, 20))) o.push(` ${isTestSkip(it.kind) ? '🔴' : (it.documented ? ' ' : '✎ ')} ${it.file}:${it.line} ${String(it.kind).trim()}${it.documented ? '' : ' (bare - no code/reason)'}`);
48
+ for (const it of added.slice(0, Math.max(top, 20))) o.push(` ${it.testSkip ? '🔴' : (it.documented ? ' ' : '✎ ')} ${it.file}:${it.line} ${String(it.kind).trim()}${it.documented ? '' : ' (bare - no code/reason)'}`);
83
49
  o.push(`\n ✎ = bare/undocumented (the suspect kind); declare intent (a rule code or reason) or remove it.`);
84
50
  if (failMode) o.push(fail ? `\n ❌ debt gate FAILED (--fail-on ${failMode}): this branch adds debt that must be declared or removed.` : `\n ✓ debt gate passed (--fail-on ${failMode}).`);
85
51
  console.log(o.join('\n'));
@@ -118,11 +84,11 @@ const fragilityOf = (file) => {
118
84
  const byFile = new Map();
119
85
  let testSkips = 0, codeSup = 0;
120
86
  for (const it of live) {
121
- const t = isTestSkip(it.kind); t ? testSkips++ : codeSup++;
87
+ const t = it.testSkip; t ? testSkips++ : codeSup++;
122
88
  const f = byFile.get(it.file) || { file: it.file, skips: 0, bareSup: 0, docSup: 0, kinds: new Set(), oldest: null };
123
89
  if (t) f.skips++; else (it.documented ? f.docSup++ : f.bareSup++);
124
90
  f.kinds.add(String(it.kind).trim());
125
- if (it.date && (!f.oldest || it.date < f.oldest)) f.oldest = it.date;
91
+ if (it.since && (!f.oldest || it.since < f.oldest)) f.oldest = it.since;
126
92
  byFile.set(it.file, f);
127
93
  }
128
94
  // RISK = severity (disabled test 3, bare suppression 1.5, documented 1) x file fragility.
@@ -149,10 +115,10 @@ for (const f of files.slice(0, top)) {
149
115
  const frag = [f.reverts ? `${f.reverts} revert${f.reverts === 1 ? '' : 's'}` : '', f.hot ? 'fix-hotspot' : '', (!f.hot && f.fixes >= 3) ? `${f.fixes} fixes` : ''].filter(Boolean).join(', ');
150
116
  out.push(` ${f.reverts || f.hot ? '🔥 ' : ' '}${f.file} · ${f.n} (${bits})${frag ? ` · ${frag}` : ''} · risk ${f.risk}`);
151
117
  }
152
- const dated = live.filter((it) => it.date).sort((a, b) => String(a.date).localeCompare(String(b.date)));
118
+ const dated = live.filter((it) => it.since).sort((a, b) => String(a.since).localeCompare(String(b.since)));
153
119
  if (dated.length) {
154
120
  out.push(`\n oldest still-silenced (most stale):`);
155
- for (const it of dated.slice(0, 5)) out.push(` ${it.file}:${it.line} ${String(it.kind).trim()} (since ${it.date})`);
121
+ for (const it of dated.slice(0, 5)) out.push(` ${it.file}:${it.line} ${String(it.kind).trim()} (since ${it.since})`);
156
122
  }
157
123
  out.push(`\n bare = no code/reason, the suspect kind. leads, not verdicts; full dated ledger: logbook audit`);
158
124
  console.log(out.join('\n'));
package/memory.mjs ADDED
@@ -0,0 +1,207 @@
1
+ // memory.mjs, Sawbuck's memory adapter: the single seam between the glue and the memory provider(s).
2
+ // It speaks the Memory Contract (recall / debt / record) over a provider's CLI, so no other glue file
3
+ // imports a provider's internals. Zero-dep.
4
+ //
5
+ // Providers: SAWBUCK_MEMORY is an ordered, comma-separated list (default "logbook"). An entry is
6
+ // either the built-in "logbook" (the reference provider, adapted to its native query/audit/annotate
7
+ // verbs) or another provider that speaks the clean contract verbs: `recall --file P --json`,
8
+ // `debt [--since REF] --json`, `record --ref SHA --summary S [--side X] [--evidence-file F] --by WHO`.
9
+ // The shipped reference non-git provider is "obsidian" (providers/obsidian.mjs); point at any other
10
+ // with SAWBUCK_MEMORY_<NAME>_BIN or a path entry.
11
+ //
12
+ // Degradation is explicit: a read returns null when its PRIMARY (git-truth) source failed, [] when it
13
+ // ran clean. A provider that does not support a capability signals it by exiting nonzero (we skip it),
14
+ // never by printing []. git-truth stays authoritative; other providers add supplementary notes.
15
+
16
+ import { execFileSync } from 'node:child_process';
17
+ import { createRequire } from 'node:module';
18
+ import { existsSync } from 'node:fs';
19
+ import { join, dirname, relative, resolve, basename, sep } from 'node:path';
20
+ import { fileURLToPath } from 'node:url';
21
+ import { homedir } from 'node:os';
22
+
23
+ const HERE = dirname(fileURLToPath(import.meta.url));
24
+ const require = createRequire(import.meta.url);
25
+
26
+ function logbookBin() {
27
+ if (process.env.SAWBUCK_MEMORY_BIN) return process.env.SAWBUCK_MEMORY_BIN; // back-compat single-provider override
28
+ if (process.env.LB_BIN) return process.env.LB_BIN;
29
+ for (const spec of ['@promptwheel/logbook/bin/logbook.mjs', '@promptwheel/logbook']) { try { return require.resolve(spec); } catch {} }
30
+ return join(HERE, '..', 'logbook', 'bin', 'logbook.mjs');
31
+ }
32
+ const envKey = (name) => 'SAWBUCK_MEMORY_' + name.toUpperCase().replace(/[^A-Z0-9]/g, '_') + '_BIN';
33
+ // the ordered provider list. Each: { name, bin, dialect: 'logbook' | 'contract' }.
34
+ const SHIPPED = new Set(['obsidian', 'mem0', 'local', 'claude']); // reference contract providers that ship in providers/
35
+ export function providers() {
36
+ const spec = (process.env.SAWBUCK_MEMORY || 'logbook').split(',').map((s) => s.trim()).filter(Boolean);
37
+ return spec.map((name) => {
38
+ if (name === 'logbook') return { name, bin: logbookBin(), dialect: 'logbook' };
39
+ if (SHIPPED.has(name)) return { name, bin: process.env[envKey(name)] || join(HERE, 'providers', name + '.mjs'), dialect: 'contract' };
40
+ return { name, bin: process.env[envKey(name)] || name, dialect: 'contract' }; // a bin path, or a name with an env-set bin
41
+ });
42
+ }
43
+
44
+ // scopeChain(): the folder-based scope chain for a repo, most-specific first: the repo, then each
45
+ // ancestor up to the memory root, then the root ("" = the shared/global scope). ISOLATION BY DEFAULT:
46
+ // with no configured root the chain is just the leaf, so a repo sees only itself and never a sibling.
47
+ // A root is SAWBUCK_MEMORY_ROOT, or the nearest ancestor holding a `.sawbuck-root` marker; with one,
48
+ // a repo also inherits its ancestors' shared memory. Ids are paths relative to the root (forward
49
+ // slashes), so sibling repos with the same basename never collide and never leak into each other.
50
+ export function scopeChain(repo) {
51
+ const start = resolve(repo);
52
+ let root = process.env.SAWBUCK_MEMORY_ROOT ? resolve(process.env.SAWBUCK_MEMORY_ROOT) : null;
53
+ if (!root) {
54
+ let d = start; const stop = resolve(homedir());
55
+ for (let i = 0; i < 64; i++) {
56
+ if (existsSync(join(d, '.sawbuck-root'))) { root = d; break; }
57
+ const up = dirname(d);
58
+ if (up === d || d === stop) break;
59
+ d = up;
60
+ }
61
+ }
62
+ if (root && (start === root || start.startsWith(root + sep))) {
63
+ const idOf = (dir) => relative(root, dir).split(sep).join('/'); // "" for the root itself
64
+ const chain = [];
65
+ let d = start;
66
+ for (let i = 0; i < 64; i++) {
67
+ chain.push(idOf(d));
68
+ if (d === root) break;
69
+ const up = dirname(d);
70
+ if (up === d) break;
71
+ d = up;
72
+ }
73
+ return chain; // leaf-first, ending with "" (root = shared/global)
74
+ }
75
+ return [basename(start) || '.']; // no root in play: an isolated leaf, no inheritance
76
+ }
77
+
78
+ // run a provider bin; null on a real failure / unsupported-capability (nonzero exit), stdout otherwise.
79
+ function run(bin, repo, args, maxBuffer = 512 * 1024 * 1024) {
80
+ try { return execFileSync(process.execPath, [bin, ...args], { cwd: repo, encoding: 'utf8', maxBuffer, stdio: ['ignore', 'pipe', 'ignore'] }); }
81
+ catch { return null; }
82
+ }
83
+ function parseArray(out) {
84
+ const t = (out || '').trim();
85
+ if (!t.startsWith('[')) return [];
86
+ try { const v = JSON.parse(t); return Array.isArray(v) ? v : []; } catch { return []; }
87
+ }
88
+
89
+ // --- debt sub-classification (Sawbuck policy; applied once so the ledger and the per-turn surface
90
+ // agree). Exported so the conformance checker can reuse them.
91
+ export const isTestSkip = (m) => /\bit\.skip\b|\btest\.skip\b|xit\(|xdescribe\(|\bdescribe\.skip\b|pytest\.mark\.skip|unittest\.skip|\bt\.Skip\b|@Disabled\b|@Ignore\b|^\[Ignore\b|^Skip\s*=|^#\[ignore|markTestSkipped|markTestIncomplete/.test(String(m).trim());
92
+ export const isDocumented = (c) => {
93
+ c = String(c || '');
94
+ if (/eslint-disable/.test(c)) return /eslint-disable(?:-next-line|-line)?\s+\S/.test(c);
95
+ if (/@ts-ignore/.test(c)) return false;
96
+ if (/@ts-expect-error/.test(c)) return /@ts-expect-error\s+\S/.test(c);
97
+ if (/noqa/i.test(c)) return /noqa:\s*\w/i.test(c);
98
+ if (/type:\s*ignore/i.test(c)) return /ignore\s*\[/.test(c);
99
+ return /\(\s*["']/.test(c) || /=\s*["']/.test(c);
100
+ };
101
+
102
+ const classify = (r, source) => ({
103
+ file: r.file, line: r.line, kind: String(r.kind ?? '').trim(), content: r.content ?? '',
104
+ since: r.since ?? null, sha: r.sha ?? null,
105
+ documented: r.content ? isDocumented(r.content) : false,
106
+ testSkip: isTestSkip(r.kind ?? r.content ?? ''),
107
+ source,
108
+ });
109
+
110
+ // events(): the raw git event record (the ranking input), from the logbook-dialect provider only
111
+ // (events with revert/fix/suppression semantics are git-truth; a note provider does not produce them).
112
+ // [] when there is no such provider or it fails, exactly as "no history".
113
+ export function events(repo) {
114
+ const lb = providers().find((p) => p.dialect === 'logbook');
115
+ if (!lb) return [];
116
+ const out = run(lb.bin, repo, ['query', '--json'], 256 * 1024 * 1024);
117
+ if (out === null) return [];
118
+ return out.split('\n').filter(Boolean).map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
119
+ }
120
+
121
+ // the per-provider debt call, dialect-aware.
122
+ function debtRaw(p, repo, { paths, ref }) {
123
+ const args = p.dialect === 'logbook'
124
+ ? ['audit', '--json', ...(ref ? ['--ref', ref] : []), ...((paths || []).flatMap((f) => ['--file', f]))]
125
+ : ['debt', '--json', ...(ref ? ['--since', ref] : []), ...((paths || []).flatMap((f) => ['--file', f]))];
126
+ return run(p.bin, repo, args);
127
+ }
128
+
129
+ // debt(): live suppressions still in the tree, each classified. Fans out across providers that SUPPORT
130
+ // debt (git-truth); a provider that does not implement it exits nonzero and is skipped. Returns null
131
+ // only when NO provider answered (unmeasurable, not clean); [] when a provider ran and the tree is
132
+ // clean. Default (logbook-only) is byte-identical to the single-provider behavior.
133
+ export function debt(repo, { paths = null, ref = null } = {}) {
134
+ let answered = false;
135
+ const rows = [];
136
+ for (const p of providers()) {
137
+ const out = debtRaw(p, repo, { paths, ref });
138
+ if (out === null) continue; // failed or unsupported -> skip
139
+ answered = true;
140
+ for (const r of parseArray(out)) rows.push(classify(r, p.name));
141
+ }
142
+ return answered ? rows : null;
143
+ }
144
+
145
+ // recall(): supplementary relevant NOTES for the touched paths, from providers that produce them
146
+ // (contract-dialect; logbook's own history is surfaced via rank, not here). Each is a Finding of kind
147
+ // note/decision, source-tagged. [] when nothing relevant. git-truth is never diluted by these.
148
+ const normText = (s) => String(s || '').toLowerCase().replace(/\s+/g, ' ').replace(/[^\w ]+/g, '').trim();
149
+ export function recall(repo, paths) {
150
+ const scopeArgs = scopeChain(repo).flatMap((id) => ['--scope', id]); // leaf-first: self + ancestors, never siblings
151
+ const raw = [];
152
+ for (const p of providers()) {
153
+ if (p.dialect !== 'contract') continue;
154
+ const r = run(p.bin, repo, ['recall', '--json', ...((paths || []).flatMap((f) => ['--file', f])), ...scopeArgs]);
155
+ if (r === null) continue;
156
+ for (const it of parseArray(r)) raw.push({ kind: String(it.kind || 'note'), path: it.path ?? null, ref: it.ref ?? null, date: it.date ?? null, summary: String(it.summary || '').slice(0, 280), source: p.name, scope: it.scope ?? null, score: typeof it.score === 'number' ? it.score : null });
157
+ }
158
+ // Cross-provider dedup + unified ordering. The same note surfaced by two providers collapses to one
159
+ // (keep the higher score; record that both knew it). Ordering is best-effort by the providers' own
160
+ // relevance scores: these are SUPPLEMENTARY leads, never git-truth, so a fuzzy order is fine.
161
+ const byKey = new Map();
162
+ for (const f of raw) {
163
+ const k = (f.path ? normText(f.path) + '\u0000' : '') + normText(f.summary);
164
+ const prev = byKey.get(k);
165
+ if (!prev) { byKey.set(k, f); continue; }
166
+ const keep = (f.score ?? 0) > (prev.score ?? 0) ? f : prev, other = keep === f ? prev : f;
167
+ if (other.source && !String(keep.source).split('+').includes(other.source)) keep.source = `${keep.source}+${other.source}`;
168
+ byKey.set(k, keep);
169
+ }
170
+ return [...byKey.values()].sort((a, b) => (b.score ?? 0) - (a.score ?? 0)).slice(0, 8);
171
+ }
172
+
173
+ // conform(): read-only conformance probe of a contract-dialect provider bin. Checks recall returns a
174
+ // JSON array (or errors cleanly) and that debt either returns an array (git-truth) or signals
175
+ // unsupported by exiting nonzero (never a bare []). Does not write, so record is validated elsewhere
176
+ // (the record fan-out). Returns a report the `memory-conformance` command prints.
177
+ export function conform(bin, repo, probePath = 'README.md') {
178
+ const probe = (args) => { try { return { out: execFileSync(process.execPath, [bin, ...args], { cwd: repo, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }), code: 0 }; } catch (e) { return { out: (e.stdout || '').toString(), code: e.status || 1 }; } };
179
+ const isArray = (s) => { s = (s || '').trim(); if (!s.startsWith('[')) return false; try { return Array.isArray(JSON.parse(s)); } catch { return false; } };
180
+ const r = probe(['recall', '--json', '--file', probePath]);
181
+ const recall = r.code !== 0 ? 'error (nonzero exit on recall)' : (isArray(r.out) ? 'ok (Finding[])' : 'bad shape (recall must print a JSON array)');
182
+ const d = probe(['debt', '--json']);
183
+ const debt = d.code !== 0 ? 'unsupported (exits nonzero, ok for a non-git provider)' : (isArray(d.out) ? 'supported (Suppression[])' : 'bad shape (debt must print a JSON array or exit nonzero)');
184
+ const pass = /^ok/.test(recall) && !/bad shape/.test(debt);
185
+ return { bin, recall, debt, pass };
186
+ }
187
+
188
+ // record(): persist a machine finding to every WRITABLE provider (the write-back). Best-effort; never
189
+ // throws into the caller. Returns true if at least one provider accepted it.
190
+ export function record(repo, { sha, reason, side, evidenceFile, by = 'sawbuck' }) {
191
+ const text = String(reason).slice(0, 280);
192
+ const leaf = scopeChain(repo)[0]; // a finding belongs to the most-specific scope that produced it
193
+ let ok = false;
194
+ for (const p of providers()) {
195
+ const exec = (a) => execFileSync(process.execPath, [p.bin, ...a], { cwd: repo, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
196
+ try {
197
+ if (p.dialect === 'logbook') {
198
+ const full = ['annotate', sha, text, ...(side ? ['--side', side] : []), ...(evidenceFile ? ['--evidence-file', evidenceFile] : []), '--by', by];
199
+ try { exec(full); } catch { exec(['annotate', sha, text, '--by', by]); }
200
+ } else {
201
+ exec(['record', '--ref', sha, '--summary', text, ...(side ? ['--side', side] : []), ...(evidenceFile ? ['--evidence-file', evidenceFile] : []), '--scope', leaf, '--by', by]);
202
+ }
203
+ ok = true;
204
+ } catch { /* this provider declined; others may still accept */ }
205
+ }
206
+ return ok;
207
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sawbuck",
3
- "version": "0.1.13",
3
+ "version": "0.1.20",
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",
@@ -27,6 +27,11 @@
27
27
  "rank.mjs",
28
28
  "debt.mjs",
29
29
  "status.mjs",
30
+ "memory.mjs",
31
+ "providers/obsidian.mjs",
32
+ "providers/mem0.mjs",
33
+ "providers/local.mjs",
34
+ "providers/claude.mjs",
30
35
  "run.mjs",
31
36
  "README.md",
32
37
  "GLOSSARY.md",
@@ -0,0 +1,49 @@
1
+ #!/usr/bin/env node
2
+ // claude.mjs, a REFERENCE read-only memory provider: your project's Claude Code memory (CLAUDE.md,
3
+ // AGENTS.md, and top-level .claude/*.md) as a recall source. It is the most UBIQUITOUS local memory -
4
+ // a Claude Code repo already has it - so it is zero-setup. READ-ONLY by design: recall only; debt and
5
+ // record EXIT NONZERO (unsupported). This is a view of memory you already keep, not a write target
6
+ // (writing findings into CLAUDE.md would bloat the always-loaded project context), and the contract
7
+ // lets a provider implement a subset. Inherently leaf-scoped (reads only this repo's files, so no
8
+ // cross-repo leak). Zero-dep, offline, local-notes axis.
9
+ //
10
+ // recall --file P [--file Q] --json relevant lines from your project memory -> Finding[] (kind note)
11
+ // debt | record EXIT NONZERO (unsupported)
12
+
13
+ import { readFileSync, readdirSync, existsSync, statSync } from 'node:fs';
14
+ import { join, basename, extname } from 'node:path';
15
+
16
+ const argv = process.argv.slice(2);
17
+ const cmd = argv[0];
18
+ const flagAll = (n) => argv.reduce((a, v, i) => (argv[i - 1] === n ? [...a, v] : a), []);
19
+ const die = (m, c = 2) => { process.stderr.write(`claude provider: ${m}\n`); process.exit(c); };
20
+ const CWD = process.cwd();
21
+
22
+ if (cmd === 'debt' || cmd === 'record') die(`${cmd} is unsupported (claude memory is a read-only recall source)`, 2);
23
+
24
+ if (cmd === 'recall') {
25
+ const paths = flagAll('--file');
26
+ if (!paths.length) { console.log('[]'); process.exit(0); }
27
+ const matches = (l) => paths.find((p) => l.includes(p) || (basename(p).length > 2 && l.includes(basename(p)))) || null;
28
+ // the project's Claude Code memory: CLAUDE.md / AGENTS.md at the root, plus top-level .claude/*.md.
29
+ const sources = [];
30
+ for (const f of ['CLAUDE.md', 'AGENTS.md']) { const p = join(CWD, f); if (existsSync(p)) sources.push(p); }
31
+ try { for (const e of readdirSync(join(CWD, '.claude'))) if (extname(e).toLowerCase() === '.md') sources.push(join(CWD, '.claude', e)); } catch {}
32
+ const out = [];
33
+ for (const src of sources) {
34
+ let text = ''; try { if (statSync(src).size > 512 * 1024) continue; text = readFileSync(src, 'utf8'); } catch { continue; }
35
+ const lines = text.split('\n');
36
+ let heading = basename(src);
37
+ for (let i = 0; i < lines.length; i++) {
38
+ const h = lines[i].match(/^#{1,6}\s+(.+)/);
39
+ if (h) { heading = h[1].trim(); continue; }
40
+ const hit = matches(lines[i]);
41
+ if (!hit) continue;
42
+ out.push({ kind: 'note', path: hit, ref: `${basename(src)}:${i + 1}`, date: null, summary: `${heading}: ${lines[i].trim()}`.slice(0, 280), score: 0.4 });
43
+ }
44
+ }
45
+ console.log(JSON.stringify(out.slice(0, 8)));
46
+ process.exit(0);
47
+ }
48
+
49
+ die(`unknown command '${cmd || ''}' (recall | debt | record)`, 2);
@@ -0,0 +1,55 @@
1
+ #!/usr/bin/env node
2
+ // local.mjs, a REFERENCE minimal local-file memory provider: the simplest way to fill the socket with
3
+ // no external tool. Findings live as JSON in <repo>/.sawbuck/memory-notes.json (git-excluded by attach).
4
+ // It is the cleanest conformance target and a "bring your own, just a file" option. Zero-dep, offline,
5
+ // in the local-notes axis alongside obsidian. Folder-scoped like the others.
6
+ //
7
+ // recall --file P [--scope ID ...] --json -> Finding[] (kind note)
8
+ // record --ref SHA --summary S [--scope ID] [--side X] [--evidence-file F] --by WHO
9
+ // debt --json EXITS NONZERO (a note store is not git-aware)
10
+ //
11
+ // Store: SAWBUCK_LOCAL_MEMORY, else <cwd>/.sawbuck/memory-notes.json.
12
+
13
+ import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
14
+ import { join, basename, dirname } from 'node:path';
15
+
16
+ const argv = process.argv.slice(2);
17
+ const cmd = argv[0];
18
+ const flagAll = (n) => argv.reduce((a, v, i) => (argv[i - 1] === n ? [...a, v] : a), []);
19
+ const flag = (n, d) => { const i = argv.indexOf(n); return i > -1 ? (argv[i + 1] ?? true) : d; };
20
+ const die = (m, c = 2) => { process.stderr.write(`local provider: ${m}\n`); process.exit(c); };
21
+
22
+ const STORE = process.env.SAWBUCK_LOCAL_MEMORY || join(process.cwd(), '.sawbuck', 'memory-notes.json');
23
+ const load = () => { try { const v = JSON.parse(readFileSync(STORE, 'utf8')); return Array.isArray(v) ? v : []; } catch { return []; } };
24
+
25
+ if (cmd === 'debt') die('debt is unsupported by a note store (git-truth lives in a git provider like logbook)', 2);
26
+
27
+ if (cmd === 'recall') {
28
+ const paths = flagAll('--file');
29
+ if (!paths.length) { console.log('[]'); process.exit(0); }
30
+ const scopes = new Set(flagAll('--scope')); // self + ancestors; empty (direct call) = match any
31
+ const bases = paths.map((p) => basename(p));
32
+ const out = [];
33
+ for (const f of load()) {
34
+ if (scopes.size && f.scope != null && !scopes.has(f.scope)) continue; // isolation: a sibling scope is excluded
35
+ const hit = (f.path && paths.includes(f.path)) || (f.summary && bases.some((b) => f.summary.includes(b)));
36
+ if (!hit) continue;
37
+ out.push({ kind: 'note', path: f.path ?? null, ref: f.ref ?? null, date: f.date ?? null, summary: String(f.summary || '').slice(0, 280), scope: f.scope ?? '', score: 0.5 });
38
+ }
39
+ out.sort((a, b) => String(b.date).localeCompare(String(a.date)));
40
+ console.log(JSON.stringify(out.slice(0, 8)));
41
+ process.exit(0);
42
+ }
43
+
44
+ if (cmd === 'record') {
45
+ const ref = String(flag('--ref', '')), summary = String(flag('--summary', '')), by = String(flag('--by', 'sawbuck'));
46
+ const scope = flagAll('--scope')[0] ?? '';
47
+ const side = flag('--side', ''), evidence = flag('--evidence-file', '');
48
+ const notes = load();
49
+ if (notes.some((f) => f.ref === ref && f.summary === summary && f.scope === scope)) { console.log('recorded (already present)'); process.exit(0); }
50
+ notes.push({ ref, summary, path: evidence || null, scope, by, ...(side ? { side: String(side) } : {}), date: new Date().toISOString().slice(0, 10) });
51
+ try { mkdirSync(dirname(STORE), { recursive: true }); writeFileSync(STORE, JSON.stringify(notes, null, 2) + '\n'); console.log('recorded'); process.exit(0); }
52
+ catch (e) { die(`write failed: ${String(e.message).split('\n')[0]}`, 1); }
53
+ }
54
+
55
+ die(`unknown command '${cmd || ''}' (recall | debt | record)`, 2);
@@ -0,0 +1,120 @@
1
+ #!/usr/bin/env node
2
+ // mem0.mjs, a REFERENCE semantic memory provider for Sawbuck's Memory Contract: Mem0 (mem0.ai) as the
3
+ // memory.
4
+ //
5
+ // STATUS: VERIFIED against the live Mem0 platform v3 API on 2026-10-31. /v3/memories/search/ and
6
+ // /v3/memories/add/ both returned 200 with the documented shapes (search -> { results: [...] }, add ->
7
+ // { event_id, ... }) using the Token auth header. Offline degrade (no key -> [], debt unsupported) is
8
+ // also tested. Adapt the query/scoping to taste; the wire calls are known-good.
9
+ //
10
+ // It is the THIRD contract implementation and a deliberately DIFFERENT axis from the other two:
11
+ // a networked, LLM-backed semantic store, where logbook is deterministic git-truth and obsidian is
12
+ // local notes. Because of that it is the one provider that is NOT offline: it needs the NETWORK and a
13
+ // MEM0_API_KEY. So it is an OPTIONAL provider isolated behind the socket, never part of the zero-network
14
+ // glue. Zero npm deps (Node >= 18 global fetch).
15
+ //
16
+ // Opt in: SAWBUCK_MEMORY=logbook,mem0 and MEM0_API_KEY=... (compose it WITH logbook so logbook stays the
17
+ // git-truth + debt provider and Mem0 adds recalled semantic context).
18
+ //
19
+ // recall --file P [--file Q] --json semantic search of your Mem0 memories -> Finding[] (kind note)
20
+ // record --ref SHA --summary S --by WHO [--side X] [--evidence-file F] add the finding as a memory
21
+ // debt --json EXITS NONZERO: a semantic store is not git-aware (git-truth
22
+ // lives in a git provider like logbook); the contract skips it.
23
+ //
24
+ // Config: MEM0_API_KEY, or a `~/.mem0` key file (a plain token, or JSON with api_key/key); without a
25
+ // key recall degrades to [] and record errors. Also MEM0_USER_ID (scope override), MEM0_NAMESPACE
26
+ // (id prefix), MEM0_BASE_URL (default https://api.mem0.ai), MEM0_TIMEOUT_MS (default 8000). NOTE:
27
+ // record SENDS the finding text to Mem0's service (your opt-in).
28
+
29
+ import { readFileSync } from 'node:fs';
30
+ import { basename, join } from 'node:path';
31
+ import { homedir } from 'node:os';
32
+
33
+ // the API key: MEM0_API_KEY, else a ~/.mem0 file (a plain token, or JSON with api_key/apiKey/key).
34
+ function mem0Key() {
35
+ if (process.env.MEM0_API_KEY) return process.env.MEM0_API_KEY;
36
+ try {
37
+ const raw = readFileSync(join(homedir(), '.mem0'), 'utf8').trim();
38
+ if (raw.startsWith('{')) { const j = JSON.parse(raw); return j.api_key || j.apiKey || j.key || ''; }
39
+ return raw.split(/\s+/)[0] || '';
40
+ } catch { return ''; }
41
+ }
42
+
43
+ const argv = process.argv.slice(2);
44
+ const cmd = argv[0];
45
+ const flagAll = (n) => argv.reduce((a, v, i) => (argv[i - 1] === n ? [...a, v] : a), []);
46
+ const flag = (n, d) => { const i = argv.indexOf(n); return i > -1 ? (argv[i + 1] ?? true) : d; };
47
+ const die = (msg, code = 2) => { process.stderr.write(`mem0 provider: ${msg}\n`); process.exit(code); };
48
+
49
+ // FOLDER SCOPING: each folder scope maps to a Mem0 user_id. --scope carries the chain, leaf-first
50
+ // (paths relative to the memory root; "" is the root/shared scope). recall queries self + ancestors
51
+ // and merges; record writes to the LEAF scope. MEM0_NAMESPACE prefixes the ids (keep them from
52
+ // colliding with other Mem0 usage in the same account); MEM0_USER_ID, if set, overrides to a single
53
+ // flat scope. With no --scope (direct call), it falls back to the repo folder name.
54
+ const NS = process.env.MEM0_NAMESPACE || '';
55
+ const scopeUid = (id) => process.env.MEM0_USER_ID || [NS, id || 'root'].filter(Boolean).join(':');
56
+ const scopeUids = (scopes) => {
57
+ if (process.env.MEM0_USER_ID) return [process.env.MEM0_USER_ID];
58
+ if (scopes.length) return [...new Set(scopes.map(scopeUid))];
59
+ return [basename(process.cwd()) || 'sawbuck']; // back-compat: no scope passed
60
+ };
61
+
62
+ // one bounded POST to the Mem0 platform; null on any network/HTTP/auth failure, so a read never hangs
63
+ // or crashes the caller. (A semantic recall is on-demand, never per-turn, so the timeout is generous.)
64
+ async function post(path, body, key) {
65
+ const base = (process.env.MEM0_BASE_URL || 'https://api.mem0.ai').replace(/\/$/, '');
66
+ const ctrl = new AbortController();
67
+ const timer = setTimeout(() => ctrl.abort(), +(process.env.MEM0_TIMEOUT_MS) || 8000);
68
+ try {
69
+ const r = await fetch(base + path, {
70
+ method: 'POST',
71
+ headers: { Authorization: `Token ${key}`, 'Content-Type': 'application/json', Accept: 'application/json' },
72
+ body: JSON.stringify(body),
73
+ signal: ctrl.signal,
74
+ });
75
+ if (!r.ok) return null;
76
+ return await r.json();
77
+ } catch { return null; } finally { clearTimeout(timer); }
78
+ }
79
+
80
+ if (cmd === 'debt') {
81
+ die('debt is unsupported by a semantic store (git-truth lives in a git provider like logbook)', 2);
82
+ }
83
+
84
+ if (cmd === 'recall') {
85
+ const key = mem0Key();
86
+ if (!key) { console.log('[]'); process.exit(0); } // no key -> no memories, not an error
87
+ const paths = flagAll('--file');
88
+ if (!paths.length) { console.log('[]'); process.exit(0); }
89
+ const query = `code, decisions, and gotchas related to: ${paths.map((p) => basename(p)).join(', ')}`;
90
+ const seen = new Set();
91
+ const findings = [];
92
+ // one search per scope's user_id (self + ancestors), merged; isolated from sibling scopes.
93
+ for (const uid of scopeUids(flagAll('--scope'))) {
94
+ const res = await post('/v3/memories/search/', { query, filters: { user_id: uid }, top_k: 8 }, key);
95
+ for (const m of (res && Array.isArray(res.results) ? res.results : [])) {
96
+ if (!m.memory || seen.has(m.id)) continue;
97
+ seen.add(m.id);
98
+ findings.push({ kind: 'note', path: null, ref: m.id || null, date: (m.created_at || '').slice(0, 10) || null, summary: String(m.memory).slice(0, 280), scope: uid, score: typeof m.score === 'number' ? m.score : null });
99
+ }
100
+ }
101
+ console.log(JSON.stringify(findings.slice(0, 8)));
102
+ process.exit(0);
103
+ }
104
+
105
+ if (cmd === 'record') {
106
+ const key = mem0Key();
107
+ if (!key) die('cannot record: MEM0_API_KEY is unset', 2);
108
+ const ref = String(flag('--ref', '')), summary = String(flag('--summary', '')), by = String(flag('--by', 'sawbuck'));
109
+ const side = flag('--side', ''), evidence = flag('--evidence-file', '');
110
+ const leaf = flagAll('--scope')[0];
111
+ const userId = process.env.MEM0_USER_ID || (leaf !== undefined ? scopeUid(leaf) : (basename(process.cwd()) || 'sawbuck'));
112
+ const metadata = { sawbuck: 'finding', ref, by, scope: leaf ?? '', ...(side ? { side: String(side) } : {}), ...(evidence ? { evidence: String(evidence) } : {}) };
113
+ // infer:false stores the finding verbatim (no LLM extraction); we want the exact note, not a paraphrase.
114
+ const res = await post('/v3/memories/add/', { messages: [{ role: 'user', content: summary }], user_id: userId, metadata, infer: false }, key);
115
+ if (res === null) die('record failed (network or auth)', 1);
116
+ console.log('recorded');
117
+ process.exit(0);
118
+ }
119
+
120
+ die(`unknown command '${cmd || ''}' (recall | debt | record)`, 2);
@@ -0,0 +1,119 @@
1
+ #!/usr/bin/env node
2
+ // obsidian.mjs, a REFERENCE memory provider for Sawbuck's Memory Contract: an Obsidian vault as the
3
+ // memory. Shows how to fill the socket, and the second contract implementation. Zero-dep. Opt in with
4
+ // SAWBUCK_MEMORY=logbook,obsidian and OBSIDIAN_VAULT=/path/to/vault (compose it WITH logbook: logbook
5
+ // stays the git-truth + debt provider, this adds your written notes).
6
+ //
7
+ // recall --file P [--file Q] [--scope ID ...] --json notes relevant to those paths -> Finding[] (kind note)
8
+ // record --ref SHA --summary S --by WHO [--scope ID] write a note into the vault
9
+ // debt --json EXITS NONZERO: a note vault cannot know what is silenced in HEAD
10
+ //
11
+ // FOLDER SCOPING (isolation): --scope carries the folder scope chain, leaf-first, as paths relative to
12
+ // the memory root (Sawbuck computes them; "" is the vault root = shared/global). The vault MIRRORS the
13
+ // project tree: notes for scope "group/repo" live in `<vault>/group/repo/`. recall searches the LEAF
14
+ // scope recursively (so the repo's own notes + its Sawbuck/ folder are found) and each ANCESTOR scope
15
+ // TOP-LEVEL ONLY (so it never descends into a sibling repo's folder). With no --scope it searches the
16
+ // whole vault (direct calls / back-compat). record writes to the LEAF scope's Sawbuck/ folder.
17
+
18
+ import { readFileSync, writeFileSync, readdirSync, statSync, mkdirSync, existsSync } from 'node:fs';
19
+ import { join, basename, relative, extname, resolve, sep } from 'node:path';
20
+
21
+ const argv = process.argv.slice(2);
22
+ const cmd = argv[0];
23
+ const flagAll = (n) => argv.reduce((a, v, i) => (argv[i - 1] === n ? [...a, v] : a), []);
24
+ const flag = (n, d) => { const i = argv.indexOf(n); return i > -1 ? (argv[i + 1] ?? true) : d; };
25
+
26
+ const VAULT = process.env.OBSIDIAN_VAULT ? resolve(process.env.OBSIDIAN_VAULT) : '';
27
+ const die = (msg, code = 2) => { process.stderr.write(`obsidian provider: ${msg}\n`); process.exit(code); };
28
+
29
+ const MAX_FILES = +(process.env.OBSIDIAN_MAX_FILES) || 4000;
30
+ const MAX_BYTES = 512 * 1024;
31
+
32
+ // resolve a scope id to a dir INSIDE the vault; null if it would escape (path-traversal guard).
33
+ function scopeDir(id) {
34
+ if (id === '' || id == null) return VAULT;
35
+ const p = resolve(VAULT, id);
36
+ return (p === VAULT || p.startsWith(VAULT + sep)) ? p : null;
37
+ }
38
+ // collect .md files; recursive for the leaf scope, top-level only for an ancestor (so an ancestor
39
+ // search never descends into a sibling repo's folder).
40
+ function walkMd(dir, recursive, out) {
41
+ let entries = [];
42
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
43
+ for (const e of entries) {
44
+ if (out.length >= MAX_FILES) return;
45
+ if (e.name.startsWith('.') || e.name === 'node_modules') continue;
46
+ const p = join(dir, e.name);
47
+ if (e.isDirectory()) { if (recursive) walkMd(p, true, out); }
48
+ else if (extname(e.name).toLowerCase() === '.md') out.push(p);
49
+ }
50
+ }
51
+ // the dirs to search, with their scope id and recursion, from the --scope chain (leaf-first).
52
+ function searchScopes(scopes) {
53
+ if (!scopes.length) return [{ id: '', dir: VAULT, recursive: true }]; // no scope -> whole vault
54
+ const out = []; const seen = new Set();
55
+ scopes.forEach((id, i) => {
56
+ const dir = scopeDir(id);
57
+ if (!dir || seen.has(dir)) return; seen.add(dir);
58
+ out.push({ id, dir, recursive: i === 0 }); // leaf recursive; ancestors top-level only
59
+ });
60
+ if (!seen.has(VAULT)) out.push({ id: '', dir: VAULT, recursive: false }); // vault root is the shared scope
61
+ return out;
62
+ }
63
+
64
+ if (cmd === 'debt') {
65
+ die('debt is unsupported by a note vault (git-truth lives in a git provider like logbook)', 2);
66
+ }
67
+
68
+ if (cmd === 'recall') {
69
+ if (!VAULT || !existsSync(VAULT)) { console.log('[]'); process.exit(0); }
70
+ const paths = flagAll('--file');
71
+ if (!paths.length) { console.log('[]'); process.exit(0); }
72
+ const needles = [...new Set(paths.flatMap((p) => [p, basename(p)]).filter(Boolean))];
73
+ const findings = [];
74
+ for (const { id, dir, recursive } of searchScopes(flagAll('--scope'))) {
75
+ const files = []; walkMd(dir, recursive, files);
76
+ for (const file of files) {
77
+ let text = '';
78
+ try { if (statSync(file).size > MAX_BYTES) continue; text = readFileSync(file, 'utf8'); } catch { continue; }
79
+ const hitPath = paths.find((p) => text.includes(p) || text.includes(basename(p)));
80
+ if (!hitPath) continue;
81
+ let hits = 0; for (const n of needles) hits += text.split(n).length - 1;
82
+ const title = (text.match(/^#\s+(.+)$/m) || [, basename(file, '.md')])[1].trim();
83
+ const lineWith = (text.split('\n').find((l) => needles.some((n) => l.includes(n))) || '').trim().slice(0, 160);
84
+ let date = null; try { date = new Date(statSync(file).mtimeMs).toISOString().slice(0, 10); } catch {}
85
+ findings.push({ kind: 'note', path: hitPath, ref: relative(VAULT, file), date, summary: `${title}: ${lineWith}`.slice(0, 280), scope: id, score: Math.min(1, hits / 5), _hits: hits });
86
+ }
87
+ }
88
+ findings.sort((a, b) => b._hits - a._hits || String(b.date).localeCompare(String(a.date)));
89
+ console.log(JSON.stringify(findings.slice(0, 8).map(({ _hits, ...f }) => f)));
90
+ process.exit(0);
91
+ }
92
+
93
+ if (cmd === 'record') {
94
+ if (!VAULT || !existsSync(VAULT)) die('cannot record: OBSIDIAN_VAULT is unset or missing', 2);
95
+ const ref = flag('--ref', ''), summary = String(flag('--summary', '')), by = String(flag('--by', 'sawbuck'));
96
+ const side = flag('--side', ''), evidence = flag('--evidence-file', '');
97
+ const leaf = flagAll('--scope')[0] ?? '';
98
+ const base = scopeDir(leaf) || VAULT; // the finding belongs to its most-specific scope
99
+ const dir = join(base, 'Sawbuck');
100
+ try { mkdirSync(dir, { recursive: true }); } catch {}
101
+ const safeRef = String(ref || Date.now()).replace(/[^\w.-]/g, '_').slice(0, 40);
102
+ const date = new Date().toISOString();
103
+ // a [[wikilink]] to the evidence path so Obsidian's graph connects the finding to the code note.
104
+ const link = evidence ? `\n- evidence: [[${String(evidence).replace(/[[\]]/g, '')}]]` : '';
105
+ const body = `---\nsawbuck: finding\nref: ${ref}\nby: ${by}\ndate: ${date}\nscope: ${leaf}\n---\n\n# ${summary.slice(0, 80)}\n\n${summary}\n\n- ref: \`${ref}\`${side ? `\n- side: ${side}` : ''}${link}\n`;
106
+ // no-clobber + dedupe: if this exact finding is already noted, skip; if a DIFFERENT note shares the
107
+ // ref, write a suffixed sibling instead of overwriting the earlier one.
108
+ let target = join(dir, `${safeRef}.md`);
109
+ if (existsSync(target)) {
110
+ let prev = ''; try { prev = readFileSync(target, 'utf8'); } catch {}
111
+ if (prev.includes(summary)) { console.log('recorded (already present)'); process.exit(0); }
112
+ let i = 2; while (existsSync(join(dir, `${safeRef}-${i}.md`))) i++;
113
+ target = join(dir, `${safeRef}-${i}.md`);
114
+ }
115
+ try { writeFileSync(target, body); console.log('recorded'); process.exit(0); }
116
+ catch (e) { die(`write failed: ${String(e.message).split('\n')[0]}`, 1); }
117
+ }
118
+
119
+ die(`unknown command '${cmd || ''}' (recall | debt | record)`, 2);
package/rank.mjs CHANGED
@@ -12,21 +12,7 @@
12
12
  // renames). v1 does not follow a file across a pre-rename name.
13
13
 
14
14
  import { execFileSync } from 'node:child_process';
15
- import { createRequire } from 'node:module';
16
- import { join, dirname } from 'node:path';
17
- import { fileURLToPath, pathToFileURL } from 'node:url';
18
-
19
- const HERE = dirname(fileURLToPath(import.meta.url));
20
- const require = createRequire(import.meta.url);
21
- const engineBin = (envVar, pkgSubpath, devPath) => {
22
- if (process.env[envVar]) return process.env[envVar];
23
- // deep bin subpath (no-exports pkgs, e.g. promptwheel), then the bare package name
24
- // (pkgs whose exports map "." to the bin, e.g. @promptwheel/logbook), then the dev sibling.
25
- const bare = pkgSubpath.replace(/\/bin\/[^/]*$/, '');
26
- for (const spec of [pkgSubpath, bare]) { try { return require.resolve(spec); } catch {} }
27
- return devPath;
28
- };
29
- const LB = engineBin('LB_BIN', '@promptwheel/logbook/bin/logbook.mjs', join(HERE, '..', 'logbook', 'bin', 'logbook.mjs'));
15
+ import { events as memEvents, debt as memDebt } from './memory.mjs';
30
16
 
31
17
  const arg = (n, d) => { const i = process.argv.indexOf(n); return i > -1 ? process.argv[i + 1] : d; };
32
18
  const multi = (n) => process.argv.reduce((a, v, i) => (process.argv[i - 1] === n ? [...a, v] : a), []);
@@ -42,11 +28,9 @@ let paths = multi('--file');
42
28
  if (!paths.length) paths = git('diff', '--name-only', base, head).split('\n').filter(Boolean);
43
29
  if (!paths.length) { console.log(asJson ? '[]' : 'rank: no paths to rank'); process.exit(0); }
44
30
 
45
- // pull the full event record once (query --file does not narrow --json)
46
- let record = '';
47
- try { record = execFileSync(process.execPath, [LB, 'query', '--json'], { cwd: repo, encoding: 'utf8', maxBuffer: 256 * 1024 * 1024 }); }
48
- catch (e) { record = (e.stdout || '').toString(); }
49
- const EVENTS = record.split('\n').filter(Boolean).map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
31
+ // the full event record (the memory provider's recall facts), ranked client-side below
32
+ // (the provider's --file does not narrow the record, so we pull it once and filter by path here).
33
+ const EVENTS = memEvents(repo);
50
34
 
51
35
  const resolveRename = (f) => f.replace(/\{[^}]*=>\s*([^}]*)\}/g, '$1').replace(/\/{2,}/g, '/');
52
36
  const touches = (e, p) => (e.files || []).some((f) => resolveRename(f) === p);
@@ -90,57 +74,32 @@ const normMult = (p, kind) => {
90
74
  return Math.max(0.4, Math.min(1, REF / b));
91
75
  };
92
76
 
93
- // Classify a live suppression MARKER (disabled test = hidden-failure risk) and its line as
94
- // DOCUMENTED (a rule code/reason follows) vs BARE (just the directive, the suspect kind). Same
95
- // splits `sawbuck debt` uses, so the ambient surface and the ledger agree.
96
- const isTestSkip = (m) => /\bit\.skip\b|\btest\.skip\b|xit\(|xdescribe\(|\bdescribe\.skip\b|pytest\.mark\.skip|unittest\.skip|\bt\.Skip\b|@Disabled\b|@Ignore\b|^\[Ignore\b|^Skip\s*=|^#\[ignore|markTestSkipped|markTestIncomplete/.test(String(m).trim());
97
- const isDocumented = (c) => {
98
- c = String(c || '');
99
- if (/eslint-disable/.test(c)) return /eslint-disable(?:-next-line|-line)?\s+\S/.test(c);
100
- if (/@ts-ignore/.test(c)) return false;
101
- if (/@ts-expect-error/.test(c)) return /@ts-expect-error\s+\S/.test(c);
102
- if (/noqa/i.test(c)) return /noqa:\s*\w/i.test(c);
103
- if (/type:\s*ignore/i.test(c)) return /ignore\s*\[/.test(c);
104
- return /\(\s*["']/.test(c) || /=\s*["']/.test(c);
105
- };
106
77
  // The "slider": debt rises on its own, but gently. A disabled test or an undocumented suppression
107
78
  // on a touched file always surfaces; a file that is churning (hot / reverted) surfaces any debt;
108
79
  // a stable file's routine documented suppressions stay quiet until they pile past DEBT_MIN. Tune
109
80
  // with SAWBUCK_DEBT_MIN. This is how debt rises by itself in the per-turn context, no command run.
110
81
  const DEBT_MIN = Math.max(1, +(process.env.SAWBUCK_DEBT_MIN) || 6);
111
82
 
112
- // Still-live filter (lead #4). A suppression on a path that is now CLEAN of suppressions is churn,
113
- // not current risk (the study: AutoMapper carries 4 live `[Ignore]` vs ~158/repo across C# history).
114
- // logbook's own SUPPRESS_PAT, git-grepped at HEAD but SCOPED to just the touched paths so the
115
- // per-turn hook stays cheap, tells us which still carry one. Imported so the pattern never drifts;
116
- // degrades to "treat all as live" if logbook cannot be imported. --raw turns it off.
83
+ // Still-live filter (lead #4) + ambient debt, via the memory adapter (the Memory Contract's `debt`
84
+ // capability), scoped to the touched paths so the per-turn hook stays cheap. A suppression on a path
85
+ // now CLEAN of suppressions is churn, not current risk (the study: AutoMapper carries 4 live
86
+ // `[Ignore]` vs ~158/repo across C# history). The adapter runs the provider's own detection + gating
87
+ // and returns classified suppressions, so the glue never imports the provider's pattern. null = the
88
+ // provider failed -> treat all as live (do not penalize); [] = genuinely clean. --raw turns it off.
117
89
  let liveSet = null;
118
- const liveDebt = new Map(); // path -> { n, skips, bare }: live suppressions in HEAD, for ambient surfacing
90
+ const liveDebt = new Map(); // path -> { n, skips, bare }: live suppressions, for ambient surfacing
119
91
  if (!raw && paths.length) {
120
- try {
121
- const { SUPPRESS_PAT, kindAllowedInFile, isMention } = await import(pathToFileURL(LB).href);
122
- if (SUPPRESS_PAT && SUPPRESS_PAT.source) {
123
- // -nP (content), still scoped to the touched paths, so we can both weight AND surface the debt.
124
- let grep = '';
125
- try { grep = execFileSync('git', ['-C', repo, 'grep', '-nP', SUPPRESS_PAT.source, 'HEAD', '--', ...paths], { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }); }
126
- catch (e) { grep = (e.stdout || '').toString(); } // git grep exits 1 when nothing matches
127
- liveSet = new Set();
128
- for (const row of grep.split('\n')) {
129
- const m = /^HEAD:(.+?):\d+:(.*)$/.exec(row); if (!m) continue;
130
- const file = m[1], content = m[2];
131
- const re = new RegExp(SUPPRESS_PAT.source, 'g'); let mm;
132
- while ((mm = re.exec(content))) {
133
- if (kindAllowedInFile && !kindAllowedInFile(mm[0], file)) continue; // gate exactly like audit
134
- if (isMention && isMention(content, mm.index, mm[0])) continue; // a comment ABOUT it is not live
135
- liveSet.add(file);
136
- const d = liveDebt.get(file) || { n: 0, skips: 0, bare: 0 };
137
- d.n++;
138
- if (isTestSkip(mm[0])) d.skips++; else if (!isDocumented(content)) d.bare++;
139
- liveDebt.set(file, d);
140
- }
141
- }
92
+ const sup = memDebt(repo, { paths });
93
+ if (sup !== null) {
94
+ liveSet = new Set();
95
+ for (const it of sup) {
96
+ liveSet.add(it.file);
97
+ const d = liveDebt.get(it.file) || { n: 0, skips: 0, bare: 0 };
98
+ d.n++;
99
+ if (it.testSkip) d.skips++; else if (!it.documented) d.bare++;
100
+ liveDebt.set(it.file, d);
142
101
  }
143
- } catch { liveSet = null; liveDebt.clear(); } // logbook unavailable -> skip, do not crash the read face
102
+ }
144
103
  }
145
104
  // a suppression on a still-silenced path keeps its weight; on a now-clean path it is near-zero.
146
105
  const stillLive = (p) => (liveSet === null || liveSet.has(p) ? 1 : 0.25);
package/reflect.mjs CHANGED
@@ -19,11 +19,13 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
19
19
  import { join, dirname } from 'node:path';
20
20
  import { fileURLToPath } from 'node:url';
21
21
  import { createRequire } from 'node:module';
22
+ import { record as memRecord } from './memory.mjs';
22
23
 
23
24
  const HERE = dirname(fileURLToPath(import.meta.url));
24
25
  const require = createRequire(import.meta.url);
25
- // Resolve each engine CLI: env override, then the installed package (published
26
- // install), then the monorepo sibling (dev checkout).
26
+ // Resolve the check engine CLI: env override, then the installed package (published
27
+ // install), then the monorepo sibling (dev checkout). The MEMORY engine is reached only
28
+ // through memory.mjs (the Memory Contract seam), never resolved or shelled directly here.
27
29
  const engineBin = (envVar, pkgSubpath, devPath) => {
28
30
  if (process.env[envVar]) return process.env[envVar];
29
31
  // deep bin subpath (no-exports pkgs, e.g. promptwheel), then the bare package name
@@ -33,7 +35,6 @@ const engineBin = (envVar, pkgSubpath, devPath) => {
33
35
  return devPath;
34
36
  };
35
37
  const PW = engineBin('PW_BIN', 'promptwheel/bin/promptwheel.mjs', join(HERE, '..', 'referee', 'bin', 'promptwheel.mjs'));
36
- const LB = engineBin('LB_BIN', '@promptwheel/logbook/bin/logbook.mjs', join(HERE, '..', 'logbook', 'bin', 'logbook.mjs'));
37
38
 
38
39
  const flag = (name, def) => { const i = process.argv.indexOf(name); return i > -1 ? process.argv[i + 1] : def; };
39
40
  const repo = flag('--repo', process.cwd());
@@ -81,13 +82,10 @@ const reason = kind === 'gamed'
81
82
  : `sawbuck: a change here regressed a guarded metric beyond noise`;
82
83
 
83
84
  // --- 3) record the finding in memory (unreviewed, machine-authored note) ----
85
+ // The Memory Contract's `record` capability, through the adapter (no direct engine shell here).
84
86
  // M0 keeps it simple: a commit-attached digest note. M1 adds --span grounding
85
- // + true coupling (the fix diff → "change X ⇒ also Y").
86
- try {
87
- runNode(LB, ['annotate', sha, reason.slice(0, 280), '--side', 'diff', '--evidence-file', evidenceFile, '--by', 'sawbuck']);
88
- } catch {
89
- try { runNode(LB, ['annotate', sha, reason.slice(0, 280), '--by', 'sawbuck']); } catch { /* best-effort */ }
90
- }
87
+ // + true coupling (the fix diff -> "change X also needs Y").
88
+ memRecord(repo, { sha, reason: reason.slice(0, 280), side: 'diff', evidenceFile, by: 'sawbuck' });
91
89
 
92
90
  // --- 4) tick the results record (the visible proof-of-work) -----------------
93
91
  const recDir = join(repo, '.sawbuck');
package/sawbuck.mjs CHANGED
@@ -18,6 +18,7 @@ import { join, dirname, resolve } from 'node:path';
18
18
  import { fileURLToPath } from 'node:url';
19
19
  import { homedir } from 'node:os';
20
20
  import { createRequire } from 'node:module';
21
+ import { recall as memRecall, conform as memConform } from './memory.mjs';
21
22
 
22
23
  const SELF = fileURLToPath(import.meta.url);
23
24
  const HERE = dirname(SELF);
@@ -86,6 +87,10 @@ Usage:
86
87
  sawbuck rank [--base R --head R] [--file P ...]
87
88
  Show the relevant past findings for the paths a change touches
88
89
  sawbuck context [repo] Ranked relevant history for your current working changes (the read face)
90
+ sawbuck recall [repo] Related notes for your current changes from extra memory providers
91
+ (SAWBUCK_MEMORY=logbook,obsidian + OBSIDIAN_VAULT); supplementary to context
92
+ sawbuck memory-conformance <bin>
93
+ Check a provider bin speaks the Memory Contract (recall/debt/record)
89
94
  sawbuck debt [repo] [--since REF] [--fail-on undocumented|test|any|N]
90
95
  The whole-repo live suppression debt: what is still silenced in HEAD now,
91
96
  risk-ranked (severity x file fragility), undocumented flagged. --since REF
@@ -181,6 +186,32 @@ switch (cmd) {
181
186
  else if (speak) out('🐴 sawbuck: no notable history for the files you are touching (a clean trail).');
182
187
  break;
183
188
  }
189
+ case 'recall': {
190
+ // the notes face: supplementary relevant notes for your current changes, from any non-git memory
191
+ // provider (e.g. an Obsidian vault). On-demand. Git history + live debt come from context / debt.
192
+ const repo = repoArg();
193
+ const g = (...a) => { try { return execFileSync('git', ['-C', repo, ...a], { encoding: 'utf8' }).trim(); } catch { return ''; } };
194
+ let changed = g('diff', '--name-only', 'HEAD').split('\n').filter(Boolean);
195
+ if (!changed.length) changed = g('diff', '--name-only', 'HEAD~1', 'HEAD').split('\n').filter(Boolean);
196
+ if (!changed.length) { out('🐴 sawbuck: nothing changed to look up.'); break; }
197
+ const notes = memRecall(repo, changed);
198
+ if (!notes.length) { out('🐴 sawbuck: no related notes (configure extra memory providers with SAWBUCK_MEMORY, e.g. logbook,obsidian; logbook history is in `sawbuck context`).'); break; }
199
+ out(`🐴 related notes for ${changed.length} changed path${changed.length === 1 ? '' : 's'} (leads, not conclusions):`);
200
+ for (const n of notes) out(` - [${n.source}] ${n.summary}${n.ref ? ` (${n.ref})` : ''}`);
201
+ break;
202
+ }
203
+ case 'memory-conformance': {
204
+ // check that a provider bin speaks the Memory Contract (recall returns Finding[]; debt returns
205
+ // Suppression[] or exits nonzero to signal it is not a git-truth provider). Read-only probe.
206
+ const bin = positional[0];
207
+ if (!bin) die('usage: sawbuck memory-conformance <provider-bin> (a bin speaking the contract verbs recall/debt/record)');
208
+ const r = memConform(bin, process.cwd());
209
+ out(`🐴 memory-conformance: ${bin}`);
210
+ out(` recall: ${r.recall}`);
211
+ out(` debt: ${r.debt}`);
212
+ out(r.pass ? ' ✓ conforms to the Memory Contract.' : ' ✗ does not conform (see above).');
213
+ process.exit(r.pass ? 0 : 1);
214
+ }
184
215
  case 'run': {
185
216
  try { execFileSync(process.execPath, [join(HERE, 'run.mjs'), ...rest], { stdio: 'inherit' }); }
186
217
  catch (e) { process.exit(e.status || 1); }