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 +74 -0
- package/bin/softwareobservatory.mjs +302 -0
- package/data/sensors.json +7169 -0
- package/lib/core.mjs +189 -0
- package/lib/mcp.mjs +190 -0
- package/package.json +41 -0
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();
|