mirror of
https://github.com/Heretek-AI/IUMBTEMS.git
synced 2026-10-12 02:03:00 +00:00
- 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]>
207 lines
9.1 KiB
TypeScript
207 lines
9.1 KiB
TypeScript
// 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` : ""})`)
|
||
}
|