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.
Files changed (142) hide show
  1. package/LICENSING.md +5 -3
  2. package/README.md +57 -19
  3. package/bin/segreant-statusline.mjs +20 -0
  4. package/bin/segreant.mjs +19 -8
  5. package/dist/alerts/detect.js +13 -0
  6. package/dist/billing/epistemic.js +4 -0
  7. package/dist/billing/importer.js +29 -10
  8. package/dist/billing/reconcile.js +122 -32
  9. package/dist/billing/scope.js +7 -0
  10. package/dist/budget/guard.js +33 -4
  11. package/dist/cli/allocCmd.js +73 -4
  12. package/dist/cli/askModelPrices.js +59 -0
  13. package/dist/cli/backupCmd.js +2 -1
  14. package/dist/cli/basisLines.js +45 -0
  15. package/dist/cli/billingCmd.js +68 -9
  16. package/dist/cli/capitalCmd.js +2 -1
  17. package/dist/cli/connectCmd.js +50 -9
  18. package/dist/cli/egressCmd.js +15 -3
  19. package/dist/cli/evidenceCmd.js +6 -3
  20. package/dist/cli/flags.js +68 -0
  21. package/dist/cli/homeCmd.js +117 -0
  22. package/dist/cli/importCmd.js +210 -54
  23. package/dist/cli/launchCmd.js +2 -1
  24. package/dist/cli/opsCmd.js +49 -2
  25. package/dist/cli/planCmd.js +181 -0
  26. package/dist/cli/pricingDeclareCmd.js +178 -0
  27. package/dist/cli/quotaCmd.js +190 -0
  28. package/dist/cli/runCmd.js +203 -51
  29. package/dist/cli/showCmd.js +37 -11
  30. package/dist/cli/teamCmd.js +5 -5
  31. package/dist/cli/ui.js +24 -8
  32. package/dist/cli/valueCmd.js +189 -14
  33. package/dist/cli.js +102 -21
  34. package/dist/config.js +95 -6
  35. package/dist/connect/antigravity.js +310 -0
  36. package/dist/connect/claudeCode.js +43 -3
  37. package/dist/connect/codex.js +38 -1
  38. package/dist/connect/importShared.js +58 -5
  39. package/dist/connect/opencode.js +37 -9
  40. package/dist/connect/protobuf.js +126 -0
  41. package/dist/connect/registry.js +46 -0
  42. package/dist/connect/repoRoot.js +112 -0
  43. package/dist/cost/basis.js +168 -0
  44. package/dist/cost/declarations.js +37 -0
  45. package/dist/cost/exactPricing.js +4 -1
  46. package/dist/cost/firstSight.js +98 -0
  47. package/dist/cost/overrides.js +230 -0
  48. package/dist/cost/pricing.js +228 -15
  49. package/dist/cost/reprice.js +130 -0
  50. package/dist/dashboard/claim-support.js +11 -8
  51. package/dist/dashboard/contracts.js +23 -3
  52. package/dist/dashboard/routes.js +980 -112
  53. package/dist/dashboard/sessions.js +345 -0
  54. package/dist/dashboard/settings.js +2 -1
  55. package/dist/dashboard/value-cache.js +105 -0
  56. package/dist/dashboard/web/app/components/claimInspector.js +6 -5
  57. package/dist/dashboard/web/app/components/drawer.js +105 -35
  58. package/dist/dashboard/web/app/components/ruleFields.js +144 -0
  59. package/dist/dashboard/web/app/components/spine.js +54 -24
  60. package/dist/dashboard/web/app/components/workflow.js +82 -0
  61. package/dist/dashboard/web/app/core/actions.js +734 -23
  62. package/dist/dashboard/web/app/core/api.js +37 -1
  63. package/dist/dashboard/web/app/core/chain.js +11 -4
  64. package/dist/dashboard/web/app/core/claimLayers.js +179 -51
  65. package/dist/dashboard/web/app/core/claimTypes.js +30 -0
  66. package/dist/dashboard/web/app/core/fmt.js +19 -2
  67. package/dist/dashboard/web/app/core/generated-contract.js +23 -3
  68. package/dist/dashboard/web/app/core/generated-payload-contract.js +1944 -465
  69. package/dist/dashboard/web/app/core/generated-types.js +1 -1
  70. package/dist/dashboard/web/app/core/griffin.js +10 -0
  71. package/dist/dashboard/web/app/core/jobs.js +12 -0
  72. package/dist/dashboard/web/app/core/period.js +35 -0
  73. package/dist/dashboard/web/app/core/periodWords.js +10 -0
  74. package/dist/dashboard/web/app/core/periods.js +32 -0
  75. package/dist/dashboard/web/app/core/pricingActions.js +264 -0
  76. package/dist/dashboard/web/app/core/privacyText.js +78 -0
  77. package/dist/dashboard/web/app/core/reconcileWords.js +63 -0
  78. package/dist/dashboard/web/app/core/registry.js +79 -46
  79. package/dist/dashboard/web/app/core/slug.js +9 -0
  80. package/dist/dashboard/web/app/core/theme.js +48 -0
  81. package/dist/dashboard/web/app/core/view.js +69 -0
  82. package/dist/dashboard/web/app/main.js +197 -15
  83. package/dist/dashboard/web/app/views/allocation.js +249 -61
  84. package/dist/dashboard/web/app/views/control.js +57 -16
  85. package/dist/dashboard/web/app/views/data.js +4 -4
  86. package/dist/dashboard/web/app/views/evidence.js +336 -87
  87. package/dist/dashboard/web/app/views/privacy.js +13 -0
  88. package/dist/dashboard/web/app/views/spend.js +30 -15
  89. package/dist/dashboard/web/app/views/statement.js +189 -0
  90. package/dist/dashboard/web/app/views/system.js +44 -4
  91. package/dist/dashboard/web/app/views/value.js +105 -59
  92. package/dist/dashboard/web/app/views/workbench.js +312 -0
  93. package/dist/dashboard/web/classic.html +16 -4
  94. package/dist/dashboard/web/index.html +1 -1
  95. package/dist/dashboard/web/styles/app.css +536 -23
  96. package/dist/demo/seed.js +153 -14
  97. package/dist/economics/ledger.js +1 -1
  98. package/dist/egress/policy.js +129 -5
  99. package/dist/export/allocationCsv.js +70 -0
  100. package/dist/export/billingCsv.js +6 -2
  101. package/dist/export/csv.js +10 -2
  102. package/dist/export/economic.js +6 -2
  103. package/dist/export/statementCsv.js +104 -0
  104. package/dist/git/correlate.js +86 -9
  105. package/dist/git/quality.js +107 -24
  106. package/dist/git/repoScope.js +207 -0
  107. package/dist/git/survivalStore.js +186 -0
  108. package/dist/guide.js +9 -4
  109. package/dist/plans/detect.js +147 -0
  110. package/dist/proxy/server.js +134 -14
  111. package/dist/proxy/usage.js +9 -4
  112. package/dist/quota/limits.js +138 -0
  113. package/dist/quota/statusline.js +68 -0
  114. package/dist/quota/view.js +122 -0
  115. package/dist/scan/knownApps.js +6 -2
  116. package/dist/scan/scan.js +9 -0
  117. package/dist/store/allocation.js +14 -0
  118. package/dist/store/db.js +259 -34
  119. package/dist/store/realization.js +78 -14
  120. package/dist/store/schema.js +59 -1
  121. package/dist/store/scopeEvidence.js +67 -0
  122. package/dist/util/pool.js +19 -0
  123. package/dist/util/time.js +7 -0
  124. package/dist/value/realization.js +321 -30
  125. package/dist/value/report.js +28 -8
  126. package/dist/value/selfReported.js +10 -9
  127. package/docs/ARCHITECTURE.md +8 -4
  128. package/docs/CAPABILITY-EVIDENCE-CONTRACT.md +33 -11
  129. package/docs/CLI-JSON-CONTRACT.md +38 -0
  130. package/docs/DATA-BOUNDARIES.md +66 -6
  131. package/docs/FAQ.md +43 -9
  132. package/docs/GETTING-STARTED.md +19 -12
  133. package/docs/GUIDE.md +81 -18
  134. package/docs/INTEGRATIONS.md +9 -9
  135. package/docs/METHODOLOGY.md +3 -2
  136. package/docs/NEUTRALITY.md +1 -1
  137. package/docs/PRIVACY.md +51 -0
  138. package/docs/README.md +1 -0
  139. package/docs/RELEASE-GATE.md +52 -7
  140. package/docs/THREAT-MODEL.md +2 -1
  141. package/package.json +3 -2
  142. package/pricing/models.json +1366 -60
package/dist/cli/flags.js CHANGED
@@ -49,6 +49,74 @@ export function parseFlags(argv) {
49
49
  }
50
50
  return flags;
51
51
  }
52
+ /**
53
+ * Every option name some command reads, used ONLY to suggest a correction for
54
+ * a mistyped one. It is not an allowlist: commands read options through
55
+ * helpers and arrays a source scan cannot follow, so rejecting by this list
56
+ * would refuse valid options.
57
+ */
58
+ const KNOWN_OPTION_NAMES = [
59
+ 'account-ref', 'all', 'allow-unmetered', 'apply', 'archive', 'as-of', 'at', 'attempts', 'auto', 'category', 'centre',
60
+ 'cache-read', 'cache-write', 'cache-write-1h', 'clear', 'clear-webhook', 'close-status', 'commit', 'control', 'coverage', 'cwd', 'daily', 'dashboard-port', 'data-class',
61
+ 'days', 'debug', 'decision', 'deep', 'demo', 'detail', 'disabled', 'dry-run', 'economic', 'effective-at', 'every',
62
+ 'exact-money', 'file', 'finalize', 'format', 'free', 'from', 'grain', 'help', 'id', 'import-id', 'include-imported', 'input', 'into',
63
+ 'json', 'key', 'key-id', 'kind', 'labor-rate', 'like', 'limit', 'list', 'manifest', 'materiality', 'me', 'method', 'mode',
64
+ 'name', 'note', 'notify', 'notify-url', 'origin', 'out', 'output', 'owner', 'path-prefix', 'policy', 'port', 'priority',
65
+ 'project', 'project-ref', 'pubkey', 'purpose', 'rating', 'reason', 'recommend', 'reconcile', 'record-id', 'refresh',
66
+ 'reopen', 'repo', 'request', 'rescan', 'risk', 'root', 'runaway', 'scope', 'sensitivity', 'serve', 'session',
67
+ 'set-webhook', 'setup', 'sign', 'soft', 'start', 'target-currency', 'to', 'tool', 'trust', 'ts', 'tsf', 'unit',
68
+ 'until', 'url', 'use', 'verdict', 'verify', 'watch', 'window', 'wrap', 'write',
69
+ ];
70
+ function editDistance(a, b) {
71
+ const row = Array.from({ length: b.length + 1 }, (_, j) => j);
72
+ for (let i = 1; i <= a.length; i++) {
73
+ let prev = row[0];
74
+ row[0] = i;
75
+ for (let j = 1; j <= b.length; j++) {
76
+ const keep = row[j];
77
+ row[j] = Math.min(row[j] + 1, row[j - 1] + 1, prev + (a[i - 1] === b[j - 1] ? 0 : 1));
78
+ prev = keep;
79
+ }
80
+ }
81
+ return row[b.length];
82
+ }
83
+ /** The closest known option name, when one is close enough to be the intended one. */
84
+ export function suggestOption(name) {
85
+ let best = null;
86
+ let bestD = Infinity;
87
+ for (const known of KNOWN_OPTION_NAMES) {
88
+ const d = editDistance(name, known);
89
+ if (d < bestD) {
90
+ best = known;
91
+ bestD = d;
92
+ }
93
+ }
94
+ return best !== null && bestD <= Math.max(1, Math.floor(name.length / 3)) ? best : null;
95
+ }
96
+ /**
97
+ * Wrap parsed flags so the options a command actually read are recorded.
98
+ * `segreant budget --dayly 5` used to exit 0 having set nothing (H014), and a
99
+ * command that ignores `--json` printed prose without saying so (H013). After
100
+ * a command finishes, every option it never read is reported and the exit code
101
+ * is 2. A mistyped `--apply` is therefore caught after the default preview,
102
+ * which writes nothing.
103
+ */
104
+ export function trackFlags(flags) {
105
+ const read = new Set(['_']);
106
+ const proxy = new Proxy(flags, {
107
+ get(target, key, receiver) {
108
+ if (typeof key === 'string')
109
+ read.add(key);
110
+ return Reflect.get(target, key, receiver);
111
+ },
112
+ has(target, key) {
113
+ if (typeof key === 'string')
114
+ read.add(key);
115
+ return Reflect.has(target, key);
116
+ },
117
+ });
118
+ return { flags: proxy, unread: () => Object.keys(flags).filter((k) => !read.has(k)) };
119
+ }
52
120
  export function rangeFor(window) {
53
121
  const now = Date.now();
54
122
  const day = 24 * 60 * 60 * 1000;
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Bare `segreant`: the first thing anyone sees.
3
+ *
4
+ * Read-only, like every command without --apply: it never imports on its own.
5
+ * With nothing recorded yet it says what Segreant does, which tools it can read
6
+ * on this machine, and the one command that reads them. With a ledger it leads
7
+ * with the month, the plans, and how much of the AI work stayed in the code
8
+ * (from the last measurement), then the next step. `segreant guide` keeps the
9
+ * full checklist.
10
+ */
11
+ import { existsSync } from 'node:fs';
12
+ import { Store } from '../store/db.js';
13
+ import { dbPath, isDemo } from '../config.js';
14
+ import { defaultClaudeCodeRoot } from '../connect/claudeCode.js';
15
+ import { defaultCodexRoot } from '../connect/codex.js';
16
+ import { defaultOpencodeDbPath } from '../connect/opencode.js';
17
+ import { defaultAntigravityRoot } from '../connect/antigravity.js';
18
+ import { detectPlans, planName } from '../plans/detect.js';
19
+ import { keptSummary, realizationFromStore } from '../value/realization.js';
20
+ import { planComparisons, printPlanLines } from './planCmd.js';
21
+ import { summarizeBasis } from '../cost/basis.js';
22
+ import { basisLines } from './basisLines.js';
23
+ import { C, color, usd, num, printJson } from './ui.js';
24
+ import { rangeFor } from './flags.js';
25
+ const DAY = 86_400_000;
26
+ function toolsOnThisMachine() {
27
+ const plans = new Map(detectPlans().map((p) => [p.source, planName(p)]));
28
+ const out = [];
29
+ if (existsSync(defaultClaudeCodeRoot()))
30
+ out.push({ id: 'claude-code', label: 'Claude Code', plan: plans.get('claude-code') ?? null });
31
+ const codex = defaultCodexRoot();
32
+ if (codex !== null && existsSync(codex))
33
+ out.push({ id: 'codex', label: 'Codex', plan: plans.get('codex') ?? null });
34
+ const opencode = defaultOpencodeDbPath();
35
+ if (opencode !== null && existsSync(opencode))
36
+ out.push({ id: 'opencode', label: 'opencode', plan: null });
37
+ if (defaultAntigravityRoot() !== null)
38
+ out.push({ id: 'antigravity', label: 'Antigravity', plan: null });
39
+ return out;
40
+ }
41
+ export function cmdHome(flags) {
42
+ const store = new Store(dbPath());
43
+ const month = rangeFor('month');
44
+ const summary = store.summary(month.startMs, month.endMs);
45
+ const everything = store.summary(0, Date.now() + 1000);
46
+ const tools = toolsOnThisMachine();
47
+ const now = Date.now();
48
+ // The last measurement of what stayed in the code, over the last 90 days of commits.
49
+ const stored = realizationFromStore(store);
50
+ const recent = stored.units.filter((u) => u.tsEpochMs >= now - 90 * DAY);
51
+ const kept = recent.length > 0 ? keptSummary({ units: recent, windowDays: stored.windowDays }) : null;
52
+ const plans = everything.requests > 0 && !isDemo() ? planComparisons(store, month.startMs, month.endMs) : [];
53
+ const basis = summarizeBasis(store.pricingEvidenceByModel(month.startMs, month.endMs));
54
+ store.close();
55
+ if (flags.json) {
56
+ printJson({
57
+ empty: everything.requests === 0,
58
+ tools,
59
+ last30Days: { listCostUsd: summary.costUsd, requests: summary.requests, basis },
60
+ kept: kept === null ? null : { ...kept, asOf: new Date(stored.generatedAt).toISOString() },
61
+ plans,
62
+ });
63
+ return;
64
+ }
65
+ const tty = process.stdout.isTTY ?? false;
66
+ console.log('');
67
+ console.log(color(tty, C.bold, ' Segreant') + color(tty, C.gray, ' — what your AI coding spend produced, measured on your machine') + (isDemo() ? color(tty, C.yellow, ' ● DEMO DATA') : ''));
68
+ console.log('');
69
+ if (everything.requests === 0) {
70
+ if (tools.length > 0) {
71
+ const found = tools.map((t) => `${t.label}${t.plan ? ` (${t.plan})` : ''}`).join(' · ');
72
+ console.log(` Found on this machine: ${color(tty, C.bold, found)}`);
73
+ console.log('');
74
+ console.log(' Read their logs and measure what the work produced:');
75
+ console.log(color(tty, C.cyan, ' segreant scan --setup'));
76
+ console.log(color(tty, C.gray, ' Nothing about you leaves this machine. The first run takes a few minutes; the month appears after about one.'));
77
+ }
78
+ else {
79
+ console.log(' No AI coding tool logs found here (Claude Code, Codex, Antigravity and opencode are read today).');
80
+ console.log(color(tty, C.cyan, ' segreant scan') + color(tty, C.gray, ' looks further and lists what it can and cannot read'));
81
+ }
82
+ console.log('');
83
+ console.log(color(tty, C.gray, ' See every screen with sample data first: segreant demo --serve'));
84
+ console.log('');
85
+ return;
86
+ }
87
+ const plainList = basis.cohorts.length === 1 && basis.cohorts[0].id === 'list_exact';
88
+ console.log(` ${color(tty, C.bold, 'Last 30 days')} ${color(tty, C.green, usd(summary.costUsd))} ${basis.headlineLabel.toLowerCase()} ${color(tty, C.gray, plainList
89
+ ? `(${num(summary.requests)} requests · each model's API list rate · not your bill)`
90
+ : `(${num(summary.requests)} requests, priced in parts:)`)}`);
91
+ if (!plainList)
92
+ for (const line of basisLines(basis, ' '))
93
+ console.log(color(tty, C.gray, line));
94
+ printPlanLines(tty, plans);
95
+ console.log('');
96
+ if (kept !== null && kept.kept.units + kept.notKept.units + kept.contested.units + kept.unknown.units + kept.maturing.units > 0) {
97
+ const asOf = new Date(stored.generatedAt).toISOString().slice(0, 10);
98
+ console.log(color(tty, C.bold, ' Did the AI work stay in the code?') + color(tty, C.gray, ` commits of the last 90 days, all projects · measured ${asOf}`));
99
+ const row = (label, b, tone, what) => {
100
+ if (b.units === 0)
101
+ return;
102
+ console.log(` ${label.padEnd(10)} ${color(tty, tone, usd(b.costUsd).padStart(10))} ${String(b.units).padStart(4)} commits ${color(tty, C.gray, what)}`);
103
+ };
104
+ row('Kept', kept.kept, C.green, 'still in the code after 14 days, no tracked check failed');
105
+ row('Not kept', kept.notKept, C.yellow, 'rewritten, removed or reverted');
106
+ row('Disputed', kept.contested, C.gray, 'the checks disagreed; not settled either way');
107
+ row('Unknown', kept.unknown, C.gray, 'not measured');
108
+ row('Maturing', kept.maturing, C.gray, 'younger than 14 days when measured');
109
+ console.log(color(tty, C.gray, ' Refresh: segreant scan --setup · one project in full: segreant realize --repo <path>'));
110
+ }
111
+ else {
112
+ console.log(color(tty, C.gray, ' Not measured yet: what the work produced. Run segreant scan --setup, or segreant realize --repo <path> for one project.'));
113
+ }
114
+ console.log('');
115
+ console.log(` ${color(tty, C.bold, 'Next')} ${color(tty, C.cyan, 'segreant start')}${color(tty, C.gray, ' opens the dashboard')} · ${color(tty, C.cyan, 'segreant quota')}${color(tty, C.gray, ' your plan limits')} · ${color(tty, C.cyan, 'segreant guide')}${color(tty, C.gray, ' the full checklist')}`);
116
+ console.log('');
117
+ }
@@ -6,15 +6,20 @@
6
6
  */
7
7
  import { join } from 'node:path';
8
8
  import { Store } from '../store/db.js';
9
- import { dbPath, loadConfig } from '../config.js';
10
- import { pricingStatus } from '../cost/pricing.js';
11
- import { realizeDiscoveredProjects, projectValueBreakdown } from '../value/realization.js';
9
+ import { dbPath, loadConfig, isDemo, DemoWriteBlockedError } from '../config.js';
10
+ import { pricingStatus, refreshPricing, DEFAULT_MANIFEST_URL } from '../cost/pricing.js';
11
+ import { priceCheckDue } from '../cost/firstSight.js';
12
+ import { realizeDiscoveredProjects, projectValueBreakdown, CLI_PERIOD_DAYS, CLI_GIT_BUDGET_MS, SCAN_VALUE_BUDGET_MS, } from '../value/realization.js';
12
13
  import { scanWithDiff, saveScan } from '../scan/scan.js';
13
- import { importClaudeCode, defaultClaudeCodeRoot } from '../connect/claudeCode.js';
14
- import { importOpencode, defaultOpencodeDbPath } from '../connect/opencode.js';
15
- import { importCodex, defaultCodexRoot } from '../connect/codex.js';
14
+ import { defaultClaudeCodeRoot } from '../connect/claudeCode.js';
15
+ import { IMPORT_REGISTRY, importerFor } from '../connect/registry.js';
16
16
  import {} from '../connect/importShared.js';
17
17
  import { C, color, usd, num, printJson } from './ui.js';
18
+ import { rangeFor } from './flags.js';
19
+ import { askPlanPrices } from './planCmd.js';
20
+ import { askModelPrices } from './askModelPrices.js';
21
+ import { summarizeBasis } from '../cost/basis.js';
22
+ import { basisLines } from './basisLines.js';
18
23
  /**
19
24
  * Live import: poll the source(s) on an interval and fold in new traffic as it
20
25
  * appears. Reads are read-only snapshots (SQLite WAL / streamed JSONL), so the
@@ -78,33 +83,107 @@ async function cmdImportWatch(targets, opts) {
78
83
  process.on('SIGTERM', stop);
79
84
  });
80
85
  }
81
- const IMPORT_RUNNERS = {
82
- 'claude-code': {
83
- label: 'Claude Code',
84
- location: (r) => r ?? defaultClaudeCodeRoot(),
85
- run: (store, opts) => importClaudeCode(store, opts),
86
- },
87
- opencode: {
88
- label: 'opencode',
89
- location: (r) => r ?? defaultOpencodeDbPath() ?? '(opencode not found on this machine)',
90
- run: (store, opts) => importOpencode(store, opts),
91
- },
92
- codex: {
93
- label: 'Codex CLI',
94
- location: (r) => r ?? defaultCodexRoot() ?? '(Codex not found on this machine)',
95
- run: (store, opts) => importCodex(store, opts),
96
- },
97
- };
86
+ const IMPORT_RUNNERS = Object.fromEntries(IMPORT_REGISTRY.map((e) => [e.id, {
87
+ label: e.label,
88
+ // Claude Code's root is reported even when absent (it says where to look).
89
+ location: (r) => r ?? e.locate() ?? (e.id === 'claude-code' ? defaultClaudeCodeRoot() : `(${e.label} not found on this machine)`),
90
+ run: e.run,
91
+ recentFirst: e.recentFirst,
92
+ }]));
98
93
  /** Normalize the aliases users actually type. */
99
94
  function resolveImporterId(what) {
100
- const w = what.toLowerCase();
101
- if (w === 'claude-code' || w === 'claudecode' || w === 'claude')
102
- return 'claude-code';
103
- if (w === 'opencode')
104
- return 'opencode';
105
- if (w === 'codex' || w === 'codex-cli')
106
- return 'codex';
107
- return null;
95
+ return importerFor(what)?.id ?? null;
96
+ }
97
+ /**
98
+ * One source's two passes as one summary. The backfill counted every file
99
+ * again (the recent ones as unchanged), so file and truncation totals come
100
+ * from it; rows and money add, because each pass inserted different rows.
101
+ */
102
+ function mergeImportSummaries(recent, rest) {
103
+ if (recent === undefined)
104
+ return rest;
105
+ const byModel = {};
106
+ for (const part of [recent.byModel, rest.byModel]) {
107
+ for (const [model, v] of Object.entries(part)) {
108
+ const into = byModel[model] ?? { requests: 0, costUsd: 0 };
109
+ into.requests += v.requests;
110
+ into.costUsd += v.costUsd;
111
+ byModel[model] = into;
112
+ }
113
+ }
114
+ const lo = [recent.earliestMs, rest.earliestMs].filter((v) => v !== null);
115
+ const hi = [recent.latestMs, rest.latestMs].filter((v) => v !== null);
116
+ const conflicts = (recent.conflictingObservations ?? 0) + (rest.conflictingObservations ?? 0);
117
+ return {
118
+ ...rest,
119
+ inserted: recent.inserted + rest.inserted,
120
+ eventsSeen: recent.eventsSeen + rest.eventsSeen,
121
+ costUsd: recent.costUsd + rest.costUsd,
122
+ estimatedCostUsd: recent.estimatedCostUsd + rest.estimatedCostUsd,
123
+ byModel,
124
+ earliestMs: lo.length > 0 ? Math.min(...lo) : null,
125
+ latestMs: hi.length > 0 ? Math.max(...hi) : null,
126
+ relabelled: [...recent.relabelled, ...rest.relabelled],
127
+ ...(conflicts > 0 ? { conflictingObservations: conflicts } : {}),
128
+ };
129
+ }
130
+ /** "kept $1,643 · not kept $408": a project's answer from git, or why there is none yet. */
131
+ function keptLine(tty, d) {
132
+ if (!d.measured)
133
+ return color(tty, C.gray, d.skipped === 'no-spend' ? 'no priced spend' : 'not measured in this run');
134
+ const k = d.kept;
135
+ if (k === undefined || k.kept.units + k.notKept.units + k.contested.units + k.unknown.units === 0) {
136
+ return color(tty, C.gray, k !== undefined && k.maturing.units > 0 ? 'maturing (commits under 14 days)' : 'no commits to judge');
137
+ }
138
+ const parts = [];
139
+ if (k.kept.costUsd >= 0.005)
140
+ parts.push(color(tty, C.green, `kept ${usd(k.kept.costUsd)}`));
141
+ if (k.notKept.costUsd >= 0.005)
142
+ parts.push(color(tty, C.yellow, `not kept ${usd(k.notKept.costUsd)}`));
143
+ if (k.contested.costUsd >= 0.005)
144
+ parts.push(color(tty, C.gray, `disputed ${usd(k.contested.costUsd)}`));
145
+ if (k.unknown.costUsd >= 0.005)
146
+ parts.push(color(tty, C.gray, `not measured yet ${usd(k.unknown.costUsd)}`));
147
+ if (k.maturing.costUsd >= 0.005)
148
+ parts.push(color(tty, C.gray, `maturing ${usd(k.maturing.costUsd)}`));
149
+ if (parts.length === 0)
150
+ return color(tty, C.gray, 'no spend on its commits');
151
+ return parts.join(color(tty, C.gray, ' · '));
152
+ }
153
+ /**
154
+ * The part of a project's period spend that is on no measured commit, with its
155
+ * reasons. Printed under the project line so the buckets above plus this line
156
+ * add up to the project's total; before, $222 of one project's $567 had no line.
157
+ */
158
+ export function offCommitLine(d) {
159
+ const c = d.coverage;
160
+ if (c === undefined)
161
+ return null;
162
+ const parts = [];
163
+ if (c.noCommitFollowedUsd >= 0.005)
164
+ parts.push(`${usd(c.noCommitFollowedUsd)} no commit followed within 8 hours`);
165
+ if (c.beforeOldestUsd >= 0.005)
166
+ parts.push(`${usd(c.beforeOldestUsd)} before the oldest commit measured`);
167
+ if (c.notCommittedYetUsd >= 0.005)
168
+ parts.push(`${usd(c.notCommittedYetUsd)} not committed yet`);
169
+ return parts.length === 0 ? null : `not on a commit: ${parts.join(' · ')}`;
170
+ }
171
+ /** Repositories the shared deadline did not reach: named, with the command that measures them. */
172
+ function printUnmeasured(tty, discovered) {
173
+ const left = discovered.filter((d) => d.skipped === 'deadline');
174
+ if (left.length > 0) {
175
+ const spend = left.reduce((s, d) => s + (d.periodCostUsd ?? d.costUsd), 0);
176
+ console.log(color(tty, C.gray, ` ${left.length} smaller project(s) (${usd(spend)} of spend) were not measured in the time this scan allows.`));
177
+ console.log(color(tty, C.gray, ` Measure one with: segreant realize --repo "${left[0].repoPath}"`));
178
+ }
179
+ // The scan keeps to its time budget, so a large project can come back partly
180
+ // measured. Name the biggest such gap and the command that finishes it.
181
+ const partial = discovered
182
+ .filter((d) => d.measured && d.kept !== undefined && d.kept.unknown.costUsd >= 1)
183
+ .sort((a, b) => b.kept.unknown.costUsd - a.kept.unknown.costUsd)[0];
184
+ if (partial !== undefined) {
185
+ console.log(color(tty, C.gray, ` ${partial.project}: ${usd(partial.kept.unknown.costUsd)} not measured yet in the time this scan allows. Finish it with: segreant realize --repo "${partial.repoPath}"`));
186
+ }
108
187
  }
109
188
  /** "3 files, 507 lines" — the parts of an import that hit a resource bound. */
110
189
  function truncationDetail(sum) {
@@ -186,6 +265,12 @@ export async function cmdImport(flags) {
186
265
  process.exitCode = 1;
187
266
  return;
188
267
  }
268
+ // Demo mode opens demo.db. Real usage must not land there, under sample-data labels.
269
+ if (isDemo()) {
270
+ console.error(` ${new DemoWriteBlockedError('the import').message}`);
271
+ process.exitCode = 1;
272
+ return;
273
+ }
189
274
  const days = typeof flags.days === 'string' ? Number(flags.days) : null;
190
275
  const sinceMs = days && days > 0 ? Date.now() - days * 24 * 60 * 60 * 1000 : 0;
191
276
  const root = typeof flags.root === 'string' ? flags.root : undefined;
@@ -253,7 +338,10 @@ export async function cmdDiscover(flags) {
253
338
  const store = new Store(dbPath());
254
339
  const paths = store.projectPaths();
255
340
  const progress = repoProgress(flags);
256
- const discovered = await realizeDiscoveredProjects(store, { windowDays, onProgress: progress.onProgress });
341
+ const discovered = await realizeDiscoveredProjects(store, {
342
+ windowDays, onProgress: progress.onProgress, sinceDays: CLI_PERIOD_DAYS, gitScanBudgetMs: CLI_GIT_BUDGET_MS,
343
+ stageBudgetMs: SCAN_VALUE_BUDGET_MS,
344
+ });
257
345
  progress.finish();
258
346
  const projects = projectValueBreakdown(store, { windowDays });
259
347
  store.close();
@@ -286,10 +374,11 @@ export async function cmdDiscover(flags) {
286
374
  ? color(tty, C.gray, 'RoI —')
287
375
  : color(tty, roi > 60 ? C.green : roi > 30 ? C.yellow : C.red, `RoI ${Math.round(roi)}`);
288
376
  const tools = d.sources.length ? d.sources.join(', ') : 'unknown';
289
- console.log(` ${color(tty, C.bold, d.project.padEnd(22))} ${usd(d.costUsd).padStart(10)} ${d.realizedUnits}/${d.units} realized ${roiStr}`);
377
+ console.log(` ${color(tty, C.bold, d.project.padEnd(22))} ${usd(d.costUsd).padStart(10)} ${keptLine(tty, d)} ${roiStr}`);
290
378
  console.log(color(tty, C.gray, ` ${d.repoPath}`));
291
379
  console.log(color(tty, C.gray, ` coded with: ${tools}`));
292
380
  }
381
+ printUnmeasured(tty, discovered);
293
382
  console.log('');
294
383
  console.log(color(tty, C.gray, ' RoI here scores every stored unit (all time); "segreant roi --repo" scopes to a window — the two can differ.'));
295
384
  console.log(color(tty, C.gray, ' Now live in: segreant roi · segreant today · the dashboard (By project).'));
@@ -358,7 +447,7 @@ export async function cmdScan(flags) {
358
447
  console.log('');
359
448
  console.log(color(tty, C.bold, ' Scan — find your AI tools and repos, then set it all up'));
360
449
  console.log(color(tty, C.gray, ' ' + '─'.repeat(64)));
361
- console.log(color(tty, C.gray, ' Read-only: reads what exists on disk. Nothing is imported or sent.'));
450
+ console.log(color(tty, C.gray, ' Reads what exists on disk and remembers this scan to report changes. Nothing is imported or sent.'));
362
451
  console.log('');
363
452
  // Detected tools.
364
453
  console.log(color(tty, C.bold, ' AI coding tools on this machine'));
@@ -375,7 +464,7 @@ export async function cmdScan(flags) {
375
464
  if (otherPresent.length > 0) {
376
465
  console.log(color(tty, C.bold, ' Other AI tools detected (not yet natively supported)'));
377
466
  for (const a of otherPresent) {
378
- console.log(` ${color(tty, C.gray, '·')} ${a.label.padEnd(16)} ${color(tty, C.gray, a.evidence ?? '')}`);
467
+ console.log(` ${color(tty, C.gray, '·')} ${a.label.padEnd(16)} ${color(tty, C.gray, a.notReadBecause ? `not read: ${a.notReadBecause}` : a.evidence ?? '')}`);
379
468
  }
380
469
  console.log(color(tty, C.gray, ' We see these exist, but don\'t read their usage data yet — spend from them isn\'t counted above.'));
381
470
  console.log('');
@@ -412,7 +501,25 @@ export async function cmdScan(flags) {
412
501
  // Imported rows keep the price they were recorded at, so a stale card turns
413
502
  // every model newer than it into a fallback estimate for good (until a
414
503
  // reprice). Say so BEFORE the import, where it can still be avoided.
415
- const prices = pricingStatus(loadConfig().pricing.maxAgeDays);
504
+ const pricingCfg = loadConfig().pricing;
505
+ let prices = pricingStatus(pricingCfg.maxAgeDays);
506
+ // Imported rows keep the price they are recorded at, so the best moment to
507
+ // learn new models is before the import, not after it. Only when someone is
508
+ // at the terminal: a scripted import does not gain a network call it did not
509
+ // ask for (the running proxy still looks models up on first sight). Only the
510
+ // built-in public list, which is what this line and the privacy statement
511
+ // disclose: a custom pricing.manifestUrl is refreshed when its operator asks
512
+ // (segreant pricing --refresh, or autoRefresh), never by scan (PR #146 review).
513
+ const builtInList = pricingCfg.manifestUrl === null || pricingCfg.manifestUrl === undefined || pricingCfg.manifestUrl === DEFAULT_MANIFEST_URL;
514
+ if (flags.setup && !flags.json && tty && !isDemo() && pricingCfg.lookupUnknownModels && builtInList && priceCheckDue(prices)) {
515
+ console.log(color(tty, C.gray, ' Checking the public price list so imported requests get current prices (sends nothing about you)…'));
516
+ const checked = await refreshPricing(DEFAULT_MANIFEST_URL);
517
+ console.log(checked.ok
518
+ ? color(tty, C.gray, ` Prices current: ${num(checked.modelCount ?? 0)} models on the card.`)
519
+ : color(tty, C.yellow, ` Price check skipped: ${checked.error ?? 'the price list could not be read'}.`));
520
+ prices = pricingStatus(pricingCfg.maxAgeDays);
521
+ console.log('');
522
+ }
416
523
  if (prices.stale && !flags.json) {
417
524
  const age = prices.ageDays === null ? 'of unknown age' : `${num(prices.ageDays)} days old`;
418
525
  console.log(color(tty, C.yellow, ` Prices are ${age}: models released since then will be priced by a fallback estimate.`));
@@ -448,6 +555,34 @@ export async function cmdScan(flags) {
448
555
  if (!flags.json)
449
556
  console.log(color(tty, C.bold, ' Setting up…'));
450
557
  let totalNew = 0;
558
+ // Newest first: the logs of the last 30 days, then the month's number, then
559
+ // the older history. The month is exact after the first pass (see
560
+ // ImportOptions.modifiedSinceMs); the backfill only adds older rows.
561
+ const month = rangeFor('month');
562
+ const reading = !flags.json && process.stderr.isTTY;
563
+ const recent = new Map();
564
+ for (const t of present) {
565
+ const runner = IMPORT_RUNNERS[t.id];
566
+ if (!runner)
567
+ continue;
568
+ if (reading)
569
+ process.stderr.write(` Reading the last 30 days of ${t.label} logs…`);
570
+ recent.set(t.id, await runner.run(store, runner.recentFirst ? { modifiedSinceMs: month.startMs } : {}));
571
+ if (reading)
572
+ process.stderr.write('\r\x1b[2K');
573
+ }
574
+ if (!flags.json && recent.size > 0) {
575
+ const m = store.summary(month.startMs, month.endMs);
576
+ const basis = summarizeBasis(store.pricingEvidenceByModel(month.startMs, month.endMs));
577
+ const plainList = basis.cohorts.length === 1 && basis.cohorts[0].id === 'list_exact';
578
+ console.log(` ${color(tty, C.bold, 'Last 30 days')} ${color(tty, C.green, usd(m.costUsd))} ${basis.headlineLabel.toLowerCase()} ${color(tty, C.gray, plainList
579
+ ? `(${num(m.requests)} requests · each model's API list rate · not your bill)`
580
+ : `(${num(m.requests)} requests, priced in parts:)`)}`);
581
+ if (!plainList)
582
+ for (const line of basisLines(basis, ' '))
583
+ console.log(color(tty, C.gray, line));
584
+ console.log(color(tty, C.gray, ' Now reading older history, then measuring what the work produced…'));
585
+ }
451
586
  for (const t of present) {
452
587
  const runner = IMPORT_RUNNERS[t.id];
453
588
  if (!runner) {
@@ -456,12 +591,18 @@ export async function cmdScan(flags) {
456
591
  }
457
592
  continue;
458
593
  }
459
- const reading = !flags.json && process.stderr.isTTY;
460
- if (reading)
461
- process.stderr.write(` Reading ${t.label} logs…`);
462
- const sum = await runner.run(store, {});
463
- if (reading)
464
- process.stderr.write('\r\x1b[2K');
594
+ const first = recent.get(t.id);
595
+ let sum;
596
+ if (runner.recentFirst) {
597
+ if (reading)
598
+ process.stderr.write(` Reading older ${t.label} logs…`);
599
+ sum = mergeImportSummaries(first, await runner.run(store, {}));
600
+ if (reading)
601
+ process.stderr.write('\r\x1b[2K');
602
+ }
603
+ else {
604
+ sum = first;
605
+ }
465
606
  totalNew += sum.inserted;
466
607
  if (!flags.json) {
467
608
  console.log(` ${color(tty, C.green, '✓')} ${t.label.padEnd(16)} ${color(tty, C.green, `${num(sum.inserted)} new`)}` +
@@ -474,27 +615,42 @@ export async function cmdScan(flags) {
474
615
  if (present.length === 0 && !flags.json)
475
616
  console.log(color(tty, C.gray, ' No detected tools to import.'));
476
617
  const progress = repoProgress(flags);
477
- const discovered = await realizeDiscoveredProjects(store, { onProgress: progress.onProgress });
618
+ const discovered = await realizeDiscoveredProjects(store, {
619
+ onProgress: progress.onProgress, sinceDays: CLI_PERIOD_DAYS, gitScanBudgetMs: CLI_GIT_BUDGET_MS,
620
+ stageBudgetMs: SCAN_VALUE_BUDGET_MS,
621
+ });
478
622
  progress.finish();
479
- const projects = projectValueBreakdown(store, {});
480
- const roiByProject = new Map(projects.map((p) => [p.project, p.roiIndex]));
481
623
  store.close();
482
624
  if (!flags.json) {
483
625
  console.log('');
484
- console.log(color(tty, C.bold, ` Correlated ${discovered.length} project(s) into per-project RoI`));
485
- for (const d of discovered.slice(0, 12)) {
486
- const roi = roiByProject.get(d.project);
487
- const roiStr = roi == null
488
- ? color(tty, C.gray, 'RoI —')
489
- : color(tty, roi > 60 ? C.green : roi > 30 ? C.yellow : C.red, `RoI ${Math.round(roi)}`);
626
+ const shown = discovered.filter((d) => d.skipped !== 'no-spend');
627
+ console.log(color(tty, C.bold, ` Did the AI work stay in the code?`) + color(tty, C.gray, ` last ${CLI_PERIOD_DAYS} days by project · list cost (estimate), not your bill`));
628
+ for (const d of shown.slice(0, 12)) {
490
629
  const tools = d.sources.length ? d.sources.join(', ') : 'unknown';
491
- console.log(` ${color(tty, C.bold, d.project.padEnd(22))} ${usd(d.costUsd).padStart(10)} ${roiStr} ${color(tty, C.gray, `coded with: ${tools}`)}`);
630
+ console.log(` ${color(tty, C.bold, d.project.padEnd(24))} ${usd(d.periodCostUsd ?? d.costUsd).padStart(10)} ${keptLine(tty, d)} ${color(tty, C.gray, `coded with: ${tools}`)}`);
631
+ const off = offCommitLine(d);
632
+ if (off !== null)
633
+ console.log(color(tty, C.gray, ` ${''.padEnd(24)} ${''.padStart(10)} ${off}`));
492
634
  }
635
+ if (shown.length > 12)
636
+ console.log(color(tty, C.gray, ` …and ${shown.length - 12} more (segreant discover lists them all)`));
637
+ const idle = discovered.length - shown.length;
638
+ if (idle > 0)
639
+ console.log(color(tty, C.gray, ` ${idle} more repositor${idle === 1 ? 'y has' : 'ies have'} no priced AI spend in the last ${CLI_PERIOD_DAYS} days.`));
640
+ printUnmeasured(tty, discovered);
493
641
  console.log('');
494
- console.log(color(tty, C.gray, ' RoI here scores every stored unit (all time); "segreant roi --repo" scopes to a window — the two can differ.'));
495
- console.log(color(tty, C.gray, ` Imported ${num(totalNew)} new request(s). Now live in: segreant today · roi · the dashboard.`));
642
+ console.log(color(tty, C.gray, ' Kept: most of a commit\'s lines are still in the code after 14 days and no check you track failed. Not kept: a tracked check failed, or the lines were rewritten, removed or reverted.'));
643
+ console.log(color(tty, C.gray, ' The full picture for one project: segreant realize --repo <path> · value against your time: segreant roi'));
644
+ console.log(color(tty, C.gray, ` Imported ${num(totalNew)} new request(s). Now live in: segreant today · month · the dashboard.`));
496
645
  console.log(color(tty, C.gray, ' Safe to re-run any time to fold in new tools, repos, and traffic.'));
497
646
  console.log('');
647
+ if (await askPlanPrices(tty) > 0)
648
+ console.log(color(tty, C.gray, ' Saved. segreant month now shows each plan beside the work it did.'));
649
+ await askModelPrices(tty);
650
+ if (present.some((t) => t.id === 'claude-code')) {
651
+ console.log(color(tty, C.gray, ' Claude\'s live usage meter: segreant statusline --setup shows the one line to add to Claude Code.'));
652
+ }
653
+ console.log('');
498
654
  }
499
655
  if (flags.json)
500
656
  printJson({ setup: true, totalNew, discovered });
@@ -14,7 +14,8 @@ import { spawn } from 'node:child_process';
14
14
  import { loadConfig } from '../config.js';
15
15
  import { probeProxyState } from '../egress/proxyHealth.js';
16
16
  import {} from './flags.js';
17
- function capConfigured(cfg) {
17
+ /** Whether any hard cap is set. The start banner uses the same test, so its advice matches launch. */
18
+ export function capConfigured(cfg) {
18
19
  const b = cfg.budget;
19
20
  return b.dailyUsd !== null || b.sessionUsd !== null || b.runawayMaxUsd !== null;
20
21
  }