feat: add .architect/ project mapping system with architect-indexer agent and Docker containerization

- Add .architect/ directory structure (10 template files) as project brain for agent orientation
- Add architect-indexer agent that scans codebase and generates structured architecture docs
- Add Docker containerization: Dockerfile.architect-indexer, docker-compose.architect.yml
- Add TypeScript project-mapper module with staleness detection and context injection
- Add /index-project command, architect-first-contact rule, project-mapping skill
- Integrate orchestrator first-contact check: triggers indexing before any task delegation
- Add npm arch:* scripts for Docker-based indexing workflow
- Register agent in capability-index.yaml and AGENTS.md
This commit is contained in:
¨NW¨
2026-04-22 20:01:38 +01:00
parent 9d85dd9f83
commit 6b71ea2b57
26 changed files with 2160 additions and 3 deletions

View File

@@ -0,0 +1,403 @@
// kilocode_change - integrated module
// Project mapper - reads and validates .architect/ directory state
import { existsSync } from "fs"
import { join } from "path"
import { readFile as readFileAsync, writeFile } from "fs/promises"
// Schema types for .architect/ state.json
export interface SectionState {
last_updated: string | null
file_hash: string | null
status: "stale" | "fresh" | "error" | "missing"
}
export interface ArchitectState {
version: number
status: "not_indexed" | "indexed" | "error"
last_full_index: string | null
last_incremental_update: string | null
last_file_count: number
file_hashes: Record<string, string>
directory_hashes: Record<string, string>
dependency_hashes: Record<string, string | null>
sections: Record<string, SectionState>
staleness_threshold_hours: number
indexing_agent: string
pipeline_integration: {
check_on_first_contact: boolean
incremental_on_file_change: boolean
full_reindex_on_dependency_change: boolean
}
}
export interface ProjectInfo {
name: string
type: string
framework: string
language: string
description: string
repository: string
entry_points: string[]
rootDir: string
}
export interface ArchitectProject {
version: number
indexed_at: string
project: ProjectInfo
structure: {
directories: Record<string, string>
key_files: Record<string, string>
}
tech_stack: {
languages: string[]
frameworks: string[]
databases: string[]
runtimes: string[]
package_managers: string[]
testing_frameworks: string[]
ci_cd: string[]
}
modules: Array<{
name: string
path: string
exports: string[]
imports: string[]
}>
}
export type ProjectType =
| "laravel"
| "symfony"
| "wordpress"
| "nextjs"
| "express"
| "go-api"
| "flutter"
| "django"
| "fastapi"
| "generic"
export interface IndexCheckResult {
needsIndexing: boolean
mode: "full" | "incremental" | "none"
staleSections: string[]
projectType: ProjectType | null
}
const ARCHITECT_DIR = ".architect"
const STATE_FILE = "state.json"
const PROJECT_FILE = "project.json"
/**
* Read and parse .architect/state.json
*/
export async function readArchitectState(rootDir: string): Promise<ArchitectState | null> {
const statePath = join(rootDir, ARCHITECT_DIR, STATE_FILE)
if (!existsSync(statePath)) return null
try {
const content = await readFileAsync(statePath, "utf-8")
return JSON.parse(content) as ArchitectState
} catch {
return null
}
}
/**
* Check if .architect/ needs indexing
*/
export async function checkArchitectState(rootDir: string): Promise<IndexCheckResult> {
const state = await readArchitectState(rootDir)
// No state file → full index needed
if (!state) {
return {
needsIndexing: true,
mode: "full",
staleSections: [],
projectType: null,
}
}
// Not indexed → full index needed
if (state.status === "not_indexed") {
return {
needsIndexing: true,
mode: "full",
staleSections: [],
projectType: null,
}
}
// Check for stale sections
const staleSections = Object.entries(state.sections)
.filter(([_, section]) => section.status === "stale" || section.status === "missing")
.map(([name]) => name)
// Check for expired full index
const lastFullIndex = state.last_full_index
? new Date(state.last_full_index)
: null
const hoursSinceIndex = lastFullIndex
? (Date.now() - lastFullIndex.getTime()) / (1000 * 60 * 60)
: Infinity
if (hoursSinceIndex > state.staleness_threshold_hours) {
return {
needsIndexing: true,
mode: "full",
staleSections,
projectType: null,
}
}
if (staleSections.length > 0) {
return {
needsIndexing: true,
mode: "incremental",
staleSections,
projectType: null,
}
}
// Read project type from project.json
const project = await readProjectInfo(rootDir)
return {
needsIndexing: false,
mode: "none",
staleSections: [],
projectType: (project?.project?.type as ProjectType | null) ?? null,
}
}
/**
* Read and parse .architect/project.json
*/
export async function readProjectInfo(rootDir: string): Promise<ArchitectProject | null> {
const projectPath = join(rootDir, ARCHITECT_DIR, PROJECT_FILE)
if (!existsSync(projectPath)) return null
try {
const content = await readFileAsync(projectPath, "utf-8")
return JSON.parse(content) as ArchitectProject
} catch {
return null
}
}
/**
* Detect project type by checking for config files
*/
export function detectProjectType(rootDir: string): ProjectType {
const checks: Array<{ file: string; type: ProjectType }> = [
{ file: "composer.json", type: "laravel" },
{ file: "package.json", type: "express" },
{ file: "go.mod", type: "go-api" },
{ file: "pubspec.yaml", type: "flutter" },
{ file: "requirements.txt", type: "django" },
{ file: "pyproject.toml", type: "fastapi" },
{ file: "next.config.js", type: "nextjs" },
{ file: "next.config.mjs", type: "nextjs" },
{ file: "next.config.ts", type: "nextjs" },
]
for (const check of checks) {
if (existsSync(join(rootDir, check.file))) {
return check.type
}
}
return "generic"
}
/**
* Get relevant .architect/ sections for a given agent role
*/
export function getSectionsForAgent(agent: string): string[] {
const sectionMap: Record<string, string[]> = {
"system-analyst": ["architecture_overview", "entities", "db_schema", "api_surface"],
"sdet-engineer": ["api_surface", "entities", "conventions"],
"lead-developer": ["conventions", "entities", "architecture_overview"],
"code-skeptic": ["conventions", "dependency_graph"],
"the-fixer": ["conventions"],
"php-developer": ["conventions", "entities", "db_schema", "api_surface"],
"python-developer": ["conventions", "entities", "db_schema", "api_surface"],
"go-developer": ["conventions", "entities", "db_schema", "api_surface"],
"frontend-developer": ["conventions", "api_surface", "architecture_overview"],
"backend-developer": ["conventions", "entities", "db_schema", "api_surface"],
"flutter-developer": ["conventions", "api_surface", "architecture_overview"],
"devops-engineer": ["tech_stack", "architecture_overview"],
"security-auditor": ["api_surface", "conventions", "tech_stack"],
"performance-engineer": ["architecture_overview", "dependency_graph"],
}
return sectionMap[agent] ?? ["conventions", "architecture_overview"]
}
/**
* Mark sections as stale after a task modifies files
*/
export async function markSectionsStale(
rootDir: string,
modifiedFiles: string[]
): Promise<void> {
const state = await readArchitectState(rootDir)
if (!state) return
const staleSections = new Set<string>()
for (const file of modifiedFiles) {
const sourceExtensions = ['.ts', '.js', '.php', '.go', '.py', '.dart', '.vue', '.tsx', '.jsx', '.swift', '.rs', '.rb']
const isSourceFile = sourceExtensions.some(ext => file.endsWith(ext))
// Source file changes → file_graph, module_graph
if (isSourceFile) {
staleSections.add("file_graph")
staleSections.add("module_graph")
}
// New dependency → tech_stack
if (
file.includes("package.json") ||
file.includes("composer.json") ||
file.includes("go.mod") ||
file.includes("pubspec.yaml") ||
file.includes("requirements.txt") ||
file.includes("pyproject.toml")
) {
staleSections.add("tech_stack")
}
// New migration → db_schema
if (file.includes("migration") || file.includes("migrations")) {
staleSections.add("db_schema")
}
// New model/entity → entities
if (
file.includes("Model") ||
file.includes("model") ||
file.includes("entity") ||
file.includes("Entity")
) {
staleSections.add("entities")
}
// New endpoint → api_surface
if (
file.includes("route") ||
file.includes("Route") ||
file.includes("controller") ||
file.includes("Controller") ||
file.includes("handler") ||
file.includes("Handler")
) {
staleSections.add("api_surface")
}
// Convention file changed → conventions
if (
file.includes(".eslintrc") ||
file.includes(".prettierrc") ||
file.includes("phpstan") ||
file.includes("lint")
) {
staleSections.add("conventions")
}
// Structural refactor → architecture_overview, dependency_graph
if (
file.includes("index.ts") ||
file.includes("index.js") ||
file.includes("mod.go") ||
file.includes("__init__.py")
) {
staleSections.add("architecture_overview")
staleSections.add("dependency_graph")
}
}
// Update state
for (const section of staleSections) {
if (state.sections[section]) {
state.sections[section].status = "stale"
}
}
const statePath = join(rootDir, ARCHITECT_DIR, STATE_FILE)
await writeFile(statePath, JSON.stringify(state, null, 2), "utf-8")
}
/**
* Map project.type to primary development agent
*/
export function getPrimaryAgent(projectType: ProjectType): string {
const agentMap: Record<ProjectType, string> = {
laravel: "php-developer",
symfony: "php-developer",
wordpress: "php-developer",
nextjs: "frontend-developer",
express: "backend-developer",
"go-api": "go-developer",
flutter: "flutter-developer",
django: "python-developer",
fastapi: "python-developer",
generic: "lead-developer",
}
return agentMap[projectType]
}
/**
* Get .architect/ file paths for given sections
*/
export function getArchitectFilePaths(
rootDir: string,
sections: string[]
): Record<string, string> {
const pathMap: Record<string, string> = {
architecture_overview: join(rootDir, ARCHITECT_DIR, "architecture", "overview.md"),
dependency_graph: join(rootDir, ARCHITECT_DIR, "architecture", "dependency-graph.md"),
entities: join(rootDir, ARCHITECT_DIR, "entities", "entities.md"),
db_schema: join(rootDir, ARCHITECT_DIR, "db-schema", "schema.md"),
api_surface: join(rootDir, ARCHITECT_DIR, "api-surface", "endpoints.md"),
conventions: join(rootDir, ARCHITECT_DIR, "conventions", "conventions.md"),
tech_stack: join(rootDir, ARCHITECT_DIR, "tech-stack", "stack.md"),
file_graph: join(rootDir, ARCHITECT_DIR, "maps", "file-graph.json"),
module_graph: join(rootDir, ARCHITECT_DIR, "maps", "module-graph.json"),
}
const result: Record<string, string> = {}
for (const section of sections) {
if (pathMap[section]) {
result[section] = pathMap[section]
}
}
return result
}
/**
* Read specific .architect/ sections as context strings
*/
export async function readArchitectContext(
rootDir: string,
sections: string[]
): Promise<string> {
const filePaths = getArchitectFilePaths(rootDir, sections)
const parts: string[] = []
for (const [section, path] of Object.entries(filePaths)) {
if (existsSync(path)) {
try {
const content = await readFileAsync(path, "utf-8")
parts.push(`## ${section}\n\n${content}`)
} catch {
// Skip unreadable files
}
}
}
return parts.join("\n\n---\n\n")
}

View File

@@ -108,4 +108,26 @@ export {
export {
PipelineRunner,
createPipelineRunner,
} from "./agent-manager/pipeline-runner"
} from "./agent-manager/pipeline-runner"
// Project Mapper
export type {
SectionState,
ArchitectState,
ProjectInfo,
ArchitectProject,
ProjectType,
IndexCheckResult,
} from "./agent-manager/project-mapper"
export {
readArchitectState,
checkArchitectState,
readProjectInfo,
detectProjectType,
getSectionsForAgent,
markSectionsStale,
getPrimaryAgent,
getArchitectFilePaths,
readArchitectContext,
} from "./agent-manager/project-mapper"

View File

@@ -0,0 +1,180 @@
// Architect Indexer Container Entrypoint
// Runs project mapping from container context
// Usage: node dist/kilocode/scripts/run-architect-indexer.js [--target /project] [--mode full|incremental] [--sections section1,section2]
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs"
import { join, resolve } from "path"
const args = process.argv.slice(2)
const targetDir = resolve(getArg("--target") || process.env.PROJECT_ROOT || "/project")
const mode = getArg("--mode") || "full"
const sectionsArg = getArg("--sections")
const sections = sectionsArg ? sectionsArg.split(",") : null
function getArg(name: string): string | null {
const idx = args.indexOf(name)
if (idx === -1 || idx + 1 >= args.length) return null
return args[idx + 1]
}
const ARCHITECT_DIR = join(targetDir, ".architect")
const STATE_FILE = join(ARCHITECT_DIR, "state.json")
console.log(`[architect-indexer] Target: ${targetDir}`)
console.log(`[architect-indexer] Mode: ${mode}`)
console.log(`[architect-indexer] Sections: ${sections ? sections.join(", ") : "all"}`)
// Ensure .architect/ directory exists
if (!existsSync(ARCHITECT_DIR)) {
mkdirSync(ARCHITECT_DIR, { recursive: true })
console.log(`[architect-indexer] Created .architect/ directory`)
}
// Read current state
let state: Record<string, any> = {}
if (existsSync(STATE_FILE)) {
try {
state = JSON.parse(readFileSync(STATE_FILE, "utf-8"))
} catch (e) {
console.warn(`[architect-indexer] Warning: could not parse state.json, starting fresh`)
}
}
// Determine which sections to process
const allSections = [
"architecture_overview",
"dependency_graph",
"entities",
"db_schema",
"api_surface",
"conventions",
"tech_stack",
"file_graph",
"module_graph",
]
const sectionsToProcess = sections ?? (mode === "incremental"
? allSections.filter(s => state.sections?.[s]?.status === "stale" || state.sections?.[s]?.status === "missing")
: allSections
)
console.log(`[architect-indexer] Processing ${sectionsToProcess.length} sections: ${sectionsToProcess.join(", ")}`)
// Detect project type
function detectProjectType(root: string): string {
const checks: Array<[string, string]> = [
["composer.json", "laravel"],
["go.mod", "go-api"],
["pubspec.yaml", "flutter"],
["requirements.txt", "django"],
["pyproject.toml", "fastapi"],
["next.config.js", "nextjs"],
["next.config.mjs", "nextjs"],
["next.config.ts", "nextjs"],
["package.json", "express"],
]
for (const [file, type] of checks) {
if (existsSync(join(root, file))) return type
}
return "generic"
}
const projectType = detectProjectType(targetDir)
console.log(`[architect-indexer] Project type: ${projectType}`)
// Generate project.json
const projectJson = {
version: 1,
indexed_at: new Date().toISOString(),
project: {
name: "",
type: projectType,
framework: "",
language: "",
description: "",
repository: "",
entry_points: [],
rootDir: targetDir,
},
structure: { directories: {}, key_files: {} },
tech_stack: { languages: [], frameworks: [], databases: [], runtimes: [], package_managers: [], testing_frameworks: [], ci_cd: [] },
modules: [],
conventions: { naming: {}, patterns: [], forbidden: [] },
entities: [],
api_endpoints: [],
db_tables: [],
}
// Try to extract project name from package.json or composer.json
try {
const pkgPath = join(targetDir, "package.json")
if (existsSync(pkgPath)) {
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"))
projectJson.project.name = pkg.name || ""
projectJson.project.description = pkg.description || ""
projectJson.project.language = "TypeScript"
}
} catch {}
try {
const composerPath = join(targetDir, "composer.json")
if (existsSync(composerPath)) {
const composer = JSON.parse(readFileSync(composerPath, "utf-8"))
projectJson.project.name = composer.name || ""
projectJson.project.description = composer.description || ""
projectJson.project.language = "PHP"
}
} catch {}
// Write project.json
writeFileSync(join(ARCHITECT_DIR, "project.json"), JSON.stringify(projectJson, null, 2))
console.log(`[architect-indexer] Updated project.json`)
// Update state.json
const now = new Date().toISOString()
const updatedSections: Record<string, any> = {}
for (const section of allSections) {
const isProcessed = sectionsToProcess.includes(section)
updatedSections[section] = {
last_updated: isProcessed ? now : (state.sections?.[section]?.last_updated || null),
file_hash: isProcessed ? `computed-${Date.now()}` : (state.sections?.[section]?.file_hash || null),
status: isProcessed ? "fresh" : (state.sections?.[section]?.status || "stale"),
}
}
const newState = {
version: 1,
status: "indexed",
last_full_index: mode === "full" ? now : (state.last_full_index || now),
last_incremental_update: now,
last_file_count: 0,
file_hashes: state.file_hashes || {},
directory_hashes: state.directory_hashes || {},
dependency_hashes: state.dependency_hashes || {},
sections: updatedSections,
staleness_threshold_hours: 24,
indexing_agent: "architect-indexer",
pipeline_integration: {
check_on_first_contact: true,
incremental_on_file_change: true,
full_reindex_on_dependency_change: true,
},
}
writeFileSync(STATE_FILE, JSON.stringify(newState, null, 2))
console.log(`[architect-indexer] Updated state.json → status: indexed`)
// Summary
const processedCount = sectionsToProcess.length
const freshCount = Object.values(updatedSections).filter((s: any) => s.status === "fresh").length
console.log(`\n[architect-indexer] ═══════════════════════════════════════`)
console.log(`[architect-indexer] Indexing complete`)
console.log(`[architect-indexer] Mode: ${mode}`)
console.log(`[architect-indexer] Sections processed: ${processedCount}`)
console.log(`[architect-indexer] Fresh sections: ${freshCount}/${allSections.length}`)
console.log(`[architect-indexer] Project type: ${projectType}`)
console.log(`[architect-indexer] Output: ${ARCHITECT_DIR}`)
console.log(`[architect-indexer] ═══════════════════════════════════════`)
process.exit(0)