Files
IUMBTEMS/scripts/docs.ts
T
BillyOutlastandClaude Opus 5.5 020ba87f08 Release 1.6.0: 1.0 cutover, phases 0–5 and the review remediation (#151)
- 1.0 cutover: the rewrite monorepo lands on main. 1.1.5 through 1.5.0
  never published on their own and ship here as 1.6.0, in lockstep for
  es-core, es-cli and epistemic-swarm (ADR 0001).
- Phase 0 (#140): fail-closed seat models, one CLI grammar for the
  human-only rule, sealed source-cache readers, pinned MCP identity, a
  registry-generated permission probe suite, one package list.
- Phase 1 (#141): research backends with failover, the Scraper-Swarm
  gateway, callable brainstorm/harvest, research-only runs, the
  deep-research coordinator, Markdown/HTML dossiers, domain-pack tiers.
- Phase 2 (#142): ADR 0002 and the shared approveStage service, masked
  TUI approvals, the headless runner (JSONL events, metrics, cancel).
- Phase 3 (#144): the human-only es-fleet daemon, DAG scheduler, worktree
  coordinator with a gated integration branch, telemetry bus and watch.
- Phase 4 (#146): the loopback web control plane (SolidJS, ADR 0003):
  dashboard, browser approvals, config preview, evidence explorer.
- Phase 5 (#149): telemetry harvest, distillation into proposals, the
  self-dogfood preset.
- Cutover review remediation (#152): CLI approvals bound to the preview
  with attempt limiting (#143), human-only `factory run` and fleet token,
  DNS-resolving SSRF guard, value-level secret scrubbing, fleet gc and
  allocation races, CodeQL #84, boundary-gate gaps, one REPORT.md writer
  per run mode.

Closes #96.
Closes #97.
Closes #98.
Closes #99.
Closes #100.
Closes #101.
Closes #103.
Closes #106.
Closes #107.
Closes #108.
Closes #109.
Closes #110.
Closes #111.
Closes #112.
Closes #113.
Closes #114.
Closes #117.
Closes #118.
Closes #119.
Closes #120.
Closes #122.
Closes #123.
Closes #124.
Closes #125.
Closes #126.
Closes #127.
Closes #129.
Closes #130.
Closes #131.
Closes #132.
Closes #133.
Closes #135.
Closes #136.
Closes #147.
Closes #148.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 21:33:44 -04:00

207 lines
9.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Generate the machine contracts and docs from the Zod schemas, and fail on
// drift. This is the single pipeline CI runs:
//
// bun scripts/docs.ts write schemas/*.schema.json + docs/SCHEMAS.md
// bun scripts/docs.ts --check exit 1 when the committed outputs differ
//
// The capability matrix and config docs join this script in later M6 work;
// the drift mechanics live here once.
import { existsSync } from "node:fs"
import { mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises"
import path from "node:path"
import * as z from "zod"
import { findAssetDrift } from "../packages/core/src/agents/drift.ts"
import { CAPABILITY_MATRIX, enforcedWithoutProof } from "../packages/core/src/capabilities.ts"
import { HOOK_CAPABILITIES } from "../packages/core/src/hooks/compile.ts"
import * as schemas from "../packages/core/src/schema/index.ts"
const root = path.resolve(import.meta.dir, "..")
const check = process.argv.includes("--check")
const isUpper = (char: string | undefined) => char !== undefined && char >= "A" && char <= "Z"
const isLowerOrDigit = (char: string | undefined) =>
char !== undefined && ((char >= "a" && char <= "z") || (char >= "0" && char <= "9"))
/** GateFindingSchema → gate-finding, HTTPServer → http-server (a word starts at each case boundary). */
const kebab = (name: string) => {
const base = name.endsWith("Schema") ? name.slice(0, -"Schema".length) : name
let out = ""
for (let i = 0; i < base.length; i++) {
const char = base[i]!
const boundary =
i > 0 &&
isUpper(char) &&
(isLowerOrDigit(base[i - 1]) || (isUpper(base[i - 1]) && isLowerOrDigit(base[i + 1]) && base[i + 1]! >= "a"))
out += boundary ? `-${char}` : char
}
return out.toLowerCase()
}
const schemaDir = path.join(root, "schemas")
const docsFile = path.join(root, "docs", "SCHEMAS.md")
interface Output {
readonly file: string
readonly content: string
}
const outputs: Output[] = []
for (const [name, value] of Object.entries(schemas)) {
if (!(value instanceof z.ZodType)) continue
outputs.push({
file: `schemas/${kebab(name)}.schema.json`,
content: `${JSON.stringify(z.toJSONSchema(value), null, 2)}\n`,
})
}
outputs.sort((a, b) => a.file.localeCompare(b.file))
const docs = [
"# Generated contracts",
"",
"JSON Schema renderings of the Zod contracts in `packages/core/src/schema/`.",
"Do not edit by hand: run `bun run docs:gen` after changing a schema (CI runs `bun run docs:check` on drift).",
"",
"| schema | file |",
"| --- | --- |",
...outputs.map((output) => `| ${path.basename(output.file).replace(/\.schema\.json$/, "")} | \`${output.file}\` |`),
"",
].join("\n")
// ------------------------------------------------- capability matrix doc
// ENFORCED rows must name a proof that exists in the repo: a missing file is
// a contract error, not a warning.
const missingProofs = Object.values(CAPABILITY_MATRIX)
.flatMap((rows) => rows.filter((row) => row.support === "enforced" && row.test))
.map((row) => row.test!)
.filter((test) => !existsSync(path.join(root, test)))
for (const harness of Object.keys(CAPABILITY_MATRIX) as Array<keyof typeof CAPABILITY_MATRIX>)
for (const row of enforcedWithoutProof(harness)) missingProofs.push(`${harness}: ${row.capability} (no proof)`)
if (missingProofs.length) {
console.error(`docs: ENFORCED capability rows without a proof:\n${[...new Set(missingProofs)].map((item) => ` ${item}`).join("\n")}`)
process.exit(1)
}
// ------------------------------------------------- asset drift (M3-C)
// Prompt, skill and registry drift, checked in one place for CI: a prompt
// whose frontmatter seat has drifted from the registry, an orphaned prompt or
// skill, a skill without a SKILL.md, an unknown spawn or tool. The canary in
// packages/core/test/drift.test.ts proves each class is caught.
const assetDriftProblems = await findAssetDrift({
promptsDir: path.join(root, "packages/core/assets/prompts"),
skillsDir: path.join(root, "packages/core/assets/skills"),
})
if (assetDriftProblems.length) {
console.error(`docs: asset drift:\n${assetDriftProblems.map((item) => ` ${item}`).join("\n")}`)
process.exit(1)
}
const capabilitiesDoc = [
"# Capability matrix",
"",
"What each harness actually enforces, declared once in `packages/core/src/capabilities.ts`.",
"ENFORCED rows name the test or spike that proves them; ADVISORY rows are best-effort and",
"labelled as such in the hook inspector; UNSUPPORTED rows are declared gaps (the Claude, Pi",
"and Antigravity adapters ship in 1.1–1.3). Generated: do not edit by hand.",
"",
...Object.entries(CAPABILITY_MATRIX).flatMap(([harness, rows]) => [
`## ${harness}`,
"",
"| capability | support | proof | detail |",
"| --- | --- | --- | --- |",
...rows.map(
(row) =>
`| ${row.capability} | ${row.support.toUpperCase()} | ${row.test ? `\`${row.test}\`` : "—"} | ${row.detail.replaceAll("\\", "\\\\").replaceAll("|", "\\|")} |`,
),
"",
]),
"## Hook bridge detail",
"",
"| harness | event | support |",
"| --- | --- | --- |",
...Object.entries(HOOK_CAPABILITIES).flatMap(([harness, caps]) =>
Object.entries(caps.events).map(([event, support]) => `| ${harness} | ${event} | ${support.toUpperCase()} |`),
),
"",
].join("\n")
// ------------------------------------------------- config doc
// Nested models.* keys (#96): the generated JSON Schema carries them under
// properties.models.properties, so document them in their own table.
function nestedConfigDoc(configJson: {
properties?: Record<string, { description?: string; properties?: Record<string, { description?: string }> }>
}): string[] {
const nested = configJson.properties?.models?.properties ?? {}
const names = Object.keys(nested)
if (names.length === 0) return []
return [
"Nested `models.*` keys (fail-closed: malformed refs and unknown agent ids are rejected at load; a",
"configured model missing on the host refuses that seat at launch):",
"",
"| field | description |",
"| --- | --- |",
...names.map(
(name) => `| \`models.${name}\` | ${(nested[name]?.description ?? "").replaceAll("\\", "\\\\").replaceAll("|", "\\|")} |`,
),
"",
]
}
const configSchema = outputs.find((output) => output.file === "schemas/es-config.schema.json")
if (!configSchema) throw new Error("docs: schemas/es-config.schema.json missing; is EsConfigSchema exported?")
const configJson = JSON.parse(configSchema.content) as {
properties?: Record<string, { type?: string; default?: unknown; description?: string; enum?: unknown[]; anyOf?: unknown[] }>
}
const configDoc = [
"# Config",
"",
"The canonical Epistemic Swarm config (`packages/core/src/schema/config.ts`), layered:",
"user-global (`~/.config/epistemic-swarm/config.json`) → project (`.factory/config.json`) →",
"plugin options in `opencode.json`. An unknown key in a config file is rejected; an unknown",
"plugin option is ignored with a warning (TUI toast and `/config`). Generated: do not edit by hand.",
"",
"| field | type | default | description |",
"| --- | --- | --- | --- |",
...Object.entries(configJson.properties ?? {}).map(([name, property]) => {
const type = property.type ?? (property.enum ? "enum" : property.anyOf ? "union" : "object")
const fallback = property.default === undefined ? "—" : `\`${JSON.stringify(property.default)}\``
return `| \`${name}\` | ${type}${property.enum ? ` (${property.enum.join(", ")})` : ""} | ${fallback} | ${(property.description ?? "").replaceAll("\\", "\\\\").replaceAll("|", "\\|")} |`
}),
"",
...nestedConfigDoc(configJson),
"`es config show` and `/config` print the effective config and which files contributed.",
"",
].join("\n")
const desired = new Map<string, string>([
...outputs.map((output) => [output.file, output.content] as const),
["docs/SCHEMAS.md", docs],
["docs/CAPABILITIES.md", capabilitiesDoc],
["docs/CONFIG.md", configDoc],
])
// Stale files: committed schemas with no counterpart in code.
const existing = (await readdir(schemaDir).catch(() => [] as string[]))
.filter((file) => file.endsWith(".schema.json"))
.map((file) => `schemas/${file}`)
const stale = existing.filter((file) => !desired.has(file))
if (check) {
const drift: string[] = []
for (const [file, content] of desired) {
const onDisk = await readFile(path.join(root, file), "utf8").catch(() => undefined)
if (onDisk !== content) drift.push(file)
}
for (const file of stale) drift.push(`${file} (stale)`)
if (drift.length) {
console.error(`docs: ${drift.length} generated file(s) out of date:\n${drift.map((file) => ` ${file}`).join("\n")}`)
console.error("Run `bun run docs:gen` and commit the result.")
process.exit(1)
}
console.log(`docs: ${desired.size} generated file(s) up to date`)
} else {
await mkdir(schemaDir, { recursive: true })
await mkdir(path.dirname(docsFile), { recursive: true })
for (const [file, content] of desired) await writeFile(path.join(root, file), content)
for (const file of stale) await rm(path.join(root, file), { force: true })
console.log(`docs: wrote ${desired.size} file(s) (${outputs.length} schemas${stale.length ? `, removed ${stale.length} stale` : ""})`)
}