@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.
@@ -0,0 +1,272 @@
1
+ // Pure functions for auditing a committed package-lock.json or pnpm-lock.yaml
2
+ // against the npm advisory registry -- no network here, so every branch is
3
+ // unit-testable.
4
+ //
5
+ // Why this exists: on 2026-09-17 GitHub's Dependabot alert coverage turned out
6
+ // to be silently per-manifest. One repo reported alerts on its root lockfile
7
+ // and ZERO on site/package-lock.json; another had the same two files and
8
+ // reported zero on both, while npm audit found a critical in each. No API
9
+ // forces a re-parse, pushes do not repopulate the graph, and the SBOM
10
+ // endpoint's 404 turned out not to be the signal (a repo can answer 404 while
11
+ // counting). So the warehouse stops trusting the counter and reads the
12
+ // lockfiles itself. Absent renders as fine; this is the counter switched on.
13
+
14
+ /**
15
+ * Map every resolved package in a lockfile to the set of versions it appears
16
+ * at. Handles lockfileVersion 2/3 (`packages`, keyed by path) and 1
17
+ * (`dependencies`, nested). The root entry ("") is the project itself and is
18
+ * skipped. A name is the path after the LAST `node_modules/`, so a nested
19
+ * duplicate (`node_modules/a/node_modules/b`) counts as `b`, which is what the
20
+ * advisory registry is keyed on.
21
+ *
22
+ * Returns { name: [version, ...] } with versions deduplicated and sorted, so
23
+ * two lockfiles resolving to the same set produce byte-identical maps.
24
+ */
25
+ export function packageMapFromLockfile(lock) {
26
+ const out = new Map();
27
+ const add = (name, version) => {
28
+ if (!name || !version || typeof version !== 'string') return;
29
+ if (!out.has(name)) out.set(name, new Set());
30
+ out.get(name).add(version);
31
+ };
32
+
33
+ if (lock && typeof lock.packages === 'object' && lock.packages) {
34
+ for (const [path, entry] of Object.entries(lock.packages)) {
35
+ if (!path || !entry || typeof entry !== 'object') continue; // "" is the root
36
+ if (entry.link) continue; // workspace symlink, not a resolved package
37
+ const name = entry.name ?? path.slice(path.lastIndexOf('node_modules/') + 'node_modules/'.length);
38
+ add(name, entry.version);
39
+ }
40
+ } else if (lock && typeof lock.dependencies === 'object' && lock.dependencies) {
41
+ const walk = (deps) => {
42
+ for (const [name, entry] of Object.entries(deps)) {
43
+ if (!entry || typeof entry !== 'object') continue;
44
+ add(name, entry.version);
45
+ if (entry.dependencies) walk(entry.dependencies);
46
+ }
47
+ };
48
+ walk(lock.dependencies);
49
+ }
50
+
51
+ return Object.fromEntries(
52
+ [...out.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([k, v]) => [k, [...v].sort()]),
53
+ );
54
+ }
55
+
56
+ /**
57
+ * Names that appear in the lockfile ONLY as dev dependencies (`dev: true` on
58
+ * every entry). A name resolved as prod anywhere in the tree is prod: the
59
+ * shipped surface is what matters, and one prod edge is enough to ship it.
60
+ *
61
+ * Kept apart from the package map because the map is the registry payload and
62
+ * must stay {name: [versions]}; dev-ness is a second axis the report needs
63
+ * because many audit gates run `--omit=dev`, and a critical in a test
64
+ * runner is a different finding from a critical in what users install.
65
+ */
66
+ export function devOnlyPackages(lock) {
67
+ const prod = new Set(), dev = new Set();
68
+ const see = (name, entry) => {
69
+ if (!name || !entry || typeof entry !== 'object') return;
70
+ (entry.dev === true ? dev : prod).add(name);
71
+ };
72
+ if (lock && typeof lock.packages === 'object' && lock.packages) {
73
+ for (const [path, entry] of Object.entries(lock.packages)) {
74
+ if (!path || entry?.link) continue;
75
+ see(entry.name ?? path.slice(path.lastIndexOf('node_modules/') + 'node_modules/'.length), entry);
76
+ }
77
+ } else if (lock && typeof lock.dependencies === 'object' && lock.dependencies) {
78
+ const walk = (deps) => {
79
+ for (const [name, entry] of Object.entries(deps)) {
80
+ see(name, entry);
81
+ if (entry?.dependencies) walk(entry.dependencies);
82
+ }
83
+ };
84
+ walk(lock.dependencies);
85
+ }
86
+ return [...dev].filter(n => !prod.has(n)).sort();
87
+ }
88
+
89
+ /**
90
+ * The npm bulk endpoint (POST /-/npm/v1/security/advisories/bulk) takes exactly
91
+ * the { name: [versions] } map above and answers { name: [advisory...] } with
92
+ * only the advisories whose `vulnerable_versions` range matches a version sent.
93
+ * Flatten that to one row per (package, version, advisory) so a finding can
94
+ * name the version that is actually resolved.
95
+ *
96
+ * `vulnerable_versions` is a semver range string; the registry has already
97
+ * applied it, so this does NOT re-evaluate it -- doing so with a home-grown
98
+ * range parser is how a rule ends up disagreeing with the tool it cites.
99
+ */
100
+ export function advisoriesFromBulk(packageMap, response, devOnly = []) {
101
+ const rows = [];
102
+ if (!response || typeof response !== 'object') return rows;
103
+ const dev = new Set(devOnly);
104
+ for (const [name, list] of Object.entries(response)) {
105
+ const versions = packageMap[name] ?? [];
106
+ for (const a of Array.isArray(list) ? list : []) {
107
+ const ghsa = (a.url ?? '').split('/').pop() || (a.id != null ? String(a.id) : null);
108
+ for (const version of versions) {
109
+ rows.push({
110
+ package: name,
111
+ version,
112
+ severity: normaliseSeverity(a.severity),
113
+ ghsa,
114
+ title: (a.title ?? '').slice(0, 200),
115
+ url: a.url ?? null,
116
+ vulnerable_versions: a.vulnerable_versions ?? null,
117
+ dev: dev.has(name),
118
+ });
119
+ }
120
+ }
121
+ }
122
+ return rows;
123
+ }
124
+
125
+ // npm says "moderate"; GitHub says "medium". The report already speaks
126
+ // GitHub's dialect (repo_security.medium), so translate at the boundary once.
127
+ export function normaliseSeverity(s) {
128
+ const v = String(s ?? '').toLowerCase();
129
+ if (v === 'moderate') return 'medium';
130
+ if (['critical', 'high', 'medium', 'low'].includes(v)) return v;
131
+ return 'unknown';
132
+ }
133
+
134
+ /**
135
+ * Per-severity counts in the shape repo_security uses, plus the prod-only
136
+ * subset. Count distinct advisories, not (advisory x version) rows: a package
137
+ * resolved at two vulnerable versions is one exposure to fix, not two.
138
+ */
139
+ export function countBySeverity(advisories) {
140
+ const c = { critical: 0, high: 0, medium: 0, low: 0, prod_critical: 0, prod_high: 0 };
141
+ const seen = new Set();
142
+ for (const a of advisories) {
143
+ const key = `${a.package}|${a.ghsa}`;
144
+ if (seen.has(key)) continue;
145
+ seen.add(key);
146
+ if (a.severity in c) c[a.severity]++;
147
+ if (!a.dev && (a.severity === 'critical' || a.severity === 'high')) c[`prod_${a.severity}`]++;
148
+ }
149
+ return c;
150
+ }
151
+
152
+ // A lockfile's kind, from its file name. Both kinds resolve packages from the
153
+ // npm registry, so both are audited against the same advisory endpoint.
154
+ const LOCK_KINDS = { 'package-lock.json': 'npm', 'pnpm-lock.yaml': 'pnpm' };
155
+
156
+ /** Tree entries that are committed npm or pnpm lockfiles worth auditing. */
157
+ export function lockfilePathsFromTree(entries) {
158
+ return (entries ?? [])
159
+ .filter(e => e && e.type === 'blob' && typeof e.path === 'string')
160
+ .map(e => ({ e, kind: LOCK_KINDS[e.path.split('/').pop()] }))
161
+ .filter(({ kind }) => kind)
162
+ // A vendored node_modules tree is someone else's lockfile.
163
+ .filter(({ e }) => !e.path.includes('node_modules/'))
164
+ .map(({ e, kind }) => ({ path: e.path, sha: e.sha, size: e.size ?? null, kind }));
165
+ }
166
+
167
+ // ---- pnpm ---------------------------------------------------------------
168
+ //
169
+ // pnpm-lock.yaml keys its packages by name and version, in three spellings
170
+ // across lockfile versions:
171
+ // 9.x `name@1.2.3` in `packages`, and `name@1.2.3(peer@2.0.0)` in
172
+ // `snapshots`, which carries the dependency edges
173
+ // 6.x `/name@1.2.3` or `/name@1.2.3(peer@2.0.0)`, with `dev: true|false`
174
+ // 5.x `/name/1.2.3` or `/name/1.2.3_peer@2.0.0`, with `dev: true|false`
175
+ // A scoped name keeps its leading `@`, so the version starts after the LAST
176
+ // `@` (or, in 5.x, the last `/`). A key whose version is not a registry
177
+ // version -- a git or tarball URL, `file:`, `link:` -- is not in the advisory
178
+ // registry and is left out, as the npm path leaves out workspace links.
179
+
180
+ const SEMVER = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.+-]*)?$/;
181
+
182
+ /** { name, version, key } for one pnpm package key, or null. Pure. */
183
+ // A package name: an optional `@scope/`, then a name with no `/` or `@`.
184
+ // 5.x is tried first because its peer suffix (`_peer@1.0.0`) carries an `@`
185
+ // of its own, which the 6.x/9.x spelling would take for the version's.
186
+ const PNPM_V5_KEY = /^\/((?:@[^/@]+\/)?[^/@]+)\/([^/_(]+)(?:_.*)?$/;
187
+ const PNPM_KEY = /^\/?((?:@[^/@]+\/)?[^/@]+)@([^(]+)(?:\(.*)?$/;
188
+
189
+ export function parsePnpmKey(key) {
190
+ if (typeof key !== 'string' || !key) return null;
191
+ const m = PNPM_V5_KEY.exec(key) ?? PNPM_KEY.exec(key);
192
+ if (!m) return null;
193
+ const [, name, version] = m;
194
+ return SEMVER.test(version) ? { name, version } : null;
195
+ }
196
+
197
+ /**
198
+ * The package map and dev-only names of a parsed pnpm-lock.yaml. Pure.
199
+ *
200
+ * Dev-ness: 5.x and 6.x mark each package `dev: true|false`, read like npm's
201
+ * flag. 9.x marks nothing, so what ships is computed: every package reachable
202
+ * through `snapshots` from any importer's `dependencies` or
203
+ * `optionalDependencies` is prod; the rest of the lock is dev-only. A 9.x
204
+ * lock with no `snapshots` cannot be walked, and then nothing is called
205
+ * dev-only -- the louder reading, never a guess that hides a shipped package.
206
+ */
207
+ export function pnpmPackageMap(lock) {
208
+ const out = new Map();
209
+ const add = (name, version) => {
210
+ if (!out.has(name)) out.set(name, new Set());
211
+ out.get(name).add(version);
212
+ };
213
+ const pkgs = lock?.packages && typeof lock.packages === 'object' ? lock.packages : {};
214
+ const flagged = { prod: new Set(), dev: new Set() };
215
+ let anyFlag = false;
216
+ for (const [key, entry] of Object.entries(pkgs)) {
217
+ const p = parsePnpmKey(key);
218
+ if (!p) continue;
219
+ add(p.name, p.version);
220
+ if (entry && typeof entry.dev === 'boolean') { anyFlag = true; (entry.dev ? flagged.dev : flagged.prod).add(p.name); }
221
+ }
222
+ const packages = Object.fromEntries(
223
+ [...out.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([k, v]) => [k, [...v].sort()]),
224
+ );
225
+
226
+ let dev = [];
227
+ if (anyFlag) {
228
+ dev = [...flagged.dev].filter(n => !flagged.prod.has(n));
229
+ } else if (lock?.snapshots && typeof lock.snapshots === 'object') {
230
+ const snaps = lock.snapshots;
231
+ const prod = new Set();
232
+ const queue = [];
233
+ const visit = (name, ver) => {
234
+ if (typeof ver !== 'string' || ver.startsWith('link:') || ver.startsWith('file:')) return;
235
+ const key = `${name}@${ver}`;
236
+ if (prod.has(key)) return;
237
+ prod.add(key);
238
+ queue.push(key);
239
+ };
240
+ for (const imp of Object.values(lock.importers ?? {})) {
241
+ for (const field of ['dependencies', 'optionalDependencies']) {
242
+ for (const [name, d] of Object.entries(imp?.[field] ?? {})) visit(name, typeof d === 'string' ? d : d?.version);
243
+ }
244
+ }
245
+ while (queue.length) {
246
+ const s = snaps[queue.shift()];
247
+ for (const field of ['dependencies', 'optionalDependencies']) {
248
+ for (const [name, ver] of Object.entries(s?.[field] ?? {})) visit(name, ver);
249
+ }
250
+ }
251
+ const prodNames = new Set([...prod].map(k => parsePnpmKey(k)?.name).filter(Boolean));
252
+ dev = Object.keys(packages).filter(n => !prodNames.has(n));
253
+ }
254
+ return { packages, dev: dev.sort() };
255
+ }
256
+
257
+ /**
258
+ * One committed lockfile, read: { version, packages, dev }. Pure apart from
259
+ * parsing. `kind` is lockfilePathsFromTree's. Throws on a file that does not
260
+ * parse; the collector records that as not measured.
261
+ */
262
+ export function readLockfile(text, kind, parseYaml) {
263
+ if (kind === 'pnpm') {
264
+ const lock = parseYaml(text);
265
+ if (!lock || typeof lock !== 'object') throw new Error('pnpm-lock.yaml does not parse to a mapping');
266
+ const { packages, dev } = pnpmPackageMap(lock);
267
+ const v = parseFloat(lock.lockfileVersion);
268
+ return { version: Number.isFinite(v) ? v : null, packages, dev };
269
+ }
270
+ const lock = JSON.parse(text);
271
+ return { version: lock.lockfileVersion ?? null, packages: packageMapFromLockfile(lock), dev: devOnlyPackages(lock) };
272
+ }
package/src/log.mjs ADDED
@@ -0,0 +1,41 @@
1
+ // How much the tool says on stderr. stdout is for results only.
2
+ //
3
+ // silent errors only
4
+ // normal one line per pass of a sweep, and what was written (the default)
5
+ // verbose adds one line per GitHub call
6
+ // debug adds stack traces on errors
7
+ //
8
+ // Set with --quiet / --verbose / --debug on the CLI, or HK_LOG in the
9
+ // environment. Nothing logged at any level can hold a credential: the tool
10
+ // never reads one. Authentication belongs to `gh`.
11
+ import { userError } from './errors.mjs';
12
+
13
+ export const LEVELS = ['silent', 'normal', 'verbose', 'debug'];
14
+
15
+ /** The level a name stands for. An unknown name is an error, never a silent default. Pure. */
16
+ export function parseLevel(name) {
17
+ if (name === undefined || name === null || name === '') return 'normal';
18
+ const level = String(name).toLowerCase();
19
+ if (!LEVELS.includes(level)) {
20
+ throw userError('CONFIG_INVALID', `unknown log level "${name}"`, `HK_LOG must be one of: ${LEVELS.join(', ')}`);
21
+ }
22
+ return level;
23
+ }
24
+
25
+ /** The level CLI flags ask for; null when none is given. The loudest flag wins. Pure. */
26
+ export function levelFromFlags(flags) {
27
+ if (flags.has('--debug')) return 'debug';
28
+ if (flags.has('--verbose')) return 'verbose';
29
+ if (flags.has('--quiet')) return 'silent';
30
+ return null;
31
+ }
32
+
33
+ const rank = level => LEVELS.indexOf(level);
34
+ const current = () => parseLevel(process.env.HK_LOG);
35
+ export const setLevel = level => { process.env.HK_LOG = parseLevel(level); };
36
+ export const isDebug = () => current() === 'debug';
37
+
38
+ /** A `normal`-level line. */
39
+ export const info = (...a) => { if (rank(current()) >= rank('normal')) console.error(...a); };
40
+ /** A `verbose`-level line. */
41
+ export const verbose = (...a) => { if (rank(current()) >= rank('verbose')) console.error(...a); };
package/src/mcp.mjs ADDED
@@ -0,0 +1,298 @@
1
+ #!/usr/bin/env node
2
+ // MCP server over the housekeeping warehouse.
3
+ //
4
+ // Why this exists: the raw snapshot is ~3 MB of JSON. Pulling that into a model's
5
+ // context to answer "which repos have red CI" is the wrong shape. These tools
6
+ // answer questions and return small tables instead.
7
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
8
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
9
+ import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';
10
+ import { DatabaseSync } from 'node:sqlite';
11
+ import { existsSync, readFileSync } from 'node:fs';
12
+ import { join } from 'node:path';
13
+ import { EXIT, formatError, readOnlyQuery, structured, userError } from './errors.mjs';
14
+ import { DATA_DIR, PACKAGE_ROOT } from './paths.mjs';
15
+ import { HEAD_CHECKS_RED } from './analyze.mjs';
16
+
17
+ const DB_FILE = process.env.HK_DB || join(DATA_DIR, 'housekeeping.db');
18
+ const VERSION = JSON.parse(readFileSync(join(PACKAGE_ROOT, 'package.json'), 'utf8')).version;
19
+
20
+ if (!existsSync(DB_FILE)) {
21
+ console.error(formatError(userError('NO_DATABASE', `no database at ${DB_FILE}`, 'run `hk refresh` first')));
22
+ process.exit(EXIT.USER);
23
+ }
24
+ const db = new DatabaseSync(DB_FILE, { readOnly: true });
25
+ const sid = () => db.prepare('SELECT MAX(id) AS id FROM snapshot').get().id;
26
+
27
+ /** Render rows as a pipe table: far cheaper in tokens than pretty-printed JSON. */
28
+ function asTable(rows, note) {
29
+ if (!rows.length) return (note ? note + '\n' : '') + '(no rows)';
30
+ const cols = Object.keys(rows[0]);
31
+ const body = [
32
+ cols.join(' | '),
33
+ cols.map(() => '---').join(' | '),
34
+ ...rows.map(r => cols.map(c => String(r[c] ?? '').replace(/\n/g, ' ')).join(' | ')),
35
+ ].join('\n');
36
+ return (note ? note + '\n\n' : '') + body + `\n\n(${rows.length} rows)`;
37
+ }
38
+
39
+ const str = (d) => ({ type: 'string', description: d });
40
+ const int = (d) => ({ type: 'integer', description: d });
41
+
42
+ const TOOLS = [
43
+ {
44
+ name: 'hk_summary',
45
+ description: 'Portfolio totals for the newest snapshot: repo/issue/PR/workflow/finding counts.',
46
+ inputSchema: { type: 'object', properties: {} },
47
+ run: () => {
48
+ const s = db.prepare('SELECT * FROM snapshot WHERE id=?').get(sid());
49
+ const t = db.prepare(`SELECT
50
+ (SELECT COUNT(*) FROM repo WHERE snapshot_id=?1) repos,
51
+ (SELECT COUNT(*) FROM repo WHERE snapshot_id=?1 AND is_archived=1) archived,
52
+ (SELECT COUNT(*) FROM repo WHERE snapshot_id=?1 AND is_private=1) private,
53
+ (SELECT COUNT(*) FROM issue WHERE snapshot_id=?1) open_issues,
54
+ (SELECT COUNT(*) FROM pull_request WHERE snapshot_id=?1) open_prs,
55
+ (SELECT COUNT(*) FROM workflow WHERE snapshot_id=?1) workflows,
56
+ (SELECT COUNT(*) FROM finding WHERE snapshot_id=?1) findings`).get(sid());
57
+ return asTable([t], `snapshot ${s.id} of ${s.org}, collected ${s.taken_at}`);
58
+ },
59
+ },
60
+ {
61
+ name: 'hk_findings',
62
+ description: 'Audit findings, optionally filtered. Omit all filters for counts grouped by code.',
63
+ inputSchema: {
64
+ type: 'object',
65
+ properties: {
66
+ severity: str('critical | high | medium | low | info'),
67
+ category: str('ci | security | actions | version | hygiene | metadata | docs | backlog | lifecycle | atlas'),
68
+ code: str('exact finding code, e.g. CI_RUN_FAILING'),
69
+ repo: str('repo name'),
70
+ limit: int('max rows, default 100'),
71
+ },
72
+ },
73
+ run: ({ severity, category, code, repo, limit = 100 }) => {
74
+ const where = ['snapshot_id = ?'], args = [sid()];
75
+ for (const [col, val] of [['severity', severity], ['category', category], ['code', code], ['repo', repo]]) {
76
+ if (val) { where.push(`${col} = ?`); args.push(val); }
77
+ }
78
+ if (!severity && !category && !code && !repo) {
79
+ return asTable(db.prepare(`SELECT severity, category, code, COUNT(*) n,
80
+ COUNT(DISTINCT repo) repos FROM finding WHERE snapshot_id=?
81
+ GROUP BY severity, category, code
82
+ ORDER BY CASE severity WHEN 'critical' THEN 0 WHEN 'high' THEN 1
83
+ WHEN 'medium' THEN 2 WHEN 'low' THEN 3 ELSE 4 END, n DESC`).all(sid()),
84
+ 'All findings grouped by code. Pass a filter to see individual rows.');
85
+ }
86
+ return asTable(db.prepare(`SELECT repo, severity, code, message, evidence
87
+ FROM finding WHERE ${where.join(' AND ')} ORDER BY repo LIMIT ?`).all(...args, limit));
88
+ },
89
+ },
90
+ {
91
+ name: 'hk_ci',
92
+ description: 'CI health. Default lists repos whose DEFAULT BRANCH is red (the ones that matter).',
93
+ inputSchema: {
94
+ type: 'object',
95
+ properties: {
96
+ scope: str('red_main (default) | scheduled | branches | none | all_runs'),
97
+ },
98
+ },
99
+ run: ({ scope = 'red_main' }) => {
100
+ if (scope === 'scheduled' || scope === 'branches' || scope === 'none') {
101
+ const codes = {
102
+ scheduled: ['CI_SCHEDULED_FAILING'],
103
+ branches: ['CI_DEPENDABOT_FAILING', 'CI_BRANCH_FAILING'],
104
+ none: ['CI_NO_WORKFLOWS', 'CI_NEVER_RAN'],
105
+ }[scope];
106
+ return asTable(db.prepare(`SELECT repo, code, message, evidence FROM finding
107
+ WHERE snapshot_id=? AND code IN (${codes.map(() => '?').join(',')})
108
+ ORDER BY repo`).all(sid(), ...codes));
109
+ }
110
+ if (scope === 'all_runs') {
111
+ return asTable(db.prepare(`SELECT conclusion, COUNT(*) runs FROM workflow_run
112
+ WHERE snapshot_id=? GROUP BY conclusion ORDER BY runs DESC`).all(sid()));
113
+ }
114
+ return asTable(db.prepare(`SELECT r.name repo, r.default_branch branch, r.ci_rollup rollup,
115
+ (SELECT GROUP_CONCAT(wr.workflow_name, '; ') FROM workflow_run wr
116
+ WHERE wr.snapshot_id=r.snapshot_id AND wr.repo=r.name AND wr.is_latest_on_default=1
117
+ AND wr.conclusion='failure' AND wr.event NOT IN ('dynamic','schedule')) failing
118
+ FROM repo r WHERE r.snapshot_id=? AND r.is_archived=0
119
+ AND (${HEAD_CHECKS_RED} OR EXISTS (
120
+ SELECT 1 FROM workflow_run wr WHERE wr.snapshot_id=r.snapshot_id AND wr.repo=r.name
121
+ AND wr.is_latest_on_default=1 AND wr.conclusion='failure'
122
+ AND wr.event NOT IN ('dynamic','schedule')))
123
+ ORDER BY r.name`).all(sid()), 'Repos whose default branch is failing.');
124
+ },
125
+ },
126
+ {
127
+ name: 'hk_repo',
128
+ description: 'Everything known about one repo: metadata, findings, workflows, open PRs and issues.',
129
+ inputSchema: {
130
+ type: 'object',
131
+ properties: { name: str('repo name') },
132
+ required: ['name'],
133
+ },
134
+ run: ({ name }) => {
135
+ const r = db.prepare('SELECT * FROM repo WHERE snapshot_id=? AND name=?').get(sid(), name);
136
+ if (!r) return `No repo named "${name}" in the newest snapshot.`;
137
+ const part = (label, rows) => `\n### ${label}\n${asTable(rows)}`;
138
+ return [
139
+ asTable([r], `## ${name}`),
140
+ part('findings', db.prepare('SELECT severity, code, message FROM finding WHERE snapshot_id=? AND repo=? ORDER BY severity').all(sid(), name)),
141
+ part('workflows', db.prepare('SELECT path, on_triggers, has_paths_filter, has_concurrency, runners, job_count FROM workflow WHERE snapshot_id=? AND repo=?').all(sid(), name)),
142
+ part('open PRs', db.prepare('SELECT number, age_days, mergeable, author, title FROM pull_request WHERE snapshot_id=? AND repo=? ORDER BY age_days DESC').all(sid(), name)),
143
+ part('open issues', db.prepare('SELECT number, age_days, stale_days, author, title FROM issue WHERE snapshot_id=? AND repo=? ORDER BY stale_days DESC').all(sid(), name)),
144
+ ].join('\n');
145
+ },
146
+ },
147
+ {
148
+ name: 'hk_backlog',
149
+ description: 'Open pull requests or issues across the org, oldest first.',
150
+ inputSchema: {
151
+ type: 'object',
152
+ properties: {
153
+ kind: str('prs (default) | issues'),
154
+ repo: str('restrict to one repo'),
155
+ min_age_days: int('only items older than this'),
156
+ limit: int('max rows, default 50'),
157
+ },
158
+ },
159
+ run: ({ kind = 'prs', repo, min_age_days = 0, limit = 50 }) => {
160
+ const t = kind === 'issues' ? 'issue' : 'pull_request';
161
+ const cols = kind === 'issues'
162
+ ? 'repo, number, age_days, stale_days, comments, author, title'
163
+ : 'repo, number, age_days, mergeable, is_draft, author, title';
164
+ const where = ['snapshot_id = ?', 'age_days >= ?'], args = [sid(), min_age_days];
165
+ if (repo) { where.push('repo = ?'); args.push(repo); }
166
+ return asTable(db.prepare(`SELECT ${cols} FROM ${t}
167
+ WHERE ${where.join(' AND ')} ORDER BY age_days DESC LIMIT ?`).all(...args, limit));
168
+ },
169
+ },
170
+ {
171
+ name: 'hk_health',
172
+ description: 'Per-repo health score (100 minus severity-weighted findings), worst first.',
173
+ inputSchema: { type: 'object', properties: { limit: int('default 25') } },
174
+ run: ({ limit = 25 }) => asTable(db.prepare(`
175
+ SELECT r.name repo,
176
+ COALESCE(SUM(CASE f.severity WHEN 'high' THEN 15 WHEN 'medium' THEN 6
177
+ WHEN 'low' THEN 2 ELSE 0 END),0) penalty,
178
+ COUNT(f.code) findings
179
+ FROM repo r LEFT JOIN finding f ON f.snapshot_id=r.snapshot_id AND f.repo=r.name
180
+ WHERE r.snapshot_id=? AND r.is_archived=0
181
+ GROUP BY r.name ORDER BY penalty DESC, r.name LIMIT ?`).all(sid(), limit)),
182
+ },
183
+ {
184
+ name: 'hk_cost',
185
+ description: 'GitHub Actions spend. `scope` picks the grain: "repo" (the invoice, per repo), '
186
+ + '"workflow" or "job" (computed from per-job durations). IMPORTANT: gross and net are '
187
+ + 'different numbers. GitHub meters public repos at full price and discounts them to zero, '
188
+ + 'so gross is compute consumed and net is money owed - never report gross as a bill. '
189
+ + 'Workflow and job grains cover only the repos whose totals reconciled against the invoice; '
190
+ + 'a repo missing from them was not measured, which is not the same as costing nothing.',
191
+ inputSchema: {
192
+ type: 'object',
193
+ properties: {
194
+ scope: str('repo | workflow | job (default repo)'),
195
+ repo: str('restrict to one repo'),
196
+ limit: int('default 25'),
197
+ },
198
+ },
199
+ run: ({ scope = 'repo', repo, limit = 25 }) => {
200
+ const s = sid();
201
+ const has = db.prepare(
202
+ "SELECT COUNT(*) n FROM billing_usage WHERE snapshot_id=? AND product='actions'").get(s);
203
+ if (!has.n) return 'No cost data in this snapshot (billing pass absent or unauthorized). '
204
+ + 'This means NOT MEASURED, not zero cost.';
205
+ if (scope === 'repo') {
206
+ return asTable(db.prepare(`SELECT b.repo,
207
+ ROUND(SUM(b.gross),2) gross_usd, ROUND(SUM(b.net),2) net_usd,
208
+ CASE WHEN r.is_private=1 THEN 'private' ELSE 'public' END visibility,
209
+ ROUND(SUM(CASE WHEN b.sku='Actions Linux' THEN b.quantity ELSE 0 END)) linux_min,
210
+ ROUND(SUM(CASE WHEN b.sku='Actions Windows' THEN b.quantity ELSE 0 END)) windows_min,
211
+ ROUND(SUM(CASE WHEN b.sku LIKE 'Actions macOS%' THEN b.quantity ELSE 0 END)) macos_min
212
+ FROM billing_usage b LEFT JOIN repo r ON r.snapshot_id=b.snapshot_id AND r.name=b.repo
213
+ WHERE b.snapshot_id=? AND b.product='actions' AND b.sku<>'Actions storage'
214
+ AND b.repo IS NOT NULL AND (?2 IS NULL OR b.repo=?2)
215
+ GROUP BY b.repo ORDER BY SUM(b.gross) DESC LIMIT ?3`).all(s, repo ?? null, limit));
216
+ }
217
+ if (scope === 'workflow') {
218
+ return asTable(db.prepare(`SELECT c.repo, c.workflow_name workflow, COUNT(*) runs,
219
+ SUM(c.billable_minutes) billable_min, ROUND(SUM(c.cost_usd),2) cost_usd,
220
+ ROUND(AVG(c.billed_job_count),1) jobs_per_run,
221
+ SUM(CASE WHEN c.conclusion='failure' THEN c.billable_minutes ELSE 0 END) failed_min,
222
+ SUM(CASE WHEN c.conclusion='cancelled' THEN c.billable_minutes ELSE 0 END) cancelled_min
223
+ FROM run_cost c
224
+ JOIN cost_reconciliation x ON x.snapshot_id=c.snapshot_id AND x.repo=c.repo
225
+ WHERE c.snapshot_id=? AND x.trustworthy=1 AND (?2 IS NULL OR c.repo=?2)
226
+ GROUP BY c.repo, c.workflow_name ORDER BY SUM(c.cost_usd) DESC LIMIT ?3`)
227
+ .all(s, repo ?? null, limit));
228
+ }
229
+ return asTable(db.prepare(`SELECT j.repo, c.workflow_name workflow,
230
+ TRIM(CASE WHEN j.name LIKE '%(%)' THEN SUBSTR(j.name,1,INSTR(j.name,' (')-1)
231
+ ELSE j.name END) job,
232
+ COUNT(DISTINCT j.name) matrix_cells, SUM(j.minutes) billable_min,
233
+ ROUND(SUM(j.cost_usd),2) cost_usd
234
+ FROM run_cost_job j JOIN run_cost c
235
+ ON c.snapshot_id=j.snapshot_id AND c.run_id=j.run_id
236
+ JOIN cost_reconciliation x ON x.snapshot_id=j.snapshot_id AND x.repo=j.repo
237
+ WHERE j.snapshot_id=? AND x.trustworthy=1 AND (?2 IS NULL OR j.repo=?2)
238
+ GROUP BY j.repo, c.workflow_name, job ORDER BY SUM(j.cost_usd) DESC LIMIT ?3`)
239
+ .all(s, repo ?? null, limit));
240
+ },
241
+ },
242
+ {
243
+ name: 'hk_sql',
244
+ description: 'Read-only SQL against the warehouse. Tables: snapshot, repo, topic, language, '
245
+ + 'issue, pull_request, release, workflow, workflow_run, file_presence, finding, collect_error, '
246
+ + 'billing_usage, run_cost, run_cost_job, cost_reconciliation. '
247
+ + 'Every table is keyed by snapshot_id.',
248
+ inputSchema: {
249
+ type: 'object',
250
+ properties: { query: str('a single SELECT or WITH statement') },
251
+ required: ['query'],
252
+ },
253
+ run: ({ query }) => {
254
+ return asTable(db.prepare(readOnlyQuery(query)).all());
255
+ },
256
+ },
257
+ {
258
+ name: 'hk_schema',
259
+ description: 'The warehouse schema - table and column names, for writing hk_sql queries.',
260
+ inputSchema: { type: 'object', properties: { table: str('one table, or omit for all') } },
261
+ run: ({ table }) => {
262
+ const tables = table ? [table]
263
+ : db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name")
264
+ .all().map(r => r.name);
265
+ return tables.map(t => {
266
+ const cols = db.prepare(`PRAGMA table_info(${t})`).all();
267
+ if (!cols.length) return `${t}: (unknown table)`;
268
+ return `${t}(${cols.map(c => c.name).join(', ')})`;
269
+ }).join('\n');
270
+ },
271
+ },
272
+ ];
273
+
274
+ const server = new Server(
275
+ { name: 'housekeeping', version: VERSION },
276
+ { capabilities: { tools: {} } },
277
+ );
278
+
279
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
280
+ tools: TOOLS.map(({ name, description, inputSchema }) => ({ name, description, inputSchema })),
281
+ }));
282
+
283
+ server.setRequestHandler(CallToolRequestSchema, async (req) => {
284
+ const tool = TOOLS.find(t => t.name === req.params.name);
285
+ // Every failure is a structured result: { code, message, hint, retryable }.
286
+ // A bad argument or a failing query must never take the server down, and a
287
+ // stack trace is never sent to the caller.
288
+ const fail = e => ({ isError: true, content: [{ type: 'text', text: JSON.stringify(structured(e)) }] });
289
+ if (!tool) return fail(userError('UNKNOWN_TOOL', `unknown tool "${req.params.name}"`, `tools: ${TOOLS.map(t => t.name).join(', ')}`));
290
+ try {
291
+ return { content: [{ type: 'text', text: tool.run(req.params.arguments ?? {}) }] };
292
+ } catch (e) {
293
+ return fail(e);
294
+ }
295
+ });
296
+
297
+ await server.connect(new StdioServerTransport());
298
+ console.error(`[hk-mcp] serving ${TOOLS.length} tools from ${DB_FILE}`);
package/src/paths.mjs ADDED
@@ -0,0 +1,31 @@
1
+ // Where housekeeping reads its own files and where it writes yours.
2
+ //
3
+ // Two kinds of path, kept apart on purpose:
4
+ //
5
+ // PACKAGE_ROOT what ships inside the package: src/schema.sql, package.json.
6
+ // Always relative to this file.
7
+ // WORK_DIR what a sweep writes and reads back: data/ (snapshots, the
8
+ // database, the caches, the sweep log), reports/, and
9
+ // housekeeping.config.json.
10
+ //
11
+ // WORK_DIR is HK_HOME when it is set. Otherwise it is the package root when
12
+ // housekeeping runs from a git checkout -- a clone, or `npm link` of one --
13
+ // which is how it has always behaved; and the current directory when it runs
14
+ // from an installed package, the way git and npm themselves work. Writing into
15
+ // an installed package would put a warehouse inside node_modules, where it can
16
+ // be read-only and where the next upgrade deletes it.
17
+ import { existsSync } from 'node:fs';
18
+ import { dirname, join, resolve } from 'node:path';
19
+ import { fileURLToPath } from 'node:url';
20
+
21
+ export const PACKAGE_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
22
+
23
+ /** The directory housekeeping writes to. Pure given its inputs. */
24
+ export function workDir({ env = process.env, root = PACKAGE_ROOT, cwd = process.cwd(), exists = existsSync } = {}) {
25
+ if (env.HK_HOME) return resolve(env.HK_HOME);
26
+ return exists(join(root, '.git')) ? root : cwd;
27
+ }
28
+
29
+ export const WORK_DIR = workDir();
30
+ export const DATA_DIR = join(WORK_DIR, 'data');
31
+ export const REPORTS_DIR = join(WORK_DIR, 'reports');