@mcptoolshop/housekeeping 0.0.0 → 1.3.1

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/src/config.mjs ADDED
@@ -0,0 +1,75 @@
1
+ // Which org a checkout audits, and which of its repos are not products.
2
+ //
3
+ // The rules do not depend on whose org they read, so the org's name and its
4
+ // exemptions live in a file beside the code, never in the code. The file is
5
+ // optional: without one, the org is given on the command line and only the
6
+ // `.github` repo -- which exists to hold org-wide defaults in any org -- is
7
+ // treated as a non-product.
8
+ //
9
+ // housekeeping.config.json
10
+ // { "org": "your-org", "metaRepos": [".github", "design-assets"] }
11
+ //
12
+ // No network and no database here, so every branch is unit-testable.
13
+ import { readFileSync } from 'node:fs';
14
+ import { join } from 'node:path';
15
+ import { userError } from './errors.mjs';
16
+ import { WORK_DIR } from './paths.mjs';
17
+
18
+ export const CONFIG_PATH = process.env.HK_CONFIG ?? join(WORK_DIR, 'housekeeping.config.json');
19
+
20
+ const KEYS = ['org', 'metaRepos'];
21
+ const invalid = message => userError('CONFIG_INVALID', message,
22
+ 'fix housekeeping.config.json; housekeeping.config.example.json shows the shape');
23
+ const DEFAULT_META = ['.github'];
24
+
25
+ /**
26
+ * Parse config text. Pure. Every malformed shape THROWS rather than falling
27
+ * back to a default: `metaRepos` suppresses hygiene findings, so a typo that
28
+ * silently emptied it would file findings against repos that are exempt, and a
29
+ * typo in the key name (`metaRepo`) would do the same while looking configured.
30
+ */
31
+ export function parseConfig(text) {
32
+ let raw;
33
+ try { raw = JSON.parse(text); }
34
+ catch (e) { throw invalid(`housekeeping config is not valid JSON: ${e.message}`); }
35
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
36
+ throw invalid('housekeeping config must be a JSON object');
37
+ }
38
+ const unknown = Object.keys(raw).filter(k => !KEYS.includes(k));
39
+ if (unknown.length) {
40
+ throw invalid(`housekeeping config has unknown key(s): ${unknown.join(', ')} (known: ${KEYS.join(', ')})`);
41
+ }
42
+ const org = raw.org ?? null;
43
+ if (org !== null && (typeof org !== 'string' || !org.trim())) {
44
+ throw invalid('housekeeping config: "org" must be a non-empty string');
45
+ }
46
+ const meta = raw.metaRepos ?? DEFAULT_META;
47
+ if (!Array.isArray(meta) || meta.some(m => typeof m !== 'string' || !m.trim())) {
48
+ throw invalid('housekeeping config: "metaRepos" must be an array of repo names');
49
+ }
50
+ return { org, metaRepos: [...meta] };
51
+ }
52
+
53
+ /** Read the config file. A missing file is the defaults; an unreadable or malformed one throws. */
54
+ export function loadConfig(path = CONFIG_PATH) {
55
+ let text;
56
+ try { text = readFileSync(path, 'utf8'); }
57
+ catch (e) {
58
+ if (e.code === 'ENOENT') return { org: null, metaRepos: [...DEFAULT_META] };
59
+ throw e;
60
+ }
61
+ return parseConfig(text);
62
+ }
63
+
64
+ /**
65
+ * The org a sweep should collect: the command-line argument, else the config.
66
+ * Throws when neither names one -- a sweep must never guess whose org to read.
67
+ */
68
+ export function resolveOrg(arg, config = loadConfig()) {
69
+ const org = arg ?? config.org;
70
+ if (!org) {
71
+ throw userError('NO_ORG', 'no org given',
72
+ 'pass one (hk refresh <org>) or set "org" in housekeeping.config.json');
73
+ }
74
+ return org;
75
+ }
package/src/cost.mjs ADDED
@@ -0,0 +1,271 @@
1
+ // Actions cost arithmetic: runner classification, billable minutes, and the
2
+ // price table.
3
+ //
4
+ // Split from collect.mjs because the two change for different reasons. This
5
+ // module changes when GitHub changes how it *bills* (rounding, runner classes,
6
+ // SKU names); collect.mjs changes when GitHub changes how it *reports*. Mixing
7
+ // them means a pricing fix has to be made inside a network path that cannot be
8
+ // unit-tested.
9
+ //
10
+ // Two facts drive every function here, both verified against live billing data
11
+ // on 2026-09-21:
12
+ //
13
+ // 1. GitHub bills per JOB, rounded UP to the whole minute, then multiplied by
14
+ // the runner class rate. A run's billable minutes are therefore NOT its
15
+ // wall-clock duration: six 10-second jobs bill 6 minutes, not 1. Measured:
16
+ // one run billed 119 minutes across 9 jobs whose wall time summed to well
17
+ // under 30.
18
+ //
19
+ // 2. Public repositories on standard runners are metered and then discounted
20
+ // to zero. The usage API returns grossAmount == discountAmount and
21
+ // netAmount == 0 for every such line. Gross is real compute; net is real
22
+ // money. A rule that conflates them either invents a bill that does not
23
+ // exist or hides waste because nobody is charged for it. Both are wrong,
24
+ // so both numbers are carried separately all the way to the report.
25
+
26
+ /** Runner classes we can price. OTHER is deliberately unpriced -- see below. */
27
+ export const RUNNER_CLASSES = ['UBUNTU', 'WINDOWS', 'MACOS', 'SELF_HOSTED', 'OTHER'];
28
+
29
+ // GitHub's published list prices, used ONLY when the billing API is unreadable.
30
+ // The real rates come from ratesFromUsage(): an account can be billed 0.006 for
31
+ // Linux where the list price is 0.008, and a hardcoded table would then
32
+ // overstate every number by a third. The fallback exists so a missing billing scope
33
+ // degrades to "approximate" rather than "no cost data at all", and anything
34
+ // priced this way is flagged `estimated` for the whole pipeline.
35
+ export const FALLBACK_RATES = { UBUNTU: 0.008, WINDOWS: 0.016, MACOS: 0.08 };
36
+
37
+ // Zero-cost classes. Self-hosted runners bill nothing; OTHER is anything we
38
+ // could not identify, which must stay unpriced rather than be guessed at the
39
+ // Linux rate -- a larger runner costs several times standard and would be
40
+ // silently undercounted.
41
+ const UNPRICED = new Set(['SELF_HOSTED', 'OTHER']);
42
+
43
+ /**
44
+ * Classify a job's `labels` into a billing class.
45
+ *
46
+ * Order matters. `runs-on: [self-hosted, linux, x64]` carries a `linux` label
47
+ * and would otherwise be priced as a hosted Ubuntu runner -- billing a repo for
48
+ * hardware it owns. Self-hosted is therefore checked first, unconditionally.
49
+ *
50
+ * Larger runners (`ubuntu-latest-4-core`, `windows-latest-8-core`, and
51
+ * org-named custom labels) bill at their own rates under their own SKUs, which
52
+ * this module does not know. They return OTHER so they are counted as jobs and
53
+ * minutes but contribute no cost, and the reconciliation against the billing
54
+ * API surfaces the gap instead of absorbing it.
55
+ */
56
+ export function runnerClass(labels) {
57
+ const list = (Array.isArray(labels) ? labels : [labels])
58
+ .filter(Boolean).map(l => String(l).toLowerCase());
59
+ if (!list.length) return 'OTHER';
60
+ if (list.some(l => l === 'self-hosted')) return 'SELF_HOSTED';
61
+ // A core-count suffix means a larger runner on a different SKU.
62
+ if (list.some(l => /-\d+-core\b/.test(l))) return 'OTHER';
63
+ if (list.some(l => /^macos/.test(l))) return 'MACOS';
64
+ if (list.some(l => /^windows/.test(l))) return 'WINDOWS';
65
+ if (list.some(l => /^ubuntu/.test(l))) return 'UBUNTU';
66
+ return 'OTHER';
67
+ }
68
+
69
+ /**
70
+ * Billable minutes for one job: wall time rounded UP to the whole minute.
71
+ *
72
+ * Returns 0 -- never null -- for a job that did not run. A skipped job carries
73
+ * a null `started_at`, and a job cancelled before dispatch can report
74
+ * completed_at <= started_at. Both are free, and both must be distinguishable
75
+ * from "not measured", which is the absence of the row entirely.
76
+ */
77
+ export function billableMinutes(startedAt, completedAt) {
78
+ if (!startedAt || !completedAt) return 0;
79
+ const ms = new Date(completedAt) - new Date(startedAt);
80
+ if (!Number.isFinite(ms) || ms <= 0) return 0;
81
+ return Math.ceil(ms / 60000);
82
+ }
83
+
84
+ // Exact SKU spellings observed in the enhanced billing usage API. Matching is
85
+ // exact rather than by substring because "Actions Linux" and "Actions Linux
86
+ // 4-core" differ by a multiple, and a substring match would price the larger
87
+ // runner as standard.
88
+ const SKU_CLASS = new Map([
89
+ ['actions linux', 'UBUNTU'],
90
+ ['actions windows', 'WINDOWS'],
91
+ ['actions macos', 'MACOS'],
92
+ ['actions macos 3-core', 'MACOS'],
93
+ ['actions macos 12-core', 'MACOS'],
94
+ ]);
95
+
96
+ /**
97
+ * Derive this account's actual per-minute rates from its own billing lines.
98
+ *
99
+ * Self-calibrating on purpose: rates vary by plan and change without notice, so
100
+ * reading them back out of the invoice is the only way to stay correct without
101
+ * a maintenance burden. Returns the rates it could establish plus the SKUs it
102
+ * could not map, so an unrecognised runner class shows up in the report as a
103
+ * named gap instead of vanishing into a wrong total.
104
+ */
105
+ export function ratesFromUsage(items = []) {
106
+ const seen = new Map(); // class -> Set(rate)
107
+ const unmapped = new Set();
108
+ for (const i of items) {
109
+ if (i?.product !== 'actions') continue;
110
+ const sku = String(i.sku ?? '').trim().toLowerCase();
111
+ if (!sku || sku === 'actions storage') continue;
112
+ const cls = SKU_CLASS.get(sku);
113
+ if (!cls) { unmapped.add(String(i.sku)); continue; }
114
+ // Both spellings on purpose. The billing API answers in camelCase, but
115
+ // collect.mjs normalises to the snake_case shape the snapshot and the
116
+ // `billing_usage` table use, and this function is handed BOTH: raw items
117
+ // during collection, stored rows when re-deriving from a snapshot. Reading
118
+ // only `pricePerUnit` silently found no rates at all on 2026-09-21 and fell
119
+ // back to list prices, overstating every figure by up to 60% -- the run
120
+ // logged "billing revealed no rates" and kept going. Matching one spelling
121
+ // is the bug; matching both is the fix.
122
+ const rate = i.price_per_unit ?? i.pricePerUnit;
123
+ if (!(rate > 0)) continue;
124
+ if (!seen.has(cls)) seen.set(cls, new Set());
125
+ seen.get(cls).add(rate);
126
+ }
127
+ const rates = {};
128
+ const ambiguous = [];
129
+ for (const [cls, set] of seen) {
130
+ const vals = [...set];
131
+ // Two rates for one class means a SKU split we do not model (e.g. both a
132
+ // 3-core and a 12-core macOS line). Take the dearest so an estimate errs
133
+ // toward over-reporting cost, and say so rather than picking silently.
134
+ if (vals.length > 1) ambiguous.push(`${cls}: ${vals.join(', ')}`);
135
+ rates[cls] = Math.max(...vals);
136
+ }
137
+ return {
138
+ rates,
139
+ estimated: Object.keys(rates).length === 0,
140
+ unmapped: [...unmapped],
141
+ ambiguous,
142
+ };
143
+ }
144
+
145
+ /** Rates with the fallback filled in for any class billing did not reveal. */
146
+ export function withFallback(rates = {}) {
147
+ return { ...FALLBACK_RATES, ...rates };
148
+ }
149
+
150
+ /**
151
+ * Price one run from its jobs.
152
+ *
153
+ * `jobs` are raw objects from GET /actions/runs/{id}/jobs. Returns per-class
154
+ * minutes alongside the total so a caller can ask the question this whole
155
+ * module exists for -- "few minutes, most of the cost?" -- without re-deriving
156
+ * the split. `unpriced_minutes` is minutes we counted but could not price;
157
+ * non-zero means the cost figure is a floor, not a total.
158
+ */
159
+ export function priceRun(jobs = [], rates = FALLBACK_RATES) {
160
+ const minutes = { UBUNTU: 0, WINDOWS: 0, MACOS: 0, SELF_HOSTED: 0, OTHER: 0 };
161
+ let cost = 0, counted = 0;
162
+ for (const j of jobs) {
163
+ const m = billableMinutes(j?.started_at, j?.completed_at);
164
+ if (m <= 0) continue;
165
+ const cls = runnerClass(j?.labels ?? []);
166
+ minutes[cls] += m;
167
+ counted++;
168
+ if (!UNPRICED.has(cls)) cost += m * (rates[cls] ?? 0);
169
+ }
170
+ const billable = minutes.UBUNTU + minutes.WINDOWS + minutes.MACOS;
171
+ return {
172
+ job_count: jobs.length,
173
+ billed_job_count: counted,
174
+ billable_minutes: billable,
175
+ ubuntu_minutes: minutes.UBUNTU,
176
+ windows_minutes: minutes.WINDOWS,
177
+ macos_minutes: minutes.MACOS,
178
+ self_hosted_minutes: minutes.SELF_HOSTED,
179
+ unpriced_minutes: minutes.OTHER,
180
+ cost_usd: Number(cost.toFixed(6)),
181
+ };
182
+ }
183
+
184
+ /**
185
+ * Compare computed attribution against what GitHub actually billed.
186
+ *
187
+ * This is the verifier for everything else in the pipeline. The per-job sum is
188
+ * derived from a different endpoint than the invoice, so agreement is real
189
+ * evidence and divergence is a real warning: it means the runs we priced are
190
+ * not the runs that were billed (a truncated window, a deleted run, a runner
191
+ * class we cannot price). Rules consult `trustworthy` before making any claim
192
+ * about a repo's cost, because a confident number built on a partial sample is
193
+ * exactly the failure this repo keeps catching.
194
+ *
195
+ * `tolerance` is fractional. 0.15 was chosen because same-window comparisons on
196
+ * the five costliest repos of the first measured sweep landed within 2%, so
197
+ * 15% is loose enough to absorb rounding and mid-sweep runs while still
198
+ * catching a window that missed real spend.
199
+ *
200
+ * KNOWN DIVERGENCE, unexplained. On that sweep, 11 of 12 drilled repos landed
201
+ * between 0.99 and 1.08 but one came back at 1.236 -- all Linux, no unpriced
202
+ * or self-hosted minutes, so it is a minute count that is too high rather than
203
+ * a wrong rate. The two worst ratios also had the shortest average jobs (1.51
204
+ * and 1.33 min), which is what per-job round-up would do, but the correlation
205
+ * is not clean -- a third repo averaged 1.6 min and reconciled at 1.010 -- so
206
+ * rounding is a hypothesis, not a finding. Do NOT widen the tolerance to make
207
+ * it fit. The gate is doing its job: a repo that does not reconcile is
208
+ * excluded from every cost rule until someone works out where the minutes
209
+ * come from.
210
+ */
211
+ /**
212
+ * Reconcile every drilled repo, producing the rows `cost_reconciliation` holds.
213
+ *
214
+ * Pure so the gate can be tested without a database.
215
+ *
216
+ * `ratesEstimated` voids trust on its own, independent of whether the two sides
217
+ * agree. The two checks fail for different reasons and neither subsumes the
218
+ * other: the ratio catches a wrong SET of runs, the flag catches a wrong RATE.
219
+ * A rate error smaller than the tolerance would pass the ratio check while
220
+ * being wrong everywhere, so the flag is not redundant. When this actually
221
+ * happened on 2026-09-21 both fired -- list prices put every repo at 1.32-1.43x
222
+ * its invoice -- but that was luck about the size of the error, not a property
223
+ * of the check.
224
+ */
225
+ export function reconcileRepos({ billingItems = [], runs = [], drilled = [], ratesEstimated = false } = {}) {
226
+ const billed = new Map();
227
+ for (const i of billingItems) {
228
+ if (i.product !== 'actions' || i.sku === 'Actions storage' || !i.repo) continue;
229
+ const cur = billed.get(i.repo) ?? { gross: 0, net: 0 };
230
+ cur.gross += i.gross ?? 0;
231
+ cur.net += i.net ?? 0;
232
+ billed.set(i.repo, cur);
233
+ }
234
+ const computed = new Map();
235
+ for (const r of runs) {
236
+ computed.set(r.repo, (computed.get(r.repo) ?? 0) + (r.cost_usd ?? 0));
237
+ }
238
+ return drilled.map(repo => {
239
+ const b = billed.get(repo) ?? { gross: 0, net: 0 };
240
+ const c = computed.get(repo) ?? 0;
241
+ const rec = reconcile(c, b.gross);
242
+ return {
243
+ repo,
244
+ computed_usd: Number(c.toFixed(4)),
245
+ billed_gross_usd: Number(b.gross.toFixed(4)),
246
+ billed_net_usd: Number(b.net.toFixed(4)),
247
+ ratio: rec.ratio,
248
+ trustworthy: (rec.trustworthy && !ratesEstimated) ? 1 : 0,
249
+ reason: ratesEstimated ? 'rates estimated from list prices - not measured' : rec.reason,
250
+ };
251
+ });
252
+ }
253
+
254
+ export function reconcile(computedUsd, billedGrossUsd, tolerance = 0.15) {
255
+ const billed = Number(billedGrossUsd ?? 0);
256
+ const computed = Number(computedUsd ?? 0);
257
+ if (!(billed > 0)) {
258
+ // No invoice line to check against. Never claim agreement we cannot show.
259
+ return { ratio: null, delta_usd: null, trustworthy: false, reason: 'no billing line' };
260
+ }
261
+ const ratio = computed / billed;
262
+ const ok = Math.abs(1 - ratio) <= tolerance;
263
+ return {
264
+ ratio: Number(ratio.toFixed(4)),
265
+ delta_usd: Number((computed - billed).toFixed(4)),
266
+ trustworthy: ok,
267
+ reason: ok ? 'within tolerance'
268
+ : ratio < 1 ? 'computed below billed - window misses runs that were charged'
269
+ : 'computed above billed - double counting or a rate that is too high',
270
+ };
271
+ }
package/src/errors.mjs ADDED
@@ -0,0 +1,102 @@
1
+ // The one shape an error leaves this tool in, for the CLI and the MCP server.
2
+ //
3
+ // { code, message, hint, retryable, cause? }
4
+ //
5
+ // A message says what happened; a hint says what to do about it. A stack trace
6
+ // is for the person debugging the tool, not the person using it, so it is
7
+ // printed only on request (--debug).
8
+ //
9
+ // Exit codes:
10
+ // 0 ok
11
+ // 1 user error the command, its arguments or the config file are wrong
12
+ // 2 runtime error GitHub, the database or the disk failed; retry or repair
13
+ // 3 partial the command finished, and part of what it was asked for
14
+ // is missing (a sweep with collection errors, or a cost
15
+ // pass that ran out of budget)
16
+ export const EXIT = Object.freeze({ OK: 0, USER: 1, RUNTIME: 2, PARTIAL: 3 });
17
+
18
+ export class HkError extends Error {
19
+ constructor(code, message, { hint = null, cause, retryable = false, exit = EXIT.RUNTIME } = {}) {
20
+ super(message, cause === undefined ? undefined : { cause });
21
+ this.name = 'HkError';
22
+ this.code = code;
23
+ this.hint = hint;
24
+ this.retryable = retryable;
25
+ this.exit = exit;
26
+ }
27
+ }
28
+
29
+ /** A mistake in how the tool was invoked or configured. Never retryable as-is. */
30
+ export const userError = (code, message, hint = null) =>
31
+ new HkError(code, message, { hint, exit: EXIT.USER });
32
+
33
+ /** Any thrown value as the structured shape. Pure. */
34
+ export function structured(e) {
35
+ if (e instanceof HkError) {
36
+ const out = { code: e.code, message: e.message, hint: e.hint, retryable: e.retryable };
37
+ if (e.cause !== undefined) out.cause = String(e.cause?.message ?? e.cause);
38
+ return out;
39
+ }
40
+ // gh.mjs throws its own class for throttling; it predates this module and
41
+ // carries the facts a hint needs.
42
+ if (e?.isRateLimit) {
43
+ return {
44
+ code: 'RATE_LIMITED',
45
+ message: e.message,
46
+ hint: e.kind === 'primary'
47
+ ? 'the hourly quota is spent; wait for it to reset'
48
+ : 'a burst limit, not the quota; retry in a few minutes with fewer concurrent callers',
49
+ retryable: true,
50
+ };
51
+ }
52
+ return {
53
+ code: 'UNEXPECTED',
54
+ message: String(e?.message ?? e),
55
+ hint: 're-run with --debug for the stack trace',
56
+ retryable: false,
57
+ };
58
+ }
59
+
60
+ /** The process exit code for a thrown value. Anything unclassified is a runtime error. */
61
+ export function exitCodeFor(e) {
62
+ return e instanceof HkError ? e.exit : EXIT.RUNTIME;
63
+ }
64
+
65
+ /** Lines for stderr. The stack appears only with `debug`. Pure. */
66
+ export function formatError(e, { debug = false } = {}) {
67
+ const s = structured(e);
68
+ const lines = [`error [${s.code}] ${s.message}`];
69
+ if (s.cause) lines.push(`cause: ${s.cause}`);
70
+ if (s.hint) lines.push(`hint: ${s.hint}`);
71
+ if (s.retryable) lines.push('retryable: yes');
72
+ if (debug && e?.stack) lines.push('', e.stack);
73
+ return lines.join('\n');
74
+ }
75
+
76
+ /**
77
+ * Admit one read-only statement, or refuse. Returns the statement to run.
78
+ * Shared by `hk sql` and the MCP `hk_sql` tool so the two cannot disagree
79
+ * about what "read-only" means.
80
+ */
81
+ export function readOnlyQuery(query) {
82
+ const q = String(query ?? '').trim().replace(/;\s*$/, '');
83
+ if (!q) throw userError('USAGE', 'no query given', 'hk sql "SELECT ..."');
84
+ if (!/^(select|with)\b/i.test(q)) {
85
+ throw userError('SQL_REFUSED', 'only SELECT and WITH statements are allowed',
86
+ 'the warehouse is derived data; change it by loading a snapshot, not by SQL');
87
+ }
88
+ if (/;/.test(q)) throw userError('SQL_REFUSED', 'multiple statements are not allowed', 'send one statement');
89
+ return q;
90
+ }
91
+
92
+ /**
93
+ * Did a sweep finish with part of its work missing? Returns the reasons, which
94
+ * is empty for a complete sweep. Pure.
95
+ */
96
+ export function sweepGaps(snapshot) {
97
+ const gaps = [];
98
+ const errors = snapshot?.errors?.length ?? 0;
99
+ if (errors) gaps.push(`${errors} collection error(s) recorded`);
100
+ if (snapshot?.run_costs?.budget_hit) gaps.push('the cost pass ran out of budget; cost attribution is incomplete');
101
+ return gaps;
102
+ }