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/dist/cloud.js
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { createHmac } from 'node:crypto';
|
|
2
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { basename, join } from 'node:path';
|
|
4
|
+
import { BUILTIN_RULES } from './rules.js';
|
|
5
|
+
import { git } from './sources/git.js';
|
|
6
|
+
import { UsageError } from './util.js';
|
|
7
|
+
/**
|
|
8
|
+
* Gespann Cloud: `login`, `sync`, `logout`. Sync sends metadata only. The shape is
|
|
9
|
+
* fixed here and the server rejects anything else (see SyncPayload).
|
|
10
|
+
*/
|
|
11
|
+
export const DEFAULT_API = 'https://gespann.app';
|
|
12
|
+
export function configDir(env, home) {
|
|
13
|
+
if (env.GESPANN_CONFIG_DIR)
|
|
14
|
+
return env.GESPANN_CONFIG_DIR;
|
|
15
|
+
return join(env.XDG_CONFIG_HOME || join(home, '.config'), 'gespann');
|
|
16
|
+
}
|
|
17
|
+
export function isApiKey(key) {
|
|
18
|
+
return /^gsp_(live|test)_[\w-]{32}$/.test(key);
|
|
19
|
+
}
|
|
20
|
+
/** The key never goes over plain http, except to a local test server. */
|
|
21
|
+
export function checkApiUrl(url) {
|
|
22
|
+
const u = url.replace(/\/+$/, '');
|
|
23
|
+
if (!/^https:\/\/[^/]+$/.test(u) && !/^http:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(u)) {
|
|
24
|
+
throw new UsageError(`API URL must be https: ${url}`);
|
|
25
|
+
}
|
|
26
|
+
return u;
|
|
27
|
+
}
|
|
28
|
+
export function readCredentials(env, home) {
|
|
29
|
+
const api = checkApiUrl(env.GESPANN_API_URL || DEFAULT_API);
|
|
30
|
+
if (env.GESPANN_API_KEY)
|
|
31
|
+
return { api, key: env.GESPANN_API_KEY.trim() };
|
|
32
|
+
const path = join(configDir(env, home), 'credentials.json');
|
|
33
|
+
if (!existsSync(path))
|
|
34
|
+
return null;
|
|
35
|
+
try {
|
|
36
|
+
const c = JSON.parse(readFileSync(path, 'utf8'));
|
|
37
|
+
if (typeof c.key !== 'string')
|
|
38
|
+
return null;
|
|
39
|
+
return { api: env.GESPANN_API_URL ? api : checkApiUrl(c.api ?? DEFAULT_API), key: c.key };
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
export function writeCredentials(env, home, creds) {
|
|
46
|
+
const dir = configDir(env, home);
|
|
47
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
48
|
+
const path = join(dir, 'credentials.json');
|
|
49
|
+
writeFileSync(path, `${JSON.stringify(creds, null, 2)}\n`, { mode: 0o600 });
|
|
50
|
+
chmodSync(path, 0o600);
|
|
51
|
+
return path;
|
|
52
|
+
}
|
|
53
|
+
export function deleteCredentials(env, home) {
|
|
54
|
+
const path = join(configDir(env, home), 'credentials.json');
|
|
55
|
+
if (!existsSync(path))
|
|
56
|
+
return false;
|
|
57
|
+
rmSync(path);
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
/** github.com/owner/repo for every common remote URL form. */
|
|
61
|
+
export function normalizeRemote(url) {
|
|
62
|
+
let u = url.trim();
|
|
63
|
+
const scp = /^[\w.-]+@([^:/]+):(.+)$/.exec(u);
|
|
64
|
+
if (scp)
|
|
65
|
+
u = `${scp[1]}/${scp[2]}`;
|
|
66
|
+
else
|
|
67
|
+
u = u.replace(/^[a-z][a-z0-9+.-]*:\/\//i, '').replace(/^[^@/]+@/, '');
|
|
68
|
+
u = u.replace(/\.git$/, '').replace(/\/+$/, '');
|
|
69
|
+
const slash = u.indexOf('/');
|
|
70
|
+
return slash < 0 ? u.toLowerCase() : u.slice(0, slash).toLowerCase() + u.slice(slash);
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Ids and the repo are hashed with the account's hash key (from /api/v1/me), so a
|
|
74
|
+
* hash cannot be reversed by guessing file names or public repo URLs. Members of
|
|
75
|
+
* one team share the key, so the same repo gets the same hash.
|
|
76
|
+
*/
|
|
77
|
+
export const PREVIEW_KEY = 'gespann-preview';
|
|
78
|
+
const hmac = (key, s) => createHmac('sha256', key).update(s).digest('hex');
|
|
79
|
+
export function repoIdentity(repo, key) {
|
|
80
|
+
let remote = '';
|
|
81
|
+
try {
|
|
82
|
+
remote = git(repo, ['remote', 'get-url', 'origin']).trim();
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
// No origin: the path identifies the repo on this machine only.
|
|
86
|
+
}
|
|
87
|
+
const id = remote ? normalizeRemote(remote) : `local:${repo}`;
|
|
88
|
+
const raw = remote ? (id.split('/').pop() ?? '') : basename(repo);
|
|
89
|
+
const name = raw.replace(/[^\w.-]/g, '-').slice(0, 100) || 'repo';
|
|
90
|
+
return { hash: hmac(key, `repo:${id}`), name };
|
|
91
|
+
}
|
|
92
|
+
const PUBLIC_IDS = new Set([...BUILTIN_RULES.map((r) => `cmd-${r.id}`), 'reverts']);
|
|
93
|
+
/**
|
|
94
|
+
* Ids can contain words from commit messages, file names or prompts. Only built-in
|
|
95
|
+
* ids leave the machine as they are, unless the user asks for names.
|
|
96
|
+
*/
|
|
97
|
+
export function publicId(id, names, key) {
|
|
98
|
+
if (PUBLIC_IDS.has(id))
|
|
99
|
+
return id;
|
|
100
|
+
if (names && /^[a-z0-9][a-z0-9-]{0,63}$/.test(id))
|
|
101
|
+
return id;
|
|
102
|
+
return `h:${hmac(key, `id:${id}`).slice(0, 12)}`;
|
|
103
|
+
}
|
|
104
|
+
/** Blocks counted by installed hooks, per guardrail and day, for the last 35 days. */
|
|
105
|
+
export function readBlocks(repo, now) {
|
|
106
|
+
const path = join(repo, '.gespann', 'local', 'blocks.jsonl');
|
|
107
|
+
if (!existsSync(path))
|
|
108
|
+
return [];
|
|
109
|
+
const since = new Date(now.getTime() - 35 * 86_400_000).toISOString().slice(0, 10);
|
|
110
|
+
const counts = new Map();
|
|
111
|
+
for (const line of readFileSync(path, 'utf8').split('\n')) {
|
|
112
|
+
try {
|
|
113
|
+
const e = JSON.parse(line);
|
|
114
|
+
if (typeof e.id !== 'string' || typeof e.at !== 'string')
|
|
115
|
+
continue;
|
|
116
|
+
const day = e.at.slice(0, 10);
|
|
117
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(day) || day < since)
|
|
118
|
+
continue;
|
|
119
|
+
const k = `${e.id}\u0000${day}`;
|
|
120
|
+
counts.set(k, (counts.get(k) ?? 0) + 1);
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
// Skip a torn line.
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return [...counts].map(([k, count]) => {
|
|
127
|
+
const [id, day] = k.split('\u0000');
|
|
128
|
+
return { id, day, count };
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
const day = (iso) => iso.slice(0, 10);
|
|
132
|
+
export function buildPayload(opts) {
|
|
133
|
+
const { report, names, key } = opts;
|
|
134
|
+
const windowMs = Date.parse(report.generatedAt) - Date.parse(report.since);
|
|
135
|
+
const merged = new Map();
|
|
136
|
+
for (const b of opts.blocks) {
|
|
137
|
+
const guardrail = publicId(b.id, names, key);
|
|
138
|
+
const k = `${guardrail} ${b.day}`;
|
|
139
|
+
const prev = merged.get(k);
|
|
140
|
+
merged.set(k, {
|
|
141
|
+
guardrail,
|
|
142
|
+
day: b.day,
|
|
143
|
+
count: Math.min(100_000, (prev?.count ?? 0) + b.count),
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
return {
|
|
147
|
+
version: 1,
|
|
148
|
+
cli_version: opts.version,
|
|
149
|
+
taken_at: opts.now.toISOString(),
|
|
150
|
+
repo: names ? { hash: opts.repo.hash, name: opts.repo.name } : { hash: opts.repo.hash },
|
|
151
|
+
window_days: Math.min(3650, Math.max(1, Math.ceil(windowMs / 86_400_000) || 1)),
|
|
152
|
+
stats: {
|
|
153
|
+
commits: report.stats.gitCommits,
|
|
154
|
+
fix_commits: report.stats.fixCommits,
|
|
155
|
+
reverts: report.stats.reverts,
|
|
156
|
+
sessions: report.stats.claudeSessions + report.stats.codexSessions,
|
|
157
|
+
incidents: report.stats.incidents,
|
|
158
|
+
},
|
|
159
|
+
mistakes: report.clusters.slice(0, 500).map((c) => ({
|
|
160
|
+
id: publicId(c.id, names, key),
|
|
161
|
+
kind: c.kind,
|
|
162
|
+
suggestion: c.suggestion,
|
|
163
|
+
count: c.count,
|
|
164
|
+
sessions: c.sessions,
|
|
165
|
+
fix_commits: c.cost.fixCommits,
|
|
166
|
+
minutes: Math.round(c.cost.minutes),
|
|
167
|
+
first_seen: day(c.firstSeen),
|
|
168
|
+
last_seen: day(c.lastSeen),
|
|
169
|
+
})),
|
|
170
|
+
guardrails: opts.checks
|
|
171
|
+
.filter((g) => ['hook', 'lint', 'prose'].includes(g.kind))
|
|
172
|
+
.slice(0, 500)
|
|
173
|
+
.map((g) => ({ id: publicId(g.id, names, key), kind: g.kind, ok: g.ok, warn: g.warn })),
|
|
174
|
+
blocks: [...merged.values()].slice(0, 5000),
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
export class CloudError extends Error {
|
|
178
|
+
status;
|
|
179
|
+
constructor(message, status) {
|
|
180
|
+
super(message);
|
|
181
|
+
this.status = status;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
export async function cloudRequest(doFetch, creds, method, path, body) {
|
|
185
|
+
let res;
|
|
186
|
+
try {
|
|
187
|
+
res = await doFetch(`${creds.api}${path}`, {
|
|
188
|
+
method,
|
|
189
|
+
headers: {
|
|
190
|
+
Authorization: `Bearer ${creds.key}`,
|
|
191
|
+
'Content-Type': 'application/json',
|
|
192
|
+
Accept: 'application/json',
|
|
193
|
+
},
|
|
194
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
195
|
+
signal: AbortSignal.timeout(20_000),
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
catch (e) {
|
|
199
|
+
throw new CloudError(`Could not reach ${creds.api}: ${e.message}`, 0);
|
|
200
|
+
}
|
|
201
|
+
const text = await res.text();
|
|
202
|
+
let data = {};
|
|
203
|
+
try {
|
|
204
|
+
data = text ? JSON.parse(text) : {};
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
// Not JSON: use the status below.
|
|
208
|
+
}
|
|
209
|
+
if (!res.ok) {
|
|
210
|
+
const msg = typeof data.message === 'string' ? data.message : `HTTP ${res.status}`;
|
|
211
|
+
throw new CloudError(msg, res.status);
|
|
212
|
+
}
|
|
213
|
+
return data;
|
|
214
|
+
}
|
package/dist/cluster.js
ADDED
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
import { adviceFor } from './harness.js';
|
|
2
|
+
import { incidentMinutes } from './sources/analyze.js';
|
|
3
|
+
import { slugify } from './util.js';
|
|
4
|
+
/** Docs change with almost every fix; they are not where the mistake is. */
|
|
5
|
+
const DOC_FILE = /\.(?:md|mdx|txt|rst)$|(?:^|\/)(?:CHANGELOG|LICENSE)[^/]*$/i;
|
|
6
|
+
/**
|
|
7
|
+
* How much one occurrence weighs in the ranking. A broken explicit rule or a human correction
|
|
8
|
+
* counts more than a single fix commit, because it is a mistake the agent was told about.
|
|
9
|
+
*/
|
|
10
|
+
const WEIGHT = {
|
|
11
|
+
'rule-violation': 3,
|
|
12
|
+
correction: 3,
|
|
13
|
+
revert: 2,
|
|
14
|
+
'user-rejected': 2,
|
|
15
|
+
interrupt: 2,
|
|
16
|
+
'fix-commit': 1,
|
|
17
|
+
'fix-hotspot': 1,
|
|
18
|
+
'hook-block': 1,
|
|
19
|
+
'command-not-found': 1,
|
|
20
|
+
'tool-misuse': 1,
|
|
21
|
+
};
|
|
22
|
+
const STOPWORDS = new Set(`the and for that this with you your are was were not but have has had from they them then than
|
|
23
|
+
what when why how which who will would should could can cant can't dont don't does did doing done
|
|
24
|
+
just also again still only into onto out over under there here its it's all any some more most
|
|
25
|
+
very really please now yes okay make made use used using want need get got let lets see look
|
|
26
|
+
der die das und oder aber nicht noch schon wieder doch mal ich du wir ihr sie ist sind war waren
|
|
27
|
+
hat habe hast haben ein eine einen einem einer mit von für auf aus bei nach wie was wer warum
|
|
28
|
+
dass dem den des sich auch nur jetzt bitte kann kannst soll sollst muss musst hier dort immer
|
|
29
|
+
nein falsch wrong again stop wenn dann eigentlich genau alles andere anderen ersten erste einzige
|
|
30
|
+
einfach gerade also weil denn sondern ganz gibt geht machen macht sein seine diese dieser dieses
|
|
31
|
+
diesen etwas viel mehr frage fragt beispiel thing things something everything work works working
|
|
32
|
+
should've think know like sure maybe`.split(/\s+/));
|
|
33
|
+
function tokens(text) {
|
|
34
|
+
const out = new Set();
|
|
35
|
+
for (const m of text.matchAll(/`([^`]{2,40})`/g))
|
|
36
|
+
out.add(`\`${(m[1] ?? '').toLowerCase()}\``);
|
|
37
|
+
for (const w of text.toLowerCase().split(/[^a-z0-9äöüß_.-]+/)) {
|
|
38
|
+
const t = w.replace(/^[.-]+|[.-]+$/g, '');
|
|
39
|
+
if (t.length >= 4 && !STOPWORDS.has(t) && !/^\d+$/.test(t))
|
|
40
|
+
out.add(t);
|
|
41
|
+
}
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
/** Group corrections that share at least two uncommon words (single link). */
|
|
45
|
+
export function clusterCorrections(items) {
|
|
46
|
+
const toks = items.map((i) => tokens(i.text));
|
|
47
|
+
const df = new Map();
|
|
48
|
+
for (const t of toks)
|
|
49
|
+
for (const w of t)
|
|
50
|
+
df.set(w, (df.get(w) ?? 0) + 1);
|
|
51
|
+
const maxDf = Math.max(5, Math.floor(items.length * 0.3));
|
|
52
|
+
const salient = toks.map((t) => new Set([...t].filter((w) => (df.get(w) ?? 0) >= 2 && (df.get(w) ?? 0) <= maxDf)));
|
|
53
|
+
const parent = items.map((_, i) => i);
|
|
54
|
+
const find = (i) => {
|
|
55
|
+
let r = i;
|
|
56
|
+
while (parent[r] !== r)
|
|
57
|
+
r = parent[r] ?? r;
|
|
58
|
+
parent[i] = r;
|
|
59
|
+
return r;
|
|
60
|
+
};
|
|
61
|
+
for (let i = 0; i < items.length; i++) {
|
|
62
|
+
for (let j = i + 1; j < items.length; j++) {
|
|
63
|
+
const a = salient[i];
|
|
64
|
+
const b = salient[j];
|
|
65
|
+
if (!a || !b)
|
|
66
|
+
continue;
|
|
67
|
+
let shared = 0;
|
|
68
|
+
for (const w of a)
|
|
69
|
+
if (b.has(w))
|
|
70
|
+
shared++;
|
|
71
|
+
if (shared >= 2)
|
|
72
|
+
parent[find(j)] = find(i);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const groups = new Map();
|
|
76
|
+
items.forEach((it, i) => {
|
|
77
|
+
const r = find(i);
|
|
78
|
+
const g = groups.get(r) ?? [];
|
|
79
|
+
g.push(it);
|
|
80
|
+
groups.set(r, g);
|
|
81
|
+
});
|
|
82
|
+
return [...groups.values()];
|
|
83
|
+
}
|
|
84
|
+
function topSharedTokens(items, n = 3) {
|
|
85
|
+
const count = new Map();
|
|
86
|
+
for (const it of items)
|
|
87
|
+
for (const w of tokens(it.text))
|
|
88
|
+
count.set(w, (count.get(w) ?? 0) + 1);
|
|
89
|
+
return [...count.entries()]
|
|
90
|
+
.filter(([, c]) => c >= 2)
|
|
91
|
+
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
|
92
|
+
.slice(0, n)
|
|
93
|
+
.map(([w]) => w);
|
|
94
|
+
}
|
|
95
|
+
function evidenceOf(items, max) {
|
|
96
|
+
const seen = new Set();
|
|
97
|
+
const out = [];
|
|
98
|
+
for (const it of [...items].sort((a, b) => b.at.localeCompare(a.at))) {
|
|
99
|
+
if (seen.has(it.text))
|
|
100
|
+
continue;
|
|
101
|
+
seen.add(it.text);
|
|
102
|
+
out.push({ source: it.source, ref: it.ref, at: it.at, text: it.text });
|
|
103
|
+
if (out.length >= max)
|
|
104
|
+
break;
|
|
105
|
+
}
|
|
106
|
+
return out;
|
|
107
|
+
}
|
|
108
|
+
function describe(key, items, rules) {
|
|
109
|
+
const first = items[0];
|
|
110
|
+
if (!first)
|
|
111
|
+
return undefined;
|
|
112
|
+
const label = first.label ?? '';
|
|
113
|
+
const n = items.length;
|
|
114
|
+
const [prefix, ...rest] = key.split(':');
|
|
115
|
+
const tail = rest.join(':');
|
|
116
|
+
switch (prefix) {
|
|
117
|
+
case 'rule': {
|
|
118
|
+
const rule = rules.find((r) => r.id === tail);
|
|
119
|
+
if (!rule)
|
|
120
|
+
return undefined;
|
|
121
|
+
return {
|
|
122
|
+
id: `cmd-${rule.id}`,
|
|
123
|
+
kind: 'rule-violation',
|
|
124
|
+
title: rule.title,
|
|
125
|
+
summary: `Agents ran a blocked command pattern ${n} times. ${rule.advice}`,
|
|
126
|
+
suggestion: 'hook',
|
|
127
|
+
rule,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
case 'hook':
|
|
131
|
+
return {
|
|
132
|
+
id: `hook-${tail}`.slice(0, 60),
|
|
133
|
+
kind: 'hook-block',
|
|
134
|
+
title: `A hook keeps blocking: ${label}`,
|
|
135
|
+
summary: `An existing hook blocked agents ${n} times. They retry instead of following the rule.`,
|
|
136
|
+
suggestion: 'prose',
|
|
137
|
+
};
|
|
138
|
+
case 'not-found':
|
|
139
|
+
return {
|
|
140
|
+
id: `missing-${tail}`,
|
|
141
|
+
kind: 'command-not-found',
|
|
142
|
+
title: `Calls \`${label}\`, which is not installed`,
|
|
143
|
+
summary: `Agents called \`${label}\` ${n} times and got "command not found".`,
|
|
144
|
+
suggestion: 'prose',
|
|
145
|
+
};
|
|
146
|
+
case 'misuse': {
|
|
147
|
+
const titles = {
|
|
148
|
+
'edit-before-read': 'Edits a file before reading it',
|
|
149
|
+
'edit-string-not-found': 'Edits with text that is not in the file',
|
|
150
|
+
'unknown-tool': 'Calls a tool that does not exist',
|
|
151
|
+
};
|
|
152
|
+
return {
|
|
153
|
+
id: `misuse-${tail}`,
|
|
154
|
+
kind: 'tool-misuse',
|
|
155
|
+
title: titles[tail] ?? `Tool misuse: ${tail}`,
|
|
156
|
+
summary: `The tool call failed the same way ${n} times.`,
|
|
157
|
+
suggestion: 'prose',
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
case 'rejected':
|
|
161
|
+
case 'denied':
|
|
162
|
+
return {
|
|
163
|
+
id: `${prefix}-${tail}`,
|
|
164
|
+
kind: 'user-rejected',
|
|
165
|
+
title: prefix === 'rejected' ? `User rejects ${tail} calls` : `Permission denied for ${tail}`,
|
|
166
|
+
summary: `A human or policy said no ${n} times.`,
|
|
167
|
+
suggestion: 'prose',
|
|
168
|
+
};
|
|
169
|
+
case 'interrupt':
|
|
170
|
+
return {
|
|
171
|
+
id: `stopped-${rest.join('-')}`.slice(0, 60),
|
|
172
|
+
kind: 'interrupt',
|
|
173
|
+
title: `User stops \`${label}\` mid-run`,
|
|
174
|
+
summary: `The user interrupted this tool call ${n} times.`,
|
|
175
|
+
suggestion: 'prose',
|
|
176
|
+
};
|
|
177
|
+
case 'fix-scope':
|
|
178
|
+
return {
|
|
179
|
+
id: `fixes-${slugify(tail, 40)}`,
|
|
180
|
+
kind: 'fix-commit',
|
|
181
|
+
title: `Repeated fixes in \`${tail}\``,
|
|
182
|
+
summary: `${n} fix commits touched the \`${tail}\` area.`,
|
|
183
|
+
suggestion: 'prose',
|
|
184
|
+
};
|
|
185
|
+
case 'revert':
|
|
186
|
+
return {
|
|
187
|
+
id: 'reverts',
|
|
188
|
+
kind: 'revert',
|
|
189
|
+
title: 'Commits get reverted',
|
|
190
|
+
summary: `${n} commits were reverted.`,
|
|
191
|
+
suggestion: 'prose',
|
|
192
|
+
};
|
|
193
|
+
default:
|
|
194
|
+
return undefined;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
/** Incidents and minutes per day, oldest first. */
|
|
198
|
+
export function timelineOf(items) {
|
|
199
|
+
const days = new Map();
|
|
200
|
+
for (const it of items) {
|
|
201
|
+
const day = it.at.slice(0, 10);
|
|
202
|
+
const d = days.get(day) ?? { day, n: 0, minutes: 0, blocked: 0 };
|
|
203
|
+
d.n++;
|
|
204
|
+
d.minutes += incidentMinutes(it);
|
|
205
|
+
if (it.blockedBy)
|
|
206
|
+
d.blocked++;
|
|
207
|
+
days.set(day, d);
|
|
208
|
+
}
|
|
209
|
+
return [...days.values()]
|
|
210
|
+
.map((d) => ({ ...d, minutes: Math.round(d.minutes * 10) / 10 }))
|
|
211
|
+
.sort((a, b) => a.day.localeCompare(b.day));
|
|
212
|
+
}
|
|
213
|
+
/** Distinct sessions per harness. */
|
|
214
|
+
function harnessCounts(items) {
|
|
215
|
+
const seen = new Map();
|
|
216
|
+
for (const it of items) {
|
|
217
|
+
if (!it.harness || !it.session)
|
|
218
|
+
continue;
|
|
219
|
+
const s = seen.get(it.harness) ?? new Set();
|
|
220
|
+
s.add(it.session);
|
|
221
|
+
seen.set(it.harness, s);
|
|
222
|
+
}
|
|
223
|
+
return Object.fromEntries([...seen].map(([h, s]) => [h, s.size]));
|
|
224
|
+
}
|
|
225
|
+
/** Group incidents into ranked clusters. */
|
|
226
|
+
export function clusterIncidents(incidents, opts) {
|
|
227
|
+
const minCount = opts.minCount ?? 2;
|
|
228
|
+
const fixCount = new Set(incidents.filter((i) => i.kind === 'fix-commit').map((i) => i.ref)).size;
|
|
229
|
+
const hotspotMin = opts.hotspotMin ?? Math.max(3, Math.ceil(fixCount * 0.02));
|
|
230
|
+
const maxEvidence = opts.maxEvidence ?? 5;
|
|
231
|
+
const drafts = [];
|
|
232
|
+
const byKey = new Map();
|
|
233
|
+
for (const it of incidents) {
|
|
234
|
+
if (it.kind === 'correction' || it.key === 'fix')
|
|
235
|
+
continue;
|
|
236
|
+
const g = byKey.get(it.key) ?? [];
|
|
237
|
+
g.push(it);
|
|
238
|
+
byKey.set(it.key, g);
|
|
239
|
+
}
|
|
240
|
+
for (const [key, items] of byKey) {
|
|
241
|
+
const d = describe(key, items, opts.rules);
|
|
242
|
+
if (d)
|
|
243
|
+
drafts.push({ ...d, items });
|
|
244
|
+
}
|
|
245
|
+
// Corrections: group by shared words.
|
|
246
|
+
for (const group of clusterCorrections(incidents.filter((i) => i.kind === 'correction'))) {
|
|
247
|
+
const words = topSharedTokens(group);
|
|
248
|
+
if (words.length === 0)
|
|
249
|
+
continue;
|
|
250
|
+
const shortest = [...group].sort((a, b) => a.text.length - b.text.length)[0];
|
|
251
|
+
drafts.push({
|
|
252
|
+
id: `correction-${slugify(words.join('-'), 40)}`,
|
|
253
|
+
kind: 'correction',
|
|
254
|
+
title: `User corrects the agent about ${words.join(', ')}`,
|
|
255
|
+
summary: `The user corrected agents ${group.length} times on this topic, e.g. "${shortest?.text.slice(0, 100) ?? ''}".`,
|
|
256
|
+
suggestion: 'prose',
|
|
257
|
+
items: group,
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
// Fix hotspots: files touched by many fix commits.
|
|
261
|
+
const perFile = new Map();
|
|
262
|
+
for (const it of incidents) {
|
|
263
|
+
if (it.kind !== 'fix-commit')
|
|
264
|
+
continue;
|
|
265
|
+
for (const f of it.files ?? []) {
|
|
266
|
+
if (DOC_FILE.test(f))
|
|
267
|
+
continue;
|
|
268
|
+
const g = perFile.get(f) ?? [];
|
|
269
|
+
g.push(it);
|
|
270
|
+
perFile.set(f, g);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
for (const [file, items] of perFile) {
|
|
274
|
+
if (items.length < hotspotMin)
|
|
275
|
+
continue;
|
|
276
|
+
drafts.push({
|
|
277
|
+
id: `hotspot-${slugify(file, 50)}`,
|
|
278
|
+
kind: 'fix-hotspot',
|
|
279
|
+
title: `Fix hotspot: \`${file}\``,
|
|
280
|
+
summary: `${items.length} fix commits changed \`${file}\`.`,
|
|
281
|
+
suggestion: 'prose',
|
|
282
|
+
items,
|
|
283
|
+
files: [file],
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
const clusters = [];
|
|
287
|
+
const usedIds = new Map();
|
|
288
|
+
for (const d of drafts) {
|
|
289
|
+
if (d.items.length < minCount)
|
|
290
|
+
continue;
|
|
291
|
+
const sorted = [...d.items].sort((a, b) => a.at.localeCompare(b.at));
|
|
292
|
+
const git = d.items.filter((i) => i.source === 'git');
|
|
293
|
+
const fixCommits = new Set(git.map((i) => i.ref)).size;
|
|
294
|
+
const linesChanged = git.reduce((n, i) => n + (i.linesChanged ?? 0), 0);
|
|
295
|
+
const minutes = Math.round(d.items.reduce((n, i) => n + incidentMinutes(i), 0));
|
|
296
|
+
const sources = [...new Set(d.items.map((i) => i.source))].sort();
|
|
297
|
+
const sessions = new Set(d.items.filter((i) => i.session).map((i) => i.session)).size;
|
|
298
|
+
const harnesses = harnessCounts(d.items);
|
|
299
|
+
// Occurrences: separate sessions for transcripts, separate commits for git.
|
|
300
|
+
const occurrences = sessions + fixCommits;
|
|
301
|
+
const score = occurrences * WEIGHT[d.kind];
|
|
302
|
+
let id = d.id;
|
|
303
|
+
const dup = usedIds.get(id) ?? 0;
|
|
304
|
+
usedIds.set(id, dup + 1);
|
|
305
|
+
if (dup > 0)
|
|
306
|
+
id = `${id}-${dup + 1}`;
|
|
307
|
+
const advice = adviceFor({ id, rule: d.rule });
|
|
308
|
+
clusters.push({
|
|
309
|
+
id,
|
|
310
|
+
rank: 0,
|
|
311
|
+
kind: d.kind,
|
|
312
|
+
title: d.title,
|
|
313
|
+
summary: d.summary,
|
|
314
|
+
count: d.items.length,
|
|
315
|
+
sessions,
|
|
316
|
+
score,
|
|
317
|
+
firstSeen: sorted[0]?.at ?? '',
|
|
318
|
+
lastSeen: sorted[sorted.length - 1]?.at ?? '',
|
|
319
|
+
cost: { fixCommits, linesChanged, minutes },
|
|
320
|
+
sources,
|
|
321
|
+
evidence: evidenceOf(d.items, maxEvidence),
|
|
322
|
+
suggestion: d.suggestion,
|
|
323
|
+
...(d.rule ? { rule: d.rule } : {}),
|
|
324
|
+
...(d.files ? { files: d.files } : {}),
|
|
325
|
+
...(Object.keys(harnesses).length ? { harnesses } : {}),
|
|
326
|
+
...(Object.keys(advice).length ? { advice } : {}),
|
|
327
|
+
timeline: timelineOf(d.items),
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
clusters.sort((a, b) => b.score - a.score ||
|
|
331
|
+
b.cost.minutes - a.cost.minutes ||
|
|
332
|
+
b.count - a.count ||
|
|
333
|
+
b.lastSeen.localeCompare(a.lastSeen) ||
|
|
334
|
+
a.id.localeCompare(b.id));
|
|
335
|
+
clusters.forEach((c, i) => {
|
|
336
|
+
c.rank = i + 1;
|
|
337
|
+
});
|
|
338
|
+
return clusters;
|
|
339
|
+
}
|