sawbuck 0.1.19 → 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 +2 -0
- package/README.md +1 -1
- package/memory.mjs +1 -1
- package/package.json +3 -1
- package/providers/claude.mjs +49 -0
- package/providers/local.mjs +55 -0
- package/providers/obsidian.mjs +10 -1
package/CONTRACT.md
CHANGED
|
@@ -8,6 +8,8 @@ This is an open interface for making an AI coding harness trustworthy. It define
|
|
|
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
|
+
|
|
11
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.
|
|
12
14
|
>
|
|
13
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.
|
package/README.md
CHANGED
|
@@ -77,7 +77,7 @@ Debt also rises on its own: once attached, the per-turn `context` hook surfaces
|
|
|
77
77
|
Sawbuck is thin glue over two roles, each behind an open interface that points in opposite directions:
|
|
78
78
|
|
|
79
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`.
|
|
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
81
|
|
|
82
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.
|
|
83
83
|
|
package/memory.mjs
CHANGED
|
@@ -31,7 +31,7 @@ function logbookBin() {
|
|
|
31
31
|
}
|
|
32
32
|
const envKey = (name) => 'SAWBUCK_MEMORY_' + name.toUpperCase().replace(/[^A-Z0-9]/g, '_') + '_BIN';
|
|
33
33
|
// the ordered provider list. Each: { name, bin, dialect: 'logbook' | 'contract' }.
|
|
34
|
-
const SHIPPED = new Set(['obsidian', 'mem0']); // reference contract providers that ship in providers/
|
|
34
|
+
const SHIPPED = new Set(['obsidian', 'mem0', 'local', 'claude']); // reference contract providers that ship in providers/
|
|
35
35
|
export function providers() {
|
|
36
36
|
const spec = (process.env.SAWBUCK_MEMORY || 'logbook').split(',').map((s) => s.trim()).filter(Boolean);
|
|
37
37
|
return spec.map((name) => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sawbuck",
|
|
3
|
-
"version": "0.1.
|
|
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",
|
|
@@ -30,6 +30,8 @@
|
|
|
30
30
|
"memory.mjs",
|
|
31
31
|
"providers/obsidian.mjs",
|
|
32
32
|
"providers/mem0.mjs",
|
|
33
|
+
"providers/local.mjs",
|
|
34
|
+
"providers/claude.mjs",
|
|
33
35
|
"run.mjs",
|
|
34
36
|
"README.md",
|
|
35
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);
|
package/providers/obsidian.mjs
CHANGED
|
@@ -103,7 +103,16 @@ if (cmd === 'record') {
|
|
|
103
103
|
// a [[wikilink]] to the evidence path so Obsidian's graph connects the finding to the code note.
|
|
104
104
|
const link = evidence ? `\n- evidence: [[${String(evidence).replace(/[[\]]/g, '')}]]` : '';
|
|
105
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
|
-
|
|
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); }
|
|
107
116
|
catch (e) { die(`write failed: ${String(e.message).split('\n')[0]}`, 1); }
|
|
108
117
|
}
|
|
109
118
|
|