UNPKG

trellis

Version:

Agentic State Engine — event-sourced causal graph with branching, decision traces, and realtime sync for AI-native applications

1,725 lines (1,611 loc) 326 kB
import { getSigningMaterial, init_pairing, pairingResolver } from "./chunk-QC5OHKIJ.js"; import { acquirePromoteLock, init_promote_lock, releasePromoteLock } from "./chunk-DIHRG6LA.js"; import { getDecision, getDecisionChain, init_decisions, queryDecisions, recordDecision } from "./chunk-2DFWNMEW.js"; import { EAVStore, init_eav_store } from "./chunk-G3XIHPSQ.js"; import { JsonOpLog, LaneOpLog, addCriterion, applyIssueStartCriteria, assignIssue, blockIssue, buildFileStateAtOp, checkCompletionReadiness, closeIssue, createBranch, createCheckpoint, createIssue, createLaneMeta, createMilestone, createProjectAttestation, decompose, deleteBranch, diffFileStates, diffOpRange, getActiveIssues, getBranchHeadOpHash, getIssue, init_branch, init_checkpoint, init_decompose, init_diff, init_issue, init_lane, init_merge, init_milestone, init_op_log, init_project, init_signing_middleware, init_transcript, laneDir, listBranches, listChatMessages, listCheckpoints, listIssues, listLaneMetas, listMilestones, loadBranchState, loadLaneMeta, normalizeLaneName, pauseIssue, recordChatMessage, removeCriterion, reopenIssue, resolveLaneHeadFromJournal, resumeIssue, runCriteria, saveBranchState, saveLaneMeta, setCriterionStatus, shouldAdvanceBranchHead, startIssue, switchBranch, threeWayMerge, threeWayTextMerge, triageIssue, unblockIssue, updateIssue, updateLaneHead, verifyOpBatch } from "./chunk-Q4FKTPX4.js"; import { allTestRunsPassed, init_test_runner, runPromoteRequiredTests, runTestSuites, tryLoadTestManifest } from "./chunk-LNCBUJNO.js"; import { ensureDefaultTestManifest, init_test_manifest } from "./chunk-PBH357QR.js"; import { DEFAULT_CONFIG, init_types, issueEntityId } from "./chunk-E2CFJKLU.js"; import { BlobStore, init_blob_store } from "./chunk-MFZ22U6M.js"; import { PROVENANCE, init_canonical_op } from "./chunk-RUMOVKR4.js"; import { createVcsOp, init_ops, isVcsOpKind, verifyVcsOpHash } from "./chunk-GRWQPKYK.js"; import { __esm } from "./chunk-2ESYSVXG.js"; // src/watcher/fs-watcher.ts import { watch } from "fs"; import { readdir, stat, readFile } from "fs/promises"; import { join, relative } from "path"; async function hashFile(filePath) { const content = await readFile(filePath); const hashBuffer = await crypto.subtle.digest("SHA-256", content); const hashArray = Array.from(new Uint8Array(hashBuffer)); return hashArray.map((b) => b.toString(16).padStart(2, "0")).join(""); } function shouldIgnore(relPath, patterns) { for (const pattern of patterns) { if (pattern.startsWith("*.")) { const ext = pattern.slice(1); if (relPath.endsWith(ext)) return true; } else if (relPath.includes(pattern)) { return true; } } return false; } var FileWatcher; var init_fs_watcher = __esm({ "src/watcher/fs-watcher.ts"() { "use strict"; FileWatcher = class { config; watchers = []; debounceTimers = /* @__PURE__ */ new Map(); knownFiles = /* @__PURE__ */ new Map(); // relPath → contentHash running = false; constructor(config) { this.config = config; } /** * Scans the directory tree and builds an initial map of all tracked files. * Returns the list of FileChangeEvents for the initial state (all adds). */ async scan(opts) { const events = []; opts?.onProgress?.({ phase: "discovering", current: 0, total: 0, message: "Discovering existing files\u2026" }); const entries = await this.walkDir(this.config.rootPath); opts?.onProgress?.({ phase: "hashing", current: 0, total: entries.length, message: `Hashing ${entries.length} existing files\u2026` }); for (let i = 0; i < entries.length; i++) { const absPath = entries[i]; const relPath = relative(this.config.rootPath, absPath); if (shouldIgnore(relPath, this.config.ignorePatterns)) continue; try { const hash = await hashFile(absPath); const stats = await stat(absPath); this.knownFiles.set(relPath, hash); events.push({ type: "add", path: relPath, contentHash: hash, size: stats.size, timestamp: (/* @__PURE__ */ new Date()).toISOString() }); } catch { } if ((i + 1) % 25 === 0 || i === entries.length - 1) { opts?.onProgress?.({ phase: "hashing", current: i + 1, total: entries.length, message: `Hashed ${i + 1}/${entries.length} files` }); } } opts?.onProgress?.({ phase: "done", current: events.length, total: events.length, message: `Discovered ${events.length} trackable files` }); return events; } /** * Starts watching the directory tree for changes. */ start() { if (this.running) return; this.running = true; try { const watcher = watch( this.config.rootPath, { recursive: true }, (eventType, filename) => { if (!filename) return; const relPath = filename.toString(); if (shouldIgnore(relPath, this.config.ignorePatterns)) return; this.debouncedHandle(relPath); } ); this.watchers.push(watcher); } catch { console.warn("Recursive watch not supported; using scan-based polling."); } } /** * Stops all watchers. */ stop() { this.running = false; for (const w of this.watchers) { w.close(); } this.watchers = []; for (const timer of this.debounceTimers.values()) { clearTimeout(timer); } this.debounceTimers.clear(); } /** * Returns the current known file map (path → contentHash). */ getKnownFiles() { return new Map(this.knownFiles); } debouncedHandle(relPath) { const existing = this.debounceTimers.get(relPath); if (existing) clearTimeout(existing); const timer = setTimeout(async () => { this.debounceTimers.delete(relPath); await this.handleChange(relPath); }, this.config.debounceMs); this.debounceTimers.set(relPath, timer); } async handleChange(relPath) { const absPath = join(this.config.rootPath, relPath); const known = this.knownFiles.get(relPath); try { const stats = await stat(absPath); if (!stats.isFile()) return; const hash = await hashFile(absPath); if (!known) { this.knownFiles.set(relPath, hash); await this.config.onEvent({ type: "add", path: relPath, contentHash: hash, size: stats.size, timestamp: (/* @__PURE__ */ new Date()).toISOString() }); } else if (known !== hash) { const oldHash = known; this.knownFiles.set(relPath, hash); await this.config.onEvent({ type: "modify", path: relPath, contentHash: hash, oldContentHash: oldHash, size: stats.size, timestamp: (/* @__PURE__ */ new Date()).toISOString() }); } } catch { if (known) { this.knownFiles.delete(relPath); await this.config.onEvent({ type: "delete", path: relPath, contentHash: known, timestamp: (/* @__PURE__ */ new Date()).toISOString() }); } } } async walkDir(dir) { const results = []; try { const entries = await readdir(dir, { withFileTypes: true }); for (const entry of entries) { const fullPath = join(dir, entry.name); const relFromRoot = relative(this.config.rootPath, fullPath); if (shouldIgnore(relFromRoot, this.config.ignorePatterns)) continue; if (entry.isDirectory()) { const sub = await this.walkDir(fullPath); results.push(...sub); } else if (entry.isFile()) { results.push(fullPath); } } } catch { } return results; } }; } }); // src/watcher/ingestion.ts import { extname } from "path"; function detectLanguage(filePath) { const ext = extname(filePath).toLowerCase(); return EXT_LANGUAGE[ext]; } var EXT_LANGUAGE, Ingestion; var init_ingestion = __esm({ "src/watcher/ingestion.ts"() { "use strict"; init_ops(); EXT_LANGUAGE = { ".ts": "typescript", ".tsx": "typescript", ".js": "javascript", ".jsx": "javascript", ".py": "python", ".rs": "rust", ".go": "go", ".rb": "ruby", ".java": "java", ".c": "c", ".cpp": "cpp", ".h": "c", ".hpp": "cpp", ".cs": "csharp", ".swift": "swift", ".kt": "kotlin", ".md": "markdown", ".json": "json", ".yaml": "yaml", ".yml": "yaml", ".toml": "toml", ".html": "html", ".css": "css", ".scss": "scss", ".vue": "vue", ".svelte": "svelte" }; Ingestion = class { agentId; lastOpHash; onOp; constructor(opts) { this.agentId = opts.agentId; this.lastOpHash = opts.lastOpHash; this.onOp = opts.onOp; } /** * Processes a single FileChangeEvent, producing and emitting a VcsOp. */ async process(event) { let kind; switch (event.type) { case "add": kind = "vcs:fileAdd"; break; case "modify": kind = "vcs:fileModify"; break; case "delete": kind = "vcs:fileDelete"; break; case "rename": kind = "vcs:fileRename"; break; } const op = await createVcsOp(kind, { agentId: this.agentId, previousHash: this.lastOpHash, vcs: { filePath: event.path, oldFilePath: event.oldPath, contentHash: event.contentHash, oldContentHash: event.oldContentHash, size: event.size, language: detectLanguage(event.path) } }); this.lastOpHash = op.hash; await this.onOp(op); return op; } /** * Processes a batch of FileChangeEvents in order. */ async processBatch(events) { const ops = []; for (const event of events) { ops.push(await this.process(event)); } return ops; } getLastOpHash() { return this.lastOpHash; } setLastOpHash(hash) { this.lastOpHash = hash; } }; } }); // src/scaffold/infer.ts import { existsSync, readdirSync, statSync, readFileSync } from "fs"; import { join as join2 } from "path"; function detectEcosystem(rootPath) { const indicators = []; let ecosystem = null; let name = null; let description = null; let domain = null; const pkgPath = join2(rootPath, "package.json"); if (existsSync(pkgPath)) { indicators.push("package.json"); try { const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")); name = pkg.name ?? null; description = pkg.description ?? null; ecosystem = "node"; const deps = { ...pkg.dependencies, ...pkg.devDependencies, ...pkg.peerDependencies }; const depNames = Object.keys(deps); if (pkg.engines?.bun || depNames.includes("@types/bun")) { ecosystem = "bun"; } if (depNames.some((d) => d.includes("motion-canvas"))) { domain = "animation-studio"; } else if (depNames.some( (d) => ["react", "vue", "svelte", "next", "nuxt"].includes(d) )) { domain = "web-app"; } else if (depNames.some( (d) => ["express", "fastify", "hono", "elysia"].includes(d) )) { domain = "api-server"; } else if (depNames.some( (d) => d.includes("@tensorflow") || d.includes("langchain") || d.includes("openai") )) { domain = "ai-ml"; } } catch { } } if (existsSync(join2(rootPath, "pyproject.toml"))) { indicators.push("pyproject.toml"); ecosystem = ecosystem ?? "python"; } else if (existsSync(join2(rootPath, "requirements.txt"))) { indicators.push("requirements.txt"); ecosystem = ecosystem ?? "python"; } const cargoPath = join2(rootPath, "Cargo.toml"); if (existsSync(cargoPath)) { indicators.push("Cargo.toml"); ecosystem = ecosystem ?? "rust"; try { const cargo = readFileSync(cargoPath, "utf-8"); const nameMatch = cargo.match(/^name\s*=\s*"(.+?)"/m); if (nameMatch) name = name ?? nameMatch[1] ?? null; } catch { } } if (existsSync(join2(rootPath, "go.mod"))) { indicators.push("go.mod"); ecosystem = ecosystem ?? "go"; } if (existsSync(join2(rootPath, "Dockerfile")) || existsSync(join2(rootPath, "docker-compose.yml"))) { indicators.push("Dockerfile"); domain = domain ?? "infrastructure"; } let framework = null; if (existsSync(pkgPath)) { try { const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")); const deps = { ...pkg.dependencies, ...pkg.devDependencies, ...pkg.peerDependencies }; const depNames = Object.keys(deps); if (depNames.includes("next")) { framework = "next"; } else if (depNames.includes("nuxt")) { framework = "nuxt"; } else if (depNames.includes("svelte")) { framework = "svelte"; } else if (depNames.includes("vue")) { framework = "vue"; } else if (depNames.includes("remotion") || depNames.includes("@remotion/server")) { framework = "remotion"; } else if (depNames.includes("expo") || depNames.includes("react-native")) { framework = "expo"; } else if (depNames.some((d) => ["commander", "cac", "oclif", "yargs"].includes(d))) { framework = "cli"; } else if (depNames.includes("react")) { framework = "react"; } } catch { } } return { ecosystem: ecosystem ?? "unknown", name, description, domain, framework, indicators }; } function extractReadmeDescription(rootPath) { const candidates = ["README.md", "README.MD", "readme.md", "README.txt"]; for (const candidate of candidates) { const readmePath = join2(rootPath, candidate); if (!existsSync(readmePath)) continue; try { const content = readFileSync(readmePath, "utf-8"); const lines = content.split("\n").slice(0, 60); for (const line of lines) { const trimmed = line.trim(); if (trimmed && !trimmed.startsWith("#") && !trimmed.startsWith("!") && trimmed.length > 20) { const clean = trimmed.replace(/[*_`[\]]/g, "").replace(/\(https?:\/\/[^\)]+\)/g, "").trim(); if (clean.length > 10) { return { description: clean.slice(0, 200), indicators: [candidate] }; } } } } catch { } } return { description: null, indicators: [] }; } function shallowFileCount(rootPath, maxDepth = 3) { let count = 0; function walk(dir, depth) { if (depth > maxDepth) return; let entries = []; try { entries = readdirSync(dir); } catch { return; } for (const entry of entries) { if (entry.startsWith(".") || entry === "node_modules" || entry === ".trellis") continue; try { const full = join2(dir, entry); const stat2 = statSync(full); if (stat2.isDirectory()) { walk(full, depth + 1); } else { count++; } } catch { } } } walk(rootPath, 0); return count; } function computeConfidence(fileCount, indicators) { if (fileCount <= 5) return "high"; if (indicators.length >= 2) return "high"; if (fileCount >= 1e4) return "low"; if (indicators.length === 1) return fileCount <= 500 ? "high" : "medium"; return "medium"; } async function inferProjectContext(rootPath, opts) { const fileCount = opts?.precomputedFileCount ?? shallowFileCount(rootPath); const ecosystem = detectEcosystem(rootPath); const readme = extractReadmeDescription(rootPath); const allIndicators = [...ecosystem.indicators, ...readme.indicators]; const confidence = computeConfidence(fileCount, allIndicators); return { domain: ecosystem.domain, description: readme.description ?? ecosystem.description, ecosystem: ecosystem.ecosystem, name: ecosystem.name, framework: ecosystem.framework, fileCount, confidence, indicators: allIndicators }; } var init_infer = __esm({ "src/scaffold/infer.ts"() { "use strict"; } }); // src/scaffold/profile.ts import { existsSync as existsSync2, mkdirSync, readFileSync as readFileSync2, writeFileSync } from "fs"; import { join as join3 } from "path"; import { homedir } from "os"; function getProfileDir() { return join3(homedir(), ".trellis"); } function getProfilePath() { return join3(getProfileDir(), "profile.json"); } function loadProfile() { const profilePath = getProfilePath(); if (!existsSync2(profilePath)) return null; try { const raw = readFileSync2(profilePath, "utf-8"); return JSON.parse(raw); } catch { return null; } } function saveProfile(profile) { const dir = getProfileDir(); if (!existsSync2(dir)) { mkdirSync(dir, { recursive: true }); } writeFileSync(getProfilePath(), JSON.stringify(profile, null, 2)); } function hasProfile() { return existsSync2(getProfilePath()); } function updateProfile(updates) { const existing = loadProfile(); const base = existing ?? { name: "Unknown", bio: "", skills: [], style: "", preferences: { verbosity: "balanced", tone: "peer" }, createdAt: (/* @__PURE__ */ new Date()).toISOString(), updatedAt: (/* @__PURE__ */ new Date()).toISOString() }; const updated = { ...base, ...updates, preferences: { ...base.preferences, ...updates.preferences ?? {} }, updatedAt: (/* @__PURE__ */ new Date()).toISOString() }; saveProfile(updated); return updated; } var init_profile = __esm({ "src/scaffold/profile.ts"() { "use strict"; } }); // src/scaffold/write.ts import { existsSync as existsSync3, mkdirSync as mkdirSync2, writeFileSync as writeFileSync2, readFileSync as readFileSync3 } from "fs"; import { dirname, join as join4 } from "path"; import { fileURLToPath } from "url"; function writeTrellisHooks(rootPath, ide) { const hooksDir = join4(rootPath, ".cursor", "hooks"); const trellisHarnessDir = join4(hooksDir, "trellis-harness"); const adaptersDir = join4(hooksDir, "adapters"); if (!existsSync3(trellisHarnessDir)) { mkdirSync2(trellisHarnessDir, { recursive: true }); } if (!existsSync3(adaptersDir)) { mkdirSync2(adaptersDir, { recursive: true }); } writeFileSync2( join4(trellisHarnessDir, "trellis-cli.sh"), readHarnessTemplate("trellis-cli.sh"), "utf-8" ); writeFileSync2( join4(trellisHarnessDir, "pre-prompt-recall.sh"), renderPrePromptRecallScript(), "utf-8" ); writeFileSync2( join4(trellisHarnessDir, "normalize-op.jq"), readHarnessTemplate("normalize-op.jq"), "utf-8" ); writeFileSync2( join4(trellisHarnessDir, "post-tool-oplog.sh"), renderPostToolOplogScript(), "utf-8" ); writeFileSync2( join4(trellisHarnessDir, "post-tool-memory-capture.sh"), renderPostToolMemoryCaptureScript(), "utf-8" ); writeFileSync2( join4(trellisHarnessDir, "stop-triage.sh"), renderStopTriageScript(), "utf-8" ); writeFileSync2( join4(trellisHarnessDir, "bug-intake.sh"), renderBugIntakeScript(), "utf-8" ); writeFileSync2( join4(trellisHarnessDir, "bug-investigate.sh"), renderBugInvestigateScript(), "utf-8" ); writeFileSync2( join4(trellisHarnessDir, "milestone-triage.sh"), renderMilestoneTriageScript(), "utf-8" ); writeFileSync2( join4(trellisHarnessDir, "cycle-planning.sh"), renderCyclePlanningScript(), "utf-8" ); const adapterName = `${ide}-adapter.sh`; writeFileSync2( join4(adaptersDir, adapterName), renderAdapterScript(ide), "utf-8" ); switch (ide) { case "cursor": writeFileSync2( join4(rootPath, ".cursor", "hooks.json"), JSON.stringify(renderCursorHooksConfig(), null, 2), "utf-8" ); break; case "devin": writeFileSync2( join4(rootPath, ".devin", "hooks.json"), JSON.stringify(renderDevinHooksConfig(), null, 2), "utf-8" ); break; case "claude": writeFileSync2( join4(rootPath, ".claude", "settings.local.json"), JSON.stringify(renderClaudeHooksConfig(), null, 2), "utf-8" ); break; case "codex": writeFileSync2( join4(rootPath, ".codex", "hooks.json"), JSON.stringify(renderCodexHooksConfig(), null, 2), "utf-8" ); break; case "gemini": writeFileSync2( join4(rootPath, ".gemini", "settings.json"), JSON.stringify(renderGeminiHooksConfig(), null, 2), "utf-8" ); break; } } function renderAgentsMd(profile, context) { const userName = profile?.name ?? "the user"; const userBio = profile?.bio || "(No bio provided \u2014 run `trellis season` to set up your profile.)"; const userSkills = profile?.skills?.length ? profile.skills.join(", ") : "(not specified)"; const userStyle = profile?.style || "(not specified)"; const userVerbosity = profile?.preferences?.verbosity ?? "balanced"; const userTone = profile?.preferences?.tone ?? "peer"; const projectName = context.name ?? "(unnamed)"; const projectDomain = context.domain ?? "(not determined \u2014 run `trellis season` to specify)"; const projectDesc = context.description ?? "(no description found)"; const projectEco = context.ecosystem ?? "unknown"; const confidence = context.confidence; return `# Trellis Agent Context > This file was generated by \`trellis init\` and should be kept up to date. > Inference confidence: **${confidence}** --- ## About the User | Field | Value | |-------|-------| | **Name** | ${userName} | | **Bio** | ${userBio} | | **Skills** | ${userSkills} | | **Style** | ${userStyle} | | **Preferred verbosity** | ${userVerbosity} | | **Preferred tone** | ${userTone} | --- ## About This Project | Field | Value | |-------|-------| | **Name** | ${projectName} | | **Domain** | ${projectDomain} | | **Description** | ${projectDesc} | | **Ecosystem** | ${projectEco} | | **File count** | ~${context.fileCount} | --- ## Agent Instructions You are operating in a Trellis-tracked repository. Follow these guidelines: 1. **Read \`agent-context.json\`** in this directory for registered tools, ontologies, and domain settings. 2. **Check \`skills/\`** for domain-specific operating instructions relevant to this project. 3. **Check \`workflows/\`** for repeatable task procedures. Favor existing workflows before improvising. 4. **Update this file** as the project evolves \u2014 new teammates, new goals, new tools. 5. **Communicate through Trellis** \u2014 prefer writing to the graph kernel over mutating files directly when recording decisions or state. 6. **Multi-agent lanes** \u2014 use \`trellis issue start\` or \`trellis lane\`; \`trellis init\` sets \`lanes.worktreeBind\` and \`git.syncOnPromote\` by default. 7. **Run \`trellis season\`** if context seems incomplete or out of date. --- ## Quick Reference \`\`\`bash trellis status # Current repo state trellis log # Causal operation history trellis issue start TRL-N # Branch + agent lane (default) trellis lane status # Active lane + worktree path when bound trellis milestone # Create narrative checkpoints trellis ask "..." # Semantic search across the repo trellis season # Re-run domain onboarding \`\`\` `; } function renderConfigJson(context) { return { domain: context.domain, ecosystem: context.ecosystem, tools: [], ontologies: [], generatedAt: (/* @__PURE__ */ new Date()).toISOString(), confidence: context.confidence }; } function renderSkillsReadme() { return `# Skills Place domain-specific operating instructions here as \`.md\` files. Each skill file should follow the format: \`\`\`markdown --- name: Skill Name description: When to use this skill. --- # Instructions ... \`\`\` The agent will discover and read skill files relevant to the current task. `; } function renderWorkflowsReadme() { return `# Workflows Place repeatable task procedures here as \`.md\` files. Each workflow file should follow the format: \`\`\`markdown --- description: Short description of what this workflow does. --- ## Steps 1. Step one 2. Step two // turbo 3. Auto-runnable step \`\`\` Add \`// turbo\` above a step to allow the agent to auto-run it without confirmation. Add \`// turbo-all\` anywhere in the file to auto-run every step. `; } function writeAgentScaffold(rootPath, input) { const agentsDir = join4(rootPath, ".trellis", "agents"); const skillsDir = join4(agentsDir, "skills"); const workflowsDir = join4(agentsDir, "workflows"); for (const dir of [agentsDir, skillsDir, workflowsDir]) { if (!existsSync3(dir)) { mkdirSync2(dir, { recursive: true }); } } writeFileSync2( join4(agentsDir, "AGENTS.md"), renderAgentsMd(input.profile, input.context), "utf-8" ); writeFileSync2( join4(agentsDir, "agent-context.json"), JSON.stringify(renderConfigJson(input.context), null, 2), "utf-8" ); const skillsReadmePath = join4(skillsDir, "README.md"); if (!existsSync3(skillsReadmePath)) { writeFileSync2(skillsReadmePath, renderSkillsReadme(), "utf-8"); } const workflowsReadmePath = join4(workflowsDir, "README.md"); if (!existsSync3(workflowsReadmePath)) { writeFileSync2(workflowsReadmePath, renderWorkflowsReadme(), "utf-8"); } } function renderCursorRules(input) { const projectName = input.context.name ?? "this project"; const domain = input.context.domain ?? "general"; const eco = input.context.ecosystem ?? "unknown"; const framework = input.framework && input.framework !== "none" ? input.framework : "vanilla"; const plugins = input.plugins.length > 0 ? input.plugins.map((p) => `- ${p}`).join("\n") : "(none selected)"; return `# Cursor Rules for ${projectName} > Generated by \`trellis init\` ## Project Context - **Domain**: ${domain} - **Framework**: ${framework} - **Ecosystem**: ${eco} - **Confidence**: ${input.context.confidence} ## Selected Features ${plugins} ## Agent Instructions You are working in a Trellis-tracked repository. See \`.trellis/agents/AGENTS.md\` for full context. ## Commands - \`trellis status\` \u2014 Check repo state - \`trellis seed\` \u2014 Refresh this context file - \`trellis log\` \u2014 View causal history --- *Auto-generated by Trellis. Run \`trellis seed\` to refresh context.* `; } function renderDevinRules(input) { const projectName = input.context.name ?? "this project"; const domain = input.context.domain ?? "general"; const eco = input.context.ecosystem ?? "unknown"; const framework = input.framework && input.framework !== "none" ? input.framework : "vanilla"; const plugins = input.plugins.length > 0 ? input.plugins.map((p) => `- ${p}`).join("\n") : "(none selected)"; return `# Devin Rules for ${projectName} > Generated by \`trellis init\` ## Project Context - **Domain**: ${domain} - **Framework**: ${framework} - **Ecosystem**: ${eco} - **Confidence**: ${input.context.confidence} ## Selected Features ${plugins} ## Agent Instructions You are working in a Trellis-tracked repository. See \`.trellis/agents/AGENTS.md\` for full context. ## Commands - \`trellis status\` \u2014 Check repo state - \`trellis seed\` \u2014 Refresh this context file - \`trellis log\` \u2014 View causal history --- *Auto-generated by Trellis. Run \`trellis seed\` to refresh context.* `; } function renderClaudeMd(input) { const projectName = input.context.name ?? "this project"; const domain = input.context.domain ?? "general"; const eco = input.context.ecosystem ?? "unknown"; const framework = input.framework && input.framework !== "none" ? input.framework : "vanilla"; const plugins = input.plugins.length > 0 ? input.plugins.map((p) => `- ${p}`).join("\n") : "(none selected)"; return `# Claude Context for ${projectName} > Generated by \`trellis init\` ## Project Context - **Domain**: ${domain} - **Framework**: ${framework} - **Ecosystem**: ${eco} - **Confidence**: ${input.context.confidence} ## Selected Features ${plugins} ## Agent Instructions You are working in a Trellis-tracked repository. See \`.trellis/agents/AGENTS.md\` for full context. ## Commands - \`trellis status\` \u2014 Check repo state - \`trellis seed\` \u2014 Refresh this context file - \`trellis log\` \u2014 View causal history --- *Auto-generated by Trellis. Run \`trellis seed\` to refresh context.* `; } function renderCopilotConfig(input) { return { version: "1.0", generatedBy: "trellis init", context: { domain: input.context.domain, framework: input.framework, ecosystem: input.context.ecosystem, projectName: input.context.name, confidence: input.context.confidence }, plugins: input.plugins }; } function renderCodexConfig(input) { return { version: "1.0", generatedBy: "trellis init", context: { domain: input.context.domain, framework: input.framework, ecosystem: input.context.ecosystem, projectName: input.context.name, confidence: input.context.confidence }, features: input.plugins }; } function renderGeminiConfig(input) { return { version: "1.0", generatedBy: "trellis init", context: { domain: input.context.domain, framework: input.framework, ecosystem: input.context.ecosystem, projectName: input.context.name, confidence: input.context.confidence }, features: input.plugins }; } function renderPrePromptRecallScript() { return `#!/usr/bin/env bash # Pre-prompt memory and context recall for Trellis integration # Normalized contract: TRELLIS_ORIGIN, TRELLIS_DESK_ROOT, TRELLIS_HOOK_OUTPUT set -euo pipefail # Import desk root detection + trellis CLI helpers source "$(dirname "$0")/../desk-root.sh" source "$(dirname "$0")/trellis-cli.sh" # Environment variables from contract ORIGIN="\${TRELLIS_ORIGIN:-unknown}" OUTPUT="\${TRELLIS_HOOK_OUTPUT:-stdout}" # Only run if we're in a Trellis workspace if ! command -v trellis >/dev/null 2>&1; then exit 0 fi # Try to get Trellis context (non-blocking) if [ -f ".trellis/config.json" ] || [ -f "$(trellis_harness_vcs_path)/.trellis/config.json" ]; then CONTEXT_OUTPUT='{"entities":[],"relations":[]}' OPS_COUNT=$(trellis_harness_recent_log_count 3) ISSUES_COUNT=$(trellis_harness_active_issue_count) OPS_OUTPUT="{\\"count\\":\${OPS_COUNT}}" ISSUES_OUTPUT="{\\"count\\":\${ISSUES_COUNT},\\"status\\":\\"in_progress\\"}" case "$OUTPUT" in "agent-stop") echo "{}" ;; "gemini") cat << EOF { "decision": "allow", "context": { "memories": $CONTEXT_OUTPUT, "operations": $OPS_OUTPUT, "issues": $ISSUES_OUTPUT, "origin": "$ORIGIN" } } EOF ;; *) echo "" echo "\u{1F33F} Trellis Context ($ORIGIN):" echo " Recent ops: $OPS_COUNT" echo " Active issues: $ISSUES_COUNT" echo "" ;; esac fi exit 0`; } function readHarnessTemplate(name) { return readFileSync3( join4(SCAFFOLD_MODULE_DIR, "..", "..", "templates", "trellis-harness", name), "utf-8" ); } function renderPostToolOplogScript() { return `#!/usr/bin/env bash # Post-tool operation logging for Trellis integration (agent-ops v1 schema). set -euo pipefail source "$(dirname "$0")/../desk-root.sh" ORIGIN="\${TRELLIS_ORIGIN:-unknown}" TOOL_DATA=$(cat) if [ ! -f ".trellis/config.json" ]; then exit 0 fi if ! command -v jq >/dev/null 2>&1; then exit 0 fi TS=$(date -u +%Y-%m-%dT%H:%M:%SZ) LOG_DIR=".trellis/agent-ops" mkdir -p "$LOG_DIR" LOG_FILE="$LOG_DIR/ops-$(date +%Y-%m-%d).jsonl" NORMALIZE_JQ="$(dirname "$0")/normalize-op.jq" OP_ENTRY=$(printf '%s' "$TOOL_DATA" | jq -c -f "$NORMALIZE_JQ" \\ --arg ts "$TS" \\ --arg origin "$ORIGIN" 2>/dev/null) || OP_ENTRY="{\\"schema_version\\":1,\\"timestamp\\":\\"$TS\\",\\"origin\\":\\"$ORIGIN\\",\\"tool\\":\\"unknown\\",\\"action\\":\\"unknown\\",\\"target\\":\\"\\",\\"command\\":\\"\\",\\"pattern\\":\\"\\",\\"mcp_server\\":\\"\\",\\"mcp_tool\\":\\"\\",\\"model\\":\\"\\",\\"tool_use_id\\":\\"\\",\\"type\\":\\"agent-operation\\"}" printf '%s\\n' "$OP_ENTRY" >> "$LOG_FILE" find "$LOG_DIR" -name "ops-*.jsonl" -mtime +7 -delete 2>/dev/null || true exit 0`; } function renderPostToolMemoryCaptureScript() { return `#!/usr/bin/env bash # Post-tool memory and entity capture for Trellis integration # Normalized contract: TRELLIS_ORIGIN, TRELLIS_DESK_ROOT, stdin with tool data set -euo pipefail # Import desk root detection source "$(dirname "$0")/../desk-root.sh" source "$(dirname "$0")/trellis-cli.sh" # Environment variables from contract ORIGIN="\${TRELLIS_ORIGIN:-unknown}" # Read tool data from stdin TOOL_DATA=$(cat) NORMALIZE_JQ="$(dirname "$0")/normalize-op.jq" TS=$(date -u +%Y-%m-%dT%H:%M:%SZ) NORM=$(printf '%s' "$TOOL_DATA" | jq -c -f "$NORMALIZE_JQ" --arg ts "$TS" --arg origin "$ORIGIN" 2>/dev/null || echo "") TOOL_NAME=$(echo "$NORM" | jq -r '.tool // "unknown"' 2>/dev/null || echo "unknown") ACTION=$(echo "$NORM" | jq -r '.action // "unknown"' 2>/dev/null || echo "unknown") FILE_PATH=$(echo "$NORM" | jq -r '.target // ""' 2>/dev/null || echo "") # Only run if we're in a Trellis workspace if ! command -v trellis >/dev/null 2>&1; then exit 0 fi if [ -f ".trellis/config.json" ]; then # Suggest memory creation for significant operations case "$TOOL_NAME-$ACTION" in "edit-create"|"write-create"|"Edit-create"|"Write-create") # File creation - suggest creating a memory if [ -n "$FILE_PATH" ]; then echo "\u{1F9E0} Consider creating a memory for the new file: $FILE_PATH" echo " Run: trellis memory create -t "Created $FILE_PATH" -c "Created via $ORIGIN agent"" fi ;; "edit-update"|"Edit-update") # File update - suggest updating memory if significant if [ -n "$FILE_PATH" ]; then echo "\u{1F504} Consider updating relevant memories for: $FILE_PATH" echo " Run: trellis memory list -q "$FILE_PATH"" fi ;; "issue-create"|"issue-update") # Issue operations - always suggest memory echo "\u{1F4DD} Consider creating a memory for this issue operation" echo " Run: trellis memory create -t "Issue $ACTION via $ORIGIN"" ;; esac # Store potential memory suggestions for later MEMORY_DIR=".trellis/agent-suggestions" mkdir -p "$MEMORY_DIR" SUGGESTION=$(cat << EOF { "timestamp": "$(date -u +%Y-%m-%dT%H:%M:%S.%3NZ)", "origin": "$ORIGIN", "tool": "$TOOL_NAME", "action": "$ACTION", "target": "$FILE_PATH", "suggestion_type": "memory-creation" } EOF ) SUGGESTIONS_FILE="$MEMORY_DIR/suggestions-$(date +%Y-%m-%d).jsonl" echo "$SUGGESTION" >> "$SUGGESTIONS_FILE" # Keep only last 3 days of suggestions find "$MEMORY_DIR" -name "suggestions-*.jsonl" -mtime +3 -delete 2>/dev/null || true fi exit 0`; } function renderStopTriageScript() { return `#!/usr/bin/env bash # Session stop issue triage and workflow suggestions for Trellis integration # Normalized contract: TRELLIS_ORIGIN, TRELLIS_DESK_ROOT, TRELLIS_HOOK_OUTPUT set -euo pipefail # Import desk root detection + trellis CLI helpers source "$(dirname "$0")/../desk-root.sh" source "$(dirname "$0")/trellis-cli.sh" # Environment variables from contract ORIGIN="\${TRELLIS_ORIGIN:-unknown}" OUTPUT="\${TRELLIS_HOOK_OUTPUT:-stdout}" # Only run if we're in a Trellis workspace if ! command -v trellis >/dev/null 2>&1; then exit 0 fi if [ -f ".trellis/config.json" ] || [ -f "$(trellis_harness_vcs_path)/.trellis/config.json" ]; then ACTIVE_COUNT=$(trellis_harness_active_issue_count) # Check for pending memory suggestions MEMORY_DIR=".trellis/agent-suggestions" SUGGESTION_COUNT=0 if [ -d "$MEMORY_DIR" ]; then SUGGESTION_COUNT=$(find "$MEMORY_DIR" -name "suggestions-*.jsonl" -exec wc -l {} ; 2>/dev/null | awk '{sum+=$1} END {print sum}' || echo "0") fi # Check for uncommitted changes UNCOMMITTED=0 if git rev-parse --git-dir >/dev/null 2>&1; then UNCOMMITTED=$(git status --porcelain 2>/dev/null | wc -l || echo "0") fi # Build recommendations RECOMMENDATIONS=() if [ "$ACTIVE_COUNT" -gt 0 ]; then RECOMMENDATIONS+=("\u{1F3AF} $ACTIVE_COUNT active issues need attention - run 'trellis issue active'") fi if [ "$SUGGESTION_COUNT" -gt 0 ]; then RECOMMENDATIONS+=("\u{1F9E0} $SUGGESTION_COUNT memory suggestions pending - review .trellis/agent-suggestions/") fi if [ "$UNCOMMITTED" -gt 0 ]; then RECOMMENDATIONS+=("\u{1F4DD} $UNCOMMITTED uncommitted changes - consider committing or creating a checkpoint") fi # Format output based on hook type case "$OUTPUT" in "agent-stop") # For Codex/Gemini blocking hooks if [ \${#RECOMMENDATIONS[@]} -gt 0 ]; then MESSAGE=$(printf '%s ' "\${RECOMMENDATIONS[@]}") cat << EOF { "decision": "block", "reason": "Trellis workflow items need attention: $MESSAGE" } EOF else echo "{}" fi ;; "gemini") # For Gemini CLI format if [ \${#RECOMMENDATIONS[@]} -gt 0 ]; then MESSAGE=$(printf '%s\\n' "\${RECOMMENDATIONS[@]}") cat << EOF { "decision": "deny", "reason": "Trellis workflow items need attention: $MESSAGE" } EOF else echo '{"decision": "allow"}' fi ;; *) # Default stdout for Cursor/Devin/Claude if [ \${#RECOMMENDATIONS[@]} -gt 0 ]; then echo "" echo "\u{1F33F} Trellis Session Summary ($ORIGIN):" printf ' %s ' "\${RECOMMENDATIONS[@]}" echo "" echo "Run 'trellis status' for full workspace state" else echo "\u{1F33F} Trellis: All clear! No pending items." fi ;; esac fi exit 0`; } function renderAdapterScript(ide) { switch (ide) { case "cursor": return `#!/usr/bin/env bash # Cursor adapter for Trellis harness \u2014 raw stdin \u2192 shared normalizer set -euo pipefail source "$(dirname "$0")/../desk-root.sh" export TRELLIS_ORIGIN="cursor" export TRELLIS_DESK_ROOT="$PWD" EVENT_TYPE="\${1:-unknown}" HARNESS="$(dirname "$0")/../trellis-harness" log_tool_event() { local data data=$(cat) printf '%s' "$data" | "$HARNESS/post-tool-oplog.sh" printf '%s' "$data" | "$HARNESS/post-tool-memory-capture.sh" } case "$EVENT_TYPE" in "session-start") exec "$HARNESS/pre-prompt-recall.sh" ;; "post-tool"|"post-tool-use"|"after-shell"|"afterShellExecution"|"after-mcp"|"afterMCPExecution"|"post-tool-edit"|"afterFileEdit") log_tool_event ;; "stop") export TRELLIS_HOOK_OUTPUT="stdout" exec "$HARNESS/stop-triage.sh" ;; *) echo "Unknown Cursor event: $EVENT_TYPE" >&2 exit 1 ;; esac`; case "devin": return `#!/usr/bin/env bash # Devin adapter for Trellis harness # Translates Devin events to normalized contract set -euo pipefail # Import desk root detection source "$(dirname "$0")/../desk-root.sh" # Set origin export TRELLIS_ORIGIN="devin" export TRELLIS_DESK_ROOT="$PWD" # Route based on event type EVENT_TYPE="\${1:-unknown}" case "$EVENT_TYPE" in "pre-prompt") # Cascade pre_user_prompt hook exec "$(dirname "$0")/../trellis-harness/pre-prompt-recall.sh" ;; "post-tool") # Cascade post_write_code hook - read stdin for tool data TOOL_DATA=$(cat) # Transform Cascade format to normalized format NORMALIZED_DATA=$(cat << EOF { "tool": "write_file", "action": "create", "file_path": $(echo "$TOOL_DATA" | jq -r '.file_path // ""' 2>/dev/null || echo '""'), "timestamp": "$(date -u +%Y-%m-%dT%H:%M:%S.%3NZ)" } EOF ) # Call op logger echo "$NORMALIZED_DATA" | "$(dirname "$0")/../trellis-harness/post-tool-oplog.sh" # Call memory capture echo "$NORMALIZED_DATA" | "$(dirname "$0")/../trellis-harness/post-tool-memory-capture.sh" ;; "post-response") # Cascade post_cascade_response hook export TRELLIS_HOOK_OUTPUT="stdout" exec "$(dirname "$0")/../trellis-harness/stop-triage.sh" ;; *) echo "Unknown Cascade event: $EVENT_TYPE" >&2 exit 1 ;; esac`; case "claude": return `#!/usr/bin/env bash # Claude Code adapter for Trellis harness # Translates Claude Code events to normalized contract set -euo pipefail # Import desk root detection source "$(dirname "$0")/../desk-root.sh" # Set origin export TRELLIS_ORIGIN="claude" export TRELLIS_DESK_ROOT="$PWD" # Route based on event type EVENT_TYPE="\${1:-unknown}" case "$EVENT_TYPE" in "pre-tool-use") # Claude Code PreToolUse hook exec "$(dirname "$0")/../trellis-harness/pre-prompt-recall.sh" ;; "post-tool-use") data=$(cat) printf '%s' "$data" | "$(dirname "$0")/../trellis-harness/post-tool-oplog.sh" printf '%s' "$data" | "$(dirname "$0")/../trellis-harness/post-tool-memory-capture.sh" ;; "post-tool-batch") jq -c '.[]' 2>/dev/null | while read -r row; do printf '%s\\n' "$row" | "$(dirname "$0")/../trellis-harness/post-tool-oplog.sh" printf '%s\\n' "$row" | "$(dirname "$0")/../trellis-harness/post-tool-memory-capture.sh" done ;; "permission-denied") TS=$(date -u +%Y-%m-%dT%H:%M:%SZ) printf '%s\\n' "{\\"tool_name\\":\\"permission_denied\\",\\"action\\":\\"blocked\\",\\"timestamp\\":\\"$TS\\"}" | "$(dirname "$0")/../trellis-harness/post-tool-oplog.sh" ;; *) echo "Unknown Claude Code event: $EVENT_TYPE" >&2 exit 1 ;; esac`; case "codex": return `#!/usr/bin/env bash # Codex adapter for Trellis harness # Translates Codex events to normalized contract set -euo pipefail # Import desk root detection source "$(dirname "$0")/../desk-root.sh" # Set origin export TRELLIS_ORIGIN="codex" export TRELLIS_DESK_ROOT="$PWD" # Route based on event type EVENT_TYPE="\${1:-unknown}" case "$EVENT_TYPE" in "session-start") # Codex SessionStart hook exec "$(dirname "$0")/../trellis-harness/pre-prompt-recall.sh" ;; "post-tool") # Codex PostToolUse hook - read stdin for tool data TOOL_DATA=$(cat) # Transform Codex format to normalized format NORMALIZED_DATA=$(cat << EOF { "tool": $(echo "$TOOL_DATA" | jq -r '.tool_name // "unknown"' 2>/dev/null || echo '"unknown"'), "action": $(echo "$TOOL_DATA" | jq -r '.action // "unknown"' 2>/dev/null || echo '"unknown"'), "file_path": $(echo "$TOOL_DATA" | jq -r '.file_path // .filePath // ""' 2>/dev/null || echo '""'), "timestamp": "$(date -u +%Y-%m-%dT%H:%M:%S.%3NZ)" } EOF ) # Call op logger echo "$NORMALIZED_DATA" | "$(dirname "$0")/../trellis-harness/post-tool-oplog.sh" # Call memory capture echo "$NORMALIZED_DATA" | "$(dirname "$0")/../trellis-harness/post-tool-memory-capture.sh" ;; "stop") # Codex Stop hook export TRELLIS_HOOK_OUTPUT="agent-stop" exec "$(dirname "$0")/../trellis-harness/stop-triage.sh" ;; *) echo "Unknown Codex event: $EVENT_TYPE" >&2 exit 1 ;; esac`; case "gemini": return `#!/usr/bin/env bash # Gemini CLI adapter for Trellis harness # Translates Gemini events to normalized contract set -euo pipefail # Import desk root detection source "$(dirname "$0")/../desk-root.sh" # Set origin export TRELLIS_ORIGIN="gemini" export TRELLIS_DESK_ROOT="$PWD" # Route based on event type EVENT_TYPE="\${1:-unknown}" case "$EVENT_TYPE" in "session-start"|"before-agent") # Gemini SessionStart or BeforeAgent hook exec "$(dirname "$0")/../trellis-harness/pre-prompt-recall.sh" ;; "post-tool") # Gemini AfterTool hook - read stdin for tool data TOOL_DATA=$(cat) # Transform Gemini format to normalized format NORMALIZED_DATA=$(cat << EOF { "tool": $(echo "$TOOL_DATA" | jq -r '.tool_name // "unknown"' 2>/dev/null || echo '"unknown"'), "action": $(echo "$TOOL_DATA" | jq -r '.action // "unknown"' 2>/dev/null || echo '"unknown"'), "file_path": $(echo "$TOOL_DATA" | jq -r '.file_path // .filePath // ""' 2>/dev/null || echo '""'), "timestamp": "$(date -u +%Y-%m-%dT%H:%M:%S.%3NZ)" } EOF ) # Call op logger echo "$NORMALIZED_DATA" | "$(dirname "$0")/../trellis-harness/post-tool-oplog.sh" # Call memory capture echo "$NORMALIZED_DATA" | "$(dirname "$0")/../trellis-harness/post-tool-memory-capture.sh" ;; "after-agent") # Gemini AfterAgent hook export TRELLIS_HOOK_OUTPUT="gemini" exec "$(dirname "$0")/../trellis-harness/stop-triage.sh" ;; *) echo "Unknown Gemini event: $EVENT_TYPE" >&2 exit 1 ;; esac`; default: return `#!/usr/bin/env bash # Default adapter for Trellis harness echo "Unsupported IDE: ${ide}" >&2 exit 1`; } } function renderCursorHooksConfig() { const hookCmd = (event) => `bash .cursor/hooks/adapters/cursor-adapter.sh ${event}`; return { version: 1, hooks: { sessionStart: [{ command: hookCmd("session-start"), timeout: 15 }], postToolUse: [{ command: hookCmd("post-tool-use"), timeout: 10 }], afterShellExecution: [{ command: hookCmd("after-shell"), timeout: 10 }], afterMCPExecution: [{ command: hookCmd("after-mcp"), timeout: 10 }], afterFileEdit: [{ command: hookCmd("afterFileEdit"), timeout: 10 }], stop: [{ command: hookCmd("stop"), timeout: 10 }] } }; } function renderDevinHooksConfig() { return { hooks: { pre_user_prompt: [ { command: "bash .cursor/hooks/adapters/devin-adapter.sh pre-prompt", show_output: true } ], post_write_code: [ { command: "bash .cursor/hooks/adapters/devin-adapter.sh post-tool", show_output: false } ], post_cascade_response: [ { command: "bash .cursor/hooks/adapters/devin-adapter.sh post-response", show_output: true } ] } }; } function renderClaudeHooksConfig() { return { hooks: { PreToolUse: [ { command: "bash .cursor/hooks/adapters/claude-adapter.sh pre-tool-use", timeout: 15 } ], PostToolUse: [ { command: "bash .cursor/hooks/adapters/claude-adapter.sh post-tool-use", timeout: 10 } ], PostToolBatch: [ { command: "bash .cursor/hooks/adapters/claude-adapter.sh post-tool-batch", timeout: 15 } ], PermissionDenied: [ { command: "bash .cursor/hooks/adapters/claude-adapter.sh permission-denied", timeout: 5 } ] }, permissions: { allow: [ "Bash(trellis --version)", "Bash(trellis issue *)", "Bash(trellis --help)", "Bash(trellis search *)", "Bash(trellis entity *)", "Bash(trellis ontology *)", "Bash(trellis query *)", "Bash(lsof -nP -iTCP -sTCP:LISTEN)", "Bash(awk '{print $9, $1, $2}')", "Bash(curl -s --max-time 1 http://localhost:__TRACKED_VAR__/api/graph/health)", "Bash(curl -s --max-time 1 http://localhost:__TRACKED_VAR__/api/graph/health -H 'Accept: application/json')", 'Bash(grep -ivE "node_modules|dist|.vercel|.git$|.git/|.logs")' ] }, spinnerTipsEnabled: true }; } function renderCodexHooksConfig() { return { hooks: { SessionStart: [ { matcher: "startup|resume|clear", hooks: [ { type: "command", command: "bash .cursor/hooks/adapters/codex-adapter.sh session-start", timeout: 15, statusMessage: "Loading Trellis desk context" } ] } ], PostToolUse: [ { matcher: "apply_patch|Edit|Write", hooks: [ { type: "command", command: "bash .cursor/hooks/adapters/codex-adapter.sh post-tool", timeout: 10, statusMessage: "Recording Trellis operation" } ] } ], Stop: [ { hooks: [ { type: "command", command: "bash .cursor/hooks/adapters/codex-adapter.sh stop", timeout: 10, statusMessage: "Checking Trellis workflow status" } ] } ] } }; } function renderGeminiHooksConfig() { return { hooks: { SessionStart: [ { matcher: "startup|resume|clear", hooks: [ { name: "trellis-desk-context", type: "command", command: "bash .cursor/hooks/adapters/gemini-adapter.sh session-start", timeout: 15e3, description: "Refresh pending Trellis context at session start" }