segreant 0.1.0 → 0.2.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/LICENSING.md +5 -3
- package/README.md +57 -19
- package/bin/segreant-statusline.mjs +20 -0
- package/bin/segreant.mjs +19 -8
- package/dist/alerts/detect.js +13 -0
- package/dist/billing/epistemic.js +4 -0
- package/dist/billing/importer.js +29 -10
- package/dist/billing/reconcile.js +122 -32
- package/dist/billing/scope.js +7 -0
- package/dist/budget/guard.js +33 -4
- package/dist/cli/allocCmd.js +73 -4
- package/dist/cli/askModelPrices.js +59 -0
- package/dist/cli/backupCmd.js +2 -1
- package/dist/cli/basisLines.js +45 -0
- package/dist/cli/billingCmd.js +68 -9
- package/dist/cli/capitalCmd.js +2 -1
- package/dist/cli/connectCmd.js +50 -9
- package/dist/cli/egressCmd.js +15 -3
- package/dist/cli/evidenceCmd.js +6 -3
- package/dist/cli/flags.js +68 -0
- package/dist/cli/homeCmd.js +117 -0
- package/dist/cli/importCmd.js +210 -54
- package/dist/cli/launchCmd.js +2 -1
- package/dist/cli/opsCmd.js +49 -2
- package/dist/cli/planCmd.js +181 -0
- package/dist/cli/pricingDeclareCmd.js +178 -0
- package/dist/cli/quotaCmd.js +190 -0
- package/dist/cli/runCmd.js +203 -51
- package/dist/cli/showCmd.js +37 -11
- package/dist/cli/teamCmd.js +5 -5
- package/dist/cli/ui.js +24 -8
- package/dist/cli/valueCmd.js +189 -14
- package/dist/cli.js +102 -21
- package/dist/config.js +95 -6
- package/dist/connect/antigravity.js +310 -0
- package/dist/connect/claudeCode.js +43 -3
- package/dist/connect/codex.js +38 -1
- package/dist/connect/importShared.js +58 -5
- package/dist/connect/opencode.js +37 -9
- package/dist/connect/protobuf.js +126 -0
- package/dist/connect/registry.js +46 -0
- package/dist/connect/repoRoot.js +112 -0
- package/dist/cost/basis.js +168 -0
- package/dist/cost/declarations.js +37 -0
- package/dist/cost/exactPricing.js +4 -1
- package/dist/cost/firstSight.js +98 -0
- package/dist/cost/overrides.js +230 -0
- package/dist/cost/pricing.js +228 -15
- package/dist/cost/reprice.js +130 -0
- package/dist/dashboard/claim-support.js +11 -8
- package/dist/dashboard/contracts.js +23 -3
- package/dist/dashboard/routes.js +980 -112
- package/dist/dashboard/sessions.js +345 -0
- package/dist/dashboard/settings.js +2 -1
- package/dist/dashboard/value-cache.js +105 -0
- package/dist/dashboard/web/app/components/claimInspector.js +6 -5
- package/dist/dashboard/web/app/components/drawer.js +105 -35
- package/dist/dashboard/web/app/components/ruleFields.js +144 -0
- package/dist/dashboard/web/app/components/spine.js +54 -24
- package/dist/dashboard/web/app/components/workflow.js +82 -0
- package/dist/dashboard/web/app/core/actions.js +734 -23
- package/dist/dashboard/web/app/core/api.js +37 -1
- package/dist/dashboard/web/app/core/chain.js +11 -4
- package/dist/dashboard/web/app/core/claimLayers.js +179 -51
- package/dist/dashboard/web/app/core/claimTypes.js +30 -0
- package/dist/dashboard/web/app/core/fmt.js +19 -2
- package/dist/dashboard/web/app/core/generated-contract.js +23 -3
- package/dist/dashboard/web/app/core/generated-payload-contract.js +1944 -465
- package/dist/dashboard/web/app/core/generated-types.js +1 -1
- package/dist/dashboard/web/app/core/griffin.js +10 -0
- package/dist/dashboard/web/app/core/jobs.js +12 -0
- package/dist/dashboard/web/app/core/period.js +35 -0
- package/dist/dashboard/web/app/core/periodWords.js +10 -0
- package/dist/dashboard/web/app/core/periods.js +32 -0
- package/dist/dashboard/web/app/core/pricingActions.js +264 -0
- package/dist/dashboard/web/app/core/privacyText.js +78 -0
- package/dist/dashboard/web/app/core/reconcileWords.js +63 -0
- package/dist/dashboard/web/app/core/registry.js +79 -46
- package/dist/dashboard/web/app/core/slug.js +9 -0
- package/dist/dashboard/web/app/core/theme.js +48 -0
- package/dist/dashboard/web/app/core/view.js +69 -0
- package/dist/dashboard/web/app/main.js +197 -15
- package/dist/dashboard/web/app/views/allocation.js +249 -61
- package/dist/dashboard/web/app/views/control.js +57 -16
- package/dist/dashboard/web/app/views/data.js +4 -4
- package/dist/dashboard/web/app/views/evidence.js +336 -87
- package/dist/dashboard/web/app/views/privacy.js +13 -0
- package/dist/dashboard/web/app/views/spend.js +30 -15
- package/dist/dashboard/web/app/views/statement.js +189 -0
- package/dist/dashboard/web/app/views/system.js +44 -4
- package/dist/dashboard/web/app/views/value.js +105 -59
- package/dist/dashboard/web/app/views/workbench.js +312 -0
- package/dist/dashboard/web/classic.html +16 -4
- package/dist/dashboard/web/index.html +1 -1
- package/dist/dashboard/web/styles/app.css +536 -23
- package/dist/demo/seed.js +153 -14
- package/dist/economics/ledger.js +1 -1
- package/dist/egress/policy.js +129 -5
- package/dist/export/allocationCsv.js +70 -0
- package/dist/export/billingCsv.js +6 -2
- package/dist/export/csv.js +10 -2
- package/dist/export/economic.js +6 -2
- package/dist/export/statementCsv.js +104 -0
- package/dist/git/correlate.js +86 -9
- package/dist/git/quality.js +107 -24
- package/dist/git/repoScope.js +207 -0
- package/dist/git/survivalStore.js +186 -0
- package/dist/guide.js +9 -4
- package/dist/plans/detect.js +147 -0
- package/dist/proxy/server.js +134 -14
- package/dist/proxy/usage.js +9 -4
- package/dist/quota/limits.js +138 -0
- package/dist/quota/statusline.js +68 -0
- package/dist/quota/view.js +122 -0
- package/dist/scan/knownApps.js +6 -2
- package/dist/scan/scan.js +9 -0
- package/dist/store/allocation.js +14 -0
- package/dist/store/db.js +259 -34
- package/dist/store/realization.js +78 -14
- package/dist/store/schema.js +59 -1
- package/dist/store/scopeEvidence.js +67 -0
- package/dist/util/pool.js +19 -0
- package/dist/util/time.js +7 -0
- package/dist/value/realization.js +321 -30
- package/dist/value/report.js +28 -8
- package/dist/value/selfReported.js +10 -9
- package/docs/ARCHITECTURE.md +8 -4
- package/docs/CAPABILITY-EVIDENCE-CONTRACT.md +33 -11
- package/docs/CLI-JSON-CONTRACT.md +38 -0
- package/docs/DATA-BOUNDARIES.md +66 -6
- package/docs/FAQ.md +43 -9
- package/docs/GETTING-STARTED.md +19 -12
- package/docs/GUIDE.md +81 -18
- package/docs/INTEGRATIONS.md +9 -9
- package/docs/METHODOLOGY.md +3 -2
- package/docs/NEUTRALITY.md +1 -1
- package/docs/PRIVACY.md +51 -0
- package/docs/README.md +1 -0
- package/docs/RELEASE-GATE.md +52 -7
- package/docs/THREAT-MODEL.md +2 -1
- package/package.json +3 -2
- package/pricing/models.json +1366 -60
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which recorded AI spend belongs to a repository.
|
|
3
|
+
*
|
|
4
|
+
* The plain rule is the project label: spend recorded while a tool ran inside
|
|
5
|
+
* the repository's folder. That rule loses a repository's history as soon as it
|
|
6
|
+
* moves (the old folder's label no longer matches), and it cannot see work done
|
|
7
|
+
* from a git worktree or from a parent folder.
|
|
8
|
+
*
|
|
9
|
+
* This module adds what the agents' own logs PROVE. An agent session that
|
|
10
|
+
* created a commit printed git's "[branch sha] subject" line, which the
|
|
11
|
+
* importers record (observed_commits). An observation is accepted as evidence
|
|
12
|
+
* for this repository only when all three hold:
|
|
13
|
+
*
|
|
14
|
+
* 1. the sha names a commit in this repository,
|
|
15
|
+
* 2. the subject git printed starts the commit's subject (or the reverse), and
|
|
16
|
+
* 3. the observation was logged within 30 minutes of the commit time.
|
|
17
|
+
*
|
|
18
|
+
* From the accepted observations two links follow:
|
|
19
|
+
*
|
|
20
|
+
* - session: every request of a session that verifiably committed here
|
|
21
|
+
* belongs here, wherever the session was started;
|
|
22
|
+
* - folder: a folder a verified commit was made from belongs here when it no
|
|
23
|
+
* longer exists (a moved or deleted checkout) or is itself a checkout of
|
|
24
|
+
* this repository (a worktree). An existing folder that is not this
|
|
25
|
+
* repository (a parent folder, another repo) is never linked as a whole:
|
|
26
|
+
* only its verified sessions are.
|
|
27
|
+
*
|
|
28
|
+
* Nothing here is persisted and no ledger row is relabelled. The links are
|
|
29
|
+
* recomputed from evidence on every run and reported beside the result.
|
|
30
|
+
*/
|
|
31
|
+
import { execFile } from 'node:child_process';
|
|
32
|
+
import { existsSync } from 'node:fs';
|
|
33
|
+
import { resolve } from 'node:path';
|
|
34
|
+
import { promisify } from 'node:util';
|
|
35
|
+
import { evidenceMatcher, normPath as norm, pathUnder as under } from '../store/scopeEvidence.js';
|
|
36
|
+
import { projectName } from './correlate.js';
|
|
37
|
+
const run = promisify(execFile);
|
|
38
|
+
async function git(repoPath, args) {
|
|
39
|
+
const { stdout } = await run('git', ['-C', repoPath, ...args], { maxBuffer: 64 * 1024 * 1024 });
|
|
40
|
+
return stdout;
|
|
41
|
+
}
|
|
42
|
+
const MAX_OBSERVATION_SKEW_MS = 30 * 60 * 1000;
|
|
43
|
+
const SUBJECT_PREFIX = 40;
|
|
44
|
+
/** True when the subject git printed and the commit's subject are the same subject. */
|
|
45
|
+
export function subjectsAgree(printed, actual) {
|
|
46
|
+
const a = printed.trim().replace(/\s+/g, ' ');
|
|
47
|
+
const b = actual.trim().replace(/\s+/g, ' ');
|
|
48
|
+
if (a.length === 0 || b.length === 0)
|
|
49
|
+
return false;
|
|
50
|
+
const n = Math.min(SUBJECT_PREFIX, b.length);
|
|
51
|
+
return a.startsWith(b.slice(0, n)) || b.startsWith(a.slice(0, Math.min(SUBJECT_PREFIX, a.length)));
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* A repository's identity: its root commit(s), the one thing every clone,
|
|
55
|
+
* worktree and moved copy of it shares and no unrelated repository does. The
|
|
56
|
+
* folder is not the identity (it moves) and the remote URL is not either (an
|
|
57
|
+
* agent's working copy is often cloned from a local path).
|
|
58
|
+
*/
|
|
59
|
+
const identityCache = new Map();
|
|
60
|
+
export function repoIdentity(dir) {
|
|
61
|
+
const key = norm(dir);
|
|
62
|
+
let pending = identityCache.get(key);
|
|
63
|
+
if (pending === undefined) {
|
|
64
|
+
pending = git(dir, ['rev-list', '--max-parents=0', 'HEAD']).then((out) => {
|
|
65
|
+
const roots = out.split('\n').map((s) => s.trim()).filter(Boolean).sort();
|
|
66
|
+
return roots.length > 0 ? roots.join(',') : null;
|
|
67
|
+
}, () => null);
|
|
68
|
+
identityCache.set(key, pending);
|
|
69
|
+
}
|
|
70
|
+
return pending;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* A checkout's top level, spelled the way `dir` is spelled. `--show-toplevel`
|
|
74
|
+
* returns the physical path (macOS adds /private, Windows expands 8.3 short
|
|
75
|
+
* names), while ledger rows keep the spelling the agent logged; a linked folder
|
|
76
|
+
* spelled differently would never match them. `--show-cdup` is relative, so
|
|
77
|
+
* resolving it against `dir` keeps that spelling.
|
|
78
|
+
*/
|
|
79
|
+
async function topLevel(dir) {
|
|
80
|
+
try {
|
|
81
|
+
const up = (await git(dir, ['rev-parse', '--show-cdup'])).trim();
|
|
82
|
+
return resolve(dir, up);
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
async function commonDir(dir) {
|
|
89
|
+
try {
|
|
90
|
+
const out = (await git(dir, ['rev-parse', '--path-format=absolute', '--git-common-dir'])).trim();
|
|
91
|
+
return out === '' ? null : norm(out);
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Build the evidence-extended spend scope for one repository. `maxCommits`
|
|
99
|
+
* bounds the history read to resolve observed shas (newest first).
|
|
100
|
+
*/
|
|
101
|
+
export async function repoSpendScope(store, repoPath, opts = {}) {
|
|
102
|
+
const label = await projectName(repoPath);
|
|
103
|
+
const labels = new Set(store.projectFamily(label));
|
|
104
|
+
// A caller scoping many repositories reads the observations once and passes them in.
|
|
105
|
+
const observations = opts.observations ?? store.observedCommits();
|
|
106
|
+
const linkedSessions = new Set();
|
|
107
|
+
const folderCounts = new Map();
|
|
108
|
+
let verifiedObservations = 0;
|
|
109
|
+
if (observations.length > 0) {
|
|
110
|
+
// One read of this repository's history: sha -> (time, subject).
|
|
111
|
+
let log = '';
|
|
112
|
+
try {
|
|
113
|
+
log = await git(repoPath, ['log', '--all', `-n${opts.maxCommits ?? 20_000}`, '--format=%H%x1f%ct%x1f%s']);
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
log = '';
|
|
117
|
+
}
|
|
118
|
+
const byPrefix = new Map();
|
|
119
|
+
for (const line of log.split('\n')) {
|
|
120
|
+
const [hash, ct, subject] = line.split('\x1f');
|
|
121
|
+
if (!hash || hash.length < 7 || ct === undefined || subject === undefined)
|
|
122
|
+
continue;
|
|
123
|
+
const key = hash.slice(0, 7);
|
|
124
|
+
const list = byPrefix.get(key) ?? [];
|
|
125
|
+
list.push({ hash, tsMs: Number(ct) * 1000, subject });
|
|
126
|
+
byPrefix.set(key, list);
|
|
127
|
+
}
|
|
128
|
+
for (const o of observations) {
|
|
129
|
+
const candidates = byPrefix.get(o.shortSha.slice(0, 7)) ?? [];
|
|
130
|
+
const hit = candidates.find((c) => c.hash.startsWith(o.shortSha)
|
|
131
|
+
&& subjectsAgree(o.subject, c.subject)
|
|
132
|
+
&& Math.abs(o.tsEpochMs - c.tsMs) <= MAX_OBSERVATION_SKEW_MS);
|
|
133
|
+
if (!hit)
|
|
134
|
+
continue;
|
|
135
|
+
verifiedObservations += 1;
|
|
136
|
+
linkedSessions.add(o.sessionId);
|
|
137
|
+
if (o.cwd) {
|
|
138
|
+
const key = norm(o.cwd);
|
|
139
|
+
const entry = folderCounts.get(key) ?? { path: o.cwd, count: 0 };
|
|
140
|
+
entry.count += 1;
|
|
141
|
+
folderCounts.set(key, entry);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
// A folder is linked as a whole only when it is gone (moved or deleted) or is
|
|
146
|
+
// a checkout of this very repository. Its own top level is what is linked.
|
|
147
|
+
const repoCommon = await commonDir(repoPath);
|
|
148
|
+
const repoId = await repoIdentity(repoPath);
|
|
149
|
+
const repoRoot = norm(repoPath);
|
|
150
|
+
const linkedFolders = [];
|
|
151
|
+
for (const [key, entry] of folderCounts) {
|
|
152
|
+
if (under(key, repoRoot))
|
|
153
|
+
continue; // already covered by the label
|
|
154
|
+
if (!existsSync(entry.path)) {
|
|
155
|
+
linkedFolders.push({ path: entry.path, reason: 'moved', verifiedCommits: entry.count });
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
const common = await commonDir(entry.path);
|
|
159
|
+
const reason = common !== null && repoCommon !== null && common === repoCommon
|
|
160
|
+
? 'worktree'
|
|
161
|
+
: repoId !== null && (await repoIdentity(entry.path)) === repoId
|
|
162
|
+
? 'clone'
|
|
163
|
+
: null;
|
|
164
|
+
if (reason === null)
|
|
165
|
+
continue;
|
|
166
|
+
// The checkout's own top level is linked, so the rest of its work counts too.
|
|
167
|
+
const top = (await topLevel(entry.path)) ?? entry.path;
|
|
168
|
+
if (under(norm(top), repoRoot))
|
|
169
|
+
continue;
|
|
170
|
+
const existing = linkedFolders.find((f) => norm(f.path) === norm(top));
|
|
171
|
+
if (existing)
|
|
172
|
+
existing.verifiedCommits += entry.count;
|
|
173
|
+
else
|
|
174
|
+
linkedFolders.push({ path: top, reason, verifiedCommits: entry.count });
|
|
175
|
+
}
|
|
176
|
+
const linked = evidenceMatcher({ sessions: [...linkedSessions], folders: linkedFolders.map((f) => f.path) });
|
|
177
|
+
const labelled = (row) => labels.has(row.projectCanonical ?? row.project) || labels.has(row.project);
|
|
178
|
+
return {
|
|
179
|
+
labels,
|
|
180
|
+
linkedFolders,
|
|
181
|
+
linkedSessions,
|
|
182
|
+
verifiedObservations,
|
|
183
|
+
extended: linkedFolders.length > 0 || linkedSessions.size > 0,
|
|
184
|
+
matches(row) {
|
|
185
|
+
return labelled(row) || linked(row);
|
|
186
|
+
},
|
|
187
|
+
evidenceFor(rows) {
|
|
188
|
+
const sessions = new Set();
|
|
189
|
+
const folders = new Set();
|
|
190
|
+
for (const row of rows) {
|
|
191
|
+
if (labelled(row))
|
|
192
|
+
continue;
|
|
193
|
+
if (row.sessionId !== null && linkedSessions.has(row.sessionId))
|
|
194
|
+
sessions.add(row.sessionId);
|
|
195
|
+
if (row.cwd) {
|
|
196
|
+
const c = norm(row.cwd);
|
|
197
|
+
for (const f of linkedFolders)
|
|
198
|
+
if (under(c, norm(f.path)))
|
|
199
|
+
folders.add(f.path);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return sessions.size === 0 && folders.size === 0
|
|
203
|
+
? undefined
|
|
204
|
+
: { sessions: [...sessions].sort(), folders: [...folders].sort() };
|
|
205
|
+
},
|
|
206
|
+
};
|
|
207
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Survival work kept between runs, so a later run spends its budget on what
|
|
3
|
+
* an earlier run did not reach.
|
|
4
|
+
*
|
|
5
|
+
* Two kinds of git output are kept, per repository, in one JSON file under
|
|
6
|
+
* `<SEGREANT_HOME>/survival/`:
|
|
7
|
+
*
|
|
8
|
+
* - a commit's file list (`git show --numstat`). A commit hash names fixed
|
|
9
|
+
* content, so its file list never changes; a rewritten commit has a new hash.
|
|
10
|
+
* - one file's blame at a HEAD: how many of its lines belong to each commit.
|
|
11
|
+
* Survival is measured at the CURRENT HEAD, so an old blame may only stand in
|
|
12
|
+
* for a new one when it is the same observation. It is reused only when
|
|
13
|
+
* 1. it was taken at this HEAD, or at an ancestor of it (history was not
|
|
14
|
+
* rewritten), and
|
|
15
|
+
* 2. no commit between the two touched the path, counting both sides of a
|
|
16
|
+
* rename and every parent of a merge. Same file contents is not enough:
|
|
17
|
+
* a change followed by a revert gives the reverted lines a new author.
|
|
18
|
+
* 3. it was taken with a history bound no newer than the one asked for. A
|
|
19
|
+
* bound collapses older lines onto a boundary commit; every commit newer
|
|
20
|
+
* than the stored bound has its exact count.
|
|
21
|
+
* Anything else is blamed again.
|
|
22
|
+
*
|
|
23
|
+
* This is a cache of git's answers, not evidence: deleting the directory loses
|
|
24
|
+
* time, never a figure. It is off unless an entry point turns it on
|
|
25
|
+
* (`configureSurvivalStore`), so tests and library callers never write to a
|
|
26
|
+
* home directory they did not choose.
|
|
27
|
+
*/
|
|
28
|
+
import { createHash } from 'node:crypto';
|
|
29
|
+
import { execFile } from 'node:child_process';
|
|
30
|
+
import { promisify } from 'node:util';
|
|
31
|
+
import { mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync } from 'node:fs';
|
|
32
|
+
import { join } from 'node:path';
|
|
33
|
+
const run = promisify(execFile);
|
|
34
|
+
const VERSION = 1;
|
|
35
|
+
/** Bounds per repository file. Exceeding one drops the oldest half of that map. */
|
|
36
|
+
const MAX_BLAME_PATHS = 20_000;
|
|
37
|
+
const MAX_COMMITS = 10_000;
|
|
38
|
+
let directory = null;
|
|
39
|
+
const loaded = new Map();
|
|
40
|
+
const validity = new Map();
|
|
41
|
+
let exitHooked = false;
|
|
42
|
+
/** Turn persistence on, into `dir`, or off with null. Off is the default. */
|
|
43
|
+
export function configureSurvivalStore(dir) {
|
|
44
|
+
if (directory !== dir)
|
|
45
|
+
flushSurvivalStore();
|
|
46
|
+
directory = dir;
|
|
47
|
+
loaded.clear();
|
|
48
|
+
validity.clear();
|
|
49
|
+
if (dir !== null && !exitHooked) {
|
|
50
|
+
exitHooked = true;
|
|
51
|
+
// Deliberately synchronous: the exit event runs no further asynchronous
|
|
52
|
+
// work, and a CLI command that calls process.exit() still keeps what it measured.
|
|
53
|
+
process.once('exit', flushSurvivalStore);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/** Exposed for tests: forget what was read into memory, as a new process would. */
|
|
57
|
+
export function forgetLoadedSurvivalStore() {
|
|
58
|
+
flushSurvivalStore();
|
|
59
|
+
loaded.clear();
|
|
60
|
+
validity.clear();
|
|
61
|
+
}
|
|
62
|
+
function repoKey(repoPath) {
|
|
63
|
+
let real = repoPath;
|
|
64
|
+
try {
|
|
65
|
+
real = realpathSync.native(repoPath);
|
|
66
|
+
}
|
|
67
|
+
catch { /* keep the path as given */ }
|
|
68
|
+
return createHash('sha256').update(real.replace(/\\/g, '/')).digest('hex').slice(0, 32);
|
|
69
|
+
}
|
|
70
|
+
function load(repoPath) {
|
|
71
|
+
if (directory === null)
|
|
72
|
+
return null;
|
|
73
|
+
const key = repoKey(repoPath);
|
|
74
|
+
let hit = loaded.get(key);
|
|
75
|
+
if (hit)
|
|
76
|
+
return hit;
|
|
77
|
+
const file = join(directory, `${key}.json`);
|
|
78
|
+
let data = { v: VERSION, blame: {}, numstat: {} };
|
|
79
|
+
try {
|
|
80
|
+
const parsed = JSON.parse(readFileSync(file, 'utf8'));
|
|
81
|
+
if (parsed && parsed.v === VERSION && typeof parsed.blame === 'object' && typeof parsed.numstat === 'object') {
|
|
82
|
+
data = { v: VERSION, blame: parsed.blame ?? {}, numstat: parsed.numstat ?? {} };
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
catch { /* missing or unreadable: start empty */ }
|
|
86
|
+
hit = { file, data, dirty: false };
|
|
87
|
+
loaded.set(key, hit);
|
|
88
|
+
return hit;
|
|
89
|
+
}
|
|
90
|
+
/** Write every changed store. Never throws: a cache that cannot be written only costs time. */
|
|
91
|
+
export function flushSurvivalStore() {
|
|
92
|
+
for (const store of loaded.values()) {
|
|
93
|
+
if (!store.dirty)
|
|
94
|
+
continue;
|
|
95
|
+
try {
|
|
96
|
+
mkdirSync(join(store.file, '..'), { recursive: true });
|
|
97
|
+
const tmp = `${store.file}.${process.pid}.tmp`;
|
|
98
|
+
writeFileSync(tmp, JSON.stringify(store.data));
|
|
99
|
+
renameSync(tmp, store.file);
|
|
100
|
+
store.dirty = false;
|
|
101
|
+
}
|
|
102
|
+
catch { /* read-only home or full disk: keep running without it */ }
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
function trim(map, max) {
|
|
106
|
+
const keys = Object.keys(map);
|
|
107
|
+
if (keys.length <= max)
|
|
108
|
+
return;
|
|
109
|
+
for (const k of keys.slice(0, Math.ceil(keys.length / 2)))
|
|
110
|
+
delete map[k];
|
|
111
|
+
}
|
|
112
|
+
/** A commit's stored file list, or undefined when it has not been read before. */
|
|
113
|
+
export function storedCommitFiles(repoPath, hash) {
|
|
114
|
+
const rows = load(repoPath)?.data.numstat[hash];
|
|
115
|
+
return rows?.map(([path, added]) => ({ path, added }));
|
|
116
|
+
}
|
|
117
|
+
export function rememberCommitFiles(repoPath, hash, files) {
|
|
118
|
+
const store = load(repoPath);
|
|
119
|
+
if (!store)
|
|
120
|
+
return;
|
|
121
|
+
store.data.numstat[hash] = files.map((f) => [f.path, f.added]);
|
|
122
|
+
trim(store.data.numstat, MAX_COMMITS);
|
|
123
|
+
store.dirty = true;
|
|
124
|
+
}
|
|
125
|
+
async function git(repoPath, args) {
|
|
126
|
+
const { stdout } = await run('git', ['-C', repoPath, ...args], { maxBuffer: 64 * 1024 * 1024 });
|
|
127
|
+
return stdout;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Paths any commit after `from` and up to `to` touched, or null when `from` is
|
|
131
|
+
* not an ancestor of `to` (history rewritten, or `from` gone). `-m` lists a
|
|
132
|
+
* merge's changes against every parent and `--no-renames` lists both sides of a
|
|
133
|
+
* rename, so the set can only be too large, which costs a blame, never wrong.
|
|
134
|
+
*/
|
|
135
|
+
function touchedBetween(repoPath, from, to) {
|
|
136
|
+
if (from === to)
|
|
137
|
+
return Promise.resolve(new Set());
|
|
138
|
+
const key = `${repoKey(repoPath)}\0${from}\0${to}`;
|
|
139
|
+
let pending = validity.get(key);
|
|
140
|
+
if (pending === undefined) {
|
|
141
|
+
pending = (async () => {
|
|
142
|
+
try {
|
|
143
|
+
await git(repoPath, ['merge-base', '--is-ancestor', from, to]);
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
const out = await git(repoPath, ['log', '-m', '--no-renames', '--name-only', '-z', '--format=', `${from}..${to}`]);
|
|
149
|
+
return new Set(out.split('\0').map((p) => p.replace(/^\n+/, '')).filter((p) => p !== ''));
|
|
150
|
+
})().catch(() => null);
|
|
151
|
+
validity.set(key, pending);
|
|
152
|
+
}
|
|
153
|
+
return pending;
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* A stored blame of `path` that is the same observation as blaming it at
|
|
157
|
+
* `head` with `sinceSec`, or undefined when it has to be blamed again.
|
|
158
|
+
*/
|
|
159
|
+
export async function reusableBlame(repoPath, head, path, sinceSec) {
|
|
160
|
+
const store = load(repoPath);
|
|
161
|
+
const entry = store?.data.blame[path];
|
|
162
|
+
if (!store || !entry)
|
|
163
|
+
return undefined;
|
|
164
|
+
// Condition 3: a stored bound newer than the one asked for has collapsed
|
|
165
|
+
// lines the caller needs exactly.
|
|
166
|
+
if (entry.since !== null && (sinceSec === undefined || entry.since > sinceSec))
|
|
167
|
+
return undefined;
|
|
168
|
+
if (entry.head !== head) {
|
|
169
|
+
const touched = await touchedBetween(repoPath, entry.head, head);
|
|
170
|
+
if (touched === null || touched.has(path))
|
|
171
|
+
return undefined;
|
|
172
|
+
// The same observation at the new HEAD: re-key it, so the next run checks
|
|
173
|
+
// a shorter range.
|
|
174
|
+
entry.head = head;
|
|
175
|
+
store.dirty = true;
|
|
176
|
+
}
|
|
177
|
+
return new Map(entry.counts);
|
|
178
|
+
}
|
|
179
|
+
export function rememberBlame(repoPath, head, path, sinceSec, counts) {
|
|
180
|
+
const store = load(repoPath);
|
|
181
|
+
if (!store)
|
|
182
|
+
return;
|
|
183
|
+
store.data.blame[path] = { head, since: sinceSec ?? null, counts: [...counts] };
|
|
184
|
+
trim(store.data.blame, MAX_BLAME_PATHS);
|
|
185
|
+
store.dirty = true;
|
|
186
|
+
}
|
package/dist/guide.js
CHANGED
|
@@ -65,16 +65,21 @@ export function buildGuide(f) {
|
|
|
65
65
|
? envHints
|
|
66
66
|
: ['segreant scan --setup', 'segreant start', ...envHints],
|
|
67
67
|
};
|
|
68
|
+
const capBasisSpend = f.capBasisSpend30dUsd ?? f.spend30dUsd;
|
|
68
69
|
const cap = {
|
|
69
70
|
id: 'cap',
|
|
70
71
|
title: 'Cap it',
|
|
71
72
|
done: f.dailyCapUsd !== null,
|
|
72
73
|
state: f.dailyCapUsd !== null ? `daily hard cap $${f.dailyCapUsd}` : 'metering only — no enforcement',
|
|
73
74
|
why: 'Metering without enforcement is a report, not governance. The proxy can actually say no.',
|
|
74
|
-
notice:
|
|
75
|
-
?
|
|
76
|
-
|
|
77
|
-
|
|
75
|
+
notice: capBasisSpend > 0
|
|
76
|
+
? f.capBasis === 'all_observed'
|
|
77
|
+
? 'The recommendation uses all observed spend, imported included, which is what your cap counts.'
|
|
78
|
+
: 'The recommendation uses your spend through the proxy, which is what a cap governs.'
|
|
79
|
+
: f.spend30dUsd > 0
|
|
80
|
+
? 'Your spend so far was imported from agent logs, and a cap governs requests through the proxy. A recommendation needs a few days of proxied requests; set a cap by hand until then.'
|
|
81
|
+
: 'Set a hard cap and optional soft threshold for governed spend.',
|
|
82
|
+
commands: capBasisSpend > 0
|
|
78
83
|
? ['segreant budget --recommend', 'segreant budget --daily 25 --soft 18']
|
|
79
84
|
: ['segreant budget --daily 25 --soft 18'],
|
|
80
85
|
};
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which plan each tool runs on, read from the tool's own local files.
|
|
3
|
+
*
|
|
4
|
+
* Subscription usage is the main reason a Segreant number is a list-price
|
|
5
|
+
* estimate rather than a bill: a $20 plan can do hundreds of dollars of
|
|
6
|
+
* list-price work. Saying which plan a source is on lets every surface name
|
|
7
|
+
* both bases side by side ("your $20 plan did $X of list-price work").
|
|
8
|
+
*
|
|
9
|
+
* The plan is detected; the PRICE is never assumed. What a person pays depends
|
|
10
|
+
* on the channel (an App Store subscription costs more than the web one), tax,
|
|
11
|
+
* annual billing and discounts, so the price comes only from the person
|
|
12
|
+
* (`segreant plan set`). The public prices below are offered as a suggestion
|
|
13
|
+
* when asking, with their date, and never used as a figure.
|
|
14
|
+
*
|
|
15
|
+
* Reads only plan fields. Claude Code's plan comes from `~/.claude.json`
|
|
16
|
+
* (`oauthAccount`), never from the credentials file, which holds tokens.
|
|
17
|
+
*/
|
|
18
|
+
import { closeSync, existsSync, openSync, readFileSync, readSync, readdirSync, statSync } from 'node:fs';
|
|
19
|
+
import { homedir } from 'node:os';
|
|
20
|
+
import { join } from 'node:path';
|
|
21
|
+
/** Public monthly prices, as a suggestion when asking only. Checked 2026-10-08. */
|
|
22
|
+
export const PUBLIC_PLAN_PRICES = {
|
|
23
|
+
'claude-code:pro': 20,
|
|
24
|
+
'claude-code:max:5x': 100,
|
|
25
|
+
'claude-code:max:20x': 200,
|
|
26
|
+
'codex:plus': 20,
|
|
27
|
+
'codex:pro': 200,
|
|
28
|
+
};
|
|
29
|
+
export const PUBLIC_PLAN_PRICES_CHECKED = '2026-10-08';
|
|
30
|
+
export const PLAN_LABELS = {
|
|
31
|
+
'claude-code': 'Claude',
|
|
32
|
+
codex: 'ChatGPT',
|
|
33
|
+
};
|
|
34
|
+
export function suggestedPrice(p) {
|
|
35
|
+
if (p.plan === null)
|
|
36
|
+
return null;
|
|
37
|
+
return PUBLIC_PLAN_PRICES[`${p.source}:${p.plan}${p.tier ? `:${p.tier}` : ''}`]
|
|
38
|
+
?? PUBLIC_PLAN_PRICES[`${p.source}:${p.plan}`]
|
|
39
|
+
?? null;
|
|
40
|
+
}
|
|
41
|
+
/** "Claude Max 20x", "ChatGPT Plus", or null when the plan is unknown. */
|
|
42
|
+
export function planName(p) {
|
|
43
|
+
if (p.plan === null)
|
|
44
|
+
return null;
|
|
45
|
+
const cap = (s) => s.charAt(0).toUpperCase() + s.slice(1);
|
|
46
|
+
return `${PLAN_LABELS[p.source]} ${cap(p.plan)}${p.tier ? ` ${p.tier}` : ''}`;
|
|
47
|
+
}
|
|
48
|
+
export function detectClaudePlan(home = homedir()) {
|
|
49
|
+
const path = join(home, '.claude.json');
|
|
50
|
+
const none = (evidence) => ({ source: 'claude-code', plan: null, tier: null, billing: null, evidence });
|
|
51
|
+
if (!existsSync(path))
|
|
52
|
+
return none('no ~/.claude.json');
|
|
53
|
+
let account;
|
|
54
|
+
try {
|
|
55
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
56
|
+
if (parsed.oauthAccount === null || typeof parsed.oauthAccount !== 'object')
|
|
57
|
+
return none('~/.claude.json has no signed-in account (an API key, or signed out)');
|
|
58
|
+
account = parsed.oauthAccount;
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return none('~/.claude.json could not be read');
|
|
62
|
+
}
|
|
63
|
+
const str = (k) => (typeof account[k] === 'string' && account[k] !== '' ? account[k] : null);
|
|
64
|
+
const orgType = str('organizationType');
|
|
65
|
+
const plan = orgType === null ? null : orgType.replace(/^claude_/, '');
|
|
66
|
+
// Max states its usage multiple in the rate-limit tier ("default_claude_max_20x").
|
|
67
|
+
const tierSource = str('userRateLimitTier') ?? str('organizationRateLimitTier') ?? '';
|
|
68
|
+
const tier = plan === 'max' ? (/(\d+x)\b/.exec(tierSource)?.[1] ?? null) : null;
|
|
69
|
+
return {
|
|
70
|
+
source: 'claude-code',
|
|
71
|
+
plan,
|
|
72
|
+
tier,
|
|
73
|
+
billing: str('billingType'),
|
|
74
|
+
evidence: orgType === null ? '~/.claude.json: no organizationType' : `~/.claude.json: organizationType ${orgType}`,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
const PLAN_TYPE = /"plan_type":"([a-z_]+)"/g;
|
|
78
|
+
/** The plan named in the newest Codex session log (its rate-limit events carry plan_type). */
|
|
79
|
+
export function detectCodexPlan(root = join(homedir(), '.codex', 'sessions')) {
|
|
80
|
+
const none = (evidence) => ({ source: 'codex', plan: null, tier: null, billing: null, evidence });
|
|
81
|
+
const newest = newestJsonl(root);
|
|
82
|
+
if (newest === null)
|
|
83
|
+
return none('no Codex session logs');
|
|
84
|
+
const tail = readTail(newest, 512 * 1024);
|
|
85
|
+
let plan = null;
|
|
86
|
+
for (const m of tail.matchAll(PLAN_TYPE))
|
|
87
|
+
plan = m[1];
|
|
88
|
+
if (plan === null)
|
|
89
|
+
return none('the newest Codex session log names no plan');
|
|
90
|
+
return { source: 'codex', plan, tier: null, billing: null, evidence: 'newest Codex session log: plan_type ' + plan };
|
|
91
|
+
}
|
|
92
|
+
export function detectPlans() {
|
|
93
|
+
return [detectClaudePlan(), detectCodexPlan()];
|
|
94
|
+
}
|
|
95
|
+
/** Newest *.jsonl under a sessions/YYYY/MM/DD tree, walking newest directories first. */
|
|
96
|
+
function newestJsonl(root) {
|
|
97
|
+
const dirsDesc = (dir) => {
|
|
98
|
+
try {
|
|
99
|
+
return readdirSync(dir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name).sort().reverse();
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return [];
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
const walk = (dir, depth) => {
|
|
106
|
+
if (depth === 0) {
|
|
107
|
+
let best = null;
|
|
108
|
+
let names = [];
|
|
109
|
+
try {
|
|
110
|
+
names = readdirSync(dir).filter((n) => n.endsWith('.jsonl'));
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
for (const n of names) {
|
|
116
|
+
const p = join(dir, n);
|
|
117
|
+
try {
|
|
118
|
+
const m = statSync(p).mtimeMs;
|
|
119
|
+
if (best === null || m > best.mtime)
|
|
120
|
+
best = { path: p, mtime: m };
|
|
121
|
+
}
|
|
122
|
+
catch { /* gone */ }
|
|
123
|
+
}
|
|
124
|
+
return best?.path ?? null;
|
|
125
|
+
}
|
|
126
|
+
for (const d of dirsDesc(dir)) {
|
|
127
|
+
const hit = walk(join(dir, d), depth - 1);
|
|
128
|
+
if (hit !== null)
|
|
129
|
+
return hit;
|
|
130
|
+
}
|
|
131
|
+
return null;
|
|
132
|
+
};
|
|
133
|
+
return walk(root, 3);
|
|
134
|
+
}
|
|
135
|
+
function readTail(path, bytes) {
|
|
136
|
+
const fd = openSync(path, 'r');
|
|
137
|
+
try {
|
|
138
|
+
const size = statSync(path).size;
|
|
139
|
+
const len = Math.min(size, bytes);
|
|
140
|
+
const buf = Buffer.alloc(len);
|
|
141
|
+
readSync(fd, buf, 0, len, size - len);
|
|
142
|
+
return buf.toString('utf8');
|
|
143
|
+
}
|
|
144
|
+
finally {
|
|
145
|
+
closeSync(fd);
|
|
146
|
+
}
|
|
147
|
+
}
|