Skip to content

Commit 9e3ae95

Browse files
authored
feat(observatory): agent catalog generator, live monitor & Tauri shell (#19)
Agent Observatory tooling: zero-dep catalog generator (142 agents / 108 skills / 66 commands / 8 workflows / 6 IAM profiles) with node:test suite, hardened localhost SSE live-monitor server + XSS-safe dashboard, opt-in reversible Claude Code hook installer, and a Tauri 2 desktop scaffold. Security: localhost-only bind, Origin check, body cap, path-boundary static serving, minimal CSP.
1 parent 551186d commit 9e3ae95

16 files changed

Lines changed: 5983 additions & 0 deletions

File tree

package.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,9 @@
4040
"templates/",
4141
"workflows/",
4242
"docs/",
43+
"tools/observatory/",
44+
"scripts/build-catalog.mjs",
45+
"scripts/build-catalog.test.mjs",
4346
"install.sh",
4447
"CLAUDE.md",
4548
"CREATOR_NEEDS.md",
@@ -49,6 +52,10 @@
4952
"mcp-servers/*"
5053
],
5154
"scripts": {
55+
"observatory": "node tools/observatory/server.mjs",
56+
"observatory:catalog": "node scripts/build-catalog.mjs",
57+
"observatory:install-hooks": "node tools/observatory/install-hooks.mjs",
58+
"observatory:test": "node --test scripts/build-catalog.test.mjs",
5259
"install:all": "npm install --workspaces",
5360
"build:all": "npm run build --workspaces",
5461
"typecheck:all": "npm run typecheck --workspaces --if-present",

scripts/build-catalog.mjs

Lines changed: 300 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,300 @@
1+
#!/usr/bin/env node
2+
/**
3+
* build-catalog.mjs — Agent Observatory catalog generator
4+
*
5+
* Scans the ACOS source of truth and emits a normalized, data-driven
6+
* catalog.json consumed by BOTH Observatory surfaces:
7+
* - the public showcase on frankx.ai (committed copy in public/observatory/)
8+
* - the localhost live monitor (tools/observatory/)
9+
*
10+
* Sources:
11+
* .claude/agents/**.md → agent nodes (YAML frontmatter: name, description, model, tools)
12+
* skills/registry.json → skill nodes + skill→skill dependency edges
13+
* .claude/skill-rules.json → skill auto-activation triggers (priority, keywords)
14+
* .claude/agent-iam.json → IAM profiles (rendered as the IAM matrix view)
15+
* .claude/workflows/*.js → workflow nodes + workflow→workflow compose edges
16+
* commands (union of trigger.commands) → command nodes + skill→command edges
17+
*
18+
* Zero runtime dependencies — uses a minimal frontmatter parser so this can
19+
* run anywhere Node 18+ is present.
20+
*
21+
* Usage:
22+
* node scripts/build-catalog.mjs [--out <path>]
23+
* Defaults to writing tools/observatory/public/catalog.json
24+
*/
25+
26+
import { readFileSync, readdirSync, writeFileSync, mkdirSync, existsSync, statSync } from 'node:fs'
27+
import { join, dirname, relative, basename } from 'node:path'
28+
import { fileURLToPath } from 'node:url'
29+
30+
const __dirname = dirname(fileURLToPath(import.meta.url))
31+
const ROOT = join(__dirname, '..')
32+
33+
const argv = process.argv.slice(2)
34+
const outFlag = argv.indexOf('--out')
35+
if (outFlag !== -1 && (!argv[outFlag + 1] || argv[outFlag + 1].startsWith('--'))) {
36+
console.error('✗ --out requires a path, e.g. --out tools/observatory/public/catalog.json')
37+
process.exit(1)
38+
}
39+
const OUT = outFlag !== -1 ? argv[outFlag + 1] : join(ROOT, 'tools/observatory/public/catalog.json')
40+
41+
// ---------------------------------------------------------------------------
42+
// Minimal YAML frontmatter parser (handles the two ACOS agent schemas:
43+
// `tools: A, B, C` comma lists and `key:\n - item` block lists).
44+
// ---------------------------------------------------------------------------
45+
function parseFrontmatter(raw) {
46+
const m = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/)
47+
if (!m) return {}
48+
const out = {}
49+
let key = null
50+
for (const line of m[1].split(/\r?\n/)) {
51+
if (!line.trim()) continue
52+
const listItem = line.match(/^\s+-\s+(.*)$/)
53+
if (listItem && key) {
54+
if (!Array.isArray(out[key])) out[key] = []
55+
out[key].push(listItem[1].trim())
56+
continue
57+
}
58+
const kv = line.match(/^([A-Za-z0-9_]+):\s*(.*)$/)
59+
if (kv) {
60+
key = kv[1]
61+
let val = kv[2].trim()
62+
// strip matching surrounding quotes
63+
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
64+
val = val.slice(1, -1)
65+
}
66+
out[key] = val === '' ? null : val
67+
}
68+
}
69+
return out
70+
}
71+
72+
function toToolList(val) {
73+
if (!val) return []
74+
if (Array.isArray(val)) return val
75+
return String(val).split(',').map((s) => s.trim()).filter(Boolean)
76+
}
77+
78+
function normalizeTier(model) {
79+
const m = String(model || '').toLowerCase()
80+
if (m.includes('opus')) return 'opus'
81+
if (m.includes('haiku')) return 'haiku'
82+
if (m.includes('sonnet')) return 'sonnet'
83+
return 'sonnet' // sensible default — builders/writers
84+
}
85+
86+
// ---------------------------------------------------------------------------
87+
// Walk helpers
88+
// ---------------------------------------------------------------------------
89+
function walk(dir, predicate, acc = []) {
90+
if (!existsSync(dir)) return acc
91+
for (const entry of readdirSync(dir)) {
92+
const full = join(dir, entry)
93+
const st = statSync(full)
94+
if (st.isDirectory()) walk(full, predicate, acc)
95+
else if (predicate(full)) acc.push(full)
96+
}
97+
return acc
98+
}
99+
100+
const nodes = []
101+
const edges = []
102+
const seenEdge = new Set()
103+
function addEdge(source, target, rel) {
104+
const k = `${source}->${target}:${rel}`
105+
if (seenEdge.has(k) || source === target) return
106+
seenEdge.add(k)
107+
edges.push({ source, target, rel })
108+
}
109+
110+
// ---------------------------------------------------------------------------
111+
// 1. Agents — .claude/agents/**/*.md (skip CLAUDE.md / README.md docs)
112+
// ---------------------------------------------------------------------------
113+
const agentDir = join(ROOT, '.claude/agents')
114+
const agentFiles = walk(agentDir, (f) => f.endsWith('.md') && !/\/(CLAUDE|README)\.md$/i.test(f))
115+
const agentIds = new Set()
116+
for (const file of agentFiles) {
117+
const raw = readFileSync(file, 'utf8')
118+
const fm = parseFrontmatter(raw)
119+
const rel = relative(agentDir, file)
120+
const sub = rel.includes('/') ? rel.split('/')[0] : 'core'
121+
const id = `agent:${rel.replace(/\.md$/, '')}`
122+
const name = fm.name || basename(file, '.md')
123+
agentIds.add(id)
124+
nodes.push({
125+
id,
126+
kind: 'agent',
127+
name,
128+
group: sub, // directory grouping: core, specialized, devops, consensus, ...
129+
tier: normalizeTier(fm.model),
130+
status: 'shipped', // a committed .md file is dispatchable
131+
tools: toToolList(fm.tools),
132+
mcpServers: toToolList(fm.mcpServers),
133+
description: (fm.description || '').slice(0, 400),
134+
file: relative(ROOT, file),
135+
})
136+
}
137+
138+
// ---------------------------------------------------------------------------
139+
// 2. Skills — skills/registry.json (+ skill-rules.json for triggers/priority)
140+
// ---------------------------------------------------------------------------
141+
const skillNodeId = (name) => `skill:${name}`
142+
const commandIds = new Set()
143+
function ensureCommand(cmd) {
144+
const name = cmd.replace(/^\//, '')
145+
const id = `command:${name}`
146+
if (!commandIds.has(id)) {
147+
commandIds.add(id)
148+
nodes.push({ id, kind: 'command', name: `/${name}`, group: 'command', description: '' })
149+
}
150+
return id
151+
}
152+
153+
let skillRules = { activation_rules: [] }
154+
try {
155+
skillRules = JSON.parse(readFileSync(join(ROOT, '.claude/skill-rules.json'), 'utf8'))
156+
} catch {}
157+
const ruleBySkill = new Map()
158+
for (const r of skillRules.activation_rules || []) ruleBySkill.set(r.skill, r)
159+
160+
let registry = { skills: {} }
161+
try {
162+
registry = JSON.parse(readFileSync(join(ROOT, 'skills/registry.json'), 'utf8'))
163+
} catch {}
164+
165+
// Canonical skill set = every .claude/skills/*/SKILL.md directory, enriched
166+
// with registry.json metadata when the directory name matches a registry key.
167+
const skillsDir = join(ROOT, '.claude/skills')
168+
const skillMdFiles = walk(skillsDir, (f) => /\/SKILL\.md$/i.test(f))
169+
const skillIds = new Set()
170+
const skillNamesSeen = new Set()
171+
function addSkill(name, fromRegistry, fm) {
172+
const id = skillNodeId(name)
173+
if (skillNamesSeen.has(name)) return
174+
skillNamesSeen.add(name)
175+
skillIds.add(id)
176+
const s = fromRegistry || {}
177+
const rule = ruleBySkill.get(name)
178+
nodes.push({
179+
id,
180+
kind: 'skill',
181+
name,
182+
group: s.category || 'skill',
183+
status: 'shipped',
184+
priority: rule?.priority || null,
185+
keywords: s.triggers?.keywords || rule?.triggers?.keywords || [],
186+
description: s.description || fm?.description || '',
187+
_deps: s.dependencies || [],
188+
_commands: [...(s.triggers?.commands || []), ...(rule?.triggers?.commands || [])],
189+
})
190+
}
191+
// 1. directory-backed skills (canonical) — only top-level .claude/skills/<name>/SKILL.md
192+
// (ignore nested SKILL.md belonging to bundled sub-skills / templates)
193+
for (const file of skillMdFiles) {
194+
if (dirname(dirname(file)) !== skillsDir) continue
195+
const name = basename(dirname(file))
196+
const fm = parseFrontmatter(readFileSync(file, 'utf8'))
197+
addSkill(name, registry.skills?.[name], fm)
198+
}
199+
// 2. registry-only skills not present as a directory (declared but mirrored elsewhere)
200+
for (const [name, s] of Object.entries(registry.skills || {})) addSkill(name, s)
201+
// skill→skill dependency edges + skill→command trigger edges
202+
for (const n of nodes.filter((x) => x.kind === 'skill')) {
203+
for (const dep of n._deps || []) {
204+
if (skillIds.has(skillNodeId(dep))) addEdge(n.id, skillNodeId(dep), 'uses-skill')
205+
}
206+
for (const cmd of n._commands || []) addEdge(n.id, ensureCommand(cmd), 'triggers')
207+
delete n._deps
208+
delete n._commands
209+
}
210+
// commands referenced only in skill-rules
211+
for (const r of skillRules.activation_rules || []) {
212+
for (const cmd of r.triggers?.commands || []) {
213+
if (skillIds.has(skillNodeId(r.skill))) addEdge(skillNodeId(r.skill), ensureCommand(cmd), 'triggers')
214+
}
215+
}
216+
217+
// ---------------------------------------------------------------------------
218+
// 3. Workflows — .claude/workflows/*.js (parse the `export const meta = {...}`)
219+
// ---------------------------------------------------------------------------
220+
const wfDir = join(ROOT, '.claude/workflows')
221+
const wfFiles = walk(wfDir, (f) => f.endsWith('.js'))
222+
const wfIds = new Set()
223+
const wfMeta = []
224+
for (const file of wfFiles) {
225+
const raw = readFileSync(file, 'utf8')
226+
// Best-effort extraction without importing (avoids side effects).
227+
const nameM = raw.match(/name:\s*['"]([^'"]+)['"]/)
228+
const descM = raw.match(/description:\s*['"]([^'"]+)['"]/)
229+
const tierM = raw.match(/tier:\s*['"]([^'"]+)['"]/)
230+
const composes = [...raw.matchAll(/composes:\s*\[([^\]]*)\]/g)].map((x) => x[1])
231+
const composedBy = [...raw.matchAll(/composedBy:\s*\[([^\]]*)\]/g)].map((x) => x[1])
232+
const name = nameM ? nameM[1] : basename(file, '.js')
233+
const id = `workflow:${name}`
234+
wfIds.add(id)
235+
const parseList = (s) => (s || '').split(',').map((t) => t.replace(/['"\s]/g, '')).filter(Boolean)
236+
wfMeta.push({ id, composes: parseList(composes[0]), composedBy: parseList(composedBy[0]) })
237+
nodes.push({
238+
id,
239+
kind: 'workflow',
240+
name,
241+
group: 'workflow',
242+
tierLabel: tierM ? tierM[1] : null,
243+
status: 'shipped',
244+
description: descM ? descM[1] : '',
245+
file: relative(ROOT, file),
246+
})
247+
}
248+
for (const w of wfMeta) {
249+
for (const c of w.composes) addEdge(w.id, `workflow:${c}`, 'composes')
250+
for (const c of w.composedBy) addEdge(`workflow:${c}`, w.id, 'composes')
251+
}
252+
253+
// ---------------------------------------------------------------------------
254+
// 4. IAM profiles — kept as a structured block for the matrix view
255+
// ---------------------------------------------------------------------------
256+
let iam = { profiles: {} }
257+
try {
258+
iam = JSON.parse(readFileSync(join(ROOT, '.claude/agent-iam.json'), 'utf8'))
259+
} catch {}
260+
for (const [name, p] of Object.entries(iam.profiles || {})) {
261+
nodes.push({
262+
id: `iam:${name}`,
263+
kind: 'iam-profile',
264+
name,
265+
group: 'iam',
266+
description: p.description || '',
267+
allowedTools: p.allowedTools || [],
268+
deniedTools: p.deniedTools || [],
269+
})
270+
}
271+
272+
// ---------------------------------------------------------------------------
273+
// Drop orphan edges — compose/dependency targets may reference names that have
274+
// no node (e.g. a workflow that composes one not present as a file).
275+
// ---------------------------------------------------------------------------
276+
const nodeIds = new Set(nodes.map((n) => n.id))
277+
const cleanEdges = edges.filter((e) => nodeIds.has(e.source) && nodeIds.has(e.target))
278+
279+
// ---------------------------------------------------------------------------
280+
// Emit
281+
// ---------------------------------------------------------------------------
282+
const counts = nodes.reduce((acc, n) => {
283+
acc[n.kind] = (acc[n.kind] || 0) + 1
284+
return acc
285+
}, {})
286+
287+
const catalog = {
288+
generatedAt: new Date().toISOString(),
289+
version: '1.0.0',
290+
source: 'agentic-creator-os',
291+
counts: { ...counts, edges: cleanEdges.length },
292+
iam: iam.profiles || {},
293+
nodes,
294+
edges: cleanEdges,
295+
}
296+
297+
mkdirSync(dirname(OUT), { recursive: true })
298+
writeFileSync(OUT, JSON.stringify(catalog, null, 2))
299+
console.log(`✓ catalog written → ${relative(ROOT, OUT)}`)
300+
console.log(` ${JSON.stringify(counts)} · ${cleanEdges.length} edges`)

scripts/build-catalog.test.mjs

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
/**
2+
* Tests for build-catalog.mjs — run with: node --test scripts/build-catalog.test.mjs
3+
*
4+
* Generates the catalog to a temp file and asserts the output is well-formed:
5+
* non-empty node kinds, valid edge references, unique ids, IAM present.
6+
*/
7+
8+
import { test } from 'node:test'
9+
import assert from 'node:assert/strict'
10+
import { execFileSync } from 'node:child_process'
11+
import { readFileSync, mkdtempSync } from 'node:fs'
12+
import { tmpdir } from 'node:os'
13+
import { join, dirname } from 'node:path'
14+
import { fileURLToPath } from 'node:url'
15+
16+
const __dirname = dirname(fileURLToPath(import.meta.url))
17+
const GENERATOR = join(__dirname, 'build-catalog.mjs')
18+
19+
function generate() {
20+
const out = join(mkdtempSync(join(tmpdir(), 'acos-catalog-')), 'catalog.json')
21+
execFileSync('node', [GENERATOR, '--out', out], { stdio: 'pipe' })
22+
return JSON.parse(readFileSync(out, 'utf8'))
23+
}
24+
25+
const catalog = generate()
26+
27+
test('has the expected top-level shape', () => {
28+
for (const key of ['generatedAt', 'version', 'counts', 'iam', 'nodes', 'edges']) {
29+
assert.ok(key in catalog, `missing key: ${key}`)
30+
}
31+
assert.ok(Array.isArray(catalog.nodes))
32+
assert.ok(Array.isArray(catalog.edges))
33+
})
34+
35+
test('produces a substantial, multi-kind catalog', () => {
36+
assert.ok(catalog.counts.agent > 50, `expected >50 agents, got ${catalog.counts.agent}`)
37+
assert.ok(catalog.counts.skill > 20, `expected >20 skills, got ${catalog.counts.skill}`)
38+
assert.ok(catalog.counts.workflow >= 1)
39+
assert.equal(catalog.counts['iam-profile'], Object.keys(catalog.iam).length)
40+
})
41+
42+
test('node ids are unique', () => {
43+
const ids = catalog.nodes.map((n) => n.id)
44+
assert.equal(new Set(ids).size, ids.length, 'duplicate node ids found')
45+
})
46+
47+
test('every node has the required fields', () => {
48+
for (const n of catalog.nodes) {
49+
assert.ok(n.id && n.kind && typeof n.name === 'string', `bad node: ${JSON.stringify(n)}`)
50+
assert.ok(['agent', 'skill', 'command', 'workflow', 'iam-profile'].includes(n.kind))
51+
}
52+
})
53+
54+
test('every edge references existing nodes (no orphans)', () => {
55+
const ids = new Set(catalog.nodes.map((n) => n.id))
56+
for (const e of catalog.edges) {
57+
assert.ok(ids.has(e.source), `edge source missing: ${e.source}`)
58+
assert.ok(ids.has(e.target), `edge target missing: ${e.target}`)
59+
assert.ok(['uses-skill', 'triggers', 'composes', 'governed-by'].includes(e.rel))
60+
}
61+
})
62+
63+
test('agent names and descriptions are quote-stripped', () => {
64+
const sample = catalog.nodes.find((n) => n.kind === 'agent')
65+
assert.ok(sample)
66+
assert.ok(!sample.name.startsWith('"'), 'agent name still wrapped in quotes')
67+
})

0 commit comments

Comments
 (0)