diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e3d378fa4a..f0293d1ddb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,6 +51,12 @@ jobs: - name: Doc-sync gates (doc code blocks + event taxonomy) run: pnpm run doc-sync + # Module-graph freshness: regenerate docs/module-graph.md from the + # packages' peerDependencies and fail if it differs from the committed + # file. Only reads source package.json — no build needed. + - name: Module-graph freshness + run: pnpm run verify-module-graph + - name: Tests with coverage gate (per-file 100%) run: pnpm run test:coverage diff --git a/AGENTS.md b/AGENTS.md index 3a5d13c083..c602ff4872 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,7 +37,9 @@ examples/ Runnable demos (not workspaces). echo-agent = mock model + echo tool + stdio UI + JSONL persistence, wired via cordis.yml. coding-agent = the real thing: DeepSeek V4 + bash tools (pnpm run demo:coding, needs DEEPSEEK_API_KEY). -docs/ architecture.md — the design doc. adr/ — decision records (the +docs/ architecture.md — the design doc. module-graph.md — generated + inter-package dependency graph (Mermaid; `pnpm run gen-module-graph`). + adr/ — decision records (the why behind vendoring, event-sourcing, the schema DSL, …). rfc/ — proposals for substantial future work. cookbook/ — step-by-step guides: adding a package, a tool, diff --git a/docs/module-graph.md b/docs/module-graph.md new file mode 100644 index 0000000000..4caee085f4 --- /dev/null +++ b/docs/module-graph.md @@ -0,0 +1,49 @@ + + +# Module dependency graph + +Inter-package dependencies among the `@deepseek-ai/dsh-*` harness packages, derived from each +package's `peerDependencies` (the canonical runtime-dependency signal). An edge `a --> b` means +package `a` depends on package `b`. Names have the `@deepseek-ai/dsh-` prefix stripped. + +```mermaid +graph TD + agent --> llm + agent --> session + agent-loop --> agent + agent-loop --> llm + agent-loop --> session + agent-loop --> system-prompt + agent-loop --> tools + bash-local --> bash + invariants --> agent + invariants --> llm + invariants --> session + llm-deepseek --> llm + llm-pi-ai --> llm + session --> llm + system-prompt --> llm + tool-bash --> agent + tool-bash --> bash + tool-bash --> llm + tool-bash --> tools + tools --> agent + tools --> llm + tools --> system-prompt +``` + +| Package | Depends on | +| --- | --- | +| `agent` | `llm`, `session` | +| `agent-loop` | `agent`, `llm`, `session`, `system-prompt`, `tools` | +| `bash` | — | +| `bash-local` | `bash` | +| `invariants` | `agent`, `llm`, `session` | +| `llm` | — | +| `llm-deepseek` | `llm` | +| `llm-pi-ai` | `llm` | +| `session` | `llm` | +| `system-prompt` | `llm` | +| `tool-bash` | `agent`, `bash`, `llm`, `tools` | +| `tools` | `agent`, `llm`, `system-prompt` | diff --git a/lefthook.yml b/lefthook.yml index 9789c92029..24600e4985 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -30,3 +30,6 @@ pre-push: - name: doc-sync run: pnpm run doc-sync + + - name: module-graph freshness + run: pnpm run verify-module-graph diff --git a/package.json b/package.json index 9ff3536f9b..b0a7e5ae4d 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,8 @@ "publint": "tsx scripts/publint-all.ts", "doc-typecheck": "tsx scripts/doc-typecheck.ts", "verify-event-taxonomy": "tsx scripts/verify-event-taxonomy.ts", + "gen-module-graph": "tsx scripts/gen-module-graph.ts", + "verify-module-graph": "tsx scripts/gen-module-graph.ts --check", "constraints": "tsx scripts/check-workspace-constraints.ts", "doc-sync": "pnpm run doc-typecheck && pnpm run verify-event-taxonomy", "hygiene": "pnpm run knip && pnpm run publint && pnpm run constraints", diff --git a/scripts/gen-module-graph.ts b/scripts/gen-module-graph.ts new file mode 100644 index 0000000000..58e8bd0a36 --- /dev/null +++ b/scripts/gen-module-graph.ts @@ -0,0 +1,103 @@ +/** + * Generate (and verify) the module dependency graph in docs/module-graph.md. + * + * The architectural shape of the harness lives implicitly in each package's + * `peerDependencies` — the canonical runtime-dependency signal (devDeps mirror + * these as `workspace:^` plus test-only extras, which would add noise). This + * script reads every `packages/* /package.json`, keeps only the + * `@deepseek-ai/dsh-*` peer edges (dropping the `cordis` peer), and renders a + * GitHub-viewable Mermaid graph plus a dependency table. + * + * The file is fully generated — never hand-edit it. Output is deterministic + * (packages and edges sorted) so a regenerate-and-diff freshness check is + * stable. + * + * `tsx scripts/gen-module-graph.ts` → write docs/module-graph.md + * `tsx scripts/gen-module-graph.ts --check` → exit 1 if the committed file + * is stale (CI / pre-push gate) + */ + +import { globSync, readFileSync, writeFileSync } from 'node:fs' +import { resolve } from 'node:path' + +const root = resolve(import.meta.dirname, '..') +const OUT = 'docs/module-graph.md' +const SCOPE = '@deepseek-ai/dsh-' + +interface Pkg { + /** Short name, `@deepseek-ai/dsh-` prefix stripped (e.g. `agent-loop`). */ + short: string + /** Short names of this package's in-repo peer dependencies, sorted. */ + deps: string[] +} + +/** Read every workspace package and its `@deepseek-ai/dsh-*` peer edges. */ +function collect(): Pkg[] { + const pkgs: Pkg[] = [] + for (const rel of globSync('packages/*/package.json', { cwd: root })) { + const json = JSON.parse(readFileSync(resolve(root, rel), 'utf8')) as { + name: string + peerDependencies?: Record + } + if (!json.name.startsWith(SCOPE)) continue + const deps = Object.keys(json.peerDependencies ?? {}) + .filter(d => d.startsWith(SCOPE)) + .map(d => d.slice(SCOPE.length)) + .sort() + pkgs.push({ short: json.name.slice(SCOPE.length), deps }) + } + return pkgs.sort((a, b) => a.short.localeCompare(b.short)) +} + +/** Render the full docs/module-graph.md content (pure, deterministic). */ +function render(pkgs: Pkg[]): string { + const edges: string[] = [] + for (const p of pkgs) { + for (const d of p.deps) edges.push(` ${p.short} --> ${d}`) + } + const rows = pkgs.map(p => `| \`${p.short}\` | ${p.deps.length ? p.deps.map(d => `\`${d}\``).join(', ') : '—'} |`) + return [ + '', + '', + '# Module dependency graph', + '', + 'Inter-package dependencies among the `@deepseek-ai/dsh-*` harness packages, derived from each', + 'package\'s `peerDependencies` (the canonical runtime-dependency signal). An edge `a --> b` means', + 'package `a` depends on package `b`. Names have the `@deepseek-ai/dsh-` prefix stripped.', + '', + '```mermaid', + 'graph TD', + ...edges, + '```', + '', + '| Package | Depends on |', + '| --- | --- |', + ...rows, + '', + ].join('\n') +} + +const content = render(collect()) + +if (process.argv.includes('--check')) { + let committed: string | null = null + try { + committed = readFileSync(resolve(root, OUT), 'utf8') + } catch { + // Only an ENOENT (file not yet generated) is expected here; readFileSync of + // a present-but-unreadable file is not a state this repo produces. Either + // way the remedy is the same — regenerate — so we treat a read failure as + // "stale" and fall through to the failure branch below. + committed = null + } + if (committed === content) { + console.log(`gen-module-graph: ${OUT} is up to date.`) + process.exit(0) + } + console.error(`gen-module-graph: ${OUT} is stale. Run \`pnpm run gen-module-graph\` and commit ${OUT}.`) + process.exit(1) +} + +writeFileSync(resolve(root, OUT), content) +console.log(`gen-module-graph: wrote ${OUT}.`)