sawbuck 0.1.11 → 0.1.13
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/GLOSSARY.md +3 -1
- package/README.md +23 -0
- package/debt.mjs +12 -3
- package/package.json +2 -1
- package/rank.mjs +53 -4
- package/sawbuck.mjs +8 -7
- package/status.mjs +139 -0
package/GLOSSARY.md
CHANGED
|
@@ -32,7 +32,9 @@ two vocabularies.
|
|
|
32
32
|
| what Sawbuck leaves you | **verified history** | the findings you can trust |
|
|
33
33
|
| a surfaced past outcome | **finding** | |
|
|
34
34
|
| mounting Sawbuck to a repo | **attach** / **cinch** | `sawbuck attach <repo>` |
|
|
35
|
-
| reading the repo's shape | **fit** | history + config |
|
|
35
|
+
| reading the repo's shape | **fit** | history + config; a concept, not a command (it is what `attach` reads to decide what it can measure) |
|
|
36
|
+
| 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
|
+
| the one-view accumulation | **inventory** | `sawbuck status`: what it has caught over time + the debt accruing now + whether it is armed here |
|
|
36
38
|
|
|
37
39
|
## The one humility rule
|
|
38
40
|
|
package/README.md
CHANGED
|
@@ -31,6 +31,7 @@ sawbuck: gamed recorded on test.js (the win came from editing the test, no
|
|
|
31
31
|
|
|
32
32
|
- **Verifies the win.** `check` re-runs your metric from the source diff alone. A faked green (a deleted, weakened, or skipped test; a mocked grader; an edited golden file) reads `gamed`. Deterministic, zero-LLM, $0, and it cannot be obfuscated against because it never reads the trajectory (89% recall on a labeled gaming benchmark).
|
|
33
33
|
- **Remembers what broke.** `context` and `rank` surface the relevant history (reverts, fragile areas, test-weakening) for the files you are touching: a ranked trail guide, before you act.
|
|
34
|
+
- **Shows you everything.** `status` is the inventory: what it has caught for you over time, the debt accruing now, and whether it is armed here, in one view. On-demand, so it is quiet until you go looking.
|
|
34
35
|
- **Rides on, does not replace.** Sawbuck is the verify + memory layer. Your agent and your loop stay yours.
|
|
35
36
|
- **Clean on and off.** `attach` touches nothing in your worktree (the hook lives in `.git/`, the record is excluded locally); `detach` is a true inverse. `trace` shows every byte it placed, and `detach --verify` proves the repo came back unchanged.
|
|
36
37
|
|
|
@@ -43,11 +44,33 @@ sawbuck: gamed recorded on test.js (the win came from editing the test, no
|
|
|
43
44
|
| `sawbuck check [--base R --head R]` | verify a commit; record a regression or a gamed win |
|
|
44
45
|
| `sawbuck context` · `sawbuck rank` | the ranked relevant history for the current change |
|
|
45
46
|
| `sawbuck debt` | the whole-repo live suppression debt: what is still silenced in HEAD now, disabled tests first, heaviest files ranked |
|
|
47
|
+
| `sawbuck status` · `sawbuck log` | the inventory (caught over time + debt now + armed state), in one view; and the recent per-check feed |
|
|
46
48
|
| `sawbuck run --attempt '<agent cmd>'` | thin harness: load context, run one attempt through the keep-if-verified gate |
|
|
47
49
|
| `sawbuck trace` · `sawbuck doctor` · `detach --verify` | show the footprint; check the attachment resolves; prove a clean removal |
|
|
48
50
|
|
|
49
51
|
Each repo needs a `promptwheel.config.json` saying what to measure (your tests, lint, coverage, or an eval).
|
|
50
52
|
|
|
53
|
+
## Gate debt on pull requests
|
|
54
|
+
|
|
55
|
+
`sawbuck debt --since <base>` shows only the suppression debt a branch adds. Fail the build when a PR adds the kind you care about:
|
|
56
|
+
|
|
57
|
+
```yaml
|
|
58
|
+
# .github/workflows/debt.yml
|
|
59
|
+
name: debt
|
|
60
|
+
on: pull_request
|
|
61
|
+
jobs:
|
|
62
|
+
debt:
|
|
63
|
+
runs-on: ubuntu-latest
|
|
64
|
+
steps:
|
|
65
|
+
- uses: actions/checkout@v4
|
|
66
|
+
with: { fetch-depth: 0 } # the base ref must be present to diff against
|
|
67
|
+
- run: npx -y sawbuck debt --since "origin/${{ github.base_ref }}" --fail-on undocumented
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
`--fail-on` takes `undocumented` (a bare `# type: ignore` / `# noqa` with no code), `test` (a newly disabled test), `any`, or a number of new suppressions. Exit 1 fails the check; add `--json` for the machine-readable delta. This repo is also a composite action: `uses: sawbuck-ai/sawbuck@v0` with `base` and `fail-on` inputs.
|
|
71
|
+
|
|
72
|
+
Debt also rises on its own: once attached, the per-turn `context` hook surfaces the live suppression debt on the files your agent is touching when it is the suspect kind (undocumented, a disabled test, or on code that keeps breaking), so you do not have to run anything.
|
|
73
|
+
|
|
51
74
|
## The engines
|
|
52
75
|
|
|
53
76
|
Sawbuck is thin glue over two tools it depends on:
|
package/debt.mjs
CHANGED
|
@@ -26,6 +26,8 @@ const flag = (n, d) => { const i = process.argv.indexOf(n); return i > -1 ? (pro
|
|
|
26
26
|
const repo = resolve(flag('--repo', process.cwd()));
|
|
27
27
|
const top = Math.max(1, parseInt(flag('--top', '12'), 10) || 12);
|
|
28
28
|
const since = flag('--since', null);
|
|
29
|
+
const failRaw = flag('--fail-on', null); // undocumented | test | any | <N>; bare --fail-on = undocumented
|
|
30
|
+
const failMode = failRaw === true ? 'undocumented' : (failRaw ? String(failRaw).toLowerCase() : null);
|
|
29
31
|
const asJson = process.argv.includes('--json');
|
|
30
32
|
|
|
31
33
|
// A disabled/skipped/weakened TEST is a hidden-failure risk; a lint/type/error suppression is
|
|
@@ -67,14 +69,21 @@ if (since && typeof since === 'string') {
|
|
|
67
69
|
const norm = (s) => String(s).trim().replace(/\s+/g, ' ');
|
|
68
70
|
const base = new Set(parseGrep(grepAt(since), since).map((r) => r.file + '\u0000' + norm(r.content)));
|
|
69
71
|
const added = live.filter((it) => it.content && !base.has(it.file + '\u0000' + norm(it.content)));
|
|
70
|
-
if (asJson) { console.log(JSON.stringify({ since, added: added.length, testSkips: added.filter((it) => isTestSkip(it.kind)).length, undocumented: added.filter((it) => !it.documented).length, items: added.map((it) => ({ file: it.file, line: it.line, kind: String(it.kind).trim(), documented: it.documented })) }, null, 2)); process.exit(0); }
|
|
71
|
-
if (!added.length) { console.log(`🐴 sawbuck: no new suppression debt since ${since} (clean).`); process.exit(0); }
|
|
72
72
|
const skips = added.filter((it) => isTestSkip(it.kind)).length, undoc = added.filter((it) => !it.documented).length;
|
|
73
|
+
// the gate: fail (exit 1) when the branch adds the kind of debt --fail-on names.
|
|
74
|
+
const fail = !failMode ? false
|
|
75
|
+
: /^\d+$/.test(failMode) ? added.length >= +failMode
|
|
76
|
+
: failMode === 'any' ? added.length > 0
|
|
77
|
+
: (failMode === 'test' || failMode === 'tests' || failMode === 'disabled') ? skips > 0
|
|
78
|
+
: undoc > 0; // default: undocumented
|
|
79
|
+
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
|
+
if (!added.length) { console.log(`🐴 sawbuck: no new suppression debt since ${since} (clean).`); process.exit(0); }
|
|
73
81
|
const o = [`🐴 sawbuck: ${added.length} new suppression${added.length === 1 ? '' : 's'} since ${since} · ${skips} disabled test${skips === 1 ? '' : 's'} · ${undoc} undocumented (no code/reason)`];
|
|
74
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)'}`);
|
|
75
83
|
o.push(`\n ✎ = bare/undocumented (the suspect kind); declare intent (a rule code or reason) or remove it.`);
|
|
84
|
+
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}).`);
|
|
76
85
|
console.log(o.join('\n'));
|
|
77
|
-
process.exit(0);
|
|
86
|
+
process.exit(fail ? 1 : 0);
|
|
78
87
|
}
|
|
79
88
|
|
|
80
89
|
if (!live.length) { console.log(asJson ? '{"total":0,"testSkips":0,"codeSuppressions":0,"undocumented":0,"files":[]}' : '🐴 sawbuck: no live suppression debt in HEAD (nothing silenced in src/test/config). A clean tree.'); process.exit(0); }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sawbuck",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.13",
|
|
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",
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
"sawbuck-hook.mjs",
|
|
27
27
|
"rank.mjs",
|
|
28
28
|
"debt.mjs",
|
|
29
|
+
"status.mjs",
|
|
29
30
|
"run.mjs",
|
|
30
31
|
"README.md",
|
|
31
32
|
"GLOSSARY.md",
|
package/rank.mjs
CHANGED
|
@@ -90,22 +90,57 @@ const normMult = (p, kind) => {
|
|
|
90
90
|
return Math.max(0.4, Math.min(1, REF / b));
|
|
91
91
|
};
|
|
92
92
|
|
|
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
|
+
// The "slider": debt rises on its own, but gently. A disabled test or an undocumented suppression
|
|
107
|
+
// on a touched file always surfaces; a file that is churning (hot / reverted) surfaces any debt;
|
|
108
|
+
// a stable file's routine documented suppressions stay quiet until they pile past DEBT_MIN. Tune
|
|
109
|
+
// with SAWBUCK_DEBT_MIN. This is how debt rises by itself in the per-turn context, no command run.
|
|
110
|
+
const DEBT_MIN = Math.max(1, +(process.env.SAWBUCK_DEBT_MIN) || 6);
|
|
111
|
+
|
|
93
112
|
// Still-live filter (lead #4). A suppression on a path that is now CLEAN of suppressions is churn,
|
|
94
113
|
// not current risk (the study: AutoMapper carries 4 live `[Ignore]` vs ~158/repo across C# history).
|
|
95
114
|
// logbook's own SUPPRESS_PAT, git-grepped at HEAD but SCOPED to just the touched paths so the
|
|
96
115
|
// per-turn hook stays cheap, tells us which still carry one. Imported so the pattern never drifts;
|
|
97
116
|
// degrades to "treat all as live" if logbook cannot be imported. --raw turns it off.
|
|
98
117
|
let liveSet = null;
|
|
118
|
+
const liveDebt = new Map(); // path -> { n, skips, bare }: live suppressions in HEAD, for ambient surfacing
|
|
99
119
|
if (!raw && paths.length) {
|
|
100
120
|
try {
|
|
101
|
-
const { SUPPRESS_PAT } = await import(pathToFileURL(LB).href);
|
|
121
|
+
const { SUPPRESS_PAT, kindAllowedInFile, isMention } = await import(pathToFileURL(LB).href);
|
|
102
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.
|
|
103
124
|
let grep = '';
|
|
104
|
-
try { grep = execFileSync('git', ['-C', repo, 'grep', '-
|
|
125
|
+
try { grep = execFileSync('git', ['-C', repo, 'grep', '-nP', SUPPRESS_PAT.source, 'HEAD', '--', ...paths], { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }); }
|
|
105
126
|
catch (e) { grep = (e.stdout || '').toString(); } // git grep exits 1 when nothing matches
|
|
106
|
-
liveSet = new Set(
|
|
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
|
+
}
|
|
107
142
|
}
|
|
108
|
-
} catch { liveSet = null; } // logbook unavailable -> skip
|
|
143
|
+
} catch { liveSet = null; liveDebt.clear(); } // logbook unavailable -> skip, do not crash the read face
|
|
109
144
|
}
|
|
110
145
|
// a suppression on a still-silenced path keeps its weight; on a now-clean path it is near-zero.
|
|
111
146
|
const stillLive = (p) => (liveSet === null || liveSet.has(p) ? 1 : 0.25);
|
|
@@ -140,6 +175,20 @@ const ranked = paths.map((p) => {
|
|
|
140
175
|
let burst = 0;
|
|
141
176
|
for (let i = 0; i + 2 < fixDates.length; i++) if (fixDates[i + 2] - fixDates[i] <= 30 * DAY) { burst = fixDates.filter((x) => x >= fixDates[i] && x <= fixDates[i] + 30 * DAY).length; break; }
|
|
142
177
|
if (burst >= 3) { score += 4; hits.unshift({ date: new Date(fixDates[fixDates.length - 1]).toISOString().slice(0, 10), kind: `HOT: ${burst} fixes in 30 days`, subject: 'this file was actively on fire, verify carefully', w: 99 }); }
|
|
178
|
+
// ambient debt: the live suppression debt on this file rises on its own when it is the suspect kind
|
|
179
|
+
// (a disabled test or an undocumented suppression), when the file is fragile (hot/reverted), or when
|
|
180
|
+
// it piles past the slider. Routine documented suppressions on stable files stay quiet. The score
|
|
181
|
+
// bump lets a file with ONLY debt (no history) surface at all; `sawbuck debt` is the full ledger.
|
|
182
|
+
const dbt = liveDebt.get(p);
|
|
183
|
+
if (dbt) {
|
|
184
|
+
const fragile = burst >= 3 || hits.some((h) => /revert/.test(h.kind || ''));
|
|
185
|
+
if (dbt.skips || dbt.bare || (fragile && dbt.n) || dbt.n >= DEBT_MIN) {
|
|
186
|
+
const extra = [dbt.skips ? `${dbt.skips} disabled test${dbt.skips === 1 ? '' : 's'}` : '', dbt.bare ? `${dbt.bare} undocumented` : ''].filter(Boolean).join(', ');
|
|
187
|
+
const w = dbt.skips ? 2.6 : (dbt.bare ? 2.2 : 1.4);
|
|
188
|
+
score += w;
|
|
189
|
+
hits.push({ kind: 'live debt here', subject: `${dbt.n} suppression${dbt.n === 1 ? '' : 's'} still silenced${extra ? ` (${extra})` : ''}; sawbuck debt for the ledger`, w });
|
|
190
|
+
}
|
|
191
|
+
}
|
|
143
192
|
hits.sort((a, b) => b.w - a.w);
|
|
144
193
|
return { path: p, score: +score.toFixed(2), top: hits.slice(0, 3), hot: burst >= 3 };
|
|
145
194
|
}).filter((r) => r.score > 0).sort((a, b) => b.score - a.score).slice(0, top);
|
package/sawbuck.mjs
CHANGED
|
@@ -78,17 +78,18 @@ Usage:
|
|
|
78
78
|
sawbuck doctor [repo] Check attachments are healthy (hook still resolves to a live sawbuck); --all for every repo
|
|
79
79
|
sawbuck where List every repo Sawbuck is attached to
|
|
80
80
|
sawbuck statusline One-line segment for your status line (🐴 watching | N caught)
|
|
81
|
-
sawbuck status [repo]
|
|
81
|
+
sawbuck status [repo] The inventory: everything it has accumulated here, in one view, caught
|
|
82
|
+
over time + debt accruing now + whether it is armed. --json for the feed
|
|
82
83
|
sawbuck log [repo] [-n N] Recent activity, newest first: every check (verified / gamed / regression)
|
|
83
84
|
sawbuck check [repo] Try it: verify your latest change right now (working tree if dirty, else the
|
|
84
85
|
last commit). Zero-config, no attach, no hook. The fastest way to see it work.
|
|
85
86
|
sawbuck rank [--base R --head R] [--file P ...]
|
|
86
87
|
Show the relevant past findings for the paths a change touches
|
|
87
88
|
sawbuck context [repo] Ranked relevant history for your current working changes (the read face)
|
|
88
|
-
sawbuck debt [repo] [--since REF]
|
|
89
|
+
sawbuck debt [repo] [--since REF] [--fail-on undocumented|test|any|N]
|
|
89
90
|
The whole-repo live suppression debt: what is still silenced in HEAD now,
|
|
90
91
|
risk-ranked (severity x file fragility), undocumented flagged. --since REF
|
|
91
|
-
shows only the debt a branch
|
|
92
|
+
shows only the debt a branch adds; --fail-on exits nonzero for a CI gate
|
|
92
93
|
sawbuck run --attempt '<cmd>' [--loops N]
|
|
93
94
|
Thin harness: load context, run one attempt through the keep-if-verified gate
|
|
94
95
|
sawbuck --help | --version
|
|
@@ -108,10 +109,10 @@ switch (cmd) {
|
|
|
108
109
|
break;
|
|
109
110
|
}
|
|
110
111
|
case 'status': {
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
112
|
+
// the inventory: one view unioning what the check has caught over time, what debt is accruing
|
|
113
|
+
// now, and whether it is armed here. The "show me everything" face.
|
|
114
|
+
try { execFileSync(process.execPath, [join(HERE, 'status.mjs'), '--repo', repoArg(), ...rest.filter((a) => a.startsWith('--'))], { stdio: 'inherit' }); }
|
|
115
|
+
catch (e) { process.exit(e.status || 1); }
|
|
115
116
|
break;
|
|
116
117
|
}
|
|
117
118
|
case 'check': {
|
package/status.mjs
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// status.mjs, the inventory: one on-demand view of everything Sawbuck has accumulated for a repo.
|
|
3
|
+
// Unions the three faces that are otherwise surfaced piecemeal: what it has CAUGHT for you over
|
|
4
|
+
// time (the outcome record), what debt is ACCRUING right now (the live suppression ledger), and
|
|
5
|
+
// whether it is ARMED here (what it can measure). The quiet per-turn faces stay quiet; this is the
|
|
6
|
+
// "show me everything" face you run when you go looking.
|
|
7
|
+
//
|
|
8
|
+
// Read-only, zero-dep, bounded. The debt scan is best-effort (a pointer if it cannot run in time).
|
|
9
|
+
// Usage: node status.mjs [--repo P] [--json]
|
|
10
|
+
|
|
11
|
+
import { execFileSync } from 'node:child_process';
|
|
12
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
13
|
+
import { join, dirname, resolve, basename } from 'node:path';
|
|
14
|
+
import { fileURLToPath } from 'node:url';
|
|
15
|
+
|
|
16
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
17
|
+
const flag = (n, d) => { const i = process.argv.indexOf(n); return i > -1 ? (process.argv[i + 1] ?? true) : d; };
|
|
18
|
+
const repo = resolve(flag('--repo', process.cwd()));
|
|
19
|
+
const asJson = process.argv.includes('--json');
|
|
20
|
+
|
|
21
|
+
const readJSON = (p) => { try { return JSON.parse(readFileSync(p, 'utf8')); } catch { return null; } };
|
|
22
|
+
const readLines = (p) => { try { return readFileSync(p, 'utf8').split('\n').filter(Boolean); } catch { return []; } };
|
|
23
|
+
const git = (...a) => { try { return execFileSync('git', ['-C', repo, ...a], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); } catch { return ''; } };
|
|
24
|
+
|
|
25
|
+
const ICON = { verified: '✓', gamed: '🚩', regression: '✗', inconclusive: '≈', allowed: '✎' };
|
|
26
|
+
|
|
27
|
+
// --- 1) the outcome record: what the check has done here -------------------
|
|
28
|
+
// results.json holds the all-time catch tally (gamed + regression, never trimmed); activity.jsonl is
|
|
29
|
+
// the recent per-check feed (verified included, capped ~200). We read both and keep each honest.
|
|
30
|
+
const results = readJSON(join(repo, '.sawbuck', 'results.json')) || {};
|
|
31
|
+
const feed = readLines(join(repo, '.sawbuck', 'activity.jsonl')).map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
|
|
32
|
+
const tally = { verified: 0, gamed: 0, regression: 0, inconclusive: 0, allowed: 0 };
|
|
33
|
+
for (const e of feed) if (e && tally[e.kind] != null) tally[e.kind]++;
|
|
34
|
+
const seen = feed.length;
|
|
35
|
+
const catchesAllTime = (results.regressions || 0) + (results.gamed || 0);
|
|
36
|
+
const latest = results.latest || (feed.length ? feed[feed.length - 1] : null);
|
|
37
|
+
const since = feed.length ? (feed[0].at || '').slice(0, 10) : null;
|
|
38
|
+
|
|
39
|
+
// --- 2) the armed state: what it can measure here (mirror of statusline) ---
|
|
40
|
+
const isGit = !!existsSync(join(repo, '.git'));
|
|
41
|
+
const hasConfig = existsSync(join(repo, 'promptwheel.config.json'));
|
|
42
|
+
const watching = existsSync(join(repo, '.sawbuck', 'state.json'));
|
|
43
|
+
const armed = hasConfig || existsSync(join(repo, 'package.json'));
|
|
44
|
+
const mem = existsSync(join(repo, 'LOGBOOK.md')) || existsSync(join(repo, 'events.jsonl'));
|
|
45
|
+
const state = watching ? 'watching' : armed ? 'ready' : mem ? 'memory' : 'unarmed';
|
|
46
|
+
|
|
47
|
+
// --- 3) the live debt ledger: best-effort, bounded -------------------------
|
|
48
|
+
// A full-repo HEAD grep + gating; fast on normal repos, can be heavy on a giant one. Bounded so
|
|
49
|
+
// status never hangs; on timeout/failure it degrades to a pointer at `sawbuck debt`.
|
|
50
|
+
let debt = null, debtErr = null;
|
|
51
|
+
if (isGit) {
|
|
52
|
+
const ms = +(process.env.SAWBUCK_STATUS_DEBT_MS) || 20000;
|
|
53
|
+
try {
|
|
54
|
+
const o = execFileSync(process.execPath, [join(HERE, 'debt.mjs'), '--repo', repo, '--json'], { encoding: 'utf8', timeout: ms, stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 64 * 1024 * 1024 });
|
|
55
|
+
debt = JSON.parse(o.slice(o.indexOf('{')));
|
|
56
|
+
} catch (e) { debtErr = e.killed ? 'timeout' : 'unavailable'; }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// --- JSON face -------------------------------------------------------------
|
|
60
|
+
if (asJson) {
|
|
61
|
+
console.log(JSON.stringify({
|
|
62
|
+
repo: basename(repo),
|
|
63
|
+
state,
|
|
64
|
+
armed,
|
|
65
|
+
git: isGit,
|
|
66
|
+
config: hasConfig,
|
|
67
|
+
outcomes: { seen, caughtAllTime: catchesAllTime, gamedAllTime: results.gamed || 0, regressionsAllTime: results.regressions || 0, recent: tally, latest, since },
|
|
68
|
+
debt: debt ? { total: debt.total, testSkips: debt.testSkips, undocumented: debt.undocumented, filesOnFragileCode: debt.filesOnFragileCode, top: (debt.files || []).slice(0, 5) } : null,
|
|
69
|
+
debtError: debtErr,
|
|
70
|
+
}, null, 2));
|
|
71
|
+
process.exit(0);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// --- human face ------------------------------------------------------------
|
|
75
|
+
const out = [];
|
|
76
|
+
const stateLabel = { watching: 'watching (wired in)', ready: 'ready (checkable, not wired yet)', memory: 'memory only (history, no check configured)', unarmed: 'not a measurable repo yet' }[state];
|
|
77
|
+
out.push(`🐴 sawbuck · ${basename(repo)} · ${stateLabel}`);
|
|
78
|
+
|
|
79
|
+
// Outcomes
|
|
80
|
+
if (seen || catchesAllTime) {
|
|
81
|
+
out.push('');
|
|
82
|
+
out.push(` Caught for you${seen ? ` · last ${seen} check${seen === 1 ? '' : 's'}` : ''}`);
|
|
83
|
+
if (seen) {
|
|
84
|
+
const parts = [
|
|
85
|
+
tally.verified ? `${ICON.verified} ${tally.verified} verified` : '',
|
|
86
|
+
tally.gamed ? `${ICON.gamed} ${tally.gamed} gamed` : '',
|
|
87
|
+
tally.regression ? `${ICON.regression} ${tally.regression} regression${tally.regression === 1 ? '' : 's'}` : '',
|
|
88
|
+
tally.inconclusive ? `${ICON.inconclusive} ${tally.inconclusive} inconclusive` : '',
|
|
89
|
+
tally.allowed ? `${ICON.allowed} ${tally.allowed} allowed` : '',
|
|
90
|
+
].filter(Boolean).join(' ');
|
|
91
|
+
out.push(` ${parts || 'no checks recorded yet'}`);
|
|
92
|
+
}
|
|
93
|
+
// the all-time catch count outlasts the 200-line window, so surface it when it is larger
|
|
94
|
+
if (catchesAllTime && catchesAllTime > (tally.gamed + tally.regression)) {
|
|
95
|
+
out.push(` ${catchesAllTime} caught here all-time (${results.gamed || 0} gamed, ${results.regressions || 0} regression${(results.regressions || 0) === 1 ? '' : 's'})`);
|
|
96
|
+
}
|
|
97
|
+
if (latest) {
|
|
98
|
+
const k = latest.kind || '';
|
|
99
|
+
const when = (latest.at || '').slice(0, 10);
|
|
100
|
+
const why = String(latest.reason || '').replace(/^sawbuck:\s*/i, '').slice(0, 90);
|
|
101
|
+
out.push(` latest: ${ICON[k] || '·'} ${k}${latest.file ? ` · ${latest.file}` : ''}${why ? ` · ${why}` : ''}${when ? ` (${when})` : ''}`);
|
|
102
|
+
}
|
|
103
|
+
} else {
|
|
104
|
+
out.push('');
|
|
105
|
+
out.push(` Caught for you · nothing recorded yet (the check logs here on each commit/turn)`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Debt
|
|
109
|
+
out.push('');
|
|
110
|
+
if (debt && debt.total) {
|
|
111
|
+
out.push(` Debt accruing now`);
|
|
112
|
+
out.push(` ${debt.total} silenced in HEAD · ${debt.testSkips} disabled test${debt.testSkips === 1 ? '' : 's'} · ${debt.undocumented} undocumented (no code/reason)`);
|
|
113
|
+
for (const f of (debt.files || []).slice(0, 3)) {
|
|
114
|
+
const bits = [f.testSkips ? `${f.testSkips} test` : '', f.suppressions ? `${f.suppressions} lint/type${f.undocumented ? `, ${f.undocumented} bare` : ''}` : ''].filter(Boolean).join(' + ');
|
|
115
|
+
const frag = [f.reverts ? `${f.reverts} revert${f.reverts === 1 ? '' : 's'}` : '', f.hot ? 'fix-hotspot' : ''].filter(Boolean).join(', ');
|
|
116
|
+
out.push(` ${f.reverts || f.hot ? '🔥 ' : ' '}${f.file} · ${f.live} (${bits})${frag ? ` · ${frag}` : ''} · risk ${f.risk}`);
|
|
117
|
+
}
|
|
118
|
+
out.push(` → sawbuck debt for the full ledger`);
|
|
119
|
+
} else if (debt) {
|
|
120
|
+
out.push(` Debt accruing now · none live in HEAD (nothing silenced in src/test/config). A clean tree.`);
|
|
121
|
+
} else if (debtErr === 'timeout') {
|
|
122
|
+
out.push(` Debt accruing now · scan did not finish in time here (a large repo); run \`sawbuck debt\` directly.`);
|
|
123
|
+
} else if (isGit) {
|
|
124
|
+
out.push(` Debt accruing now · could not read the ledger; run \`sawbuck debt\` for the detail.`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Measuring / next action
|
|
128
|
+
out.push('');
|
|
129
|
+
if (!isGit) {
|
|
130
|
+
out.push(` Not a git repo here, so there is nothing to verify or remember. cd into your project.`);
|
|
131
|
+
} else if (state === 'watching') {
|
|
132
|
+
out.push(` Measuring here · ${hasConfig ? '✓ wired in with a contract (promptwheel.config.json)' : '✓ wired in (zero-config auto-detect)'}. It verifies each commit/turn automatically.`);
|
|
133
|
+
} else if (state === 'ready') {
|
|
134
|
+
out.push(` Measuring here · checkable but not wired yet. Try it now: \`sawbuck check\` • make it automatic: \`sawbuck attach --claude\``);
|
|
135
|
+
} else {
|
|
136
|
+
out.push(` Measuring here · ${hasConfig ? 'a contract exists' : 'no test command detected yet'}. \`sawbuck init\` to set up the check, or \`sawbuck context\` for the history alone.`);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
console.log(out.join('\n'));
|