#!/usr/bin/env node
/**
* Read-only input assembly for `/doc-wiki:atlas` Phase 7 (global synthesis).
*
* Each global page (`overview`, `integrations`, `deploy`) has its own input
* shape — different per-topic pages contribute, different config files are
* relevant. This module is the canonical place to assemble those bundles
* so the orchestrator skill doesn't hand-roll the file walking.
*
* Used as a library:
* import { assembleOverviewInputs } from "../agents/lib/atlas_synthesize.js";
* const bundle = assembleOverviewInputs(wikiRoot);
* // hand bundle.text + bundle.sources to the LLM synthesis step.
*
* Used as a CLI:
* node agents/lib/atlas_synthesize.js overview --wiki-root
* node agents/lib/atlas_synthesize.js integrations --wiki-root
[--connectors-config
]
* node agents/lib/atlas_synthesize.js deploy --wiki-root
[--repo-root
]
*
* Each subcommand prints one JSON object on stdout with keys
* { sources, text, notes }.
*/
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { fileURLToPath } from "node:url";
import { parseFlags } from "../../skills/doc-wiki/scripts/_cli_args.js";
import { parseFrontmatter } from "../../skills/doc-wiki/scripts/_frontmatter.js";
import { type CodeInventory } from "./atlas_inventory.js";
import {
formatPhaseFlow,
type ClassDef,
type PhaseEdge,
type PhaseNode,
} from "./mermaid_format.js";
import { walkRepoTargets } from "./repo_walker.js";
import { builtinConnectorIds } from "./source_registry.js";
// ── Shared types ───────────────────────────────────────────────────
export interface SynthesisBundle {
/** Wiki- or repo-relative paths that contributed to this bundle. */
sources: string[];
/** Concatenated text the orchestrator hands to the LLM synthesis step. */
text: string;
/** Per-bundle notes (e.g. "no api.md pages found for topic X"). */
notes: string[];
}
// ── Page walking ───────────────────────────────────────────────────
interface AtlasPageMatch {
/** Wiki-relative path. */
page: string;
facet: string;
body: string;
frontmatter: Record;
}
/**
* Walk `/wiki/` and yield every `.md` page whose frontmatter has a
* `atlas_facet` field matching one of `wantedFacets`. The wanted facets
* default to all known facets; pass a smaller set to filter (e.g. just
* `architecture` for the overview bundle).
*/
function _findAtlasPages(
wikiRoot: string,
wantedFacets?: readonly string[],
): AtlasPageMatch[] {
const wikiContent = path.join(wikiRoot, "wiki");
if (!fs.existsSync(wikiContent)) return [];
const out: AtlasPageMatch[] = [];
const wanted = wantedFacets ? new Set(wantedFacets) : null;
const walk = (dir: string): void => {
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch {
return;
}
for (const e of entries) {
const full = path.join(dir, e.name);
if (e.isDirectory()) {
// Skip _-prefixed dirs (archive, drafts, etc.) — see _wiki_fs.ts EXCLUDE_PREFIX
if (!e.name.startsWith("_")) walk(full);
continue;
}
if (!e.isFile() || !full.endsWith(".md")) continue;
let body: string;
try {
body = fs.readFileSync(full, "utf-8");
} catch {
continue;
}
const { frontmatter, body: pageBody } = parseFrontmatter(body);
if (!frontmatter) continue;
const facet = frontmatter["atlas_facet"];
if (typeof facet !== "string" || facet.length === 0) continue;
if (wanted !== null && !wanted.has(facet)) continue;
out.push({
page: path.relative(wikiRoot, full).split(path.sep).join("/"),
facet,
body: pageBody,
frontmatter,
});
}
};
walk(wikiContent);
out.sort((a, b) => a.page.localeCompare(b.page));
return out;
}
/**
* Pull the TL;DR section of a page body, defined as the contents between
* `## TL;DR` and the next `## ` header (or end of body). Returns the empty
* string when no TL;DR section is present.
*/
function _extractTldr(body: string): string {
const re = /^## TL;DR\s*\n([\s\S]*?)(?=^## |\Z)/m;
const m = body.match(re);
if (!m) return "";
return (m[1] ?? "").trim();
}
/**
* Resolve the page's audience for routing purposes. Honors an explicit
* `audience` frontmatter field if present; otherwise falls back to a default
* derived from the `atlas_facet`. Mirrors the table documented in
* `references/compilation.md` ("Additional frontmatter for atlas pages").
*/
function _inferAudience(
facet: string,
frontmatterAudience: unknown,
): string {
if (typeof frontmatterAudience === "string" && frontmatterAudience.length > 0) {
return frontmatterAudience;
}
switch (facet) {
case "getting-started":
return "new-user";
case "commands":
case "configuration":
case "operations":
return "operator";
case "architecture":
case "data-model":
case "environments":
case "overview":
return "contributor";
case "api":
case "integrations":
case "deploy":
return "integrator";
case "troubleshooting":
return "debugger";
default:
return "contributor";
}
}
// ── Overview bundle ────────────────────────────────────────────────
/**
* Assemble the input for `wiki/overview.md` synthesis: every per-topic
* `architecture.md` body in full, plus the TL;DR sections of the other per-
* topic facets, prefixed by an audience-routing table generated from each
* page's `audience` frontmatter field (default-inferred from facet). The
* routing table mirrors `docs/README.md`'s "Where to start" section so the
* synthesized overview can route human readers to the right depth.
*/
export function assembleOverviewInputs(wikiRoot: string): SynthesisBundle {
const allPages = _findAtlasPages(wikiRoot);
const arch = allPages.filter((p) => p.facet === "architecture");
const others = allPages.filter(
(p) =>
p.facet === "data-model" ||
p.facet === "environments" ||
p.facet === "api" ||
p.facet === "operations",
);
const sources: string[] = [];
const parts: string[] = [];
const notes: string[] = [];
// Audience-routing table at the top — gives the LLM a structural anchor
// for the synthesized page's "Where to start" section.
if (allPages.length > 0) {
const grouped = new Map();
for (const page of allPages) {
const audience = _inferAudience(page.facet, page.frontmatter["audience"]);
const list = grouped.get(audience);
if (list) list.push(page.page);
else grouped.set(audience, [page.page]);
}
parts.push("## Audience routing\n");
parts.push("| Audience | Pages |");
parts.push("|---|---|");
const order = ["new-user", "operator", "contributor", "integrator", "debugger"];
for (const audience of order) {
const pages = grouped.get(audience);
if (!pages || pages.length === 0) continue;
parts.push(`| ${audience} | ${pages.join(", ")} |`);
}
// Surface any audience labels not in the canonical order at the end.
for (const [audience, pages] of grouped) {
if (order.includes(audience)) continue;
parts.push(`| ${audience} | ${pages.join(", ")} |`);
}
parts.push("");
}
if (arch.length === 0) {
notes.push("no architecture.md pages found — overview will be sparse");
}
for (const page of arch) {
sources.push(page.page);
parts.push(`# ${page.page}\n\n${page.body.trim()}\n`);
}
if (others.length > 0) {
parts.push("## Per-facet TL;DRs\n");
for (const page of others) {
sources.push(page.page);
const tldr = _extractTldr(page.body);
const summary = tldr.length > 0 ? tldr : "(no TL;DR section)";
parts.push(`### ${page.page} (${page.facet})\n\n${summary}\n`);
}
}
return { sources, text: parts.join("\n"), notes };
}
// ── Integrations bundle ────────────────────────────────────────────
/**
* Default lookup paths for the connector config. The first existing file
* wins; the orchestrator may override via `--connectors-config`.
*/
function _defaultConnectorConfigPaths(): string[] {
return [
path.join(process.cwd(), ".connectors", "config.yaml"),
path.join(os.homedir(), ".connectors", "config.yaml"),
];
}
/**
* Assemble the input for `wiki/integrations.md` synthesis: every per-topic
* `api.md` page plus external-service mentions in `architecture.md` pages
* (heuristic: any line that mentions a connector ID like `jira`, `github`,
* `notion`, `aws`, `gcp`, `confluence`, `db`), plus the connector config
* file if accessible.
*/
export function assembleIntegrationsInputs(
wikiRoot: string,
connectorsConfigPath?: string,
): SynthesisBundle {
const sources: string[] = [];
const parts: string[] = [];
const notes: string[] = [];
const apis = _findAtlasPages(wikiRoot, ["api"]);
if (apis.length === 0) {
notes.push("no api.md pages found — integrations will draw only from architecture mentions");
}
for (const page of apis) {
sources.push(page.page);
parts.push(`# ${page.page}\n\n${page.body.trim()}\n`);
}
// Architecture pages — extract external-service-ish lines.
const archPages = _findAtlasPages(wikiRoot, ["architecture"]);
const integrationKeywords = _getIntegrationKeywords();
for (const page of archPages) {
const lines = page.body.split("\n");
const hits: string[] = [];
for (const line of lines) {
const lower = line.toLowerCase();
for (const kw of integrationKeywords) {
if (lower.includes(kw)) {
hits.push(line.trim());
break;
}
}
}
if (hits.length > 0) {
sources.push(page.page);
parts.push(`## external-mentions in ${page.page}\n\n${hits.join("\n")}\n`);
}
}
// Connector config (read-only; redact nothing — it lives outside the wiki).
const candidates =
connectorsConfigPath && connectorsConfigPath.length > 0
? [connectorsConfigPath]
: _defaultConnectorConfigPaths();
for (const p of candidates) {
if (!fs.existsSync(p)) continue;
try {
const body = fs.readFileSync(p, "utf-8");
sources.push(p);
parts.push(`## connectors config: ${p}\n\n\`\`\`yaml\n${body}\n\`\`\`\n`);
break;
} catch {
notes.push(`could not read connector config at ${p}`);
}
}
return { sources, text: parts.join("\n"), notes };
}
// ── Deploy bundle ──────────────────────────────────────────────────
const DEPLOY_FILE_GLOBS: ReadonlyArray = [
/^Dockerfile(\.[^/]+)?$/,
/^docker-compose(\.[^/]+)?\.ya?ml$/,
/^Jenkinsfile$/,
/^Procfile$/,
/^Makefile$/,
];
const DEPLOY_DIR_PATTERNS: ReadonlyArray<{ dir: string; rx: RegExp }> = [
{ dir: ".github/workflows", rx: /\.ya?ml$/ },
{ dir: ".gitlab", rx: /\.ya?ml$/ },
{ dir: "terraform", rx: /\.tf$/ },
{ dir: "deploy", rx: /\.ya?ml$/ },
{ dir: "k8s", rx: /\.ya?ml$/ },
];
/**
* Body-truncation cap for files inlined into a synthesis bundle. Beyond
* this, the file is sliced and a marker line is appended so the LLM sees
* the truncation. Tuned to keep a single bundle well under the synthesis
* context budget while preserving most real-world config / Dockerfile /
* workflow content.
*/
const _BUNDLE_FILE_TRUNCATE_BYTES = 200 * 1024;
/**
* Read each `paths` entry under `repoRoot`, truncate at
* {@link _BUNDLE_FILE_TRUNCATE_BYTES}, and format as a fenced
* `## ` section. Per-file read failures append to `notes` and skip
* the file. Shared by `assembleDeployInputs` and
* `assembleConfigurationInputs`.
*/
function _readAndFormatBundleFiles(
repoRoot: string,
paths: readonly string[],
notes: string[],
): string[] {
const parts: string[] = [];
for (const rel of paths) {
const abs = path.join(repoRoot, rel);
let body: string;
try {
body = fs.readFileSync(abs, "utf-8");
} catch {
notes.push(`could not read ${rel}`);
continue;
}
const truncated =
body.length > _BUNDLE_FILE_TRUNCATE_BYTES
? body.slice(0, _BUNDLE_FILE_TRUNCATE_BYTES) + "\n... [truncated]\n"
: body;
parts.push(`## ${rel}\n\n\`\`\`\n${truncated}\n\`\`\`\n`);
}
return parts;
}
/**
* Walk the repo root looking for canonical build/deploy files. Returns
* the relative paths (sorted) plus a concatenated text bundle the
* orchestrator passes to `/doc-wiki:ingest --output wiki/deploy.md`.
*
* Big files (>200 KB) are truncated with a marker line — full inclusion
* would blow the synthesis context budget for little additional value.
*/
export function assembleDeployInputs(repoRoot: string): SynthesisBundle {
const { paths, notes } = walkRepoTargets(repoRoot, {
topLevelBasenames: DEPLOY_FILE_GLOBS,
subdirPatterns: DEPLOY_DIR_PATTERNS,
});
if (paths.length === 0) {
notes.push("no build/deploy files matched — wiki/deploy.md will be sparse");
return { sources: [], text: "", notes };
}
const parts = _readAndFormatBundleFiles(repoRoot, paths, notes);
return { sources: [...paths], text: parts.join("\n"), notes };
}
/**
* Sourced integration-keyword list. Used by `assembleIntegrationsInputs`
* to scan architecture pages for external-service mentions. Pulls
* builtin connector ids from `source_registry` (data-driven; satisfies
* architecture invariant #6) and unions in a curated list of common SaaS
* integrations that aren't bundled connectors but are still worth
* surfacing in the synthesized integrations page.
*
* The `db` connector is excluded — it's a database connector, not an
* external service in the sense the integrations page covers.
*
* Some connector ids whose bare name is too ambiguous for a substring
* scan are mapped to more specific, unambiguous tokens. `linear` is an
* extremely common technical word ("linear time", "linear backoff",
* "nonlinear"), so it's scanned as its source signals `linear.app` and
* `linear://` instead — those still catch genuine Linear URLs and
* `linear://` scheme mentions in architecture pages while avoiding
* false-positive integration mentions from ordinary prose.
*/
const _KEYWORD_OVERRIDES: Readonly> = {
linear: ["linear.app", "linear://"],
};
const _COMMON_SAAS_KEYWORDS: readonly string[] = [
"stripe",
"datadog",
"sentry",
"auth0",
"okta",
"twilio",
"sendgrid",
];
function _getIntegrationKeywords(): string[] {
const fromRegistry = builtinConnectorIds()
.filter((id) => id !== "db")
.flatMap((id) => _KEYWORD_OVERRIDES[id] ?? [id]);
return [...new Set([...fromRegistry, ..._COMMON_SAAS_KEYWORDS])];
}
// ── Commands bundle ────────────────────────────────────────────────
/**
* Build a Mermaid `flowchart TD` showing the slash-command fan-out:
* User → each `/` → skill orchestrator. Project-agnostic — the
* shape is identical for any repo, only the command list varies.
*
* Returned as a fenced Mermaid block wrapped in ``
* markers so the synthesis step (the orchestrator LLM) preserves it
* verbatim or refines without re-deriving the topology from the bundle
* text. Idempotent: passing the same slug list yields the same code.
*/
function _commandsLifecycleSeed(slugs: readonly string[]): string {
if (slugs.length === 0) return "";
const nodes: PhaseNode[] = [
{ id: "user", label: "User", className: "actor" },
{ id: "orch", label: "doc-wiki
skill orchestrator", className: "orch" },
];
const edges: PhaseEdge[] = [];
for (const slug of slugs) {
const id = "cmd_" + slug.replace(/[^a-z0-9]/gi, "_");
nodes.push({ id, label: `/${slug}`, className: "cmd" });
edges.push({ from: "user", to: id });
edges.push({ from: id, to: "orch" });
}
const classDefs: ClassDef[] = [
{ name: "actor", fill: "#cfe2f3", stroke: "#0c5394" },
{ name: "cmd", fill: "#fff3cd", stroke: "#856404" },
{ name: "orch", fill: "#d4edda", stroke: "#155724" },
];
const block = formatPhaseFlow("slash-command fan-out", nodes, edges, classDefs);
return [
"",
"```mermaid",
block.code,
"```",
"",
"",
].join("\n");
}
/**
* Assemble the input for `wiki/commands.md` synthesis: a Mermaid flowchart
* seed of the slash-command fan-out, every `commands/*.md` wrapper file in
* the repo, plus any `### /` headings extracted from the project's
* `SKILL.md` files. The intent is to give the LLM all the slash-command
* surface area plus a structural anchor for the diagram, so it can produce
* a single operator-friendly reference page with a real Mermaid block.
*/
export function assembleCommandsInputs(repoRoot: string): SynthesisBundle {
const sources: string[] = [];
const parts: string[] = [];
const notes: string[] = [];
const slugs: string[] = [];
// commands/*.md wrappers
const commandsDir = path.join(repoRoot, "commands");
if (fs.existsSync(commandsDir)) {
let files: string[];
try {
files = fs
.readdirSync(commandsDir)
.filter((f) => f.endsWith(".md"))
.sort();
} catch {
files = [];
notes.push(`could not read ${commandsDir}`);
}
for (const f of files) {
const abs = path.join(commandsDir, f);
try {
const body = fs.readFileSync(abs, "utf-8");
const rel = path.posix.join("commands", f);
sources.push(rel);
parts.push(`## ${rel}\n\n${body.trim()}\n`);
slugs.push(f.replace(/\.md$/, ""));
} catch {
notes.push(`could not read commands/${f}`);
}
}
} else {
notes.push("no commands/ directory — commands page will be sparse");
}
// Prepend the Mermaid seed so the LLM sees the diagram before the
// per-command bodies. Skipped silently when no commands/ exists.
const seed = _commandsLifecycleSeed(slugs);
if (seed.length > 0) parts.unshift(seed);
// ### / headings extracted from any SKILL.md under skills/
const skillsDir = path.join(repoRoot, "skills");
if (fs.existsSync(skillsDir)) {
const skillFiles: string[] = [];
const walk = (d: string): void => {
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(d, { withFileTypes: true });
} catch {
return;
}
for (const e of entries) {
const full = path.join(d, e.name);
if (e.isDirectory()) walk(full);
else if (e.isFile() && e.name === "SKILL.md") skillFiles.push(full);
}
};
walk(skillsDir);
for (const skillFile of skillFiles) {
let body: string;
try {
body = fs.readFileSync(skillFile, "utf-8");
} catch {
continue;
}
const headings: string[] = [];
for (const line of body.split("\n")) {
if (/^###\s+\//.test(line)) headings.push(line.trim());
}
if (headings.length > 0) {
const rel = path.relative(repoRoot, skillFile).split(path.sep).join("/");
sources.push(rel);
parts.push(
`## Slash-command headings in ${rel}\n\n${headings.join("\n")}\n`,
);
}
}
}
return { sources, text: parts.join("\n"), notes };
}
// ── Getting-started bundle ─────────────────────────────────────────
const _BOOTSTRAP_FILE_CANDIDATES: readonly string[] = [
"setup.sh",
"bootstrap.sh",
"install.sh",
"Makefile",
];
/**
* Assemble the input for `wiki/getting-started.md` synthesis: top-level
* README + package.json scripts + any common bootstrap files (setup.sh,
* Makefile, etc.). Gives the LLM enough raw material to produce a
* numbered first-run walkthrough for new users.
*/
export function assembleGettingStartedInputs(repoRoot: string): SynthesisBundle {
const sources: string[] = [];
const parts: string[] = [];
const notes: string[] = [];
// README.md (truncated if huge)
const readmePath = path.join(repoRoot, "README.md");
if (fs.existsSync(readmePath)) {
try {
const body = fs.readFileSync(readmePath, "utf-8");
const truncated =
body.length > 50 * 1024
? body.slice(0, 50 * 1024) + "\n... [truncated]\n"
: body;
sources.push("README.md");
parts.push(`## README.md\n\n${truncated}\n`);
} catch {
notes.push("could not read README.md");
}
} else {
notes.push("no README.md — getting-started will be sparse");
}
// package.json scripts (Node projects)
const pkgPath = path.join(repoRoot, "package.json");
if (fs.existsSync(pkgPath)) {
try {
const body = fs.readFileSync(pkgPath, "utf-8");
const json = JSON.parse(body) as { scripts?: Record };
if (json.scripts && Object.keys(json.scripts).length > 0) {
sources.push("package.json");
parts.push(
"## package.json scripts\n\n```json\n" +
JSON.stringify(json.scripts, null, 2) +
"\n```\n",
);
}
} catch {
notes.push("could not parse package.json");
}
}
// Bootstrap-y top-level files
for (const name of _BOOTSTRAP_FILE_CANDIDATES) {
const p = path.join(repoRoot, name);
if (!fs.existsSync(p)) continue;
try {
const body = fs.readFileSync(p, "utf-8");
const truncated =
body.length > 50 * 1024
? body.slice(0, 50 * 1024) + "\n... [truncated]\n"
: body;
sources.push(name);
parts.push(`## ${name}\n\n\`\`\`\n${truncated}\n\`\`\`\n`);
} catch {
notes.push(`could not read ${name}`);
}
}
return { sources, text: parts.join("\n"), notes };
}
// ── Configuration bundle ───────────────────────────────────────────
const _CONFIGURATION_FILE_GLOBS: ReadonlyArray = [
/^[a-z][a-z0-9-]*\.config\.(ya?ml|json|toml)$/i,
/^wiki\.config\.ya?ml$/i,
/^\.env\.example$/i,
/^\.env\.template$/i,
/^\.env\.sample$/i,
/^pyproject\.toml$/i,
/^Cargo\.toml$/i,
/^setup\.cfg$/i,
/^settings\.gradle(\.kts)?$/i,
/^application\.(properties|ya?ml)$/i,
];
const _CONFIGURATION_DIR_PATTERNS: ReadonlyArray<{ dir: string; rx: RegExp }> = [
{ dir: "config", rx: /\.(ya?ml|json|toml|conf|ini|properties)$/i },
{ dir: ".connectors", rx: /\.ya?ml$/i },
];
/**
* Assemble the input for `wiki/configuration.md` synthesis: top-level
* configuration files (wiki.config.yaml, *.config.yaml, .env.example, …)
* plus the contents of `config/` and `.connectors/` directories. Gives
* the LLM enough surface to produce an operator-facing schema reference.
*
* Big files (>200 KB) are truncated; a `... [truncated]` marker is left
* inline so the synthesis step can note the truncation when relevant.
*/
export function assembleConfigurationInputs(repoRoot: string): SynthesisBundle {
const { paths, notes } = walkRepoTargets(repoRoot, {
topLevelBasenames: _CONFIGURATION_FILE_GLOBS,
subdirPatterns: _CONFIGURATION_DIR_PATTERNS,
});
if (paths.length === 0) {
notes.push(
"no configuration files matched — wiki/configuration.md will be sparse",
);
return { sources: [], text: "", notes };
}
const parts = _readAndFormatBundleFiles(repoRoot, paths, notes);
return { sources: [...paths], text: parts.join("\n"), notes };
}
// ── Troubleshooting bundle ─────────────────────────────────────────
const _TROUBLESHOOTING_EVENT_LIMIT = 30;
interface AtlasErrorEventLine {
raw: string;
parsed: Record;
}
function _isErrorEvent(rec: Record): boolean {
if (rec["error"] !== undefined && rec["error"] !== null) return true;
if (rec["status"] === "failed" || rec["status"] === "error") return true;
const details = rec["details"];
if (details && typeof details === "object" && !Array.isArray(details)) {
const d = details as Record;
if (d["error"] !== undefined && d["error"] !== null) return true;
if (d["status"] === "failed" || d["status"] === "error") return true;
}
return false;
}
/**
* Assemble the input for `wiki/troubleshooting.md` synthesis: the most
* recent error/failure events from `events.jsonl` plus the latest atlas
* drift report (if present). Gives the LLM symptom signals the page can
* organize into symptom → cause → fix triplets.
*/
export function assembleTroubleshootingInputs(wikiRoot: string): SynthesisBundle {
const sources: string[] = [];
const parts: string[] = [];
const notes: string[] = [];
// Recent error events from events.jsonl
const eventsPath = path.join(wikiRoot, "log", "events.jsonl");
if (fs.existsSync(eventsPath)) {
let lines: string[];
try {
lines = fs.readFileSync(eventsPath, "utf-8").split("\n");
} catch {
lines = [];
}
const errors: AtlasErrorEventLine[] = [];
for (
let i = lines.length - 1;
i >= 0 && errors.length < _TROUBLESHOOTING_EVENT_LIMIT;
i--
) {
const line = lines[i];
if (!line) continue;
let parsed: unknown;
try {
parsed = JSON.parse(line);
} catch {
continue;
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) continue;
const rec = parsed as Record;
if (_isErrorEvent(rec)) {
errors.push({ raw: line, parsed: rec });
}
}
if (errors.length > 0) {
sources.push("log/events.jsonl");
parts.push("## Recent error/failure events\n");
parts.push("```jsonl");
// Reverse so oldest-first within the recent window
for (const e of [...errors].reverse()) parts.push(e.raw);
parts.push("```\n");
} else {
notes.push("no error events in events.jsonl — troubleshooting will be sparse");
}
} else {
notes.push("no log/events.jsonl found");
}
// Latest atlas drift report
const atlasOutputs = path.join(wikiRoot, "outputs", "atlas");
if (fs.existsSync(atlasOutputs)) {
let runs: string[];
try {
runs = fs
.readdirSync(atlasOutputs, { withFileTypes: true })
.filter((e) => e.isDirectory())
.map((e) => e.name)
.sort()
.reverse();
} catch {
runs = [];
}
for (const run of runs) {
const driftPath = path.join(atlasOutputs, run, "drift-report.md");
if (!fs.existsSync(driftPath)) continue;
try {
const body = fs.readFileSync(driftPath, "utf-8");
const rel = path.posix.join("outputs", "atlas", run, "drift-report.md");
sources.push(rel);
parts.push(`## Drift report (${run})\n\n${body.trim()}\n`);
break; // only the most recent
} catch {
notes.push(`could not read ${driftPath}`);
}
}
}
return { sources, text: parts.join("\n"), notes };
}
// ── Manifest-driven source lookups ─────────────────────────────────
/** Per-topic, per-facet source list. Only the manifest-backed facets
* appear here; other facets keep using SKILL.md heuristics. */
export interface ManifestSourcesByTopic {
[topic: string]: { "data-model": string[]; api: string[] };
}
/** Directory names that gate a topic-bearing path component. The
* directory IMMEDIATELY following one of these is treated as the
* topic candidate. Curated to match the layouts atlas already
* recognises in topic discovery (Phase 2). */
const _TOPIC_DIR_PREFIXES: ReadonlySet = new Set([
"src",
"app",
"apps",
"services",
"lib",
"internal",
"pkg",
"packages",
"modules",
"cmd",
]);
/**
* Canonicalize a topic candidate to match the topic-discovery convention
* (SKILL.md Phase 2): lowercase-kebab-case, strip `-service`, `-svc`,
* `-module` suffixes.
*/
function _canonicalizeTopic(raw: string): string {
let s = raw.toLowerCase().replace(/[_\s]+/g, "-");
s = s.replace(/-(service|svc|module)$/i, "");
return s;
}
/**
* Extract a topic candidate from a repo-relative source-file path.
* Looks for `//...` where `` is one of the
* known topic-bearing directories. Returns the canonicalized topic,
* or `null` if no recognized topic directory is found or the candidate
* is the path's leaf (i.e. a file, not a sub-directory).
*/
export function extractTopicFromPath(sourceFile: string): string | null {
const parts = sourceFile.split("/").filter((p) => p.length > 0);
// Need at least // — if `` is the leaf,
// it's likely a file (e.g. `src/utils.ts`), not a topic directory.
for (let i = 0; i < parts.length - 2; i++) {
if (_TOPIC_DIR_PREFIXES.has(parts[i] ?? "")) {
const candidate = parts[i + 1] ?? "";
if (candidate.length > 0) return _canonicalizeTopic(candidate);
}
}
return null;
}
/**
* Build a topic → facet → source-files map for the two manifest-backed
* facets — `data-model` (from `inventory.orm_entities`) and `api` (from
* `inventory.rest_endpoints`). Topic assignment is path-based via
* {@link extractTopicFromPath}; entries whose extracted topic does not
* canonicalize to a member of `topics` are dropped.
*
* Other facets (`architecture`, `environments`, `operations`) are NOT
* manifest-backed — the orchestrator continues to use SKILL.md
* heuristics for them, merging the two source lists.
*/
export function groupManifestByTopicFacet(
inventory: CodeInventory,
topics: readonly string[],
): ManifestSourcesByTopic {
const wantedTopics = new Set(topics.map((t) => _canonicalizeTopic(t)));
const out: ManifestSourcesByTopic = {};
const ensure = (topic: string): { "data-model": string[]; api: string[] } => {
const existing = out[topic];
if (existing) return existing;
const fresh = { "data-model": [] as string[], api: [] as string[] };
out[topic] = fresh;
return fresh;
};
for (const entity of inventory.orm_entities) {
const topic = extractTopicFromPath(entity.source_file);
if (!topic || !wantedTopics.has(topic)) continue;
const bucket = ensure(topic);
if (!bucket["data-model"].includes(entity.source_file)) {
bucket["data-model"].push(entity.source_file);
}
}
for (const endpoint of inventory.rest_endpoints) {
const topic = extractTopicFromPath(endpoint.file);
if (!topic || !wantedTopics.has(topic)) continue;
const bucket = ensure(topic);
if (!bucket["api"].includes(endpoint.file)) {
bucket["api"].push(endpoint.file);
}
}
return out;
}
// ── CLI ────────────────────────────────────────────────────────────
const FLAG_SPEC = {
"--wiki-root": "wikiRoot",
"--repo-root": "repoRoot",
"--connectors-config": "connectorsConfig",
} as const;
const HELP_TEXT = `usage: atlas_synthesize.js {overview,integrations,deploy,commands,configuration,getting-started,troubleshooting} [...]
Read-only input assembly for /doc-wiki:atlas global synthesis.
Subcommands:
overview --wiki-root
Audience-routing table + concatenated architecture pages +
per-facet TL;DRs.
integrations --wiki-root
[--connectors-config
]
api.md pages + external-service mentions + connector config.
deploy --wiki-root
[--repo-root
]
Build/deploy files: Dockerfile, compose, workflows, terraform.
commands --repo-root
commands/*.md wrappers + ### / headings from SKILL.md files.
configuration --repo-root
Top-level config files + config/ + .connectors/ dirs.
getting-started --repo-root
README + package.json scripts + bootstrap files.
troubleshooting --wiki-root
Recent error events + latest atlas drift report.
Each prints one JSON object on stdout with keys: sources, text, notes.
`;
export function main(argv: readonly string[] = process.argv.slice(2)): number {
if (argv.length === 0 || argv[0] === "-h" || argv[0] === "--help") {
process.stdout.write(HELP_TEXT);
return 0;
}
const sub = argv[0];
let parsed;
try {
parsed = parseFlags(argv.slice(1), FLAG_SPEC);
} catch (e) {
process.stderr.write(`${(e as Error).message}\n`);
return 2;
}
if (parsed.help) {
process.stdout.write(HELP_TEXT);
return 0;
}
const wikiRoot = parsed.values["wikiRoot"];
const repoRoot =
typeof parsed.values["repoRoot"] === "string" && parsed.values["repoRoot"].length > 0
? parsed.values["repoRoot"]
: process.cwd();
// Subcommands that need --wiki-root.
if (sub === "overview" || sub === "integrations" || sub === "troubleshooting") {
if (typeof wikiRoot !== "string" || wikiRoot.length === 0) {
process.stderr.write("--wiki-root is required\n");
return 2;
}
let bundle: SynthesisBundle;
if (sub === "overview") {
bundle = assembleOverviewInputs(wikiRoot);
} else if (sub === "integrations") {
const cfg =
typeof parsed.values["connectorsConfig"] === "string"
? parsed.values["connectorsConfig"]
: undefined;
bundle = assembleIntegrationsInputs(wikiRoot, cfg);
} else {
bundle = assembleTroubleshootingInputs(wikiRoot);
}
process.stdout.write(JSON.stringify(bundle) + "\n");
return 0;
}
// Subcommands that work off repoRoot (cwd by default).
if (sub === "deploy") {
const bundle = assembleDeployInputs(repoRoot);
process.stdout.write(JSON.stringify(bundle) + "\n");
return 0;
}
if (sub === "commands") {
const bundle = assembleCommandsInputs(repoRoot);
process.stdout.write(JSON.stringify(bundle) + "\n");
return 0;
}
if (sub === "getting-started") {
const bundle = assembleGettingStartedInputs(repoRoot);
process.stdout.write(JSON.stringify(bundle) + "\n");
return 0;
}
if (sub === "configuration") {
const bundle = assembleConfigurationInputs(repoRoot);
process.stdout.write(JSON.stringify(bundle) + "\n");
return 0;
}
process.stderr.write(`unknown subcommand: ${sub}\n`);
return 2;
}
const thisFile = fileURLToPath(import.meta.url);
if (process.argv[1] && path.resolve(process.argv[1]) === thisFile) {
process.exit(main());
}