softwareobservatory 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,74 @@
1
+ # softwareobservatory
2
+
3
+ CLI and MCP server for querying the [Software
4
+ Observatory](https://softwareobservatory.com) catalog: 56 "epistemic sensors"
5
+ for software correctness, organized into 11 families. The full catalog ships
6
+ inside the package, so every command works offline.
7
+
8
+ Built to be driven by agents as well as humans: stdout is JSON whenever it is
9
+ piped (or `--json` is passed), and `softwareobservatory mcp` speaks the Model
10
+ Context Protocol over stdio.
11
+
12
+ ## Usage
13
+
14
+ ```console
15
+ $ npx softwareobservatory list --family structural
16
+ $ npx softwareobservatory get SO-003
17
+ $ npx softwareobservatory search mutation
18
+ $ npx softwareobservatory suggest "our tests pass but bugs still ship"
19
+ $ npx softwareobservatory gaps "how do I know my ai-generated code is safe"
20
+ $ npx softwareobservatory stack linter,SO-003,canary-analysis
21
+ $ npx softwareobservatory values oracle
22
+ ```
23
+
24
+ ## Commands
25
+
26
+ | Command | Description |
27
+ |---------|-------------|
28
+ | `list [--family <slug>]` | List sensors, optionally within one family. |
29
+ | `families` | List the 11 sensor families with counts. |
30
+ | `get <id\|slug\|title>` | One sensor in full: frontmatter, entry text, related entries. |
31
+ | `search <term...>` | Substring search over titles and entry text, ranked. |
32
+ | `values <field>` | Distinct values of a frontmatter field (`oracle`, `latency`, `type`, `stack_level`, ...). |
33
+ | `suggest <question...>` | Ranked sensors relevant to a plain-language concern. |
34
+ | `gaps <question...>` | Like `suggest`, but only the first result from each newly covered family. |
35
+ | `stack <id,slug,...>` | Family/stack coverage report for a sensor set, with recommendations. |
36
+ | `mcp` | Run an MCP (stdio JSON-RPC) server. |
37
+ | `version` | CLI and dataset versions. |
38
+
39
+ ## Flags
40
+
41
+ - `--json`: machine-readable output. This is the default when stdout is not a
42
+ TTY, so piping into `jq` or an agent harness just works.
43
+ - `--plain`: force human-readable output.
44
+
45
+ ## MCP server
46
+
47
+ ```console
48
+ $ npx softwareobservatory mcp
49
+ ```
50
+
51
+ Tools exposed: `list_families`, `list_sensors`, `get_sensor`,
52
+ `suggest_sensors`, `stack_coverage`. Every sensor is also available as an MCP
53
+ resource at `softwareobservatory://sensor/<slug>`, and the family list at
54
+ `softwareobservatory://families`.
55
+
56
+ Example MCP client config (Claude Code, Crush, etc.):
57
+
58
+ ```json
59
+ {
60
+ "mcpServers": {
61
+ "softwareobservatory": {
62
+ "command": "npx",
63
+ "args": ["-y", "softwareobservatory", "mcp"]
64
+ }
65
+ }
66
+ }
67
+ ```
68
+
69
+ ## Data
70
+
71
+ `data/sensors.json` is generated from the site's markdown sources by
72
+ `scripts/export_cli_data.py` (which `scripts/build.py` runs on every build) and
73
+ committed so the package can be published straight from the repo. Dataset
74
+ schema is versioned (`version` field); the CLI prints it via `version`.
@@ -0,0 +1,302 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ loadData,
4
+ siteUrl,
5
+ listFamilies,
6
+ getFamily,
7
+ listSensors,
8
+ findSensor,
9
+ getRelated,
10
+ listValues,
11
+ suggestSensors,
12
+ stackCoverage,
13
+ } from "../lib/core.mjs";
14
+ import { startMcpServer } from "../lib/mcp.mjs";
15
+
16
+ const USAGE = `softwareobservatory - query the Software Observatory sensor catalog
17
+
18
+ Usage: softwareobservatory [--json] [--plain] <command> [args]
19
+
20
+ Commands:
21
+ list [--family <slug>] List sensors (all, or within one family)
22
+ families List the 11 sensor families
23
+ get <id|slug|title> Show one sensor in full, with related entries
24
+ search <term...> Substring search over titles and entry text
25
+ values <field> Distinct frontmatter values (oracle, latency, type, ...)
26
+ suggest <question...> Suggest sensors relevant to a concern
27
+ gaps <question...> Like suggest, but only the family-coverage gaps
28
+ stack <id,slug,...> Family/stack coverage report for a sensor set
29
+ mcp Run an MCP (stdio JSON-RPC) server for agents
30
+ version Print CLI and dataset versions
31
+ help This message
32
+
33
+ Flags:
34
+ --json Machine-readable output (full precision; stable for agents)
35
+ --plain Force human-readable output (default when stdout is a TTY)
36
+ `;
37
+
38
+ function parseFlags(argv) {
39
+ const flags = { json: false, plain: false, family: null };
40
+ const rest = [];
41
+ for (let i = 0; i < argv.length; i += 1) {
42
+ const arg = argv[i];
43
+ if (arg === "--json") flags.json = true;
44
+ else if (arg === "--plain") flags.plain = true;
45
+ else if (arg === "--family") {
46
+ flags.family = argv[i + 1];
47
+ i += 1;
48
+ } else if (arg === "--help" || arg === "-h") {
49
+ flags.help = true;
50
+ } else {
51
+ rest.push(arg);
52
+ }
53
+ }
54
+ if (!process.stdout.isTTY) flags.json = true;
55
+ if (flags.plain) flags.json = false;
56
+ return { flags, rest };
57
+ }
58
+
59
+ function emit(flags, data, renderHuman) {
60
+ if (flags.json) {
61
+ process.stdout.write(JSON.stringify(data, null, 2) + "\n");
62
+ } else {
63
+ renderHuman(data);
64
+ }
65
+ }
66
+
67
+ function sensorSummary(sensor) {
68
+ return {
69
+ id: sensor.id,
70
+ slug: sensor.slug,
71
+ title: sensor.title,
72
+ family: sensor.family,
73
+ url: siteUrl(sensor.url_path),
74
+ };
75
+ }
76
+
77
+ function searchSensors(terms) {
78
+ const needle = terms.join(" ").toLowerCase();
79
+ if (!needle) return [];
80
+ return loadData().sensors.filter(
81
+ (s) => s.title.toLowerCase().includes(needle) || s.body_text.includes(needle)
82
+ );
83
+ }
84
+
85
+ function scoreSearch(sensor, needle) {
86
+ let score = 0;
87
+ if (sensor.title.toLowerCase().includes(needle)) score += 10;
88
+ const occurrences = sensor.body_text.split(needle).length - 1;
89
+ return score + Math.min(occurrences, 5);
90
+ }
91
+
92
+ function snippet(sensor, needle) {
93
+ const index = sensor.body_text.indexOf(needle);
94
+ if (index === -1) return sensor.body_text.slice(0, 120).trim() + "...";
95
+ const start = Math.max(0, index - 60);
96
+ const end = Math.min(sensor.body_text.length, index + needle.length + 60);
97
+ return (start > 0 ? "..." : "") + sensor.body_text.slice(start, end).trim() + (end < sensor.body_text.length ? "..." : "");
98
+ }
99
+
100
+ function humanList(sensors) {
101
+ let currentFamily = null;
102
+ for (const sensor of sensors) {
103
+ if (sensor.family !== currentFamily) {
104
+ currentFamily = sensor.family;
105
+ const family = getFamily(currentFamily);
106
+ console.log(`\n${family ? family.name : currentFamily}`);
107
+ }
108
+ console.log(` ${sensor.id.padEnd(9)} ${sensor.title} (${sensor.slug})`);
109
+ }
110
+ console.log(`\n${sensors.length} sensor(s)`);
111
+ }
112
+
113
+ function humanFamilies(families) {
114
+ for (const family of families) {
115
+ console.log(`${family.num}. ${family.name} [${family.slug}] - ${family.question}`);
116
+ console.log(` ${family.count} sensor(s). Examples: ${family.examples}`);
117
+ }
118
+ }
119
+
120
+ function humanGet(sensor, related) {
121
+ console.log(`${sensor.title} (${sensor.id})`);
122
+ console.log(`Family: ${sensor.family} | ${siteUrl(sensor.url_path)}`);
123
+ console.log("");
124
+ const fm = sensor.frontmatter;
125
+ const rows = [
126
+ ["Oracle strength", fm.oracle],
127
+ ["Independence", fm.independence],
128
+ ["Scope", fm.scope],
129
+ ["Feedback latency", fm.latency],
130
+ ["Actionability", fm.actionability],
131
+ ["Type", fm.type],
132
+ ["Stack level", fm.stack_level],
133
+ ];
134
+ for (const [label, value] of rows) {
135
+ if (value) console.log(` ${label.padEnd(18)} ${value}`);
136
+ }
137
+ console.log("");
138
+ console.log(sensor.body_text);
139
+ if (related.length > 0) {
140
+ console.log("\nRelated:");
141
+ for (const rel of related) {
142
+ if (rel.kind === "sensor") console.log(` - ${rel.title} (${rel.id})`);
143
+ else if (rel.kind === "family") console.log(` - ${rel.name} family [${rel.slug}]`);
144
+ else console.log(` - page: ${rel.ref}`);
145
+ }
146
+ }
147
+ }
148
+
149
+ function humanSuggest(results, { gapsOnly }) {
150
+ const shown = gapsOnly ? results.filter((r) => r.gap) : results;
151
+ for (const result of shown) {
152
+ const marker = result.gap ? "GAP " : " ";
153
+ console.log(
154
+ `${marker}${result.id.padEnd(9)} ${result.title} [${result.family}] matched: ${result.matched_terms.join(", ") || "-"}`
155
+ );
156
+ }
157
+ if (shown.length === 0) console.log("No suggestions. Try different wording.");
158
+ else console.log(`\n${shown.length} suggestion(s)`);
159
+ }
160
+
161
+ function humanStack(report) {
162
+ console.log(`Stack: ${report.selected.length} sensor(s)`);
163
+ for (const sensor of report.selected) {
164
+ console.log(` - ${sensor.title} (${sensor.id}) [${sensor.family}]`);
165
+ }
166
+ if (report.unknown_ids.length > 0) {
167
+ console.log(`\nUnknown ids: ${report.unknown_ids.join(", ")}`);
168
+ }
169
+ console.log("\nFamily coverage:");
170
+ for (const family of listFamilies()) {
171
+ const count = report.coverage.families[family.slug] || 0;
172
+ const mark = count > 0 ? "x" : " ";
173
+ console.log(` [${mark}] ${family.name} (${count})`);
174
+ }
175
+ if (Object.keys(report.coverage.stack_levels).length > 0) {
176
+ console.log("\nStack levels: " + Object.keys(report.coverage.stack_levels).join(", "));
177
+ }
178
+ if (report.recommendations.length > 0) {
179
+ console.log("\nRecommendations:");
180
+ for (const rec of report.recommendations) {
181
+ console.log(` + ${rec.title} (${rec.id}) - ${rec.reason}`);
182
+ }
183
+ }
184
+ }
185
+
186
+ function main() {
187
+ const { flags, rest } = parseFlags(process.argv.slice(2));
188
+ const [command, ...args] = rest;
189
+
190
+ if (flags.help || !command) {
191
+ process.stdout.write(USAGE);
192
+ process.exit(command ? 0 : 1);
193
+ }
194
+
195
+ switch (command) {
196
+ case "list": {
197
+ if (flags.family && !getFamily(flags.family)) {
198
+ console.error(`Unknown family '${flags.family}'. Run 'families' to see valid slugs.`);
199
+ process.exit(1);
200
+ }
201
+ const sensors = listSensors({ family: flags.family });
202
+ emit(flags, sensors.map(sensorSummary), () => humanList(sensors));
203
+ break;
204
+ }
205
+ case "families": {
206
+ emit(flags, listFamilies(), humanFamilies);
207
+ break;
208
+ }
209
+ case "get": {
210
+ if (args.length === 0) {
211
+ console.error("Usage: softwareobservatory get <id|slug|title>");
212
+ process.exit(1);
213
+ }
214
+ const sensor = findSensor(args.join(" "));
215
+ if (!sensor) {
216
+ console.error(`No sensor matches '${args.join(" ")}'.`);
217
+ process.exit(1);
218
+ }
219
+ const related = getRelated(sensor);
220
+ emit(
221
+ flags,
222
+ { ...sensorSummary(sensor), frontmatter: sensor.frontmatter, body_text: sensor.body_text, body_html: sensor.body_html, related },
223
+ () => humanGet(sensor, related)
224
+ );
225
+ break;
226
+ }
227
+ case "search": {
228
+ const needle = args.join(" ").toLowerCase();
229
+ const matches = searchSensors(args)
230
+ .map((s) => ({ sensor: s, score: scoreSearch(s, needle) }))
231
+ .sort((a, b) => b.score - a.score || a.sensor.slug.localeCompare(b.sensor.slug));
232
+ emit(
233
+ flags,
234
+ matches.map(({ sensor, score }) => ({ ...sensorSummary(sensor), score, snippet: snippet(sensor, needle) })),
235
+ () => {
236
+ for (const { sensor } of matches) {
237
+ console.log(`${sensor.id.padEnd(9)} ${sensor.title} (${sensor.slug})`);
238
+ console.log(` ${snippet(sensor, needle)}`);
239
+ }
240
+ console.log(`\n${matches.length} match(es)`);
241
+ }
242
+ );
243
+ break;
244
+ }
245
+ case "values": {
246
+ if (args.length === 0) {
247
+ console.error("Usage: softwareobservatory values <field>");
248
+ process.exit(1);
249
+ }
250
+ const values = listValues(args[0]);
251
+ emit(flags, { field: args[0], values }, () => values.forEach((v) => console.log(v)));
252
+ break;
253
+ }
254
+ case "suggest":
255
+ case "gaps": {
256
+ if (args.length === 0) {
257
+ console.error(`Usage: softwareobservatory ${command} <question...>`);
258
+ process.exit(1);
259
+ }
260
+ const results = suggestSensors(args.join(" "));
261
+ const gapsOnly = command === "gaps";
262
+ emit(
263
+ flags,
264
+ gapsOnly ? results.filter((r) => r.gap) : results,
265
+ () => humanSuggest(results, { gapsOnly })
266
+ );
267
+ break;
268
+ }
269
+ case "stack": {
270
+ if (args.length === 0) {
271
+ console.error("Usage: softwareobservatory stack <id,slug,...>");
272
+ process.exit(1);
273
+ }
274
+ const ids = args.join(" ").split(/[,\s]+/).filter(Boolean);
275
+ const report = stackCoverage(ids);
276
+ emit(flags, report, () => humanStack(report));
277
+ break;
278
+ }
279
+ case "mcp": {
280
+ startMcpServer();
281
+ break;
282
+ }
283
+ case "version": {
284
+ const data = loadData();
285
+ emit(
286
+ flags,
287
+ { cli: "0.1.0", dataset_version: data.version, dataset_generated_at: data.generated_at, sensors: data.sensors.length },
288
+ () => {
289
+ console.log(`softwareobservatory 0.1.0`);
290
+ console.log(`dataset v${data.version}, generated ${data.generated_at}, ${data.sensors.length} sensors`);
291
+ }
292
+ );
293
+ break;
294
+ }
295
+ default:
296
+ console.error(`Unknown command '${command}'.\n`);
297
+ process.stdout.write(USAGE);
298
+ process.exit(1);
299
+ }
300
+ }
301
+
302
+ main();