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
@@ -1,24 +1,28 @@
1
1
  /**
2
- * Allocation — whose budget the money lands on, and what that number is not.
2
+ * Allocate — whose budget the money lands on, and what that number is not.
3
3
  *
4
4
  * Allocated cost is the third claim in the spine, and the one most often
5
5
  * mistaken for the first. It is a DERIVED split of local estimates: a rule said
6
6
  * this request belongs to that cost centre. It is not a provider bill, it is not
7
- * what anyone owes, and it is explicitly excluded from budget enforcement, RoI,
8
- * and model recommendations — because a showback split is not evidence about
9
- * value or about money actually charged.
7
+ * what anyone owes, and it is excluded from budget enforcement, RoI and model
8
+ * recommendations, because a showback split is not evidence about value or about
9
+ * money actually charged.
10
10
  *
11
- * So this view leads with the exclusions, not with a total. The payload states
12
- * its own basis (`showback_only`) and the list of things it must not feed; that
13
- * list is rendered as the first thing on the page rather than as a footnote,
14
- * because a reader who takes an allocation figure into a budget conversation has
15
- * already made the mistake the label exists to prevent.
11
+ * This page used to describe allocation and send you to the terminal to do it.
12
+ * It is now the job itself, in the order the engine needs it: cost centres, then
13
+ * rules, then a run. A run is previewed first and recorded only on a second,
14
+ * separate action, the GUI's form of the CLI's `--apply`. The basis and the
15
+ * exclusions stay at the top, in one strip, so the figure is never met without
16
+ * them.
16
17
  */
17
18
  import { h } from '../core/dom.js';
19
+ import { slug } from '../core/slug.js';
20
+ import { localMidnightIso, dayAfterIso, monthRange, REASON_WORDS } from '../core/periods.js';
18
21
  import { signal, scopedEffect } from '../core/signal.js';
19
22
  import { api } from '../core/api.js';
20
- import { isPrecise, relative, count } from '../core/fmt.js';
21
- import { actionCard } from './spend.js';
23
+ import { isPrecise, relative, count, usdFromMicros, date, share as shareOf } from '../core/fmt.js';
24
+ import { pageNotice, step, field, errorText, holdFocus } from '../components/workflow.js';
25
+ import { ruleFields, describeRule } from '../components/ruleFields.js';
22
26
  /** The payload's `excludedFrom` ids, said in words an operator can act on. */
23
27
  const EXCLUSION_WORDS = {
24
28
  request_metered_spend: 'the metered spend figure',
@@ -29,62 +33,246 @@ const EXCLUSION_WORDS = {
29
33
  export function allocationView() {
30
34
  const data = signal(null);
31
35
  const error = signal(null);
36
+ const projects = signal([]);
37
+ const models = signal([]);
38
+ const reloads = signal(0);
39
+ const reload = () => reloads.set(reloads() + 1);
40
+ const { node: noticeNode, say } = pageNotice();
41
+ const reloadAll = () => {
42
+ reload();
43
+ // The spine above this page reads /api/allocation once. Without this it kept
44
+ // saying "no cost centres and no rules yet" under a page that had just saved both.
45
+ window.dispatchEvent(new CustomEvent('segreant:ledger-changed', { detail: { claim: 'allocation' } }));
46
+ };
32
47
  scopedEffect(() => {
48
+ reloads();
33
49
  void api.allocation()
34
- .then((payload) => data.set(payload))
35
- .catch((e) => error.set(e instanceof Error ? e.message : String(e)));
50
+ .then((payload) => { const restore = holdFocus(page); error.set(null); data.set(payload); restore(); })
51
+ .catch((e) => error.set(errorText(e)));
36
52
  });
37
- return h('div', null, h('div', { class: 'view-head' }, h('h1', { class: 'view-title', text: 'Allocated' }), h('p', { class: 'view-plain', text: () => isPrecise()
38
- ? 'A derived split of locally estimated cost across cost centres, by recorded rule. Showback only — no settlement, no chargeback.'
39
- : 'Which team or project each pound of AI spend gets attributed to. This is a split of our own estimate — not a bill, and not something anyone is charged.' })), () => {
53
+ // Project names to match on. A typo in a match makes a rule that claims
54
+ // nothing, and the run would only say "no rule claimed it" afterwards.
55
+ void api.overview('all')
56
+ .then((o) => {
57
+ projects.set(o.byProject.map((p) => p.label).filter((l) => l && l !== '(unattributed)'));
58
+ models.set([...new Set(o.byModel.map((m) => m.label).filter(Boolean))]);
59
+ })
60
+ .catch(() => { projects.set([]); models.set([]); });
61
+ const page = h('div', null, h('div', { class: 'view-head' }, h('h1', { class: 'view-title', text: 'Allocate' }), h('p', { class: 'view-plain', text: () => isPrecise()
62
+ ? 'A derived split of locally estimated cost across cost centres, by recorded rule. Showback only: no settlement, no chargeback.'
63
+ : 'Decide which team or budget each dollar of AI spend is attributed to. This splits our own estimate. It is not a bill, and nobody is charged.' })), noticeNode, () => {
40
64
  const err = error();
41
- if (err)
42
- return h('div', { class: 'card' }, h('p', { class: 'drawer-error', role: 'alert', 'aria-live': 'assertive', text: err }));
43
65
  const d = data();
66
+ if (!d && err)
67
+ return h('div', { class: 'card' }, h('p', { class: 'drawer-error', role: 'alert', text: err }));
44
68
  if (!d)
45
- return h('div', { class: 'card' }, h('p', { class: 'drawer-muted', role: 'status', 'aria-live': 'polite', 'aria-busy': 'true', text: 'Loading…' }));
46
- const centres = d.costCentres ?? [];
47
- const rules = d.rules ?? [];
48
- const runs = d.runs ?? [];
49
- const configured = centres.length > 0 && rules.length > 0;
50
- return h('div', null,
51
- // The exclusions come first, deliberately. See the module note above.
52
- h('div', { class: 'card card-basis' }, h('div', { class: 'card-head' }, h('span', { class: 'card-title', text: () => (isPrecise() ? 'Basis and exclusions' : 'What this number is not') }), h('span', { class: 'pill pill-estimate', text: 'showback only' })), h('p', { text: () => (isPrecise()
53
- ? 'kind: derived_cost_allocation · trust: derived_allocation_of_local_estimates'
54
- : 'These figures are worked out by rules you wrote, applied to costs we estimated ourselves.') }), h('p', { class: 'excl-lede', text: () => (isPrecise() ? 'Deliberately excluded from:' : 'Segreant deliberately refuses to let this feed:') }), h('ul', { class: 'excl' }, ...(d.excludedFrom ?? []).map((id) => h('li', { text: EXCLUSION_WORDS[id] ?? id.replace(/_/g, ' ') }))), h('span', { class: 'basis', text: () => (isPrecise()
55
- ? 'A showback split is not evidence of money charged, nor of value produced.'
56
- : 'Splitting a cost does not make it a bill, and it does not prove the spend was worth it.') })),
57
- // The residual cross-reference. Allocation of a ledger nobody has checked
58
- // against a provider bill is a confident split of an unverified total, and
59
- // the operator should meet that fact here rather than infer it.
60
- h('div', { class: 'card', style: 'margin-top: var(--s4)' }, h('div', { class: 'card-head' }, h('span', { class: 'card-title', text: () => (isPrecise() ? 'Underlying ledger' : 'What this is a split of') }), h('span', {
61
- class: `pill ${d.reconciliation?.everRun ? 'pill-ok' : 'pill-unverified'}`,
62
- text: d.reconciliation?.everRun ? 'checked' : 'unchecked',
63
- })), d.reconciliation?.everRun
64
- ? h('p', { text: () => (isPrecise()
65
- ? `The underlying ledger has been reconciled against provider billing; last run ${relative(d.reconciliation.latestComputedAtMs)}.`
66
- : `We have checked the underlying spend against a real provider bill — last time ${relative(d.reconciliation.latestComputedAtMs)}.`) })
67
- : h('p', { text: () => (isPrecise()
68
- ? 'No reconciliation run recorded. Every figure below splits a total that has never been compared against provider billing, so the residual beneath it is unbounded.'
69
- : 'Nobody has checked the underlying spend against a real provider bill yet. The split below is exact arithmetic on a number we estimated ourselves.') })), configured
70
- ? h('div', null, section(() => (isPrecise() ? 'Cost centres' : 'Who the money is attributed to'), h('div', { class: 'facts' }, ...centres.map((c) => h('div', { class: 'fact' }, h('span', { class: 'fact-key', text: c.label ?? c.name ?? c.id }), h('span', { class: 'fact-val', text: c.id }))))), section(() => (isPrecise() ? 'Allocation rules' : 'The rules that decide the split'), h('div', { class: 'facts' },
71
- // A revoked rule is shown, not hidden: a past run was computed
72
- // under it, and a reader comparing runs needs to see why the
73
- // split changed rather than find a rule silently absent.
74
- ...rules.map((r) => h('div', { class: `fact${r.revokedAtMs ? ' fact-off' : ''}` }, h('span', { class: 'fact-key', text: r.id }), h('span', { class: 'fact-val', text: r.revokedAtMs
75
- ? `revoked · was ${r.method} v${r.version}`
76
- : `${r.method} · v${r.version}` }))))), section(() => (isPrecise() ? 'Recorded runs' : 'Times this has been worked out'), runs.length > 0
77
- ? h('p', { class: 'basis', text: () => (isPrecise()
78
- ? `${count(runs.length)} recorded run${runs.length === 1 ? '' : 's'}; each is a snapshot under the rule version in force at the time.`
79
- : `${count(runs.length)} saved result${runs.length === 1 ? '' : 's'}. Each one records the rules as they were on the day.`) })
80
- : h('p', { class: 'drawer-muted', text: () => (isPrecise()
81
- ? 'Rules and centres exist, but no allocation run has been recorded.'
82
- : 'You have set the rules up, but never run the split.') })))
83
- : h('div', { class: 'notyet', style: 'margin-top: var(--s5)' }, h('h2', { text: () => (isPrecise() ? 'No allocation model defined' : 'Nothing is being split yet') }), h('p', { text: () => (isPrecise()
84
- ? `${centres.length} cost centre${centres.length === 1 ? '' : 's'} and ${rules.length} rule${rules.length === 1 ? '' : 's'} recorded. Both are required before a run can produce a split.`
85
- : 'To split spend across teams or projects, Segreant needs at least one cost centre to attribute to, and one rule that decides what goes where.') })), section(() => (isPrecise() ? 'Actions on this layer' : 'Things you can do with this'), h('div', { class: 'actions' }, actionCard('alloc-centres'), actionCard('alloc-rules'), actionCard('alloc-run'), actionCard('project'))));
69
+ return h('div', { class: 'card' }, h('p', { class: 'drawer-muted', role: 'status', 'aria-busy': 'true', text: 'Loading…' }));
70
+ const open = d.costCentres.filter((c) => c.archivedAtMs == null);
71
+ // Any rule not revoked can claim spend in some period: a superseded version or
72
+ // one with an end date still governs the past it covered, which is exactly
73
+ // what a preview of an earlier period asks about.
74
+ const live = d.rules.filter((r) => r.revokedAtMs == null);
75
+ // People type names; the engine keys on ids. Show what they typed.
76
+ const names = new Map(d.costCentres.map((c) => [c.costCentreId, c.name]));
77
+ const nameOf = (id) => names.get(id) ?? id;
78
+ return h('div', null, basisStrip(d), d.demo
79
+ ? h('p', { class: 'section-note', role: 'note', text: 'This is sample data, so centres and rules cannot be saved here. You can still preview a run. Start Segreant without --demo to set up your own.' })
80
+ : null, step(1, () => (isPrecise() ? 'Cost centres' : 'Who carries the cost'), () => (isPrecise() ? 'Accounting positions that receive allocated spend.' : 'The teams, clients or budgets spend can be attributed to.'), centreList(d.costCentres, d.demo, reloadAll, say), d.demo ? null : centreForm(reloadAll, say)), step(2, () => (isPrecise() ? 'Rules' : 'What goes where'), () => (isPrecise() ? 'Priority-ordered; lower wins. Edits retain the old version and every recorded run. New previews follow effective dates.' : 'Each rule picks some spend and says which centre it belongs to. Changing a rule keeps old versions and recorded splits. A new preview follows the rule dates, so it can change an earlier period.'), ruleList(d.rules, d.demo, reloadAll, say, nameOf), d.demo ? null : open.length > 0
81
+ ? ruleForm(open, projects, models, reloadAll, say)
82
+ : h('p', { class: 'drawer-muted', text: 'Add a cost centre first. A rule needs somewhere to send spend.' })), step(3, () => (isPrecise() ? 'Run' : 'Work out the split'), () => (isPrecise() ? 'Preview computes; Record persists an immutable run.' : 'Preview the split for a month. Nothing is saved until you record it.'), live.length > 0 || d.demo
83
+ ? runPanel(d.demo, reloadAll, say, nameOf)
84
+ : h('p', { class: 'drawer-muted', text: 'Add a rule first. Without one, every dollar would show as unallocated.' })), history(d.runs));
86
85
  });
86
+ return page;
87
87
  }
88
- function section(title, ...body) {
89
- return h('section', { class: 'section' }, h('h2', { class: 'section-title', text: title }), ...body);
88
+ function basisStrip(d) {
89
+ const checked = d.reconciliation?.everRun;
90
+ return h('div', { class: 'card card-basis basis-strip' }, h('div', { class: 'card-head' }, h('span', { class: 'card-title', text: () => (isPrecise() ? 'Basis' : 'What this number is') }), h('span', { class: 'pill pill-estimate', text: 'showback only' }), h('span', { class: checked ? 'pill pill-unverified' : 'pill pill-empty', text: checked ? 'provider comparison on record' : 'no provider comparison on record' })), h('p', { text: () => (checked
91
+ ? `A split of our own cost estimate. A provider comparison is on record (${relative(d.reconciliation.latestComputedAtMs)}); it covers its own project and dates and does not verify the inputs to this allocation.`
92
+ : 'A split of our own cost estimate. No provider comparison is on record; this split is exact arithmetic on our estimate, not a verified bill.') }), h('p', { class: 'basis', text: () => `Never feeds ${(d.excludedFrom ?? []).map((id) => EXCLUSION_WORDS[id] ?? id.replace(/_/g, ' ')).join(', ')}.` }));
93
+ }
94
+ function centreList(centres, demo, reload, say) {
95
+ if (centres.length === 0)
96
+ return h('p', { class: 'drawer-muted', text: 'No cost centres yet.' });
97
+ return h('div', { class: 'facts' }, ...centres.map((c) => {
98
+ const archived = c.archivedAtMs != null;
99
+ return h('div', { class: `fact${archived ? ' fact-off' : ''}` }, h('span', { class: 'fact-key' }, h('span', { text: c.name }), c.owner ? h('span', { class: 'fact-sub', text: c.owner }) : null), h('span', { class: 'fact-val' }, h('span', { text: archived ? `archived · ${c.costCentreId}` : c.costCentreId }), !archived && !demo
100
+ ? h('button', {
101
+ class: 'link-btn',
102
+ 'aria-label': `Archive ${c.name}`,
103
+ onclick: async () => {
104
+ try {
105
+ say(true, (await api.write.allocation({ op: 'archive', id: c.costCentreId })).message);
106
+ reload();
107
+ }
108
+ catch (e) {
109
+ say(false, errorText(e));
110
+ }
111
+ },
112
+ text: 'Archive',
113
+ })
114
+ : null));
115
+ }));
116
+ }
117
+ function centreForm(reload, say) {
118
+ const name = signal('');
119
+ const owner = signal('');
120
+ const id = () => slug(name());
121
+ return h('form', {
122
+ class: 'inline-form',
123
+ onsubmit: async (event) => {
124
+ event.preventDefault();
125
+ if (!id())
126
+ return say(false, 'Give the cost centre a name with at least one letter or number.');
127
+ try {
128
+ const r = await api.write.allocation({ op: 'centre', id: id(), name: name().trim(), ...(owner().trim() ? { owner: owner().trim() } : {}) });
129
+ say(true, r.message);
130
+ name.set('');
131
+ owner.set('');
132
+ event.target.reset();
133
+ reload();
134
+ }
135
+ catch (e) {
136
+ say(false, errorText(e));
137
+ }
138
+ },
139
+ }, h('div', { class: 'form-row' }, field('centre-name', 'Name', h('input', { id: 'centre-name', class: 'drawer-input', required: true, maxlength: '120', placeholder: 'for example Engineering', autocomplete: 'off', oninput: (e) => name.set(e.target.value) })), field('centre-owner', 'Owner (optional)', h('input', { id: 'centre-owner', class: 'drawer-input', maxlength: '120', placeholder: 'who answers for it', autocomplete: 'off', oninput: (e) => owner.set(e.target.value) })), h('button', { class: 'btn-commit', type: 'submit', text: 'Add cost centre' })), h('p', { class: 'form-hint', text: () => (id() ? `Saved as "${id()}".` : '') }));
140
+ }
141
+ function ruleList(rules, demo, reload, say, nameOf) {
142
+ if (rules.length === 0)
143
+ return h('p', { class: 'drawer-muted', text: 'No rules yet.' });
144
+ return h('div', { class: 'facts' },
145
+ // A revoked or superseded version is shown, not hidden: a past run was
146
+ // computed under it, and a reader comparing runs needs to see why the split
147
+ // changed rather than find a rule silently absent.
148
+ ...rules.map((r) => {
149
+ // Superseded means a later version of the same rule exists. An end date the
150
+ // operator set is not supersession: `--until` and "Stops on" set it too, and
151
+ // calling those rules superseded misdescribed a rule that is still the
152
+ // current version.
153
+ const superseded = rules.some((o) => o.ruleId === r.ruleId && o.version > r.version);
154
+ const ended = !superseded && r.effectiveToMs != null && r.effectiveToMs <= Date.now();
155
+ const state = r.revokedAtMs != null ? 'revoked' : superseded ? 'superseded' : ended ? 'ended' : null;
156
+ const dates = [
157
+ r.effectiveFromMs > 0 ? `from ${date(r.effectiveFromMs)}` : '',
158
+ r.effectiveToMs != null && !superseded ? `${ended ? 'ended' : 'until'} ${date(r.effectiveToMs)}` : '',
159
+ ].filter(Boolean).join(' · ');
160
+ return h('div', { class: `fact${state ? ' fact-off' : ''}` }, h('span', { class: 'fact-key' }, h('span', { text: describeRule(r, nameOf) }), dates ? h('span', { class: 'fact-sub', text: dates }) : null), h('span', { class: 'fact-val' }, h('span', { text: `${r.ruleId} v${r.version}${state && state !== 'ended' ? ` · ${state}` : ` · p${r.priority}`}` }), !state && !demo
161
+ ? h('button', {
162
+ class: 'link-btn',
163
+ 'aria-label': `Revoke rule ${r.ruleId}`,
164
+ onclick: async () => {
165
+ try {
166
+ say(true, (await api.write.allocation({ op: 'revoke', id: r.ruleId })).message);
167
+ reload();
168
+ }
169
+ catch (e) {
170
+ say(false, errorText(e));
171
+ }
172
+ },
173
+ text: 'Revoke',
174
+ })
175
+ : null));
176
+ }));
177
+ }
178
+ function ruleForm(centres, projects, models, reload, say) {
179
+ const fields = ruleFields(centres, projects, models);
180
+ return h('form', {
181
+ class: 'inline-form',
182
+ onsubmit: async (event) => {
183
+ event.preventDefault();
184
+ const draft = fields.build();
185
+ if (typeof draft === 'string')
186
+ return say(false, draft);
187
+ try {
188
+ const r = await api.write.allocation(draft.body);
189
+ say(true, isPrecise() ? r.message : 'Rule saved. Recorded splits keep their results; preview again to see this rule\'s effect.');
190
+ reload();
191
+ }
192
+ catch (e) {
193
+ say(false, errorText(e));
194
+ }
195
+ },
196
+ }, ...fields.children, h('div', { class: 'form-actions' }, h('button', { class: 'btn-commit', type: 'submit', text: 'Save rule' })));
197
+ }
198
+ function runPanel(demo, reload, say, nameOf) {
199
+ const ranges = [monthRange(-1), monthRange(0)];
200
+ const CUSTOM = ranges.length;
201
+ const chosen = signal(0);
202
+ const customFrom = signal('');
203
+ const customThrough = signal('');
204
+ const preview = signal(null);
205
+ const busy = signal(false);
206
+ const run = async (apply) => {
207
+ let r;
208
+ if (chosen() === CUSTOM) {
209
+ if (!customFrom() || !customThrough())
210
+ return say(false, 'Choose both the first and the last day of the period.');
211
+ if (customThrough() < customFrom())
212
+ return say(false, 'The last day must not be before the first day.');
213
+ // Both days are included: the period ends at the local midnight after the last one.
214
+ r = { from: localMidnightIso(customFrom()), to: dayAfterIso(customThrough()) };
215
+ }
216
+ else
217
+ r = ranges[chosen()];
218
+ busy.set(true);
219
+ try {
220
+ const res = await api.write.allocation({ op: 'run', from: r.from, to: r.to, apply });
221
+ if (res.result)
222
+ preview.set(res.result);
223
+ // The preview panel is its own answer; a toast over it would cover the
224
+ // figures it announces. Only a write is confirmed.
225
+ if (apply)
226
+ say(true, 'Recorded. The split is saved and listed under Saved splits.');
227
+ if (apply) {
228
+ preview.set(null);
229
+ reload();
230
+ }
231
+ }
232
+ catch (e) {
233
+ say(false, errorText(e));
234
+ }
235
+ finally {
236
+ busy.set(false);
237
+ }
238
+ };
239
+ return h('div', { class: 'run-panel' }, h('div', { class: 'form-row run-row' }, field('run-period', 'Period', h('select', {
240
+ id: 'run-period', class: 'drawer-input',
241
+ onchange: (e) => { chosen.set(Number(e.target.value)); preview.set(null); },
242
+ }, ...ranges.map((r, i) => h('option', { value: String(i), text: r.label })), h('option', { value: String(CUSTOM), text: 'Choose dates…' }))), () => chosen() === CUSTOM
243
+ ? h('div', { class: 'form-row run-dates' }, field('run-from', 'First day', h('input', { id: 'run-from', class: 'drawer-input', type: 'date',
244
+ oninput: (e) => { customFrom.set(e.target.value); preview.set(null); } })), field('run-through', 'Last day', h('input', { id: 'run-through', class: 'drawer-input', type: 'date',
245
+ oninput: (e) => { customThrough.set(e.target.value); preview.set(null); } })))
246
+ : null, h('button', { class: 'btn-ghost', disabled: () => busy(), onclick: () => void run(false), text: () => (busy() ? 'Working…' : 'Preview split') })), () => {
247
+ const p = preview();
248
+ if (!p)
249
+ return null;
250
+ return h('div', { class: 'run-result' }, runTable(p, nameOf), p.retention?.truncated
251
+ ? h('p', { class: 'section-note', role: 'note', text: 'Some records in this period were deleted by retention. The split covers what remains, so the unallocated part may be understated.' })
252
+ : null, h('div', { class: 'form-actions' }, demo
253
+ ? h('p', { class: 'drawer-muted', text: 'Sample data: a run cannot be recorded here.' })
254
+ : h('button', { class: 'btn-commit', disabled: () => busy(), onclick: () => void run(true), text: 'Record this run' })));
255
+ });
256
+ }
257
+ function runTable(p, nameOf) {
258
+ const share = (m) => (p.totalMicros > 0 ? shareOf(m / p.totalMicros, 1) : '—');
259
+ return h('div', { class: 'table-wrap' }, h('table', null, h('caption', { class: 'sr-only', text: 'Allocation preview by cost centre' }), h('thead', null, h('tr', null, h('th', { text: 'Cost centre' }), h('th', { class: 'num', text: 'Amount' }), h('th', { class: 'num', text: 'Share' }), h('th', { class: 'num', text: 'Requests' }))), h('tbody', null, ...p.byCostCentre.map((c) => h('tr', null, h('td', { class: 'name-cell', text: nameOf(c.costCentreId) }), h('td', { class: 'num', text: usdFromMicros(c.allocatedMicros) }), h('td', { class: 'num', text: share(c.allocatedMicros) }), h('td', { class: 'num', text: count(c.requestCount) }))), ...p.unallocated.map((u) => h('tr', { class: 'row-unalloc' }, h('td', { class: 'name-cell' }, h('span', { text: `Unallocated: ${REASON_WORDS[u.reason] ?? u.reason.replace(/_/g, ' ')}` }), u.topProjects.length > 0
260
+ ? h('span', { class: 'fact-sub', text: `mostly ${u.topProjects.slice(0, 3).map((t) => t.project).join(', ')}` })
261
+ : null), h('td', { class: 'num', text: usdFromMicros(u.micros) }), h('td', { class: 'num', text: share(u.micros) }), h('td', { class: 'num', text: count(u.requestCount) })))), h('tfoot', null, h('tr', null, h('td', { text: 'Total estimated spend' }), h('td', { class: 'num', text: usdFromMicros(p.totalMicros) }), h('td', { class: 'num', text: p.totalMicros > 0 ? '100%' : '—' }), h('td', null)))));
262
+ }
263
+ function history(runs) {
264
+ return h('section', { class: 'section' }, h('h2', { class: 'section-title', text: () => (isPrecise() ? 'Recorded runs' : 'Saved splits') }), runs.length === 0
265
+ ? h('p', { class: 'drawer-muted', text: 'None recorded yet.' })
266
+ : h('div', { class: 'table-wrap' }, h('table', null, h('thead', null, h('tr', null, h('th', { text: 'Period' }), h('th', { class: 'num', text: 'Total' }), h('th', { class: 'num', text: 'Allocated' }), h('th', { text: 'Recorded' }))), h('tbody', null, ...runs.map((r) => {
267
+ const period = `${date(r.result.periodStartMs)} – ${date(r.result.periodEndMs)}`;
268
+ return h('tr', null,
269
+ // The download sits with the run it names rather than in a
270
+ // column of its own, which pushed the table past a phone screen.
271
+ // It is the recorded run exactly as stored: every line and
272
+ // unallocated reason, adding up to the total to the microdollar.
273
+ h('td', null, h('span', { class: 'run-period', text: period }), h('a', {
274
+ class: 'run-download', href: api.allocationRunCsvHref(r.allocationRunId), download: '',
275
+ 'aria-label': `Download the split for ${period} as CSV`, text: 'Export CSV',
276
+ })), h('td', { class: 'num', text: usdFromMicros(r.result.totalMicros) }), h('td', { class: 'num', text: r.result.totalMicros > 0 ? shareOf(r.result.allocatedMicros / r.result.totalMicros, 1) : '—' }), h('td', { text: relative(r.computedAtMs) }));
277
+ })))));
90
278
  }
@@ -32,6 +32,9 @@ import { signal, scopedEffect } from '../core/signal.js';
32
32
  import { api } from '../core/api.js';
33
33
  import { usd, count, pct, isPrecise } from '../core/fmt.js';
34
34
  import { actionCard } from './spend.js';
35
+ import { capability } from '../core/registry.js';
36
+ import { openAction } from '../components/drawer.js';
37
+ import { actionSpec } from '../core/actions.js';
35
38
  export function controlView() {
36
39
  const settings = signal(null);
37
40
  const today = signal(null);
@@ -51,7 +54,7 @@ export function controlView() {
51
54
  }
52
55
  });
53
56
  });
54
- return h('div', null, h('div', { class: 'view-head' }, h('h1', { class: 'view-title', text: 'Control' }), h('p', { class: 'view-plain', text: () => isPrecise()
57
+ return h('div', null, h('div', { class: 'view-head' }, h('h1', { class: 'view-title', text: 'Protect' }), h('p', { class: 'view-plain', text: () => isPrecise()
55
58
  ? 'Enforcement caps applied at the proxy, and the advisory recommendation derived from observed spend.'
56
59
  : 'The limits that stop AI spend before it happens — and how much of your spend they can actually reach.' })), () => {
57
60
  const err = error();
@@ -82,9 +85,16 @@ export function controlView() {
82
85
  // Live governance alerts, above the caps that produced them. The server
83
86
  // already computed these; until now no screen rendered them, so an
84
87
  // operator whose cap was exhausted had to infer it from a percentage.
85
- alerts.length > 0 ? alertList(alerts) : null, h('div', { class: 'card card-basis' }, h('div', { class: 'card-head' }, h('span', { class: 'card-title', text: () => (isPrecise() ? 'Daily cap' : 'Your daily limit') }), h('span', {
86
- class: `pill ${cap === null ? 'pill-unverified' : 'pill-ok'}`,
87
- text: cap === null ? 'unlimited' : 'enforced in path',
88
+ alerts.length > 0 ? alertList(alerts) : null, h('div', { class: 'card card-basis' }, h('div', { class: 'card-head' }, h('span', { class: 'card-title', text: () => (isPrecise() ? 'Daily cap' : 'Your daily limit') }),
89
+ // The action that changes this number sits on it. It was a card at
90
+ // the foot of a 2,200 px page (critique 2026-10-12).
91
+ budgetButton(),
92
+ // Green "enforced" over a day already past its limit read as a
93
+ // contradiction (critique 2026-10-12). Past the limit, the badge
94
+ // says what the proxy is doing now.
95
+ h('span', {
96
+ class: `pill ${cap === null ? 'pill-unverified' : spentToday !== null && spentToday >= cap ? 'pill-warn' : 'pill-ok'}`,
97
+ text: cap === null ? 'unlimited' : spentToday !== null && spentToday >= cap ? 'limit reached · blocking' : 'enforced in path',
88
98
  })), cap === null
89
99
  ? h('div', null, h('div', { class: 'stat band-unset', text: 'no cap set' }), h('span', { class: 'basis', text: () => (isPrecise()
90
100
  ? 'No daily cap is configured. Requests are never blocked on spend.'
@@ -95,7 +105,11 @@ export function controlView() {
95
105
  style: `--fill: ${Math.min(100, cap > 0 ? (spentToday / cap) * 100 : 0)}%`,
96
106
  })), h('span', { class: 'basis', text: () => (isPrecise()
97
107
  ? `${usd(spentToday)} counted toward the cap today — ${pct(cap > 0 ? spentToday / cap : 0, 0)} of the cap`
98
- : `${usd(spentToday)} spent today, which is ${pct(cap > 0 ? spentToday / cap : 0, 0)} of your limit`) }), importedOutsideCap > 0
108
+ : `${usd(spentToday)} spent today, which is ${pct(cap > 0 ? spentToday / cap : 0, 0)} of your limit`) }), spentToday >= cap
109
+ ? h('span', { class: 'basis', text: () => (isPrecise()
110
+ ? 'The guard blocks once the day\'s enforced total is at or over the cap. The request that crossed it and any already in flight complete, so a day can close above the cap.'
111
+ : 'A limit stops new requests once the day\'s total reaches it. The request that crossed it, and any already running, still finish, so a day can end above its limit.') })
112
+ : null, importedOutsideCap > 0
99
113
  ? h('span', { class: 'basis', text: () => (isPrecise()
100
114
  ? `${usd(importedOutsideCap)} imported today is excluded from the cap (it cannot be blocked).`
101
115
  : `Plus ${usd(importedOutsideCap)} imported from your tools' logs today, which the limit doesn't count because it can't be stopped.`) })
@@ -164,6 +178,16 @@ function alertCoveragePanel(coverage, alertCount) {
164
178
  * with the figure that triggered it rather than as a bare warning. A warning an
165
179
  * operator cannot check is a warning they learn to dismiss.
166
180
  */
181
+ function budgetButton() {
182
+ const cap = capability('budget');
183
+ if (!cap)
184
+ return null;
185
+ return h('button', {
186
+ class: 'btn-ghost card-action',
187
+ onclick: () => openAction(actionSpec(cap)),
188
+ text: () => (isPrecise() ? 'Set cap' : 'Change limit'),
189
+ });
190
+ }
167
191
  function alertList(alerts) {
168
192
  const order = { critical: 0, warn: 1, info: 2 };
169
193
  const sorted = [...alerts].sort((a, b) => (order[a.severity] ?? 3) - (order[b.severity] ?? 3));
@@ -175,34 +199,51 @@ function alertList(alerts) {
175
199
  * from one recommended from a month of it, and the difference must be visible
176
200
  * before anyone acts on it.
177
201
  */
202
+ /** A note that ends in a command ("...with: segreant budget --x on") shows it as code that does not wrap mid-flag. */
203
+ function noteWithCommand(note) {
204
+ const at = note.lastIndexOf(': segreant ');
205
+ if (at < 0)
206
+ return h('li', { text: note });
207
+ return h('li', {}, note.slice(0, at + 2), h('code', { class: 'inline-code', text: note.slice(at + 2) }));
208
+ }
178
209
  function recommendation(rec, currentCap) {
179
210
  const ready = rec.canApply === true;
180
211
  const value = rec.recommendedDailyUsd ?? null;
212
+ // Zero days on a proxy-only basis is not thin history: nothing the cap
213
+ // governs has run yet, whatever was imported. The rationale below says why.
214
+ const noProxied = rec.basisDays === 0 && rec.spendBasis === 'live_proxy';
181
215
  return h('div', { class: 'card', style: 'margin-top: var(--s4)' }, h('div', { class: 'card-head' }, h('span', { class: 'card-title', text: () => (isPrecise() ? 'Recommended cap' : 'What we would suggest') }), h('span', {
182
216
  class: `pill ${ready ? 'pill-ok' : 'pill-unverified'}`,
183
- text: ready ? 'enough data' : 'not enough data',
217
+ text: ready ? 'enough data' : noProxied ? 'no proxied spend' : 'not enough data',
184
218
  })), value !== null
185
219
  ? h('div', { class: 'stat', text: usd(value) })
186
220
  : h('div', { class: 'stat band-unset', text: 'not established' }), h('span', { class: 'basis', text: () => (isPrecise()
187
221
  ? `derived from ${count(rec.basisDays)} day(s) of observed spend on a ${rec.spendBasis === 'live_proxy' ? 'live proxy' : 'all observed'} basis; minimum ${count(rec.minActiveDays)} active days`
188
- : `worked out from ${count(rec.basisDays)} days of your actual spend`) }), !ready
222
+ : `worked out from ${count(rec.basisDays)} days of your actual spend`) }), !ready && noProxied
189
223
  ? h('p', { class: 'scope-note', text: () => (isPrecise()
190
- ? 'Status is not review-ready: too few active days stand behind this to recommend acting on it.'
191
- : 'There is not enough history yet for this to be a suggestion worth acting on.') })
192
- : currentCap !== null && value !== null && Math.abs(currentCap - value) < 0.005
224
+ ? 'No proxied spend in this window: a cap governs requests through the proxy, and none have run.'
225
+ : 'Nothing has gone through the proxy yet, and that is the spend a limit controls.') })
226
+ : !ready
193
227
  ? h('p', { class: 'scope-note', text: () => (isPrecise()
194
- ? 'Your configured cap already matches this recommendation.'
195
- : 'Your current limit already matches this.') })
196
- : null, rec.observed
228
+ ? 'Status is not review-ready: too few active days stand behind this to recommend acting on it.'
229
+ : 'There is not enough history yet for this to be a suggestion worth acting on.') })
230
+ : currentCap !== null && value !== null && Math.abs(currentCap - value) < 0.005
231
+ ? h('p', { class: 'scope-note', text: () => (isPrecise()
232
+ ? 'Your configured cap already matches this recommendation.'
233
+ : 'Your current limit already matches this.') })
234
+ : null,
235
+ // With no basis day there is no typical, busy or worst day: zeros here
236
+ // would read as measured. The rationale says why instead.
237
+ rec.observed && (rec.basisDays ?? 0) > 0
197
238
  ? h('div', { class: 'facts' }, h('div', { class: 'fact' }, h('span', { class: 'fact-key', text: () => (isPrecise() ? 'median daily' : 'a typical day') }), h('span', { class: 'fact-val', text: usd(rec.observed.medianDaily) })), h('div', { class: 'fact' }, h('span', { class: 'fact-key', text: () => (isPrecise() ? 'p90 daily' : 'a busy day') }), h('span', { class: 'fact-val', text: usd(rec.observed.p90Daily) })), h('div', { class: 'fact' }, h('span', { class: 'fact-key', text: () => (isPrecise() ? 'max daily' : 'the worst day seen') }), h('span', { class: 'fact-val', text: usd(rec.observed.maxDaily) })))
198
239
  : null,
199
240
  // Projected waste is the number worth acting on, so it is not buried in the
200
241
  // rationale list. It is explicitly a projection, and says so.
201
242
  typeof rec.projectedMonthlyWasteUsd === 'number' && rec.projectedMonthlyWasteUsd > 0
202
243
  ? h('div', { class: 'waste-call' }, h('span', { class: 'waste-fig', text: `${usd(rec.projectedMonthlyWasteUsd)}/mo` }), h('span', { class: 'waste-say', text: () => (isPrecise()
203
- ? `projected spend not converting to kept outcomes, at the current realized-value rate (${pct(rec.realizedSpendShare, 0)})`
204
- : `is heading for work that never gets used, if things carry on as they are`) }))
244
+ ? `projected spend not converting to realized outcomes, at the current realized-value rate (${pct(rec.realizedSpendShare, 0)})`
245
+ : `a month, at the current rate, goes to work that is not kept — a projection, not a measurement`) }))
205
246
  : null, rec.rationale?.length
206
- ? h('ul', { class: 'drawer-notes' }, ...rec.rationale.map((r) => h('li', { text: r })))
247
+ ? h('ul', { class: 'drawer-notes' }, ...rec.rationale.map(noteWithCommand))
207
248
  : null);
208
249
  }
@@ -61,9 +61,9 @@ export function dataView() {
61
61
  const missing = list.filter((i) => !i.available);
62
62
  const o = overview();
63
63
  const isDemo = o?.demo === true;
64
- const empty = (o?.summary.requests ?? 0) === 0;
64
+ const empty = o !== null && o.summary.requests === 0;
65
65
  return h('div', null, isDemo
66
- ? h('div', { class: 'banner banner-demo' }, h('span', { class: 'pill pill-demo', text: 'demo' }), h('p', null, h('strong', { text: 'You are looking at sample data. ' }), 'Importing below replaces it with your own usage.'))
66
+ ? h('div', { class: 'banner banner-demo' }, h('span', { class: 'pill pill-demo', text: 'demo' }), h('p', null, h('strong', { text: 'You are looking at sample data. ' }), 'Imports are off while this is sample data, and nothing here writes into it. To bring in your own usage, run the dashboard without --demo.'))
67
67
  : empty
68
68
  ? h('div', { class: 'banner' }, h('p', null, h('strong', { text: 'Nothing imported yet. ' }), 'Every other screen will stay empty until something here brings usage in.'))
69
69
  : null,
@@ -130,7 +130,7 @@ export function dataView() {
130
130
  }), o && !empty
131
131
  ? h('div', { class: 'card' }, h('div', { class: 'card-head' }, h('span', { class: 'card-title', text: () => (isPrecise() ? 'Currently held' : 'What Segreant has so far') })), h('div', { class: 'stat', text: count(o.summary.requests) }), h('span', { class: 'basis', text: () => (isPrecise()
132
132
  ? `recorded requests totalling ${usd(o.summary.costUsd)} of metered cost`
133
- : `AI calls recorded, worth ${usd(o.summary.costUsd)} by our own measurement`) }))
134
- : null, h('section', { class: 'section' }, h('h2', { class: 'section-title', text: () => (isPrecise() ? 'Actions on this layer' : 'Things you can do with this') }), h('div', { class: 'actions' }, actionCard('import'), actionCard('scan'), actionCard('connect'), actionCard('sources'), actionCard('baseline'), actionCard('demo'))));
133
+ : `AI calls recorded, with ${usd(o.summary.costUsd)} of metered cost estimates`) }))
134
+ : null, h('section', { class: 'section' }, h('h2', { class: 'section-title', text: () => (isPrecise() ? 'Actions on this layer' : 'Things you can do with this') }), h('div', { class: 'actions' }, actionCard('import'), actionCard('scan'), actionCard('connect'), actionCard('sources'), actionCard('baseline'), actionCard('pricing-unknown'), actionCard('reprice'), actionCard('demo'))));
135
135
  });
136
136
  }