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 +7 -1
- package/GLOSSARY.md +22 -4
- package/README.md +6 -3
- package/debt.mjs +18 -52
- package/memory.mjs +207 -0
- package/package.json +6 -1
- package/providers/claude.mjs +49 -0
- package/providers/local.mjs +55 -0
- package/providers/mem0.mjs +120 -0
- package/providers/obsidian.mjs +119 -0
- package/rank.mjs +21 -62
- package/reflect.mjs +7 -9
- package/sawbuck.mjs +31 -0
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
|
|
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
|
|
5
|
-
config of what to measure, runs the check when a
|
|
6
|
-
relevant past finding before the agent repeats a
|
|
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
|
|
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
|
|
77
|
+
Sawbuck is thin glue over two roles, each behind an open interface that points in opposite directions:
|
|
77
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`. 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 {
|
|
11
|
-
import {
|
|
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
|
-
//
|
|
34
|
-
|
|
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
|
-
//
|
|
59
|
-
//
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
const
|
|
63
|
-
|
|
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
|
-
|
|
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) =>
|
|
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(` ${
|
|
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 =
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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 {
|
|
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
|
-
//
|
|
46
|
-
|
|
47
|
-
|
|
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)
|
|
113
|
-
//
|
|
114
|
-
//
|
|
115
|
-
//
|
|
116
|
-
//
|
|
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
|
|
90
|
+
const liveDebt = new Map(); // path -> { n, skips, bare }: live suppressions, for ambient surfacing
|
|
119
91
|
if (!raw && paths.length) {
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
}
|
|
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
|
|
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
|
|
86
|
-
|
|
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); }
|