gespann 0.0.0-stage → 0.1.0
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/LICENSE +21 -0
- package/README.md +106 -2
- package/dist/actions.js +141 -0
- package/dist/cli.js +374 -0
- package/dist/cloud.js +214 -0
- package/dist/cluster.js +339 -0
- package/dist/guardrails/apply.js +278 -0
- package/dist/guardrails/files.js +193 -0
- package/dist/guardrails/propose.js +47 -0
- package/dist/guardrails/spec.js +127 -0
- package/dist/guardrails/validate.js +148 -0
- package/dist/harness.js +60 -0
- package/dist/llm-tasks.js +101 -0
- package/dist/llm.js +169 -0
- package/dist/redact.js +38 -0
- package/dist/report.js +112 -0
- package/dist/review.js +80 -0
- package/dist/rules.js +175 -0
- package/dist/savings.js +56 -0
- package/dist/scan.js +83 -0
- package/dist/ship.js +187 -0
- package/dist/sources/analyze.js +304 -0
- package/dist/sources/claude.js +183 -0
- package/dist/sources/codex.js +191 -0
- package/dist/sources/git.js +121 -0
- package/dist/tui/app.js +377 -0
- package/dist/tui/model.js +156 -0
- package/dist/tui/run.js +8 -0
- package/dist/types.js +1 -0
- package/dist/util.js +101 -0
- package/package.json +54 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Tobias
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,107 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Gespann
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Your coding agents keep making the same mistakes. Gespann finds them in your git history and agent transcripts, and turns each one into a check that stops it next time: a hook, a lint rule, a CI check. Prose rules only as a last resort. Then it measures whether the mistake comes back.
|
|
4
|
+
|
|
5
|
+
Works across Claude Code, Codex and T3 Code. Everything stays in your repo.
|
|
6
|
+
|
|
7
|
+
Status: MVP CLI with an interactive view. See `docs/SPEC.md`.
|
|
8
|
+
|
|
9
|
+
## Use
|
|
10
|
+
|
|
11
|
+
Needs Node 20 or newer and git.
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
bun install && bun run build
|
|
15
|
+
node dist/cli.js # interactive view: browse, propose, apply, check
|
|
16
|
+
node dist/cli.js scan --since 30d # rank recurring mistakes
|
|
17
|
+
node dist/cli.js propose cmd-gh-watch # build and test a guardrail for one of them
|
|
18
|
+
node dist/cli.js apply cmd-gh-watch # install it (hook, lint rule or prose line)
|
|
19
|
+
node dist/cli.js check # run every installed guardrail (CI or local)
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
`scan` reads:
|
|
23
|
+
|
|
24
|
+
- git: fix commits, reverts, files that keep getting fixed.
|
|
25
|
+
- Claude Code transcripts in `~/.claude/projects/` and Codex sessions in `~/.codex/sessions/` whose working directory is this repo or one of its worktrees.
|
|
26
|
+
- "never `x`" rules in `AGENTS.md`, `CLAUDE.md` and `~/.claude/CLAUDE.md`, to spot commands the agents were told not to run.
|
|
27
|
+
|
|
28
|
+
## Interactive view
|
|
29
|
+
|
|
30
|
+
Run `gespann` without a command in a terminal. It opens on the last report, refreshes it in the background when it is older than six hours, and builds and tests fixes for the top mistakes on its own.
|
|
31
|
+
|
|
32
|
+
- The headline says how much time agents lost and how much the ready fixes win back a month.
|
|
33
|
+
- The bar shows where that time went: one block per fix, brass for time lost, green for fixes you approved.
|
|
34
|
+
- Each row: time lost in the window, time the fix should win back a month, and what the fix does. The selected row opens to show the fix, its test result, the files it writes and how the estimate is made.
|
|
35
|
+
- Press enter twice to approve. Approving writes nothing yet; the fix goes into the next pull request.
|
|
36
|
+
- Press `p` to open that pull request. Gespann makes a new branch from the remote's default branch in a temporary worktree, installs the approved fixes there, commits, pushes and runs `gh pr create`. Your checkout, its branch and uncommitted changes stay as they are. Quitting with approved fixes asks once whether to open it.
|
|
37
|
+
- Open it again later: each fix shows whether it is waiting in a pull request, held (blocked calls, time saved), came back, is too early to judge, or blocks nothing and can go.
|
|
38
|
+
|
|
39
|
+
| Key | Does |
|
|
40
|
+
|---|---|
|
|
41
|
+
| `↑` `↓` / `j` `k` | choose a fix |
|
|
42
|
+
| `enter` `enter` | approve it for the next pull request |
|
|
43
|
+
| `p` | open the pull request with all approved fixes |
|
|
44
|
+
| `x` | take a fix out of the pull request |
|
|
45
|
+
| `m` | show all fixes, not just the top seven |
|
|
46
|
+
| `e` | show examples from the sessions |
|
|
47
|
+
| `r` | scan again |
|
|
48
|
+
|
|
49
|
+
`gespann review` prints the same verdicts for scripts. `gespann pr [id ...]` opens the pull request without the view. It needs a remote named `origin` and the GitHub CLI. Pipes and CI keep the plain commands.
|
|
50
|
+
|
|
51
|
+
### How time is counted
|
|
52
|
+
|
|
53
|
+
- Agent mistakes: the measured time from the tool call to its result (a `sleep` poll that ran 20 minutes counts 20 minutes), at least a small estimate per kind.
|
|
54
|
+
- Git: 15 minutes per fix commit, 30 per revert.
|
|
55
|
+
- Wins back: a hook stops about 9 in 10 repeats, a lint rule 6 in 10, a line of advice 1 in 3, and advice on code that keeps breaking 1 in 10. These are estimates and the view says so.
|
|
56
|
+
- Calls a gespann hook blocked are not counted as mistakes. They count for the fix that blocked them.
|
|
57
|
+
|
|
58
|
+
## Harnesses
|
|
59
|
+
|
|
60
|
+
Gespann tells which app drove each session:
|
|
61
|
+
|
|
62
|
+
- T3 Code: Claude transcripts with `entrypoint: "t3-code"` or T3 tools, Codex sessions with `originator: "T3 Code"`.
|
|
63
|
+
- Claude Code and Codex on their own.
|
|
64
|
+
|
|
65
|
+
The report counts sessions per harness. Where a harness has a better fix, Gespann says so. In T3 Code, a hook against `sleep` polling or `gh pr checks --watch` points to `watch_pull_request`; in plain Claude Code it says to check once. One hook script serves all of them: it reads the harness at run time (`T3CODE_THREAD_ID`, `CLAUDE_CODE_ENTRYPOINT`, the Codex payload) and validation checks each message.
|
|
66
|
+
|
|
67
|
+
Hooks go to Claude Code and, when the repo has Codex sessions, to Codex too (`.codex/hooks.json`, same script). Codex runs a project hook only after you trust it in `/hooks`.
|
|
68
|
+
|
|
69
|
+
## Files
|
|
70
|
+
|
|
71
|
+
`scan` writes `.gespann/report.json` and `.gespann/report.md`. Use `--out DIR` to write somewhere else. Secrets and tokens are redacted.
|
|
72
|
+
|
|
73
|
+
`propose` writes `.gespann/guardrails/<id>/` and checks the guardrail against its own passing and failing fixtures. Nothing in your repo changes until you run `apply`.
|
|
74
|
+
|
|
75
|
+
| Kind | Files | `apply` installs |
|
|
76
|
+
|---|---|---|
|
|
77
|
+
| hook | `hook.mjs`, `settings.snippet.json`, `codex-hooks.snippet.json`, `fixtures/{fail,pass}.json` | `.claude/hooks/gespann-<id>.mjs` and a `PreToolUse` entry in `.claude/settings.json` (and `.codex/hooks.json`) |
|
|
78
|
+
| lint | `rule.yml` (ast-grep), `rule-test.yml`, `fixtures/{fail,pass}.<ext>` | `.gespann/rules/<id>.yml` and `ruleDirs` in `sgconfig.yml` |
|
|
79
|
+
| prose | `rule.md`, `fixtures/{fail,pass}.md` | one line with source and expiry in `AGENTS.md` (and `CLAUDE.md` if it is a separate file) |
|
|
80
|
+
|
|
81
|
+
## Gespann Cloud (optional)
|
|
82
|
+
|
|
83
|
+
The CLI is complete on its own. [Gespann Cloud](https://gespann.app) keeps the history: is a mistake coming back, how often did each hook stop an agent, which repos miss a guardrail your team requires. Free for one repo.
|
|
84
|
+
|
|
85
|
+
```sh
|
|
86
|
+
gespann sync --dry-run # print exactly what would be sent; works without an account
|
|
87
|
+
gespann login # paste a key from gespann.app/account
|
|
88
|
+
gespann scan --no-llm && gespann sync
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
`sync` sends the last scan's metadata: ids, counts, dates, check results and how often each hook blocked, per day. Ids that come from your commit messages, file names or prompts are hashed with a key that belongs to your account, so outsiders cannot guess them back from a file name. The key is stored on our server too. The repo is sent as a keyed hash too. `--names` also sends readable ids and the repo name. Never code, file paths, commit messages or transcripts.
|
|
92
|
+
|
|
93
|
+
Installed hooks count each block in `.gespann/local/blocks.jsonl` (guardrail id and time only). `apply` adds `local/` to `.gespann/.gitignore`.
|
|
94
|
+
|
|
95
|
+
## LLM
|
|
96
|
+
|
|
97
|
+
By default `scan` and `propose` ask `claude -p` (or `codex exec` with `--llm codex`) to name clusters and suggest a stronger guardrail. Answers must match a strict JSON schema. If the call fails or the guardrail fails its fixtures, Gespann falls back to its own heuristics. `--no-llm` never calls a model.
|
|
98
|
+
|
|
99
|
+
## Develop
|
|
100
|
+
|
|
101
|
+
```sh
|
|
102
|
+
bun run typecheck && bun run lint && bun test
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Tests build a temp git repo and sample transcripts. They never touch the network or your real `~/.claude` / `~/.codex`.
|
|
106
|
+
|
|
107
|
+
License: MIT.
|
package/dist/actions.js
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { existsSync, readdirSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { applyGuardrail, checkAll, planApply } from './guardrails/apply.js';
|
|
4
|
+
import { guardrailDir, readManifest, writeManifest } from './guardrails/files.js';
|
|
5
|
+
import { proposeGuardrail } from './guardrails/propose.js';
|
|
6
|
+
import { readReport, reportPaths, writeReport } from './report.js';
|
|
7
|
+
import { scan } from './scan.js';
|
|
8
|
+
import { shipFixes } from './ship.js';
|
|
9
|
+
/** Install hooks for every agent that ran in this repo. Codex and Claude read the same script. */
|
|
10
|
+
export function hookTargetsFor(report) {
|
|
11
|
+
return report.stats.codexSessions > 0 ? ['claude', 'codex'] : ['claude'];
|
|
12
|
+
}
|
|
13
|
+
export function makeActions(o) {
|
|
14
|
+
return {
|
|
15
|
+
repo: o.repo,
|
|
16
|
+
outDir: o.outDir,
|
|
17
|
+
llm: Boolean(o.adapter),
|
|
18
|
+
load() {
|
|
19
|
+
if (!existsSync(reportPaths(o.outDir).json))
|
|
20
|
+
return undefined;
|
|
21
|
+
try {
|
|
22
|
+
const r = readReport(o.outDir);
|
|
23
|
+
return r.repo === o.repo ? r : undefined;
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
async scan(progress, warn) {
|
|
30
|
+
const report = await scan({
|
|
31
|
+
repo: o.repo,
|
|
32
|
+
since: o.since,
|
|
33
|
+
claudeDir: o.claudeDir,
|
|
34
|
+
codexDir: o.codexDir,
|
|
35
|
+
adapter: o.adapter,
|
|
36
|
+
minCount: o.minCount,
|
|
37
|
+
warn,
|
|
38
|
+
progress,
|
|
39
|
+
});
|
|
40
|
+
writeReport(o.outDir, report);
|
|
41
|
+
return report;
|
|
42
|
+
},
|
|
43
|
+
async propose(report, id, opts) {
|
|
44
|
+
const cluster = report.clusters.find((c) => c.id === id);
|
|
45
|
+
if (!cluster)
|
|
46
|
+
throw new Error(`No mistake "${id}" in the report.`);
|
|
47
|
+
const adapter = opts?.llm === false ? undefined : o.adapter;
|
|
48
|
+
return proposeGuardrail(cluster, o.outDir, adapter, new Date(), hookTargetsFor(report));
|
|
49
|
+
},
|
|
50
|
+
apply: (id) => applyGuardrail(o.repo, o.outDir, id),
|
|
51
|
+
check: () => checkAll(o.repo, o.outDir),
|
|
52
|
+
state(id) {
|
|
53
|
+
const m = manifestOf(o.outDir, id);
|
|
54
|
+
if (m === undefined)
|
|
55
|
+
return 'none';
|
|
56
|
+
if (m === null)
|
|
57
|
+
return 'invalid';
|
|
58
|
+
if (m.applied)
|
|
59
|
+
return 'applied';
|
|
60
|
+
return m.validation.ok ? 'proposed' : 'invalid';
|
|
61
|
+
},
|
|
62
|
+
plan(id) {
|
|
63
|
+
const m = manifestOf(o.outDir, id);
|
|
64
|
+
return m ? planApply(o.repo, m.spec) : [];
|
|
65
|
+
},
|
|
66
|
+
proposal(id) {
|
|
67
|
+
return manifestOf(o.outDir, id) ?? undefined;
|
|
68
|
+
},
|
|
69
|
+
applied: () => appliedGuardrails(o.repo, o.outDir),
|
|
70
|
+
approve(id) {
|
|
71
|
+
const m = manifestOf(o.outDir, id);
|
|
72
|
+
if (!m)
|
|
73
|
+
throw new Error(`No proposal for "${id}".`);
|
|
74
|
+
if (!m.validation.ok)
|
|
75
|
+
throw new Error(`"${id}" fails its own fixtures.`);
|
|
76
|
+
m.approved = { at: new Date().toISOString() };
|
|
77
|
+
writeManifest(guardrailDir(o.outDir, id), m);
|
|
78
|
+
},
|
|
79
|
+
unapprove(id) {
|
|
80
|
+
const m = manifestOf(o.outDir, id);
|
|
81
|
+
if (!m?.approved)
|
|
82
|
+
return;
|
|
83
|
+
m.approved = undefined;
|
|
84
|
+
writeManifest(guardrailDir(o.outDir, id), m);
|
|
85
|
+
},
|
|
86
|
+
approved() {
|
|
87
|
+
const root = join(o.outDir, 'guardrails');
|
|
88
|
+
if (!existsSync(root))
|
|
89
|
+
return [];
|
|
90
|
+
return readdirSync(root)
|
|
91
|
+
.map((id) => [id, manifestOf(o.outDir, id)])
|
|
92
|
+
.filter(([, m]) => m?.approved && !m.applied)
|
|
93
|
+
.sort((a, b) => (a[1]?.approved?.at ?? '').localeCompare(b[1]?.approved?.at ?? ''))
|
|
94
|
+
.map(([id]) => id);
|
|
95
|
+
},
|
|
96
|
+
ship: (ids, progress) => shipFixes({
|
|
97
|
+
repo: o.repo,
|
|
98
|
+
outDir: o.outDir,
|
|
99
|
+
ids,
|
|
100
|
+
progress,
|
|
101
|
+
openPullRequest: o.openPullRequest,
|
|
102
|
+
now: o.now,
|
|
103
|
+
}),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
/** Applied guardrails from `outDir`, with the result of `gespann check` for each. */
|
|
107
|
+
export async function appliedGuardrails(repo, outDir) {
|
|
108
|
+
const root = join(outDir, 'guardrails');
|
|
109
|
+
if (!existsSync(root))
|
|
110
|
+
return [];
|
|
111
|
+
const status = new Map((await checkAll(repo, outDir)).map((s) => [s.id, s]));
|
|
112
|
+
const out = [];
|
|
113
|
+
for (const id of readdirSync(root)) {
|
|
114
|
+
const m = manifestOf(outDir, id);
|
|
115
|
+
if (!m?.applied)
|
|
116
|
+
continue;
|
|
117
|
+
const st = status.get(id);
|
|
118
|
+
const failed = st?.checks.find((c) => !c.ok);
|
|
119
|
+
out.push({
|
|
120
|
+
id,
|
|
121
|
+
at: m.applied.at,
|
|
122
|
+
spec: m.applied.spec,
|
|
123
|
+
ok: st ? st.ok : undefined,
|
|
124
|
+
...(failed ? { problem: `${failed.name}: ${failed.detail}` } : {}),
|
|
125
|
+
...(m.applied.pr ? { pr: m.applied.pr } : {}),
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
return out.sort((a, b) => a.at.localeCompare(b.at));
|
|
129
|
+
}
|
|
130
|
+
/** `undefined`: no proposal. `null`: a proposal that cannot be read. */
|
|
131
|
+
function manifestOf(outDir, id) {
|
|
132
|
+
const dir = guardrailDir(outDir, id);
|
|
133
|
+
if (!existsSync(join(dir, 'guardrail.json')))
|
|
134
|
+
return undefined;
|
|
135
|
+
try {
|
|
136
|
+
return readManifest(dir);
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
}
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { existsSync, readFileSync, realpathSync } from 'node:fs';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { join, relative, resolve } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { parseArgs } from 'node:util';
|
|
7
|
+
import { appliedGuardrails, hookTargetsFor, makeActions } from './actions.js';
|
|
8
|
+
import { buildPayload, CloudError, checkApiUrl, cloudRequest, DEFAULT_API, deleteCredentials, isApiKey, PREVIEW_KEY, readBlocks, readCredentials, repoIdentity, writeCredentials, } from './cloud.js';
|
|
9
|
+
import { ApplyError, applyGuardrail, checkAll } from './guardrails/apply.js';
|
|
10
|
+
import { proposeGuardrail } from './guardrails/propose.js';
|
|
11
|
+
import { makeAdapter } from './llm.js';
|
|
12
|
+
import { findCluster, readReport, renderSummary, renderTable, reportPaths, writeReport, } from './report.js';
|
|
13
|
+
import { reviewGuardrail } from './review.js';
|
|
14
|
+
import { scan } from './scan.js';
|
|
15
|
+
import { ShipError } from './ship.js';
|
|
16
|
+
import { repoRoot } from './sources/git.js';
|
|
17
|
+
import { parseSince, UsageError } from './util.js';
|
|
18
|
+
const HELP = `gespann: turn the mistakes your coding agents repeat into checks.
|
|
19
|
+
|
|
20
|
+
Usage:
|
|
21
|
+
gespann [ui] [--since 30d] [--repo .] [--no-llm] Interactive view (needs a terminal)
|
|
22
|
+
gespann scan [--since 30d] [--repo .] [--claude-dir PATH] [--codex-dir PATH] [--out DIR] [--no-llm]
|
|
23
|
+
gespann propose <id|rank> [--repo .] [--out DIR] [--no-llm]
|
|
24
|
+
gespann apply <id> [--repo .] [--out DIR]
|
|
25
|
+
gespann check [--repo .] [--out DIR]
|
|
26
|
+
gespann review [--repo .] [--out DIR] Did applied guardrails hold? Uses the last scan.
|
|
27
|
+
gespann pr [id ...] [--repo .] [--out DIR] Open a pull request with approved fixes (or these ids)
|
|
28
|
+
|
|
29
|
+
Gespann Cloud (optional, https://gespann.app):
|
|
30
|
+
gespann login [--key KEY] Save an API key from gespann.app/account. Reads stdin if no --key.
|
|
31
|
+
gespann sync [--dry-run] [--names]
|
|
32
|
+
Send the last scan's metadata: hashed ids, counts, dates, check
|
|
33
|
+
results, blocks per day. Never code, paths, messages or transcripts.
|
|
34
|
+
gespann logout Delete the saved key.
|
|
35
|
+
|
|
36
|
+
Options:
|
|
37
|
+
--since SPAN How far back to look: 30d, 2w, 12h or a date. Default 30d.
|
|
38
|
+
--repo PATH The git repo to scan. Default: current directory.
|
|
39
|
+
--out DIR Where reports and proposals go. Default: <repo>/.gespann
|
|
40
|
+
--claude-dir PATH Claude Code home. Default: ~/.claude
|
|
41
|
+
--codex-dir PATH Codex home. Default: ~/.codex
|
|
42
|
+
--no-llm Use only the built-in heuristics. No model is called.
|
|
43
|
+
--llm NAME Model CLI to use: claude (default) or codex.
|
|
44
|
+
--limit N Rows in the table. Default 10.
|
|
45
|
+
--min-count N Incidents needed for a cluster. Default 2.
|
|
46
|
+
--dry-run sync: print exactly what would be sent, send nothing.
|
|
47
|
+
--names sync: also send readable ids and the repo name. Off by default.
|
|
48
|
+
-h, --help Show this help.
|
|
49
|
+
-v, --version Show the version.
|
|
50
|
+
`;
|
|
51
|
+
function version() {
|
|
52
|
+
try {
|
|
53
|
+
const pkg = join(fileURLToPath(new URL('.', import.meta.url)), '..', 'package.json');
|
|
54
|
+
return JSON.parse(readFileSync(pkg, 'utf8')).version;
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return '0.0.0';
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function printChecks(io, checks) {
|
|
61
|
+
for (const c of checks)
|
|
62
|
+
io.out(` ${c.ok ? 'ok ' : 'FAIL'} ${c.name}: ${c.detail}`);
|
|
63
|
+
}
|
|
64
|
+
export async function main(argv, io) {
|
|
65
|
+
let parsed;
|
|
66
|
+
try {
|
|
67
|
+
parsed = parse(argv);
|
|
68
|
+
}
|
|
69
|
+
catch (e) {
|
|
70
|
+
io.err(`${e.message}\n\n${HELP}`);
|
|
71
|
+
return 2;
|
|
72
|
+
}
|
|
73
|
+
const { values: v, positionals } = parsed;
|
|
74
|
+
if (v.version) {
|
|
75
|
+
io.out(version());
|
|
76
|
+
return 0;
|
|
77
|
+
}
|
|
78
|
+
const [given, ...rest] = positionals;
|
|
79
|
+
const cmd = given ?? (io.tty && !v.help ? 'ui' : undefined);
|
|
80
|
+
if (v.help || !cmd) {
|
|
81
|
+
io.out(HELP);
|
|
82
|
+
return given || v.help ? 0 : 2;
|
|
83
|
+
}
|
|
84
|
+
const env = io.env ?? process.env;
|
|
85
|
+
const doFetch = io.fetch ?? fetch;
|
|
86
|
+
try {
|
|
87
|
+
if (cmd === 'login' || cmd === 'logout') {
|
|
88
|
+
return await account(cmd, v.key, env, io.home ?? homedir(), doFetch, io);
|
|
89
|
+
}
|
|
90
|
+
const repo = repoRoot(resolve(v.repo ?? '.'));
|
|
91
|
+
const outDir = resolve(v.out ?? join(repo, '.gespann'));
|
|
92
|
+
const adapter = v['no-llm'] ? undefined : (io.adapter ?? makeAdapter(v.llm));
|
|
93
|
+
const home = io.home ?? homedir();
|
|
94
|
+
switch (cmd) {
|
|
95
|
+
case 'ui': {
|
|
96
|
+
if (!io.tty && !io.runTui) {
|
|
97
|
+
throw new UsageError('The interactive view needs a terminal. Use `gespann scan` instead.');
|
|
98
|
+
}
|
|
99
|
+
const actions = makeActions({
|
|
100
|
+
repo,
|
|
101
|
+
outDir,
|
|
102
|
+
since: parseSince(v.since ?? '30d'),
|
|
103
|
+
claudeDir: v['claude-dir'] ?? join(home, '.claude'),
|
|
104
|
+
codexDir: v['codex-dir'] ?? join(home, '.codex'),
|
|
105
|
+
adapter,
|
|
106
|
+
minCount: v['min-count'] ? Number(v['min-count']) : undefined,
|
|
107
|
+
});
|
|
108
|
+
const run = io.runTui ?? (await import('./tui/run.js')).runTui;
|
|
109
|
+
await run(actions);
|
|
110
|
+
return 0;
|
|
111
|
+
}
|
|
112
|
+
case 'scan': {
|
|
113
|
+
const report = await scan({
|
|
114
|
+
repo,
|
|
115
|
+
since: parseSince(v.since ?? '30d'),
|
|
116
|
+
claudeDir: v['claude-dir'] ?? join(home, '.claude'),
|
|
117
|
+
codexDir: v['codex-dir'] ?? join(home, '.codex'),
|
|
118
|
+
adapter,
|
|
119
|
+
minCount: v['min-count'] ? Number(v['min-count']) : undefined,
|
|
120
|
+
warn: (m) => io.err(m),
|
|
121
|
+
});
|
|
122
|
+
const paths = writeReport(outDir, report);
|
|
123
|
+
io.out(renderSummary(report));
|
|
124
|
+
io.out('');
|
|
125
|
+
if (report.clusters.length === 0) {
|
|
126
|
+
io.out('No recurring mistakes found.');
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
io.out('Top recurring mistakes');
|
|
130
|
+
io.out(renderTable(report, v.limit ? Number(v.limit) : 10));
|
|
131
|
+
}
|
|
132
|
+
io.out('');
|
|
133
|
+
io.out(`Wrote ${display(repo, paths.json)} and ${display(repo, paths.md)}`);
|
|
134
|
+
if (report.clusters[0])
|
|
135
|
+
io.out(`Next: gespann propose ${report.clusters[0].id}`);
|
|
136
|
+
return 0;
|
|
137
|
+
}
|
|
138
|
+
case 'propose': {
|
|
139
|
+
const ref = rest[0];
|
|
140
|
+
if (!ref)
|
|
141
|
+
throw new UsageError('Say which mistake: gespann propose <id|rank>.');
|
|
142
|
+
if (!existsSync(reportPaths(outDir).json))
|
|
143
|
+
throw new UsageError('No report yet. Run `gespann scan` first.');
|
|
144
|
+
const report = readReport(outDir);
|
|
145
|
+
const cluster = findCluster(report, ref);
|
|
146
|
+
if (!cluster)
|
|
147
|
+
throw new UsageError(`No mistake "${ref}" in the last report.`);
|
|
148
|
+
const res = await proposeGuardrail(cluster, outDir, adapter, new Date(), hookTargetsFor(report));
|
|
149
|
+
const m = res.manifest;
|
|
150
|
+
if (res.fallback)
|
|
151
|
+
io.err(`LLM proposal not used: ${res.fallback}. Used the built-in one.`);
|
|
152
|
+
io.out(`Proposed ${m.spec.kind} guardrail "${m.spec.id}": ${m.spec.title}`);
|
|
153
|
+
io.out(`Files in ${display(repo, res.dir)}: ${m.files.join(', ')}`);
|
|
154
|
+
io.out('Validation:');
|
|
155
|
+
printChecks(io, m.validation.checks);
|
|
156
|
+
if (!m.validation.ok) {
|
|
157
|
+
io.err('The guardrail does not pass its own fixtures. It will not be applied.');
|
|
158
|
+
return 1;
|
|
159
|
+
}
|
|
160
|
+
io.out(`Review it, then run: gespann apply ${m.spec.id}`);
|
|
161
|
+
return 0;
|
|
162
|
+
}
|
|
163
|
+
case 'apply': {
|
|
164
|
+
const id = rest[0];
|
|
165
|
+
if (!id)
|
|
166
|
+
throw new UsageError('Say which guardrail: gespann apply <id>.');
|
|
167
|
+
const changed = await applyGuardrail(repo, outDir, id);
|
|
168
|
+
if (changed.length === 0)
|
|
169
|
+
io.out(`"${id}" was already installed. Nothing changed.`);
|
|
170
|
+
else {
|
|
171
|
+
io.out(`Applied "${id}". Changed:`);
|
|
172
|
+
for (const f of changed)
|
|
173
|
+
io.out(` ${display(repo, f)}`);
|
|
174
|
+
if (changed.some((f) => f.endsWith(join('.codex', 'hooks.json')))) {
|
|
175
|
+
io.out('Codex runs a project hook only after you trust it: open /hooks in Codex once.');
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return 0;
|
|
179
|
+
}
|
|
180
|
+
case 'check': {
|
|
181
|
+
const results = await checkAll(repo, outDir);
|
|
182
|
+
if (results.length === 0) {
|
|
183
|
+
io.out('No applied guardrails. Run `gespann propose` and `gespann apply` first.');
|
|
184
|
+
return 0;
|
|
185
|
+
}
|
|
186
|
+
for (const r of results) {
|
|
187
|
+
io.out(`${r.ok ? (r.warn ? 'warn' : 'ok ') : 'FAIL'} ${r.id} (${r.kind})`);
|
|
188
|
+
printChecks(io, r.checks);
|
|
189
|
+
}
|
|
190
|
+
const failed = results.filter((r) => !r.ok).length;
|
|
191
|
+
io.out(failed
|
|
192
|
+
? `${failed} of ${results.length} guardrails failed.`
|
|
193
|
+
: `All ${results.length} guardrails pass.`);
|
|
194
|
+
return failed ? 1 : 0;
|
|
195
|
+
}
|
|
196
|
+
case 'pr': {
|
|
197
|
+
const actions = makeActions({ repo, outDir, since: new Date() });
|
|
198
|
+
const ids = rest.length ? rest : actions.approved();
|
|
199
|
+
if (ids.length === 0) {
|
|
200
|
+
throw new UsageError('Nothing approved yet. Approve fixes in `gespann`, or name them: gespann pr <id>.');
|
|
201
|
+
}
|
|
202
|
+
for (const id of ids) {
|
|
203
|
+
const state = actions.state(id);
|
|
204
|
+
if (state === 'none')
|
|
205
|
+
throw new UsageError(`No proposal for "${id}". Run gespann propose ${id}.`);
|
|
206
|
+
if (state === 'invalid')
|
|
207
|
+
throw new UsageError(`"${id}" fails its own fixtures.`);
|
|
208
|
+
}
|
|
209
|
+
const res = await actions.ship(ids, (step) => io.err(`${step}…`));
|
|
210
|
+
io.out(`Opened ${res.url}`);
|
|
211
|
+
io.out(`Branch ${res.branch} from ${res.base}, ${ids.length} ${ids.length === 1 ? 'fix' : 'fixes'}:`);
|
|
212
|
+
for (const f of res.files)
|
|
213
|
+
io.out(` ${f}`);
|
|
214
|
+
return 0;
|
|
215
|
+
}
|
|
216
|
+
case 'review': {
|
|
217
|
+
if (!existsSync(reportPaths(outDir).json))
|
|
218
|
+
throw new UsageError('No report yet. Run `gespann scan` first.');
|
|
219
|
+
const report = readReport(outDir);
|
|
220
|
+
const applied = await appliedGuardrails(repo, outDir);
|
|
221
|
+
if (applied.length === 0) {
|
|
222
|
+
io.out('No applied guardrails yet. Approve one in `gespann` or run `gespann apply`.');
|
|
223
|
+
return 0;
|
|
224
|
+
}
|
|
225
|
+
io.out(`Based on the scan from ${report.generatedAt.slice(0, 10)}.`);
|
|
226
|
+
const reviews = applied.map((g) => reviewGuardrail(report, g));
|
|
227
|
+
for (const r of reviews)
|
|
228
|
+
io.out(`${r.verdict.padEnd(10)} ${r.id}: ${r.text}`);
|
|
229
|
+
return reviews.some((r) => r.verdict === 'broken') ? 1 : 0;
|
|
230
|
+
}
|
|
231
|
+
case 'sync': {
|
|
232
|
+
if (!existsSync(reportPaths(outDir).json))
|
|
233
|
+
throw new UsageError('No report yet. Run `gespann scan` first.');
|
|
234
|
+
const now = io.now?.() ?? new Date();
|
|
235
|
+
const creds = readCredentials(env, home);
|
|
236
|
+
if (!creds && !v['dry-run'])
|
|
237
|
+
throw new UsageError('Not logged in. Run `gespann login` first.');
|
|
238
|
+
// The account's hash key makes hashed ids unguessable. A dry run without login uses a preview key.
|
|
239
|
+
const key = creds
|
|
240
|
+
? (await cloudRequest(doFetch, creds, 'GET', '/api/v1/me')).hash_key
|
|
241
|
+
: PREVIEW_KEY;
|
|
242
|
+
const payload = buildPayload({
|
|
243
|
+
report: readReport(outDir),
|
|
244
|
+
checks: await checkAll(repo, outDir),
|
|
245
|
+
blocks: readBlocks(repo, now),
|
|
246
|
+
repo: repoIdentity(repo, key),
|
|
247
|
+
version: version(),
|
|
248
|
+
names: !!v.names,
|
|
249
|
+
key,
|
|
250
|
+
now,
|
|
251
|
+
});
|
|
252
|
+
if (v['dry-run'] || !creds) {
|
|
253
|
+
io.out(JSON.stringify(payload, null, 2));
|
|
254
|
+
io.err(creds
|
|
255
|
+
? 'Dry run: no scan data was sent (only your hash key was fetched).'
|
|
256
|
+
: 'Dry run: nothing was sent. Hashes use a preview key until you log in.');
|
|
257
|
+
return 0;
|
|
258
|
+
}
|
|
259
|
+
const res = await cloudRequest(doFetch, creds, 'POST', '/api/v1/sync', payload);
|
|
260
|
+
const blocked = payload.blocks.reduce((n, b) => n + b.count, 0);
|
|
261
|
+
io.out(`Synced ${payload.repo.name ?? 'this repo'}: ${payload.mistakes.length} mistakes, ${payload.guardrails.length} guardrails, ${blocked} blocks.`);
|
|
262
|
+
io.out(`Dashboard: ${res.dashboard_url}`);
|
|
263
|
+
if (res.policy?.missing.length)
|
|
264
|
+
io.out(`Required by your team, missing here: ${res.policy.missing.join(', ')}`);
|
|
265
|
+
if (res.policy?.failing.length)
|
|
266
|
+
io.out(`Required by your team, failing here: ${res.policy.failing.join(', ')}`);
|
|
267
|
+
return 0;
|
|
268
|
+
}
|
|
269
|
+
default:
|
|
270
|
+
throw new UsageError(`Unknown command "${cmd}".\n\n${HELP}`);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
catch (e) {
|
|
274
|
+
if (e instanceof CloudError) {
|
|
275
|
+
io.err(e.message);
|
|
276
|
+
return 1;
|
|
277
|
+
}
|
|
278
|
+
if (e instanceof ShipError) {
|
|
279
|
+
io.err(`Could not open the pull request: ${e.message}`);
|
|
280
|
+
return 1;
|
|
281
|
+
}
|
|
282
|
+
if (e instanceof UsageError || e instanceof ApplyError) {
|
|
283
|
+
io.err(e.message);
|
|
284
|
+
return 2;
|
|
285
|
+
}
|
|
286
|
+
io.err(`gespann failed: ${e.message}`);
|
|
287
|
+
return 1;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
async function account(cmd, keyFlag, env, home, doFetch, io) {
|
|
291
|
+
if (cmd === 'logout') {
|
|
292
|
+
io.out(deleteCredentials(env, home)
|
|
293
|
+
? 'Logged out. The key is deleted from this machine.'
|
|
294
|
+
: 'Not logged in.');
|
|
295
|
+
return 0;
|
|
296
|
+
}
|
|
297
|
+
let key = keyFlag?.trim();
|
|
298
|
+
if (!key) {
|
|
299
|
+
io.err('Paste your API key from https://gespann.app/account and press Enter:');
|
|
300
|
+
key = (await (io.readStdin ?? readLine)()).trim();
|
|
301
|
+
}
|
|
302
|
+
if (!isApiKey(key))
|
|
303
|
+
throw new UsageError('That is not a Gespann API key (gsp_live_... or gsp_test_...).');
|
|
304
|
+
const api = checkApiUrl(env.GESPANN_API_URL || DEFAULT_API);
|
|
305
|
+
const me = await cloudRequest(doFetch, { api, key }, 'GET', '/api/v1/me');
|
|
306
|
+
const path = writeCredentials(env, home, { api, key });
|
|
307
|
+
io.out(`Logged in as ${me.email} (${me.plan}). Key saved to ${path}.`);
|
|
308
|
+
io.out('Next: gespann scan, then gespann sync.');
|
|
309
|
+
return 0;
|
|
310
|
+
}
|
|
311
|
+
function readLine() {
|
|
312
|
+
return new Promise((done) => {
|
|
313
|
+
let data = '';
|
|
314
|
+
process.stdin.setEncoding('utf8');
|
|
315
|
+
process.stdin.on('data', (chunk) => {
|
|
316
|
+
data += chunk;
|
|
317
|
+
if (data.includes('\n')) {
|
|
318
|
+
process.stdin.pause();
|
|
319
|
+
done(data.split('\n')[0] ?? '');
|
|
320
|
+
}
|
|
321
|
+
});
|
|
322
|
+
process.stdin.on('end', () => done(data));
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
function parse(argv) {
|
|
326
|
+
return parseArgs({
|
|
327
|
+
args: argv,
|
|
328
|
+
allowPositionals: true,
|
|
329
|
+
options: {
|
|
330
|
+
since: { type: 'string' },
|
|
331
|
+
repo: { type: 'string' },
|
|
332
|
+
out: { type: 'string' },
|
|
333
|
+
'claude-dir': { type: 'string' },
|
|
334
|
+
'codex-dir': { type: 'string' },
|
|
335
|
+
'no-llm': { type: 'boolean' },
|
|
336
|
+
llm: { type: 'string' },
|
|
337
|
+
limit: { type: 'string' },
|
|
338
|
+
'min-count': { type: 'string' },
|
|
339
|
+
key: { type: 'string' },
|
|
340
|
+
'dry-run': { type: 'boolean' },
|
|
341
|
+
names: { type: 'boolean' },
|
|
342
|
+
help: { type: 'boolean', short: 'h' },
|
|
343
|
+
version: { type: 'boolean', short: 'v' },
|
|
344
|
+
},
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
function display(repo, p) {
|
|
348
|
+
const rel = relative(repo, p);
|
|
349
|
+
return rel.startsWith('..') ? p : rel;
|
|
350
|
+
}
|
|
351
|
+
const isEntry = (() => {
|
|
352
|
+
try {
|
|
353
|
+
return (process.argv[1] !== undefined &&
|
|
354
|
+
realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url)));
|
|
355
|
+
}
|
|
356
|
+
catch {
|
|
357
|
+
return false;
|
|
358
|
+
}
|
|
359
|
+
})();
|
|
360
|
+
if (isEntry) {
|
|
361
|
+
// `gespann scan | head` closes the pipe early; that is not an error.
|
|
362
|
+
process.stdout.on('error', (e) => {
|
|
363
|
+
if (e.code === 'EPIPE')
|
|
364
|
+
process.exit(0);
|
|
365
|
+
throw e;
|
|
366
|
+
});
|
|
367
|
+
main(process.argv.slice(2), {
|
|
368
|
+
out: (s) => process.stdout.write(`${s}\n`),
|
|
369
|
+
err: (s) => process.stderr.write(`${s}\n`),
|
|
370
|
+
tty: Boolean(process.stdin.isTTY && process.stdout.isTTY),
|
|
371
|
+
}).then((code) => {
|
|
372
|
+
process.exitCode = code;
|
|
373
|
+
});
|
|
374
|
+
}
|