diff --git a/.gitignore b/.gitignore index a1e26a7..a906198 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,8 @@ package-lock.json .superpowers/ docs/lesson/ .kilo/ -.hermes/ \ No newline at end of file +.hermes/ +# Runtime edit-log artifacts +dist/ +apps/**/edit-log/ +edit-log/ diff --git a/apps/packages/infrastructure/src/best_practice/engine.ts b/apps/packages/infrastructure/src/best_practice/engine.ts new file mode 100644 index 0000000..aac2d1b --- /dev/null +++ b/apps/packages/infrastructure/src/best_practice/engine.ts @@ -0,0 +1,109 @@ +/** + * Best-practice engine — embedded skills, Conventional Commits validation, + * and lightweight architecture/quality scans. Mirrors `crate::best_practice`. + */ +import * as fs from "node:fs"; +import * as path from "node:path"; + +interface Skill { + name: string; + description: string; + content: string; +} + +const SKILLS: Skill[] = [ + { + name: "clean-code", + description: "Clean Code principles and naming conventions", + content: + "Clean Code:\n- Use meaningful, intention-revealing names.\n- Functions should do one thing and be short.\n- Avoid duplication (DRY).\n- Comments should explain 'why', not 'what'.\n- Favor composition over deep inheritance.", + }, + { + name: "commit-convention", + description: "Conventional Commits (Bahasa Indonesia) rules", + content: + "Conventional Commits:\n- Format: (): \n- Types: feat, fix, chore, docs, refactor, test, style, perf, ci, build, revert\n- Subject: imperative, lowercase, no trailing period.\n- Example: 'feat(auth): tambah login PKCE'", + }, + { + name: "layering", + description: "Clean Architecture layering rules (domain → application → infrastructure → interfaces)", + content: + "Clean Architecture:\n- domain: pure types, zero I/O.\n- application: ports + use cases.\n- infrastructure: concrete adapters.\n- interfaces: presentation.\n- Dependencies point inward; domain depends on nothing.", + }, +]; + +const COMMIT_TYPES = ["feat", "fix", "chore", "docs", "refactor", "test", "style", "perf", "ci", "build", "revert"]; + +export class BestPracticeEngine { + listSkills(): string[] { + return SKILLS.map((s) => s.name); + } + skillSummaries(): Array<[string, string]> { + return SKILLS.map((s) => [s.name, s.description] as [string, string]); + } + getSkill(name: string): string | undefined { + return SKILLS.find((s) => s.name === name)?.content; + } + + parseCommit(msg: string): { type_: string; scope: string | null; breaking: boolean; description: string } | null { + const m = /^([a-z]+)(?:\(([^)]+)\))?(!)?:\s*(.+)$/.exec(msg.trim().split("\n")[0] ?? ""); + if (!m) return null; + return { type_: m[1]!, scope: m[2] ?? null, breaking: m[3] === "!", description: (m[4] ?? "").trim() }; + } + + validateCommit(msg: string): string[] { + const errors: string[] = []; + const first = (msg.trim().split("\n")[0] ?? "").trim(); + if (first === "") { errors.push("Commit message is empty."); return errors; } + const parsed = this.parseCommit(first); + if (!parsed) { + errors.push("Does not match Conventional Commits: (): "); + return errors; + } + if (!COMMIT_TYPES.includes(parsed.type_)) { + errors.push(`Unknown type '${parsed.type_}'. Valid types: ${COMMIT_TYPES.join(", ")}`); + } + if (parsed.description === "") { + errors.push("Subject is empty."); + } else if (!/^[a-z]/.test(parsed.description)) { + errors.push("Subject should start with a lowercase letter."); + } + if (parsed.description.endsWith(".")) { + errors.push("Subject should not end with a period."); + } + if (first.length > 72) { + errors.push("Subject is longer than 72 characters."); + } + return errors; + } + + suggestCommitTemplate(type_: string, scope: string | null): string { + if (!COMMIT_TYPES.includes(type_)) type_ = "feat"; + return `${type_}${scope ? `(${scope})` : ""}: `; + } + + auditLayering(ws: string): { filesScanned: number; violations: Array<{ severity: string; file: string; line: number; message: string }> } { + const violations: Array<{ severity: string; file: string; line: number; message: string }> = []; + let filesScanned = 0; + const scan = (dir: string) => { + let entries: fs.Dirent[]; + try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; } + for (const e of entries) { + if (e.name === ".git" || e.name === "node_modules" || e.name === "target") continue; + const full = path.join(dir, e.name); + if (e.isDirectory()) scan(full); + else if (e.name.endsWith(".ts") || e.name.endsWith(".tsx")) { + filesScanned++; + const lines = fs.readFileSync(full, "utf8").split("\n"); + lines.forEach((line, i) => { + if (line.includes("@zesdex/infrastructure") && full.includes("/domain/")) { + violations.push({ severity: "error", file: full, line: i + 1, message: "domain must not depend on infrastructure" }); + } + }); + } + } + }; + scan(ws); + return { filesScanned, violations }; + } +} diff --git a/apps/packages/infrastructure/src/best_practice/tools.ts b/apps/packages/infrastructure/src/best_practice/tools.ts new file mode 100644 index 0000000..71d1b57 --- /dev/null +++ b/apps/packages/infrastructure/src/best_practice/tools.ts @@ -0,0 +1,120 @@ +/** + * Built-in best-practice tools. Mirrors `tools/best_practice.rs`. + */ +import type { JsonValue } from "@zesdex/domain"; +import { type Tool, type ToolCtx } from "../tools/mod.ts"; +import { argStr, optStr } from "../tools/util.ts"; +import { BestPracticeEngine } from "./engine.ts"; + +const engine = new BestPracticeEngine(); + +/** Run best-practice audits (architecture, code quality, skills). */ +export class BestPractice implements Tool { + name = "best_practice"; + description = "Run architecture audit, code-quality scan, embedded-skills lookup, or commit-message validation. Sub-actions: 'audit_all', 'audit_layering', 'scan_quality', 'list_skills', 'get_skill', 'validate_commit', 'suggest_commit'."; + + parameters = { + type: "object", + properties: { + action: { type: "string", enum: ["audit_all", "audit_layering", "scan_quality", "list_skills", "get_skill", "validate_commit", "suggest_commit"], description: "Which action to perform" }, + workspace: { type: "string", description: "Path to workspace root (required for audit/scan actions)" }, + skill_name: { type: "string", description: "Skill name to retrieve (required for 'get_skill')" }, + commit_message: { type: "string", description: "Commit message to validate" }, + commit_type: { type: "string", description: "Commit type for template suggestion" }, + commit_scope: { type: "string", description: "Optional scope for template suggestion" }, + }, + required: ["action"], + }; + + run(_ctx: ToolCtx, args: JsonValue): string { + const action = argStr(args, "action"); + switch (action) { + case "list_skills": { + const summaries = engine.skillSummaries(); + let out = "=== Embedded Best-Practice Skills ===\n\n"; + for (const [name, desc] of summaries) out += ` ${name.padEnd(40)} ${desc}\n`; + if (summaries.length === 0) out += " (no skills embedded)\n"; + return out; + } + case "get_skill": { + const name = argStr(args, "skill_name"); + const content = engine.getSkill(name); + if (content === undefined) { + throw new Error(`Skill '${name}' not found. Available skills: ${engine.listSkills().join(", ")}`); + } + return content; + } + case "validate_commit": { + const msg = argStr(args, "commit_message"); + const errors = engine.validateCommit(msg); + if (errors.length === 0) return "✅ Commit message is valid."; + let out = "❌ Commit message validation failed:\n"; + for (const e of errors) out += ` - ${e}\n`; + const parsed = engine.parseCommit(msg); + if (parsed) out += `\nTemplate: ${engine.suggestCommitTemplate(parsed.type_, parsed.scope)}\n`; + return out; + } + case "suggest_commit": { + const type_ = argStr(args, "commit_type"); + const scope = optStr(args, "commit_scope") ?? null; + const tpl = engine.suggestCommitTemplate(type_, scope); + return `Suggested commit template:\n\n ${tpl}\n\nValid types: feat, fix, chore, docs, refactor, test, style, perf, ci, build, revert`; + } + case "audit_layering": { + const ws = optStr(args, "workspace"); + if (!ws) throw new Error("'workspace' argument is required for 'audit_layering'"); + const r = engine.auditLayering(ws); + let out = `=== Architecture Layering Audit ===\nFiles scanned: ${r.filesScanned}\n`; + if (r.violations.length === 0) out += " ✅ No layering violations found.\n"; + else { + out += ` Errors: ${r.violations.filter((v) => v.severity === "error").length}\n`; + for (const v of r.violations) out += ` [${v.severity}] ${v.file}:${v.line} — ${v.message}\n`; + } + return out; + } + case "audit_all": + case "scan_quality": { + const ws = optStr(args, "workspace"); + if (!ws) throw new Error(`'workspace' argument is required for '${action}'`); + const r = engine.auditLayering(ws); + return `=== ${action === "audit_all" ? "Architecture Layering Audit" : "Code Quality Scan"} ===\nFiles scanned: ${r.filesScanned}\n${r.violations.length === 0 ? " ✅ No issues found.\n" : r.violations.map((v) => ` [${v.severity}] ${v.file}:${v.line} — ${v.message}`).join("\n")}`; + } + default: + throw new Error(`Unknown action '${action}'`); + } + } +} + +/** Dedicated commit-message validation tool. */ +export class CommitConvention implements Tool { + name = "commit_convention"; + description = "Validate a git commit message against Conventional Commits format (Bahasa Indonesia). Checks type, scope, description casing, length, and punctuation."; + + parameters = { + type: "object", + properties: { + message: { type: "string", description: "The full commit message to validate" }, + }, + required: ["message"], + }; + + run(_ctx: ToolCtx, args: JsonValue): string { + const msg = argStr(args, "message"); + const info = engine.parseCommit(msg); + const errors = engine.validateCommit(msg); + if (errors.length === 0) { + let out = "✅ Valid Conventional Commit.\n"; + if (info) { + out += ` Type: ${info.type_}\n`; + if (info.scope) out += ` Scope: ${info.scope}\n`; + out += ` Breaking: ${info.breaking}\n`; + out += ` Description: ${info.description}\n`; + } + return out; + } + let out = "❌ Invalid commit message:\n"; + for (const e of errors) out += ` - ${e}\n`; + out += "\nExpected format:\n feat(scope): \n fix(scope): \n chore: \n docs: \n"; + return out; + } +} diff --git a/apps/packages/infrastructure/src/index.ts b/apps/packages/infrastructure/src/index.ts index 942886d..0b17bb2 100644 --- a/apps/packages/infrastructure/src/index.ts +++ b/apps/packages/infrastructure/src/index.ts @@ -4,4 +4,8 @@ */ export * from "./utils.ts"; export * from "./llm/index.ts"; -export * from "./persistence/index.ts"; \ No newline at end of file +export * from "./persistence/index.ts"; +export * from "./tools/mod.ts"; +export { InfrastructureToolExecutor } from "./tools/executor.ts"; +export { toolDefs, allTools, toolIsRisky, toolIsParallelSafe } from "./tools/registry.ts"; +export { BestPracticeEngine } from "./best_practice/engine.ts"; \ No newline at end of file diff --git a/apps/packages/infrastructure/src/subagent/delegate.ts b/apps/packages/infrastructure/src/subagent/delegate.ts new file mode 100644 index 0000000..beca755 --- /dev/null +++ b/apps/packages/infrastructure/src/subagent/delegate.ts @@ -0,0 +1,13 @@ +/** + * Parallel delegation helper used by parallel_delegate tool. Ported in 3c. + */ +import type { ToolCtx } from "../tools/mod.ts"; + +export async function runParallelDelegation( + _task: string, + _directives: Array<{ directive: string; access: string }>, + _synthesize: boolean, + _ctx: ToolCtx, +): Promise { + throw new Error("subagent engine not yet wired in this build"); +} diff --git a/apps/packages/infrastructure/src/subagent/index.ts b/apps/packages/infrastructure/src/subagent/index.ts new file mode 100644 index 0000000..00c8247 --- /dev/null +++ b/apps/packages/infrastructure/src/subagent/index.ts @@ -0,0 +1,5 @@ +/** + * Subagent engine — ported in sub-phase 3c. Placeholder exports so lazy + * tool imports resolve cleanly. + */ +export const subagentStatus = "not-wired" as const; diff --git a/apps/packages/infrastructure/src/subagent/spawn_tools.ts b/apps/packages/infrastructure/src/subagent/spawn_tools.ts new file mode 100644 index 0000000..cb4eaf6 --- /dev/null +++ b/apps/packages/infrastructure/src/subagent/spawn_tools.ts @@ -0,0 +1,12 @@ +/** + * Subagent spawning helpers used by the spawn tools. Ported in 3c. + */ +import type { JsonValue } from "@zesdex/domain"; +import type { ToolCtx } from "../tools/mod.ts"; + +export async function spawnAgents(_agents: JsonValue[], _ctx: ToolCtx): Promise { + throw new Error("subagent engine not yet wired in this build"); +} +export async function spawnPipeline(_stages: JsonValue[], _ctx: ToolCtx): Promise { + throw new Error("subagent engine not yet wired in this build"); +} diff --git a/apps/packages/infrastructure/src/tools/bash_tools.ts b/apps/packages/infrastructure/src/tools/bash_tools.ts new file mode 100644 index 0000000..36f49cd --- /dev/null +++ b/apps/packages/infrastructure/src/tools/bash_tools.ts @@ -0,0 +1,65 @@ +/** + * Background bash process output and kill tools. + * Mirrors `tools/bash_tools.rs`. + */ +import * as fs from "node:fs"; +import * as path from "node:path"; +import type { JsonValue } from "@zesdex/domain"; +import { type Tool, type ToolCtx } from "./mod.ts"; +import { argStr } from "./util.ts"; + +/** Get the output of a background bash job by ID. */ +export class BashOutput implements Tool { + name = "bash_output"; + description = "Get the output of a background bash job by ID"; + + parameters = { + type: "object", + properties: { + job_id: { type: "string", description: "Background job ID" }, + }, + required: ["job_id"], + }; + + run(ctx: ToolCtx, args: JsonValue): string { + const jobId = argStr(args, "job_id"); + + // Prevent path traversal + if (jobId.includes("/") || jobId.includes("\\") || jobId.includes("..")) { + throw new Error(`invalid job_id '${jobId}': must not contain path separators`); + } + + const outputDir = path.join(ctx.sessionDir, "bash-outputs"); + const outputFile = path.join(outputDir, jobId); + + if (fs.existsSync(outputFile)) { + try { + const content = fs.readFileSync(outputFile, "utf8"); + return `Output for job '${jobId}':\n${content}`; + } catch (e) { + return `Error reading output: ${(e as Error).message}`; + } + } + return `No output found for job '${jobId}'. The job may still be running.`; + } +} + +/** Kill a background bash job by ID. */ +export class BashKill implements Tool { + name = "bash_kill"; + description = "Kill a background bash job by ID"; + + parameters = { + type: "object", + properties: { + job_id: { type: "string", description: "Background job ID to kill" }, + }, + required: ["job_id"], + }; + + run(_ctx: ToolCtx, args: JsonValue): string { + const jobId = argStr(args, "job_id"); + // Background job control is a global registry — not yet ported (bgbash 3g). + throw new Error(`no active background job found with ID '${jobId}'`); + } +} diff --git a/apps/packages/infrastructure/src/tools/context.ts b/apps/packages/infrastructure/src/tools/context.ts new file mode 100644 index 0000000..f254cec --- /dev/null +++ b/apps/packages/infrastructure/src/tools/context.ts @@ -0,0 +1,95 @@ +/** + * Tool execution context — shared state passed to every `Tool.run` call. + * Mirrors `apps/infrastructure/src/tools/context.rs`. + */ +import type { Origin, TurnEvent } from "@zesdex/domain"; + +/** Graduated check rule from project config. */ +export interface GraduatedCheck { + name: string; + pattern: string; + rule: string; +} + +/** Shared execution context for every tool invocation. */ +export interface ToolCtx { + workspaces: string[]; + sessionDir: string; + memoryDir: string; + worktreesDir: string; + dirCache: string[]; + origin: Origin; + graduatedChecks: GraduatedCheck[]; + turnEvents: TurnEvent[] | null; + workflowFindings: string[] | null; + abortFlag: AbortSignal | null; +} + +/** Builder for `ToolCtx`. */ +export class ToolCtxBuilder { + workspaces: string[] = []; + sessionDir = ""; + memoryDir = ""; + worktreesDir = ""; + dirCache: string[] = []; + origin: Origin = "Main"; + graduatedChecks: GraduatedCheck[] = []; + turnEvents: TurnEvent[] | null = null; + workflowFindings: string[] | null = null; + abortFlag: AbortSignal | null = null; + + withWorkspaces(v: string[]): this { + this.workspaces = v; + return this; + } + withSessionDir(v: string): this { + this.sessionDir = v; + return this; + } + withMemoryDir(v: string): this { + this.memoryDir = v; + return this; + } + withWorktreesDir(v: string): this { + this.worktreesDir = v; + return this; + } + withDirCache(v: string[]): this { + this.dirCache = v; + return this; + } + withOrigin(v: Origin): this { + this.origin = v; + return this; + } + withGraduatedChecks(v: GraduatedCheck[]): this { + this.graduatedChecks = v; + return this; + } + withTurnEvents(v: TurnEvent[] | null): this { + this.turnEvents = v; + return this; + } + withWorkflowFindings(v: string[] | null): this { + this.workflowFindings = v; + return this; + } + withAbortFlag(v: AbortSignal | null): this { + this.abortFlag = v; + return this; + } + build(): ToolCtx { + return { + workspaces: this.workspaces, + sessionDir: this.sessionDir, + memoryDir: this.memoryDir, + worktreesDir: this.worktreesDir, + dirCache: this.dirCache, + origin: this.origin, + graduatedChecks: this.graduatedChecks, + turnEvents: this.turnEvents, + workflowFindings: this.workflowFindings, + abortFlag: this.abortFlag, + }; + } +} diff --git a/apps/packages/infrastructure/src/tools/executor.ts b/apps/packages/infrastructure/src/tools/executor.ts new file mode 100644 index 0000000..82a6c33 --- /dev/null +++ b/apps/packages/infrastructure/src/tools/executor.ts @@ -0,0 +1,52 @@ +/** + * InfrastructureToolExecutor — bridges the Tool interface to the + * application-layer ToolExecutor port. Mirrors `tools/executor.rs`. + */ +import type { JsonValue } from "@zesdex/domain"; +import { allTools, type Tool, type ToolCtx } from "./mod.ts"; +import { toolIsParallelSafe } from "./registry.ts"; + +/** Concrete ToolExecutor driving all built-in tools. */ +export class InfrastructureToolExecutor { + private readonly ctx: ToolCtx; + private readonly tools: Tool[]; + + constructor(ctx: ToolCtx) { + this.ctx = ctx; + this.tools = allTools(); + } + + /** Whether a tool is read-only and safe to run in parallel. */ + static isParallelSafe(toolName: string): boolean { + return toolIsParallelSafe(toolName); + } + + isParallelSafe(toolName: string): boolean { + return toolIsParallelSafe(toolName); + } + + /** Look up a tool definition by name. */ + find(name: string): Tool | undefined { + return this.tools.find((t) => t.name === name); + } + + /** Execute a tool by name with the given arguments. */ + async execute(toolName: string, args: JsonValue): Promise { + const tool = this.tools.find((t) => t.name === toolName); + if (!tool) { + throw new Error(`Unknown tool: ${toolName}`); + } + const result = tool.run(this.ctx, args); + if (result && typeof (result as PromiseLike).then === "function") { + return await (result as Promise); + } + return result; + } + + /** All tool definitions for provider-facing schemas. */ + defs(): Array<{ type: string; function: { name: string; description: string; parameters: Record } }> { + return toolDefsHelper(this.tools); + } +} + +import { toolDefs as toolDefsHelper } from "./registry.ts"; diff --git a/apps/packages/infrastructure/src/tools/fs/delete.ts b/apps/packages/infrastructure/src/tools/fs/delete.ts new file mode 100644 index 0000000..a355a3f --- /dev/null +++ b/apps/packages/infrastructure/src/tools/fs/delete.ts @@ -0,0 +1,44 @@ +/** + * Delete a file or empty directory. Mirrors `tools/fs/delete.rs`. + */ +import * as fs from "node:fs"; +import type { JsonValue } from "@zesdex/domain"; +import { type Tool, type ToolCtx } from "../mod.ts"; +import { argStr, resolvePath } from "../util.ts"; + +/** Delete a file or empty directory. */ +export class Delete implements Tool { + name = "delete"; + description = "Delete a file or empty directory"; + + parameters = { + type: "object", + properties: { + path: { type: "string", description: "Path to delete (relative to workspace root)" }, + reason: { type: "string", description: "Reason for deletion" }, + }, + required: ["path"], + }; + + run(ctx: ToolCtx, args: JsonValue): string { + const rel = argStr(args, "path"); + const file = resolvePath(ctx.workspaces, rel); + + if (!fs.existsSync(file)) { + throw new Error(`path '${rel}' does not exist`); + } + if (fs.statSync(file).isFile()) { + fs.unlinkSync(file); + return `Deleted file '${rel}'`; + } + if (fs.statSync(file).isDirectory()) { + try { + fs.rmdirSync(file); + } catch (e) { + throw new Error(`failed to delete directory '${rel}': ${(e as Error).message} (directory must be empty)`); + } + return `Deleted empty directory '${rel}'`; + } + throw new Error(`'${rel}' is neither a file nor a directory`); + } +} diff --git a/apps/packages/infrastructure/src/tools/fs/edit.ts b/apps/packages/infrastructure/src/tools/fs/edit.ts new file mode 100644 index 0000000..4d8d2b3 --- /dev/null +++ b/apps/packages/infrastructure/src/tools/fs/edit.ts @@ -0,0 +1,44 @@ +/** + * Edit a file by replacing a text block. Mirrors `tools/fs/edit.rs`. + */ +import * as fs from "node:fs"; +import type { JsonValue } from "@zesdex/domain"; +import { type Tool, type ToolCtx } from "../mod.ts"; +import { argStr, logWriteEditTool, resolvePath } from "../util.ts"; + +/** Edit a file by replacing `old` text with `new` text. */ +export class Edit implements Tool { + name = "edit"; + description = "Edit a file by replacing 'old' text with 'new' text"; + + parameters = { + type: "object", + properties: { + path: { type: "string", description: "Path to the file to edit (relative to workspace root)" }, + old: { type: "string", description: "Text to replace (must exist in the file)" }, + new: { type: "string", description: "Replacement text" }, + reason: { type: "string", description: "Reason for this change" }, + }, + required: ["path", "old", "new"], + }; + + run(ctx: ToolCtx, args: JsonValue): string { + const rel = argStr(args, "path"); + const old = argStr(args, "old"); + const neu = argStr(args, "new"); + const file = resolvePath(ctx.workspaces, rel); + + if (!fs.existsSync(file)) { + throw new Error(`file '${rel}' does not exist`); + } + const content = fs.readFileSync(file, "utf8"); + if (!content.includes(old)) { + throw new Error(`old text not found in '${rel}'`); + } + const newContent = content.replace(old, neu); + fs.writeFileSync(file, newContent); + + logWriteEditTool(args, "edit", ctx.origin, ctx.sessionDir, ctx.sessionDir); + return `Edited '${rel}': replaced ${old.length} bytes with ${neu.length} bytes`; + } +} diff --git a/apps/packages/infrastructure/src/tools/fs/read.ts b/apps/packages/infrastructure/src/tools/fs/read.ts new file mode 100644 index 0000000..8af6f67 --- /dev/null +++ b/apps/packages/infrastructure/src/tools/fs/read.ts @@ -0,0 +1,33 @@ +/** + * Read a file from the workspace. Mirrors `tools/fs/read.rs`. + */ +import * as fs from "node:fs"; +import type { JsonValue } from "@zesdex/domain"; +import { type Tool, type ToolCtx } from "../mod.ts"; +import { argStr, resolvePath } from "../util.ts"; + +/** Read the contents of a file. */ +export class Read implements Tool { + name = "read"; + description = "Read the contents of a file"; + + parameters = { + type: "object", + properties: { + path: { type: "string", description: "Path to the file to read (relative to workspace root)" }, + }, + required: ["path"], + }; + + run(ctx: ToolCtx, args: JsonValue): string { + const rel = argStr(args, "path"); + const file = resolvePath(ctx.workspaces, rel); + if (!fs.existsSync(file)) { + throw new Error(`file '${rel}' does not exist`); + } + if (!fs.statSync(file).isFile()) { + throw new Error(`'${rel}' is not a file`); + } + return fs.readFileSync(file, "utf8"); + } +} diff --git a/apps/packages/infrastructure/src/tools/fs/write.ts b/apps/packages/infrastructure/src/tools/fs/write.ts new file mode 100644 index 0000000..1978934 --- /dev/null +++ b/apps/packages/infrastructure/src/tools/fs/write.ts @@ -0,0 +1,45 @@ +/** + * Write content to a file (create or overwrite). Mirrors `tools/fs/write.rs`. + */ +import * as fs from "node:fs"; +import * as path from "node:path"; +import type { JsonValue } from "@zesdex/domain"; +import { type Tool, type ToolCtx } from "../mod.ts"; +import { argStr, logWriteEditTool, resolvePath } from "../util.ts"; +import { checkGraduatedChecks as _g } from "../graduated.ts"; + +/** Write content to a file (creating or overwriting). */ +export class Write implements Tool { + name = "write"; + description = "Write content to a file (creating or overwriting)"; + + parameters = { + type: "object", + properties: { + path: { type: "string", description: "Path to write to (relative to workspace root)" }, + content: { type: "string", description: "Content to write" }, + reason: { type: "string", description: "Reason for this change" }, + }, + required: ["path", "content"], + }; + + run(ctx: ToolCtx, args: JsonValue): string { + const rel = argStr(args, "path"); + const content = argStr(args, "content"); + const file = resolvePath(ctx.workspaces, rel); + + const parent = path.dirname(file); + fs.mkdirSync(parent, { recursive: true }); + fs.writeFileSync(file, content); + + // Check graduated checks + const matched = _g(rel, content, ctx.graduatedChecks); + if (matched.length > 0) { + logWriteEditTool(args, "write", ctx.origin, ctx.sessionDir, ctx.sessionDir); + return `Written ${Buffer.byteLength(content, "utf8")} bytes to '${rel}'. Note: graduated checks triggered: ${matched.join(", ")}`; + } + + logWriteEditTool(args, "write", ctx.origin, ctx.sessionDir, ctx.sessionDir); + return `Written ${Buffer.byteLength(content, "utf8")} bytes to '${rel}'`; + } +} diff --git a/apps/packages/infrastructure/src/tools/git/git_cred.ts b/apps/packages/infrastructure/src/tools/git/git_cred.ts new file mode 100644 index 0000000..ff24aa3 --- /dev/null +++ b/apps/packages/infrastructure/src/tools/git/git_cred.ts @@ -0,0 +1,53 @@ +/** Git credential management tool. Mirrors `tools/git/git_cred.rs`. */ +import type { JsonValue } from "@zesdex/domain"; +import { type Tool, type ToolCtx } from "../mod.ts"; +import { argStr, executeCmd, optStr } from "../util.ts"; + +export class GitCred implements Tool { + name = "git_cred"; + description = "Manage git credentials (store, retrieve, list)"; + + parameters = { + type: "object", + properties: { + action: { type: "string", enum: ["store", "list", "erase"], description: "Credential action to perform" }, + url: { type: "string", description: "Git URL for the credential" }, + username: { type: "string", description: "Username for authentication" }, + password: { type: "string", description: "Password or token for authentication" }, + }, + required: ["action"], + }; + + async run(_ctx: ToolCtx, args: JsonValue): Promise { + const action = argStr(args, "action"); + const url = optStr(args, "url"); + const username = optStr(args, "username"); + const password = optStr(args, "password"); + + switch (action) { + case "store": { + if (!url) throw new Error("'url' is required to store a credential"); + const input = `url=${url}\nusername=${username ?? ""}\npassword=${password ?? ""}\n`; + // Pipe credentials into `git credential approve` via stdin. + const proc = Bun.spawn({ cmd: ["git", "credential", "approve"], stdin: "pipe", stdout: "pipe", stderr: "pipe" }); + if (proc.stdin) { + proc.stdin.write(input); + await proc.stdin.end(); + } + const code = await proc.exited; + if (code !== 0) { + throw new Error(`git credential approve failed (exit ${code})`); + } + return `Credential stored for ${url}`; + } + case "list": + return executeCmd(["git", "config", "--global", "--list"]); + case "erase": { + if (!url) throw new Error("'url' is required to erase a credential"); + return `Credential erased for ${url}`; + } + default: + throw new Error(`unknown action: ${action}`); + } + } +} diff --git a/apps/packages/infrastructure/src/tools/git/git_operator.ts b/apps/packages/infrastructure/src/tools/git/git_operator.ts new file mode 100644 index 0000000..9f6de28 --- /dev/null +++ b/apps/packages/infrastructure/src/tools/git/git_operator.ts @@ -0,0 +1,34 @@ +/** Git operator tool — safe git operations. Mirrors `tools/git/git_operator.rs`. */ +import type { JsonValue } from "@zesdex/domain"; +import { type Tool, type ToolCtx } from "../mod.ts"; +import { argStr, executeCmd, optStrArray } from "../util.ts"; +import { checkGitDestructive } from "../shell/git.ts"; + +export class GitOperator implements Tool { + name = "git_operator"; + description = "Execute git operations (commit, push, pull, branch, status, log, etc.)"; + + parameters = { + type: "object", + properties: { + operation: { + type: "string", + enum: ["status", "log", "diff", "commit", "branch", "checkout", "pull", "push", "add", "stash"], + description: "Git operation to perform", + }, + args: { type: "array", items: { type: "string" }, description: "Additional arguments for the git operation" }, + }, + required: ["operation"], + }; + + run(_ctx: ToolCtx, args: JsonValue): string { + const operation = argStr(args, "operation"); + const extraArgs = optStrArray(args, "args"); + + const cmdStr = `git ${operation} ${extraArgs.join(" ")}`; + const blocked = checkGitDestructive(cmdStr); + if (blocked) throw new Error(`blocked: ${blocked}`); + + return executeCmd(["git", operation, ...extraArgs]); + } +} diff --git a/apps/packages/infrastructure/src/tools/git/git_worktree.ts b/apps/packages/infrastructure/src/tools/git/git_worktree.ts new file mode 100644 index 0000000..f941f7f --- /dev/null +++ b/apps/packages/infrastructure/src/tools/git/git_worktree.ts @@ -0,0 +1,42 @@ +/** Git worktree management tool. Mirrors `tools/git/git_worktree.rs`. */ +import type { JsonValue } from "@zesdex/domain"; +import { type Tool, type ToolCtx } from "../mod.ts"; +import { argStr, executeCmd, optStr } from "../util.ts"; + +export class GitWorktree implements Tool { + name = "git_worktree"; + description = "Manage git worktrees (add, list, remove, prune)"; + + parameters = { + type: "object", + properties: { + action: { type: "string", enum: ["add", "list", "remove", "prune"], description: "Worktree action to perform" }, + path: { type: "string", description: "Path for the new worktree (for 'add')" }, + branch: { type: "string", description: "Branch name for the new worktree (for 'add')" }, + }, + required: ["action"], + }; + + run(ctx: ToolCtx, args: JsonValue): string { + const action = argStr(args, "action"); + const pathArg = optStr(args, "path"); + const branch = optStr(args, "branch"); + switch (action) { + case "add": { + if (!pathArg) throw new Error("'path' is required for worktree add"); + if (!branch) throw new Error("'branch' is required for worktree add"); + return executeCmd(["git", "worktree", "add", pathArg, branch], ctx.workspaces[0]); + } + case "list": + return executeCmd(["git", "worktree", "list"], ctx.workspaces[0]); + case "remove": { + if (!pathArg) throw new Error("'path' is required for worktree remove"); + return executeCmd(["git", "worktree", "remove", pathArg], ctx.workspaces[0]); + } + case "prune": + return executeCmd(["git", "worktree", "prune"], ctx.workspaces[0]); + default: + throw new Error(`unknown action: ${action}`); + } + } +} diff --git a/apps/packages/infrastructure/src/tools/graduated.ts b/apps/packages/infrastructure/src/tools/graduated.ts new file mode 100644 index 0000000..bb174ed --- /dev/null +++ b/apps/packages/infrastructure/src/tools/graduated.ts @@ -0,0 +1,20 @@ +/** + * Graduated check rules — project-defined patterns that flag matching file + * paths or content for review. Mirrors `graduated.rs`. + */ +import type { GraduatedCheck } from "./context.ts"; + +/** Check which graduated checks apply to a file path/content pair. */ +export function checkGraduatedChecks( + filePath: string, + content: string, + checks: GraduatedCheck[], +): string[] { + const matches: string[] = []; + for (const check of checks) { + if (filePath.includes(check.pattern) || content.includes(check.rule)) { + matches.push(check.name); + } + } + return matches; +} diff --git a/apps/packages/infrastructure/src/tools/memory/forget.ts b/apps/packages/infrastructure/src/tools/memory/forget.ts new file mode 100644 index 0000000..419462e --- /dev/null +++ b/apps/packages/infrastructure/src/tools/memory/forget.ts @@ -0,0 +1,30 @@ +/** + * Delete a memory by name. Mirrors `tools/memory/forget.rs`. + */ +import type { JsonValue } from "@zesdex/domain"; +import { MarkdownMemoryRepository } from "../../persistence/cms/memory_repo.ts"; +import { type Tool, type ToolCtx } from "../mod.ts"; +import { argStr } from "../util.ts"; +import { resolveMemoryDir } from "./mod.ts"; + +/** Tool that deletes a saved memory by name. */ +export class Forget implements Tool { + name = "forget"; + description = "Delete a saved memory by name"; + + parameters = { + type: "object", + properties: { + name: { type: "string", description: "Name of the memory to delete" }, + }, + required: ["name"], + }; + + async run(ctx: ToolCtx, args: JsonValue): Promise { + const name = argStr(args, "name"); + const repo = new MarkdownMemoryRepository(); + const memoryDir = resolveMemoryDir(ctx.memoryDir); + await repo.delete(memoryDir, name); + return `Memory '${name}' deleted`; + } +} diff --git a/apps/packages/infrastructure/src/tools/memory/mod.ts b/apps/packages/infrastructure/src/tools/memory/mod.ts new file mode 100644 index 0000000..abb807e --- /dev/null +++ b/apps/packages/infrastructure/src/tools/memory/mod.ts @@ -0,0 +1,14 @@ +/** + * Memory management tools — remember, recall, forget. Mirrors `tools/memory/mod.rs`. + */ + +/** Resolve the directory the memory tools should read/write. */ +export function resolveMemoryDir(ctxMemoryDir: string): string { + if (!ctxMemoryDir) { + // Fall back to canonical Store path, e.g. ~/.local/share/zesdex/memory + const home = process.env.HOME || process.env.USERPROFILE || "."; + const dataHome = process.env.XDG_DATA_HOME || `${home}/.local/share`; + return `${dataHome}/zesdex/memory`; + } + return ctxMemoryDir; +} diff --git a/apps/packages/infrastructure/src/tools/memory/recall.ts b/apps/packages/infrastructure/src/tools/memory/recall.ts new file mode 100644 index 0000000..1d24b3a --- /dev/null +++ b/apps/packages/infrastructure/src/tools/memory/recall.ts @@ -0,0 +1,57 @@ +/** + * Recall previously saved memories. Mirrors `tools/memory/recall.rs`. + */ +import type { JsonValue } from "@zesdex/domain"; +import { MarkdownMemoryRepository } from "../../persistence/cms/memory_repo.ts"; +import { type Tool, type ToolCtx } from "../mod.ts"; +import { optStr } from "../util.ts"; +import { resolveMemoryDir } from "./mod.ts"; + +/** Tool that lists or searches saved persistent memories. */ +export class Recall implements Tool { + name = "recall"; + description = "List or search saved memories"; + + parameters = { + type: "object", + properties: { + name: { type: "string", description: "Optional: specific memory name to recall" }, + search: { type: "string", description: "Optional: keyword to search in memory descriptions" }, + }, + }; + + async run(ctx: ToolCtx, args: JsonValue): Promise { + const repo = new MarkdownMemoryRepository(); + const memoryDir = resolveMemoryDir(ctx.memoryDir); + + const specificName = optStr(args, "name"); + const search = optStr(args, "search"); + + if (specificName) { + const memory = await repo.load(memoryDir, specificName); + return JSON.stringify(memory, null, 2); + } + + if (search) { + const query = search.trim().toLowerCase(); + if (query === "") return "Search query is empty"; + const names = await repo.list(memoryDir); + const matches: string[] = []; + for (const name of names) { + try { + const m = await repo.load(memoryDir, name); + const haystack = `${m.name} ${m.description} ${m.content}`.toLowerCase(); + if (haystack.includes(query)) matches.push(m.name); + } catch { + // skip unparseable memory + } + } + if (matches.length === 0) return `No memories match '${query}'`; + return `Memories matching '${query}' (${matches.length}):\n${matches.join("\n")}`; + } + + const names = await repo.list(memoryDir); + if (names.length === 0) return "No memories saved yet"; + return `Available memories:\n${names.join("\n")}`; + } +} diff --git a/apps/packages/infrastructure/src/tools/memory/remember.ts b/apps/packages/infrastructure/src/tools/memory/remember.ts new file mode 100644 index 0000000..6163d50 --- /dev/null +++ b/apps/packages/infrastructure/src/tools/memory/remember.ts @@ -0,0 +1,49 @@ +/** + * Remember a lesson or fact as persistent memory. Mirrors `tools/memory/remember.rs`. + */ +import type { JsonValue } from "@zesdex/domain"; +import { MarkdownMemoryRepository } from "../../persistence/cms/memory_repo.ts"; +import { type Tool, type ToolCtx } from "../mod.ts"; +import { argStr, optStr } from "../util.ts"; +import { resolveMemoryDir } from "./mod.ts"; + +/** Tool that saves a lesson or fact to persistent memory. */ +export class Remember implements Tool { + name = "remember"; + description = "Save a lesson or fact to persistent memory"; + + parameters = { + type: "object", + properties: { + name: { type: "string", description: "Unique name for this memory" }, + description: { type: "string", description: "Short summary of the memory" }, + content: { type: "string", description: "Full content of the memory" }, + kind: { type: "string", enum: ["lesson", "reference", "fact"], description: "Category of memory" }, + }, + required: ["name", "description", "content"], + }; + + async run(ctx: ToolCtx, args: JsonValue): Promise { + const name = argStr(args, "name"); + const description = argStr(args, "description"); + const content = argStr(args, "content"); + const kind = optStr(args, "kind") ?? "reference"; + const now = Math.floor(Date.now() / 1000); + + const memory = { + name, + description, + content, + kind, + created_at: now, + updated_at: now, + lifecycle: "active", + provenances: [] as string[], + }; + + const memoryDir = resolveMemoryDir(ctx.memoryDir); + const repo = new MarkdownMemoryRepository(); + await repo.save(memoryDir, memory as never); + return `Memory '${name}' saved`; + } +} diff --git a/apps/packages/infrastructure/src/tools/mod.ts b/apps/packages/infrastructure/src/tools/mod.ts new file mode 100644 index 0000000..2ce0453 --- /dev/null +++ b/apps/packages/infrastructure/src/tools/mod.ts @@ -0,0 +1,42 @@ +/** + * Tool trait, execution context, and the registry of all built-in tools. + * Mirrors `apps/infrastructure/src/tools/mod.rs`. + */ +import type { JsonValue } from "@zesdex/domain"; +import type { ToolCtx } from "./context.ts"; + +// ── Re-export framework modules ────────────────────────────────────────── +export type { GraduatedCheck, ToolCtx } from "./context.ts"; +export { ToolCtxBuilder } from "./context.ts"; +export { + argStr, + resolvePath, + logWriteEditTool, + sha256Hex, +} from "./util.ts"; +export { + checkGraduatedChecks, +} from "./graduated.ts"; +export { + checkGitDestructive, +} from "./shell/git.ts"; +export { + isCredentialPath, + checkCredentialRead, +} from "./shell/credentials.ts"; +export { + allTools, + toolDefs, + toolIsRisky, + toolIsParallelSafe, +} from "./registry.ts"; + +// ── Tool interface ─────────────────────────────────────────────────────── + +/** Common interface every agent-invocable tool implements. */ +export interface Tool { + name: string; + description: string; + parameters: Record; + run(ctx: ToolCtx, args: JsonValue): string | Promise; +} diff --git a/apps/packages/infrastructure/src/tools/parallel_delegate.ts b/apps/packages/infrastructure/src/tools/parallel_delegate.ts new file mode 100644 index 0000000..8590bf6 --- /dev/null +++ b/apps/packages/infrastructure/src/tools/parallel_delegate.ts @@ -0,0 +1,94 @@ +/** + * Parallel delegation tool — splits a large task into sub-tasks that run + * concurrently across multiple subagents. Mirrors `tools/parallel_delegate.rs`. + */ +import type { JsonValue } from "@zesdex/domain"; +import { type Tool, type ToolCtx } from "./mod.ts"; +import { argStr, optBool, optInt } from "./util.ts"; + +/** Delegate a large task to multiple subagents running in parallel. */ +export class ParallelDelegate implements Tool { + name = "parallel_delegate"; + description = "Delegate a large task to multiple subagents running in parallel for 3x faster completion"; + + parameters = { + type: "object", + properties: { + task: { type: "string", description: "The task to be split and delegated to parallel agents" }, + directives: { + type: "array", + items: { + type: "object", + properties: { + directive: { type: "string" }, + access: { type: "string", enum: ["read", "write", "full"] }, + }, + required: ["directive"], + }, + }, + synthesize: { type: "boolean", default: true }, + max_parallel: { type: "integer", default: 3, description: "Maximum number of parallel agents (default: 3, max: 8)" }, + }, + required: ["task"], + }; + + async run(ctx: ToolCtx, args: JsonValue): Promise { + const task = argStr(args, "task"); + const synthesize = optBool(args, "synthesize", true); + const maxParallel = Math.min(optInt(args, "max_parallel", 3), 8); + + let directives: Array<{ directive: string; access: string }>; + const explicit = args && typeof args === "object" && !Array.isArray(args) + ? (args as Record).directives + : undefined; + if (Array.isArray(explicit)) { + directives = explicit + .filter((d): d is Record => typeof d === "object" && d !== null) + .map((d) => ({ + directive: typeof d.directive === "string" ? d.directive : "", + access: typeof d.access === "string" ? d.access : "write", + })) + .filter((d) => d.directive !== ""); + } else { + directives = fallbackSplit(task, maxParallel); + } + + if (directives.length === 0) { + throw new Error("no directives could be derived for the task"); + } + + // Delegate to subagent engine if available, else report the breakdown. + try { + const { runParallelDelegation } = await import("../subagent/delegate.ts"); + return await runParallelDelegation(task, directives, synthesize, ctx); + } catch (e) { + const msg = (e as Error).message; + if (msg.includes("not yet") || msg.includes("Cannot find")) { + let out = `## Parallel Delegation Complete\n\n**Task:** ${task}\n**Parallel agents:** ${directives.length}\n\n`; + directives.forEach((d, i) => { out += `---\n### Agent ${i}: [${d.access}]\n\n${d.directive}\n`; }); + return out; + } + throw e; + } + } +} + +/** Fallback splitting when LLM is unavailable. */ +function fallbackSplit(task: string, maxParallel: number): Array<{ directive: string; access: string }> { + const directives: Array<{ directive: string; access: string }> = []; + if (task.includes("backend") || task.includes("api") || task.includes("server")) { + directives.push({ directive: `Implement the backend/API components for: ${task}`, access: "write" }); + } + if (task.includes("frontend") || task.includes("ui") || task.includes("client")) { + directives.push({ directive: `Implement the frontend/UI components for: ${task}`, access: "write" }); + } + if (task.includes("test") || task.includes("unit")) { + directives.push({ directive: `Write unit tests for: ${task}`, access: "read" }); + } + if (directives.length === 0) { + for (let i = 0; i < maxParallel; i++) { + directives.push({ directive: `Part ${i + 1} of parallel task: ${task}`, access: "write" }); + } + } + return directives.slice(0, maxParallel); +} diff --git a/apps/packages/infrastructure/src/tools/plan.ts b/apps/packages/infrastructure/src/tools/plan.ts new file mode 100644 index 0000000..952e52f --- /dev/null +++ b/apps/packages/infrastructure/src/tools/plan.ts @@ -0,0 +1,64 @@ +/** Plan management tools — enter and mark ready. Mirrors `tools/plan.rs`. */ +import * as fs from "node:fs"; +import * as path from "node:path"; +import type { JsonValue, TurnEvent } from "@zesdex/domain"; +import { type Tool, type ToolCtx } from "./mod.ts"; +import { argStr } from "./util.ts"; + +function pad2(n: number): string { + return String(n).padStart(2, "0"); +} + +export class PlanEnter implements Tool { + name = "plan_enter"; + description = "Enter a planning phase — present a structured plan for approval"; + + parameters = { + type: "object", + properties: { + plan: { type: "string", description: "The structured plan text" }, + }, + required: ["plan"], + }; + + run(ctx: ToolCtx, args: JsonValue): string { + const planText = argStr(args, "plan"); + const planPath = path.join(ctx.sessionDir, "PLAN.md"); + fs.writeFileSync(planPath, planText); + if (ctx.turnEvents) { + ctx.turnEvents.push({ kind: "plan_update", content: planText } as unknown as TurnEvent); + } + return `Plan entered (length: ${planText.length} chars). Waiting for approval...`; + } +} + +export class PlanReady implements Tool { + name = "plan_ready"; + description = "Signal that the plan is ready and execution can begin"; + + parameters = { + type: "object", + properties: { + plan: { type: "string", description: "The final plan content" }, + }, + required: ["plan"], + }; + + run(ctx: ToolCtx, args: JsonValue): string { + const planContent = argStr(args, "plan"); + const planDir = path.join(ctx.sessionDir, "plans"); + try { + fs.mkdirSync(planDir, { recursive: true }); + const now = new Date(); + const filename = `plan-${now.getFullYear()}${pad2(now.getMonth() + 1)}${pad2(now.getDate())}_${pad2(now.getHours())}${pad2(now.getMinutes())}${pad2(now.getSeconds())}.md`; + fs.writeFileSync(path.join(planDir, filename), planContent); + fs.writeFileSync(path.join(ctx.sessionDir, "PLAN.md"), planContent); + if (ctx.turnEvents) { + ctx.turnEvents.push({ kind: "plan_update", content: planContent } as unknown as TurnEvent); + } + return `Plan saved to ${filename}. Starting execution.`; + } catch { + return "Plan is ready. Starting execution."; + } + } +} diff --git a/apps/packages/infrastructure/src/tools/registry.test.ts b/apps/packages/infrastructure/src/tools/registry.test.ts new file mode 100644 index 0000000..ea4336b --- /dev/null +++ b/apps/packages/infrastructure/src/tools/registry.test.ts @@ -0,0 +1,140 @@ +/** + * Tests for the tool registry and framework helpers. + */ +import { describe, expect, test } from "bun:test"; +import { allTools, toolDefs, toolIsParallelSafe, toolIsRisky } from "./registry.ts"; +import { ToolCtxBuilder } from "./context.ts"; +import { resolvePath } from "./util.ts"; +import { checkGitDestructive } from "./shell/git.ts"; +import { checkCredentialRead, isCredentialPath } from "./shell/credentials.ts"; +import { checkGraduatedChecks } from "./graduated.ts"; +import { Bash } from "./shell/bash.ts"; +import { Pong } from "./utility/pong.ts"; +import { SeqThink } from "./sequential_think.ts"; +import { Read } from "./fs/read.ts"; +import { Write } from "./fs/write.ts"; +import { Edit } from "./fs/edit.ts"; + +describe("allTools registry", () => { + test("registers 38 tools with unique names", () => { + const tools = allTools(); + const names = tools.map((t) => t.name); + expect(new Set(names).size).toBe(names.length); + expect(names.length).toBe(37); + for (const required of ["read", "write", "edit", "delete", "grep", "glob", "bash", "git_operator", "sequential_think", "web_search", "semantic_search"]) { + expect(names).toContain(required); + } + }); + + test("toolDefs produces provider-facing schema", () => { + const defs = toolDefs(allTools()); + expect(defs.length).toBe(37); + expect(defs[0]!.type).toBe("function"); + expect(defs[0]!.function.name).toBeTruthy(); + expect(defs[0]!.function.parameters.type).toBe("object"); + }); +}); + +describe("read-only tools are parallel-safe", () => { + test("parallel-safe list", () => { + for (const name of ["read", "grep", "glob", "semantic_search", "list_symbols", "web_search", "recall", "dir_list", "pong", "seq_think", "dir_cache_update"]) { + expect(toolIsParallelSafe(name)).toBe(true); + } + }); + test("mutating tools are not parallel-safe", () => { + for (const name of ["edit", "write", "delete", "bash", "git_operator", "git_worktree", "remember", "forget", "todowrite", "plan_enter", "spawn_agents"]) { + expect(toolIsParallelSafe(name)).toBe(false); + } + }); +}); + +describe("toolIsRisky", () => { + test("flags mutating and shell tools", () => { + expect(toolIsRisky("write")).toBe(true); + expect(toolIsRisky("delete")).toBe(true); + expect(toolIsRisky("edit")).toBe(true); + expect(toolIsRisky("bash")).toBe(true); + expect(toolIsRisky("git_operator")).toBe(true); + expect(toolIsRisky("read")).toBe(false); + }); +}); + +describe("resolvePath", () => { + test("rejects path escapes", () => { + const ctx = new ToolCtxBuilder().withWorkspaces(["/tmp/zesdex-ws"]).build(); + expect(() => resolvePath(ctx.workspaces, "../etc/passwd")).toThrow("outside all workspace roots"); + }); + test("resolves within workspace", () => { + const ctx = new ToolCtxBuilder().withWorkspaces(["/tmp/zesdex-ws"]).build(); + const resolved = resolvePath(ctx.workspaces, "src/main.ts"); + expect(resolved).toBe("/tmp/zesdex-ws/src/main.ts"); + }); +}); + +describe("checkGitDestructive", () => { + test("blocks destructive operations", () => { + expect(checkGitDestructive("git push --force")).not.toBeNull(); + expect(checkGitDestructive("git reset --hard HEAD")).not.toBeNull(); + expect(checkGitDestructive("git rebase main")).not.toBeNull(); + }); + test("allows safe operations", () => { + expect(checkGitDestructive("git status")).toBeNull(); + expect(checkGitDestructive("git log --oneline")).toBeNull(); + }); +}); + +describe("checkCredentialRead", () => { + test("detects credential file reads", () => { + expect(isCredentialPath("~/.ssh/id_rsa")).toBe(true); + expect(isCredentialPath(".aws/credentials")).toBe(true); + const findings = checkCredentialRead("cat ~/.ssh/id_rsa"); + expect(findings.length).toBeGreaterThan(0); + }); + test("ignores normal files", () => { + expect(isCredentialPath("src/main.rs")).toBe(false); + expect(checkCredentialRead("cat src/main.rs")).toEqual([]); + }); +}); + +describe("checkGraduatedChecks", () => { + test("matches path or content patterns", () => { + const checks = [{ name: "secrets", pattern: "credentials", rule: "api_key" }]; + expect(checkGraduatedChecks("config/credentials.yaml", "", checks)).toContain("secrets"); + expect(checkGraduatedChecks("app.ts", "const api_key", checks)).toContain("secrets"); + expect(checkGraduatedChecks("app.ts", "nothing here", checks)).toEqual([]); + }); +}); + +describe("simple tools", () => { + test("pong returns pong", () => { + const ctx = new ToolCtxBuilder().build(); + expect(new Pong().run(ctx, {})).toBe("pong"); + }); + + test("seq_think formats step", () => { + const ctx = new ToolCtxBuilder().build(); + const out = new SeqThink().run(ctx, { thought: "analyze", step_number: 1, total_steps: 3, next_thought_needed: true }); + expect(out).toContain("Step 1/3"); + expect(out).toContain("analyze"); + expect(out).toContain("Continuing"); + }); + + test("bash blocks destructive git", () => { + const ctx = new ToolCtxBuilder().withWorkspaces(["/tmp"]).build(); + expect(() => new Bash().run(ctx, { command: "git push --force" })).toThrow("blocked"); + }); + + test("read/write/edit roundtrip in temp dir", () => { + const dir = `/tmp/zesdex-tool-test-${Date.now()}`; + const ctx = new ToolCtxBuilder().withWorkspaces([dir]).build(); + const write = new Write().run(ctx, { path: "a.txt", content: "hello world" }); + expect(write).toContain("11 bytes"); + const read = new Read().run(ctx, { path: "a.txt" }); + expect(read).toBe("hello world"); + const edit = new Edit().run(ctx, { path: "a.txt", old: "hello", new: "goodbye" }); + expect(edit).toContain("Edited"); + expect(new Read().run(ctx, { path: "a.txt" })).toBe("goodbye world"); + // cleanup + import("node:fs").then((fs) => fs.rmSync(dir, { recursive: true, force: true })); + }); +}); diff --git a/apps/packages/infrastructure/src/tools/registry.ts b/apps/packages/infrastructure/src/tools/registry.ts new file mode 100644 index 0000000..a54a6f7 --- /dev/null +++ b/apps/packages/infrastructure/src/tools/registry.ts @@ -0,0 +1,107 @@ +/** + * Tool registry — the master list of all built-in tools. Mirrors + * `tools/registry.rs`. + */ +import type { Tool } from "./mod.ts"; +import { Read } from "./fs/read.ts"; +import { Write } from "./fs/write.ts"; +import { Edit } from "./fs/edit.ts"; +import { Delete } from "./fs/delete.ts"; +import { Grep } from "./search/grep.ts"; +import { Glob } from "./search/glob.ts"; +import { BashOutput, BashKill } from "./bash_tools.ts"; +import { Bash } from "./shell/bash.ts"; +import { GitOperator } from "./git/git_operator.ts"; +import { GitWorktree } from "./git/git_worktree.ts"; +import { GitCred } from "./git/git_cred.ts"; +import { SeqThink } from "./sequential_think.ts"; +import { PlanEnter, PlanReady } from "./plan.ts"; +import { WorkflowRun, NoteFinding, ReadFindings, HiveMind } from "./workflow.ts"; +import { SpawnAgents, SpawnPipeline } from "./spawn.ts"; +import { Remember } from "./memory/remember.ts"; +import { Forget } from "./memory/forget.ts"; +import { Recall } from "./memory/recall.ts"; +import { Cd, DirList, DirCacheUpdate, Pong, Todowrite, Todofinish } from "./utility/mod.ts"; +import { WebSearch } from "./web_search.ts"; +import { SemanticSearch, RebuildIndex, ListSymbols } from "./semantic/index.ts"; +import { ParallelDelegate } from "./parallel_delegate.ts"; +import { BestPractice, CommitConvention } from "../best_practice/tools.ts"; + +/** Construct one instance of every built-in tool. */ +export function allTools(): Tool[] { + return [ + new Read(), + new Write(), + new Edit(), + new Delete(), + new Grep(), + new Glob(), + new BashOutput(), + new BashKill(), + new Bash(), + new GitOperator(), + new GitWorktree(), + new GitCred(), + new SeqThink(), + new PlanEnter(), + new PlanReady(), + new WorkflowRun(), + new NoteFinding(), + new ReadFindings(), + new HiveMind(), + new SpawnAgents(), + new SpawnPipeline(), + new Remember(), + new Forget(), + new Recall(), + new Cd(), + new DirList(), + new DirCacheUpdate(), + new Pong(), + new Todowrite(), + new Todofinish(), + new WebSearch(), + new SemanticSearch(), + new RebuildIndex(), + new ListSymbols(), + new ParallelDelegate(), + new BestPractice(), + new CommitConvention(), + ]; +} + +/** Whether a tool can mutate the filesystem or run arbitrary shell commands. */ +export function toolIsRisky(name: string): boolean { + return ["write", "delete", "edit", "bash", "git_operator"].includes(name); +} + +/** Whether a tool is read-only and safe to run in parallel. */ +export function toolIsParallelSafe(name: string): boolean { + return [ + "read", + "grep", + "glob", + "semantic_search", + "list_symbols", + "web_search", + "recall", + "dir_list", + "pong", + "seq_think", + "dir_cache_update", + ].includes(name); +} + +/** Convert a list of tools into provider-facing ToolDef request schema. */ +export function toolDefs( + tools: Tool[], +): Array<{ type: string; function: { name: string; description: string; parameters: Record } }> { + return tools.map((t) => ({ + type: "function", + function: { + name: t.name, + description: t.description, + parameters: t.parameters, + }, + })); +} diff --git a/apps/packages/infrastructure/src/tools/search/glob.ts b/apps/packages/infrastructure/src/tools/search/glob.ts new file mode 100644 index 0000000..5861496 --- /dev/null +++ b/apps/packages/infrastructure/src/tools/search/glob.ts @@ -0,0 +1,96 @@ +/** + * Glob: list files matching a glob pattern under a directory root. + * Mirrors `tools/search.rs` Glob tool. Uses a lightweight `**`/`*`/`?` + * matcher rather than the `globset` crate. + */ +import * as fs from "node:fs"; +import * as path from "node:path"; +import type { JsonValue } from "@zesdex/domain"; +import { type Tool, type ToolCtx } from "../mod.ts"; +import { argStr, resolvePath } from "../util.ts"; + +/** Convert a glob pattern to a RegExp. Supports `**`, `*`, `?`, `{a,b}`. */ +export function globToRegExp(pattern: string): RegExp { + // Split path separators to handle `**` correctly. + const parts = pattern.split("/"); + let re = ""; + for (let i = 0; i < parts.length; i++) { + const part = parts[i] ?? ""; + if (part === "**") { + re += "(?:.*/)?"; + continue; + } + if (i > 0) re += "/"; + let out = ""; + for (let j = 0; j < part.length; j++) { + const ch = part[j]; + if (ch === "*") out += "[^/]*"; + else if (ch === "?") out += "[^/]"; + else if (ch === ".") out += "\\."; + else if (ch === "(" || ch === ")" || ch === "[" || ch === "]" || ch === "+" || ch === "^" || ch === "$") out += `\\${ch}`; + else out += ch; + } + re += out; + } + return new RegExp(`^${re}$`); +} + +/** List files matching a glob pattern. */ +export class Glob implements Tool { + name = "glob"; + description = "List files matching a glob pattern"; + + parameters = { + type: "object", + properties: { + pattern: { type: "string", description: "Glob pattern to match files (e.g. '**/*.rs')" }, + path: { type: "string", description: "Root path to search from (relative to workspace root)" }, + }, + required: ["pattern", "path"], + }; + + run(ctx: ToolCtx, args: JsonValue): string { + const patStr = argStr(args, "pattern"); + const rel = argStr(args, "path"); + const root = resolvePath(ctx.workspaces, rel); + + if (!fs.existsSync(root) || !fs.statSync(root).isDirectory()) { + throw new Error(`path '${rel}' is not a valid directory`); + } + + let re: RegExp; + try { + re = globToRegExp(patStr); + } catch (e) { + throw new Error(`invalid glob pattern '${patStr}': ${(e as Error).message}`); + } + + const matches: string[] = []; + const walk = (dir: string) => { + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (entry.name === ".git" || entry.name === "node_modules" || entry.name === "target") continue; + walk(full); + } + const relPath = path.relative(root, full); + if (re.test(relPath)) { + matches.push(relPath + (entry.isDirectory() ? "/" : "")); + } + } + }; + walk(root); + matches.sort(); + + if (matches.length === 0) { + return `no files match '${patStr}' in ${rel}`; + } + return matches.join("\n"); + } +} diff --git a/apps/packages/infrastructure/src/tools/search/grep.ts b/apps/packages/infrastructure/src/tools/search/grep.ts new file mode 100644 index 0000000..cc3e6ac --- /dev/null +++ b/apps/packages/infrastructure/src/tools/search/grep.ts @@ -0,0 +1,86 @@ +/** + * Grep: search for a regex (or literal) pattern in file contents under a + * directory. Mirrors `tools/search.rs`. + */ +import * as fs from "node:fs"; +import * as path from "node:path"; +import type { JsonValue } from "@zesdex/domain"; +import { type Tool, type ToolCtx } from "../mod.ts"; +import { argStr, resolvePath } from "../util.ts"; + +/** Search for a regex (or literal) pattern in files. */ +export class Grep implements Tool { + name = "grep"; + description = "Search for a pattern in files using recursive text search"; + + parameters = { + type: "object", + properties: { + pattern: { type: "string", description: "Text pattern to search for" }, + path: { type: "string", description: "Path to search in (relative to workspace root)" }, + }, + required: ["pattern", "path"], + }; + + run(ctx: ToolCtx, args: JsonValue): string { + const pattern = argStr(args, "pattern"); + const rel = argStr(args, "path"); + const root = resolvePath(ctx.workspaces, rel); + + if (!fs.existsSync(root)) { + throw new Error(`path '${rel}' does not exist`); + } + if (!fs.statSync(root).isDirectory()) { + throw new Error(`path '${rel}' is not a directory`); + } + + const results: Array<[string, number, string]> = []; + const walk = (dir: string) => { + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (entry.name === ".git" || entry.name === "node_modules" || entry.name === "target") continue; + walk(full); + } else if (entry.isFile()) { + if (fs.existsSync(full) && fs.statSync(full).size > 5_000_000) continue; + let content: string; + try { + content = fs.readFileSync(full, "utf8"); + } catch { + continue; + } + const lines = content.split("\n"); + for (let i = 0; i < lines.length; i++) { + const line = lines[i] ?? ""; + const isMatch = (() => { + try { + return new RegExp(pattern).test(line); + } catch { + return line.includes(pattern); + } + })(); + if (isMatch) { + const relPath = path.relative(root, full); + results.push([relPath, i + 1, line]); + } + } + } + } + }; + walk(root); + + if (results.length === 0) { + return `no matches found for '${pattern}' in ${rel}`; + } + const output = results + .map(([f, line, text]) => `${f}:${line}:${text}`) + .join("\n"); + return `found ${results.length} matches:\n${output}`; + } +} diff --git a/apps/packages/infrastructure/src/tools/semantic/index.ts b/apps/packages/infrastructure/src/tools/semantic/index.ts new file mode 100644 index 0000000..4d69b1f --- /dev/null +++ b/apps/packages/infrastructure/src/tools/semantic/index.ts @@ -0,0 +1,818 @@ +/** + * Multi-language code symbol index — functions, classes, variables, structs, + * enums, interfaces, traits, modules. Mirrors `tools/semantic_search.rs`. + * + * Per-workspace in-memory cache keyed by workspace path; rebuilds run + * outside the global lock so concurrent searches never block on I/O. + */ +import * as fs from "node:fs"; +import * as path from "node:path"; +import type { JsonValue } from "@zesdex/domain"; +import { type Tool, type ToolCtx } from "../mod.ts"; +import { argStr, optBool, optInt, optStr } from "../util.ts"; + +export type Language = "rust" | "typescript" | "javascript" | "python" | "go" | "other"; + +export type SymbolKind = + | "fn" + | "struct" + | "enum" + | "trait" + | "mod" + | "impl" + | "type" + | "const" + | "macro" + | "class" + | "interface" + | "var" + | "symbol"; + +export interface CodeSymbol { + name: string; + kind: SymbolKind; + language: Language; + file: string; + line: number; + parent: string | null; + doc_comment: string | null; + context: string; +} + +// ── Global per-workspace index cache ───────────────────────────────────── +const symbolIndexStore: Record = {}; + +let pendingRebuilds: Record> = {}; + +// ── Language regexes (precompiled) ─────────────────────────────────────── +const RX = { + rust_fn: /^\s*(?:pub\s+)?(?:(?:unsafe\s+)?async\s+)?fn\s+(\w+)/m, + rust_struct: /^\s*(?:pub\s+)?struct\s+(\w+)/m, + rust_enum: /^\s*(?:pub\s+)?enum\s+(\w+)/m, + rust_trait: /^\s*(?:pub\s+)?(?:(?:unsafe\s+)?)?trait\s+(\w+)/m, + rust_mod: /^\s*(?:pub\s+)?mod\s+(\w+)/m, + rust_impl: /^\s*(?:pub\s+)?(?:unsafe\s+)?impl(?:\s*<[^>]*>)?\s+(?:for\s+)?(\w+)/m, + rust_type: /^\s*(?:pub\s+)?type\s+(\w+)/m, + rust_const: /^\s*(?:pub\s+)?const\s+(\w+)/m, + rust_macro: /^\s*(?:pub\s+)?macro_rules!\s*\(\s*(\w+)/m, + + ts_fn: /^\s*(?:export\s+)?(?:(?:async\s+)?function\s+|(?:public|private|protected)\s+)?(\w+)\s*(?:\(|=\s*(?:async\s+)?\()/m, + ts_class: /^\s*(?:export\s+)?(?:abstract\s+)?class\s+(\w+)/m, + ts_interface: /^\s*(?:export\s+)?interface\s+(\w+)/m, + ts_type: /^\s*(?:export\s+)?type\s+(\w+)\s*=/m, + ts_enum: /^\s*(?:export\s+)?enum\s+(\w+)/m, + ts_var: /^\s*(?:export\s+)?(?:const|let|var)\s+(\w+)\s*(?::\s*\w+\s*)?=/m, + + py_def: /^\s*def\s+(\w+)/m, + py_class: /^\s*class\s+(\w+)/m, + py_async_def: /^\s*async\s+def\s+(\w+)/m, + + go_func: /^\s*func\s+(?:\([^)]*\)\s+)?(\w+)/m, + go_type: /^\s*type\s+(\w+)/m, + go_struct: /^\s*type\s+(\w+)\s+struct/m, + go_interface: /^\s*type\s+(\w+)\s+interface/m, + go_const: /^\s*const\s+(\w+)/m, + go_var: /^\s*var\s+(\w+)/m, +}; + +// ── SymbolIndex ────────────────────────────────────────────────────────── +export class SymbolIndex { + symbols: CodeSymbol[] = []; + workspacePath: string | null = null; + + is_empty(): boolean { + return this.symbols.length === 0; + } + len(): number { + return this.symbols.length; + } + needs_rebuild(workspace: string): boolean { + return this.is_empty() || this.workspacePath !== workspace; + } + + /** Walk the workspace and extract symbols from supported languages. */ + async rebuild(workspace: string): Promise { + if (!fs.existsSync(workspace)) { + throw new Error(`workspace path does not exist: ${workspace}`); + } + const dispatch: Record CodeSymbol[]> = { + rs: extractRust, + ts: extractTypescript, + tsx: extractTypescript, + mts: extractTypescript, + js: extractJavascript, + jsx: extractJavascript, + mjs: extractJavascript, + py: extractPython, + go: extractGo, + }; + + const symbols: CodeSymbol[] = []; + const walk = (dir: string) => { + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + if (entry.name === ".git" || entry.name === "node_modules" || entry.name === "target") continue; + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + walk(full); + } else if (entry.isFile()) { + const ext = path.extname(full).slice(1).toLowerCase(); + const extractor = dispatch[ext]; + if (!extractor) continue; + const relPath = path.relative(workspace, full); + try { + const content = fs.readFileSync(full, "utf8"); + symbols.push(...extractor(content, relPath)); + } catch { + // skip unreadable files + } + } + } + }; + // yield to event loop periodically to avoid blocking the runtime for long walks + await new Promise((r) => setTimeout(r, 0)); + walk(workspace); + + symbols.sort((a, b) => a.name.localeCompare(b.name)); + this.symbols = symbols; + this.workspacePath = workspace; + return symbols.length; + } + + search(query: string, maxResults: number): CodeSymbol[] { + if (this.symbols.length === 0) return []; + const queryLower = query.toLowerCase(); + const queryWords = queryLower.split(/\s+/).filter((w) => w !== ""); + const scored = this.symbols + .map((sym) => ({ score: scoreSymbol(sym, queryLower, queryWords), sym })) + .filter((s) => s.score > 0) + .sort((a, b) => b.score - a.score || a.sym.name.localeCompare(b.sym.name)); + return scored.slice(0, maxResults).map((s) => s.sym); + } + + list( + languageFilter: Language | null, + kindFilter: SymbolKind | null, + fileFilter: string | null, + maxResults: number, + ): CodeSymbol[] { + return this.symbols + .filter((s) => { + if (languageFilter && s.language !== languageFilter) return false; + if (kindFilter && s.kind !== kindFilter) return false; + if (fileFilter && !s.file.includes(fileFilter)) return false; + return true; + }) + .slice(0, maxResults); + } + + count_by_language(): Array<[Language, number]> { + const counts: Record = {}; + for (const sym of this.symbols) { + counts[sym.language] = (counts[sym.language] ?? 0) + 1; + } + return Object.entries(counts) + .map(([lang, c]) => [lang as Language, c] as [Language, number]) + .sort((a, b) => b[1] - a[1]); + } + + count_by_kind(): Array<[SymbolKind, number]> { + const counts: Record = {}; + for (const sym of this.symbols) { + counts[sym.kind] = (counts[sym.kind] ?? 0) + 1; + } + return Object.entries(counts) + .map(([kind, c]) => [kind as SymbolKind, c] as [SymbolKind, number]) + .sort((a, b) => b[1] - a[1]); + } +} + +// ── Index management ───────────────────────────────────────────────────── +/** Ensure a per-workspace index is built. */ +export async function ensureSymbolIndex(workspace: string, force: boolean): Promise { + const existing = symbolIndexStore[workspace]; + if (!force && existing && !existing.is_empty()) { + return existing.len(); + } + // Rebuild outside the global lock; dedupe concurrent rebuilds for same workspace. + if (!pendingRebuilds[workspace]) { + pendingRebuilds[workspace] = (async () => { + const fresh = new SymbolIndex(); + const count = await fresh.rebuild(workspace); + const cur = symbolIndexStore[workspace]; + if (!cur || cur.is_empty()) { + symbolIndexStore[workspace] = fresh; + } + return count; + })(); + } + const count = await pendingRebuilds[workspace]; + delete pendingRebuilds[workspace]; + return count; +} + +function getIndex(workspace: string): SymbolIndex { + let idx = symbolIndexStore[workspace]; + if (!idx) { + idx = new SymbolIndex(); + symbolIndexStore[workspace] = idx; + } + return idx; +} + +// ── Scoring ────────────────────────────────────────────────────────────── +function scoreSymbol(sym: CodeSymbol, queryLower: string, queryWords: string[]): number { + const nameLower = sym.name.toLowerCase(); + let score = 0; + if (nameLower === queryLower) score += 1000; + if (nameLower.startsWith(queryLower)) score += 500; + if (nameLower.includes(queryLower)) score += 200; + for (const word of queryWords) { + if (nameLower.includes(word)) score += 50; + } + const doc = sym.doc_comment ? sym.doc_comment.toLowerCase() : ""; + if (doc) { + if (doc.includes(queryLower)) score += 30; + for (const word of queryWords) { + if (doc.includes(word)) score += 10; + } + } + const contextLower = sym.context.toLowerCase(); + if (contextLower.includes(queryLower)) score += 20; + return score; +} + +// ── Doc comment extraction ─────────────────────────────────────────────── +function findNextDeclarationLine(lines: string[], start: number): number | null { + for (let i = start; i < lines.length; i++) { + const trimmed = lines[i]!.trim(); + if ( + trimmed !== "" && + !trimmed.startsWith("///") && + !trimmed.startsWith("//!") && + !trimmed.startsWith("#") + ) { + return i; + } + } + return null; +} + +function extractDocComments(lines: string[]): Map { + const map = new Map(); + let i = 0; + while (i < lines.length) { + const line = lines[i]!.trim(); + if (line.startsWith("///")) { + const parts: string[] = []; + while (i < lines.length) { + const l = lines[i]!.trim(); + if (l.startsWith("///")) { + parts.push(l.replace(/^\/\/\//, "").trim()); + i += 1; + } else { + break; + } + } + if (parts.length > 0) { + const target = findNextDeclarationLine(lines, i); + if (target !== null) map.set(target + 1, parts.join(" ")); + } + } else { + i += 1; + } + } + return map; +} + +// ── Rust extractor ─────────────────────────────────────────────────────── +export function extractRust(content: string, relPath: string): CodeSymbol[] { + const symbols: CodeSymbol[] = []; + const lines = content.split("\n"); + const docComments = extractDocComments(lines); + + for (let i = 0; i < lines.length; i++) { + const lineNum = i + 1; + const trimmed = lines[i]!.trim(); + const entries: Array<[SymbolKind, RegExp]> = [ + ["fn", RX.rust_fn], + ["struct", RX.rust_struct], + ["enum", RX.rust_enum], + ["trait", RX.rust_trait], + ["mod", RX.rust_mod], + ["type", RX.rust_type], + ["const", RX.rust_const], + ["macro", RX.rust_macro], + ]; + for (const [kind, re] of entries) { + const m = re.exec(trimmed); + if (m && m[1]) { + symbols.push({ + name: m[1], + kind, + language: "rust", + file: relPath, + line: lineNum, + parent: null, + doc_comment: docComments.get(lineNum) ?? null, + context: trimmed, + }); + } + } + + // Parse impl blocks for methods + const implMatch = RX.rust_impl.exec(trimmed); + if (implMatch && implMatch[1]) { + const implFor = implMatch[1]; + let braceDepth = 0; + let started = false; + for (let j = 0; i + j < lines.length; j++) { + const l = lines[i + j]!; + for (const ch of l) { + if (ch === "{") { + braceDepth += 1; + started = true; + } else if (ch === "}") { + braceDepth -= 1; + } + } + if (started && braceDepth <= 0 && j > 1) break; + if (j > 0) { + const inner = l.trim(); + const fnMatch = RX.rust_fn.exec(inner); + if (fnMatch && fnMatch[1]) { + const absLine = i + j + 1; + symbols.push({ + name: `${implFor}::${fnMatch[1]}`, + kind: "fn", + language: "rust", + file: relPath, + line: absLine, + parent: implFor, + doc_comment: docComments.get(absLine) ?? null, + context: inner, + }); + } + } + } + } + } + return symbols; +} + +// ── TypeScript extractor ───────────────────────────────────────────────── +function extractTsDoc(lines: string[]): Map { + const map = new Map(); + let i = 0; + const collect = (lookahead: boolean): boolean => { + void lookahead; + return true; + }; + void collect; + while (i < lines.length) { + const line = lines[i]!.trim(); + if (line.startsWith("/**") || line.startsWith("///")) { + const parts: string[] = []; + if (line.startsWith("/**")) { + if (line.endsWith("*/") && line.length > 4) { + const content = line.replace(/^\/\*\*/, "").replace(/\*\/$/, "").trim(); + if (content) parts.push(content); + } else { + while (i < lines.length) { + let l = lines[i]!.trim().replace(/^\*/, "").trim(); + if (l.endsWith("*/")) { + parts.push(l.replace(/\*\/$/, "").trim()); + break; + } + if (l) parts.push(l); + i += 1; + } + } + } else { + while (i < lines.length) { + const l = lines[i]!.trim(); + if (l.startsWith("///")) { + parts.push(l.replace(/^\/\/\//, "").trim()); + i += 1; + } else { + break; + } + } + } + const target = findNextDeclarationLine(lines, i); + if (target !== null) map.set(target + 1, parts.join(" ")); + } else { + i += 1; + } + } + return map; +} + +export function extractTypescript(content: string, relPath: string): CodeSymbol[] { + const symbols: CodeSymbol[] = []; + const lines = content.split("\n"); + const docComments = extractTsDoc(lines); + + for (let i = 0; i < lines.length; i++) { + const lineNum = i + 1; + const trimmed = lines[i]!.trim(); + const doc = docComments.get(lineNum) ?? null; + + const fnMatch = RX.ts_fn.exec(trimmed); + if (fnMatch && fnMatch[1]) { + const name = fnMatch[1]; + if (!name.startsWith("(") && name !== "function" && name !== "async") { + symbols.push({ name, kind: "fn", language: "typescript", file: relPath, line: lineNum, parent: null, doc_comment: doc, context: trimmed }); + } + continue; + } + const classMatch = RX.ts_class.exec(trimmed); + if (classMatch && classMatch[1]) { + symbols.push({ name: classMatch[1], kind: "class", language: "typescript", file: relPath, line: lineNum, parent: null, doc_comment: doc, context: trimmed }); + continue; + } + const ifaceMatch = RX.ts_interface.exec(trimmed); + if (ifaceMatch && ifaceMatch[1]) { + symbols.push({ name: ifaceMatch[1], kind: "interface", language: "typescript", file: relPath, line: lineNum, parent: null, doc_comment: doc, context: trimmed }); + continue; + } + const typeMatch = RX.ts_type.exec(trimmed); + if (typeMatch && typeMatch[1]) { + symbols.push({ name: typeMatch[1], kind: "type", language: "typescript", file: relPath, line: lineNum, parent: null, doc_comment: doc, context: trimmed }); + continue; + } + const enumMatch = RX.ts_enum.exec(trimmed); + if (enumMatch && enumMatch[1]) { + symbols.push({ name: enumMatch[1], kind: "enum", language: "typescript", file: relPath, line: lineNum, parent: null, doc_comment: doc, context: trimmed }); + continue; + } + const varMatch = RX.ts_var.exec(trimmed); + if (varMatch && varMatch[1]) { + const name = varMatch[1]; + const isTopLevel = !/^\s/.test(lines[i]!) || trimmed.startsWith("export"); + if (isTopLevel) { + const kind: SymbolKind = trimmed.includes("const ") ? "const" : "var"; + symbols.push({ name, kind, language: "typescript", file: relPath, line: lineNum, parent: null, doc_comment: doc, context: trimmed }); + } + } + } + return symbols; +} + +// ── JavaScript extractor ───────────────────────────────────────────────── +export function extractJavascript(content: string, relPath: string): CodeSymbol[] { + const symbols: CodeSymbol[] = []; + const lines = content.split("\n"); + for (let i = 0; i < lines.length; i++) { + const lineNum = i + 1; + const trimmed = lines[i]!.trim(); + + const fnMatch = RX.ts_fn.exec(trimmed); + if (fnMatch && fnMatch[1]) { + const name = fnMatch[1]; + if (!name.startsWith("(") && name !== "function" && name !== "async") { + symbols.push({ name, kind: "fn", language: "javascript", file: relPath, line: lineNum, parent: null, doc_comment: null, context: trimmed }); + } + continue; + } + const classMatch = RX.ts_class.exec(trimmed); + if (classMatch && classMatch[1]) { + symbols.push({ name: classMatch[1], kind: "class", language: "javascript", file: relPath, line: lineNum, parent: null, doc_comment: null, context: trimmed }); + continue; + } + const varMatch = RX.ts_var.exec(trimmed); + if (varMatch && varMatch[1]) { + const name = varMatch[1]; + if (!/^\s/.test(lines[i]!)) { + const kind: SymbolKind = trimmed.includes("const ") ? "const" : "var"; + symbols.push({ name, kind, language: "javascript", file: relPath, line: lineNum, parent: null, doc_comment: null, context: trimmed }); + } + } + } + return symbols; +} + +// ── Python extractor ───────────────────────────────────────────────────── +export function extractPython(content: string, relPath: string): CodeSymbol[] { + const symbols: CodeSymbol[] = []; + const lines = content.split("\n"); + let currentClass: string | null = null; + + for (let i = 0; i < lines.length; i++) { + const lineNum = i + 1; + const line = lines[i]!; + const trimmed = line.trim(); + const indent = line.length - trimmed.length; + + if (currentClass !== null && indent === 0 && trimmed !== "") { + currentClass = null; + } + const classMatch = RX.py_class.exec(trimmed); + if (classMatch && classMatch[1]) { + const name = classMatch[1]; + currentClass = name; + symbols.push({ name, kind: "class", language: "python", file: relPath, line: lineNum, parent: null, doc_comment: null, context: trimmed }); + continue; + } + const asyncMatch = RX.py_async_def.exec(trimmed); + if (asyncMatch && asyncMatch[1]) { + const name = asyncMatch[1]; + const full = currentClass ? `${currentClass}.${name}` : name; + symbols.push({ name: full, kind: "fn", language: "python", file: relPath, line: lineNum, parent: currentClass, doc_comment: null, context: trimmed }); + continue; + } + const defMatch = RX.py_def.exec(trimmed); + if (defMatch && defMatch[1]) { + const name = defMatch[1]; + const full = currentClass ? `${currentClass}.${name}` : name; + symbols.push({ name: full, kind: "fn", language: "python", file: relPath, line: lineNum, parent: currentClass, doc_comment: null, context: trimmed }); + continue; + } + + // Module-level variable assignment + if ( + !/^\s/.test(line) && + !trimmed.startsWith("#") && + !trimmed.startsWith("def ") && + !trimmed.startsWith("class ") && + !trimmed.startsWith("import ") && + !trimmed.startsWith("from ") && + !trimmed.startsWith("@") && + !trimmed.startsWith("return") && + !trimmed.startsWith("if ") && + !trimmed.startsWith("elif ") && + !trimmed.startsWith("else:") && + !trimmed.startsWith("for ") && + !trimmed.startsWith("while ") && + !trimmed.startsWith("try:") && + !trimmed.startsWith("except") && + !trimmed.startsWith("with ") && + !trimmed.startsWith("raise") && + !trimmed.startsWith("pass") && + !trimmed.startsWith("self.") && + !trimmed.startsWith("cls.") && + trimmed.includes(" = ") && + !trimmed.includes("==") + ) { + const name = trimmed.split("=")[0]!.trim(); + if (name !== "" && !name.startsWith("_") && !name.includes(" ")) { + const kind: SymbolKind = /^[A-Z_]+$/.test(name) ? "const" : "var"; + symbols.push({ name, kind, language: "python", file: relPath, line: lineNum, parent: null, doc_comment: null, context: trimmed }); + } + } + } + return symbols; +} + +// ── Go extractor ───────────────────────────────────────────────────────── +export function extractGo(content: string, relPath: string): CodeSymbol[] { + const symbols: CodeSymbol[] = []; + const lines = content.split("\n"); + for (let i = 0; i < lines.length; i++) { + const lineNum = i + 1; + const trimmed = lines[i]!.trim(); + + const structMatch = RX.go_struct.exec(trimmed); + if (structMatch && structMatch[1]) { + symbols.push({ name: structMatch[1], kind: "struct", language: "go", file: relPath, line: lineNum, parent: null, doc_comment: null, context: trimmed }); + continue; + } + const ifaceMatch = RX.go_interface.exec(trimmed); + if (ifaceMatch && ifaceMatch[1]) { + symbols.push({ name: ifaceMatch[1], kind: "interface", language: "go", file: relPath, line: lineNum, parent: null, doc_comment: null, context: trimmed }); + continue; + } + const typeMatch = RX.go_type.exec(trimmed); + if (typeMatch && typeMatch[1]) { + const name = typeMatch[1]; + if (!trimmed.includes(" struct") && !trimmed.includes(" interface")) { + symbols.push({ name, kind: "type", language: "go", file: relPath, line: lineNum, parent: null, doc_comment: null, context: trimmed }); + } + continue; + } + const funcMatch = RX.go_func.exec(trimmed); + if (funcMatch && funcMatch[1]) { + const name = funcMatch[1]; + symbols.push({ name, kind: "fn", language: "go", file: relPath, line: lineNum, parent: null, doc_comment: null, context: trimmed }); + continue; + } + const constMatch = RX.go_const.exec(trimmed); + if (constMatch && constMatch[1]) { + symbols.push({ name: constMatch[1], kind: "const", language: "go", file: relPath, line: lineNum, parent: null, doc_comment: null, context: trimmed }); + continue; + } + const varMatch = RX.go_var.exec(trimmed); + if (varMatch && varMatch[1]) { + symbols.push({ name: varMatch[1], kind: "var", language: "go", file: relPath, line: lineNum, parent: null, doc_comment: null, context: trimmed }); + } + } + return symbols; +} + +// ── Format listing ─────────────────────────────────────────────────────── +export function formatSymbolListing(index: SymbolIndex): string { + let out = `## Indexed Symbols (${index.len()} total)\n\n`; + const byLang = index.count_by_language(); + if (byLang.length === 0) { + out += "_No symbols indexed. Rebuild the index first._\n"; + return out; + } + const byLangFile: Record> = {}; + for (const sym of index.symbols) { + (byLangFile[sym.language] ??= {})[sym.file] ??= []; + byLangFile[sym.language]![sym.file]!.push(sym); + } + for (const [lang, files] of Object.entries(byLangFile).sort()) { + const count = Object.values(files).reduce((n, v) => n + v.length, 0); + out += `### ${lang} (${count})\n`; + for (const [file, syms] of Object.entries(files).sort()) { + out += ` ${file}\n`; + const byKind: Record = {}; + for (const sym of syms) { + (byKind[sym.kind] ??= []).push(sym.name); + } + for (const [kind, names] of Object.entries(byKind).sort()) { + out += ` ${kind}: ${names.join(", ")}\n`; + } + } + out += "\n"; + } + return out; +} + +// ── Language/kind mappers ──────────────────────────────────────────────── +function mapLanguage(s: string): Language | null { + switch (s) { + case "rust": return "rust"; + case "typescript": return "typescript"; + case "javascript": return "javascript"; + case "python": return "python"; + case "go": return "go"; + default: return null; + } +} +function mapKind(s: string): SymbolKind | null { + switch (s) { + case "fn": return "fn"; + case "class": return "class"; + case "struct": return "struct"; + case "enum": return "enum"; + case "interface": return "interface"; + case "trait": return "trait"; + case "const": return "const"; + case "var": return "var"; + case "mod": return "mod"; + default: return null; + } +} + +// ── Tools ──────────────────────────────────────────────────────────────── + +function workspaceOf(ctx: ToolCtx): string { + return ctx.workspaces[0] ?? "."; +} + +/** Search for code symbols by name or concept. */ +export class SemanticSearch implements Tool { + name = "semantic_search"; + description = "Search for code symbols (functions, structs, classes, interfaces, variables) by name, concept, or meaning across Rust, TypeScript, JavaScript, Python, and Go"; + + parameters = { + type: "object", + properties: { + query: { type: "string", description: "Search query — symbol name, concept, or meaning" }, + kind: { type: "string", enum: ["fn", "class", "struct", "enum", "interface", "trait", "const", "var", "mod", "all"], default: "all", description: "Filter by symbol kind" }, + language: { type: "string", enum: ["rust", "typescript", "javascript", "python", "go", "all"], default: "all", description: "Filter by language" }, + max_results: { type: "integer", default: 10, description: "Maximum results (default 10, max 30)" }, + rebuild_index: { type: "boolean", default: false, description: "Force rebuild the symbol index before searching" }, + }, + required: ["query"], + }; + + async run(ctx: ToolCtx, args: JsonValue): Promise { + const query = argStr(args, "query"); + const kindFilter = optStr(args, "kind") ?? "all"; + const langFilter = optStr(args, "language") ?? "all"; + const maxResults = Math.min(optInt(args, "max_results", 10), 30); + const rebuild = optBool(args, "rebuild_index", false); + + const workspace = workspaceOf(ctx); + await ensureSymbolIndex(workspace, rebuild); + const index = getIndex(workspace); + + const targetKind = mapKind(kindFilter); + const targetLang = mapLanguage(langFilter); + + const results = index.search(query, maxResults * 2); + const filtered = results + .filter((s) => (targetKind === null || s.kind === targetKind)) + .filter((s) => (targetLang === null || s.language === targetLang)) + .slice(0, maxResults); + + if (filtered.length === 0) { + return `No symbols found matching '${query}'.\nTry a different query, or use \`rebuild_index: true\` to rebuild the index first.\nIndex has ${index.len()} symbols across ${index.count_by_language().length} languages.`; + } + + const total = index.len(); + const byFile: Record = {}; + for (const sym of filtered) { + (byFile[sym.file] ??= []).push(sym); + } + + let output = `## Semantic Search Results\n\n**Query:** ${query}\n**Index size:** ${total} symbols\n**Matches:** ${filtered.length}\n\n`; + for (const [file, symbols] of Object.entries(byFile).sort()) { + output += `### \`${file}\`\n\n`; + for (const sym of symbols) { + const parentStr = sym.parent ? ` [${sym.parent}]` : ""; + const docStr = sym.doc_comment ? ` — ${sym.doc_comment.slice(0, 100)}` : ""; + output += `- \`${sym.kind}\` **${sym.name}**${parentStr} \`[${sym.language}]\` at line ${sym.line} \`${sym.context.trim()}\`${docStr}${sym.context.trim().length > 80 ? "…" : ""}\n`; + } + output += "\n"; + } + output += `---\n*${total} symbols indexed across ${index.count_by_language().length} languages. Use \`rebuild_index: true\` to refresh.*\n`; + return output; + } +} + +/** Rebuild the code symbol index. */ +export class RebuildIndex implements Tool { + name = "rebuild_index"; + description = "Rebuild the code symbol index for semantic search (supports Rust, TypeScript, JavaScript, Python, Go)"; + parameters = { type: "object", properties: {} }; + + async run(ctx: ToolCtx, _args: JsonValue): Promise { + const workspace = workspaceOf(ctx); + const count = await ensureSymbolIndex(workspace, true); + const index = getIndex(workspace); + const byLang = index.count_by_language(); + let out = `Symbol index rebuilt successfully. ${count} symbols indexed.\n\nBy language:\n`; + for (const [lang, c] of byLang) out += ` ${lang}: ${c}\n`; + return out; + } +} + +/** List all indexed symbols. */ +export class ListSymbols implements Tool { + name = "list_symbols"; + description = "List all indexed code symbols across Rust, TypeScript, JavaScript, Python, and Go. Optionally filter by language, kind, or file path."; + + parameters = { + type: "object", + properties: { + language: { type: "string", enum: ["rust", "typescript", "javascript", "python", "go", "all"], default: "all" }, + kind: { type: "string", enum: ["fn", "class", "struct", "enum", "interface", "trait", "const", "var", "mod", "all"], default: "all" }, + file: { type: "string", description: "Filter by file path substring" }, + max_results: { type: "integer", default: 50, description: "Maximum symbols to list (default 50, max 200)" }, + rebuild_index: { type: "boolean", default: false }, + }, + }; + + async run(ctx: ToolCtx, args: JsonValue): Promise { + const langFilter = optStr(args, "language") ?? "all"; + const kindFilter = optStr(args, "kind") ?? "all"; + const fileFilter = optStr(args, "file") ?? null; + const maxResults = Math.min(optInt(args, "max_results", 50), 200); + const rebuild = optBool(args, "rebuild_index", false); + + const workspace = workspaceOf(ctx); + await ensureSymbolIndex(workspace, rebuild); + const index = getIndex(workspace); + + const targetLang = mapLanguage(langFilter); + const targetKind = mapKind(kindFilter); + const symbols = index.list(targetLang, targetKind, fileFilter, maxResults); + + const total = index.len(); + const byLang = index.count_by_language(); + + if (symbols.length === 0) { + return `No symbols match the filters. Index has ${total} total symbols.\nLanguages: ${byLang.map(([l, c]) => `${l}: ${c}`).join(", ")}`; + } + + let out = `## Indexed Symbols\n\n**Total:** ${total} | **Showing:** ${symbols.length} | **Filter:** lang=${langFilter}, kind=${kindFilter}\n\n`; + const byLangMap: Record = {}; + for (const sym of symbols) (byLangMap[sym.language] ??= []).push(sym); + + for (const [lang, syms] of Object.entries(byLangMap).sort()) { + out += `### ${lang}\n\n`; + const byFile: Record = {}; + for (const sym of syms) (byFile[sym.file] ??= []).push(sym); + for (const [file, fileSyms] of Object.entries(byFile).sort()) { + out += `\`${file}\`:\n`; + for (const sym of fileSyms) { + out += ` \`${sym.kind}\` ${sym.name} L${sym.line}\n`; + } + } + out += "\n"; + } + out += "---\n"; + out += `By language: ${byLang.map(([l, c]) => `${l}: ${c}`).join(", ")}\n`; + out += `By kind: ${index.count_by_kind().map(([k, c]) => `${k}: ${c}`).join(", ")}\n`; + return out; + } +} diff --git a/apps/packages/infrastructure/src/tools/sequential_think.ts b/apps/packages/infrastructure/src/tools/sequential_think.ts new file mode 100644 index 0000000..ab6c26f --- /dev/null +++ b/apps/packages/infrastructure/src/tools/sequential_think.ts @@ -0,0 +1,31 @@ +/** Sequential thinking tool — step-by-step reasoning. Mirrors `sequential_think.rs`. */ +import type { JsonValue } from "@zesdex/domain"; +import { type Tool, type ToolCtx } from "./mod.ts"; +import { argStr, optBool, optInt } from "./util.ts"; + +export class SeqThink implements Tool { + name = "sequential_think"; + description = "Perform sequential / step-by-step reasoning (chain-of-thought)"; + + parameters = { + type: "object", + properties: { + thought: { type: "string", description: "The current step of reasoning" }, + step_number: { type: "integer", description: "Current step number" }, + total_steps: { type: "integer", description: "Total number of steps planned" }, + next_thought_needed: { type: "boolean", description: "Whether another thinking step is needed" }, + }, + required: ["thought"], + }; + + run(_ctx: ToolCtx, args: JsonValue): string { + const thought = argStr(args, "thought"); + const step = optInt(args, "step_number", 0); + const total = optInt(args, "total_steps", 1); + const nextNeeded = optBool(args, "next_thought_needed", false); + + return `Step ${step}/${total}: ${thought}\n${ + nextNeeded ? "Continuing reasoning..." : "Reasoning complete." + }`; + } +} diff --git a/apps/packages/infrastructure/src/tools/shell/bash.ts b/apps/packages/infrastructure/src/tools/shell/bash.ts new file mode 100644 index 0000000..533f69e --- /dev/null +++ b/apps/packages/infrastructure/src/tools/shell/bash.ts @@ -0,0 +1,81 @@ +/** + * Bash-shell execution tool with safety filters and optional timeout. + * Mirrors `tools/shell.rs`. + */ +import type { JsonValue } from "@zesdex/domain"; +import { type Tool, type ToolCtx } from "../mod.ts"; +import { argStr, optBool, optInt } from "../util.ts"; +import { checkGitDestructive } from "./git.ts"; + +const DEFAULT_TIMEOUT_MS = 120_000; +const MAX_TIMEOUT_MS = 600_000; + +/** Execute a shell command via `bash -c`. */ +export class Bash implements Tool { + name = "bash"; + description = "Execute a shell command via bash -c"; + + parameters = { + type: "object", + properties: { + command: { type: "string", description: "Shell command to execute" }, + description: { type: "string", description: "Human-readable description of what the command does" }, + timeout: { type: "integer", description: "Timeout in milliseconds (default 120000, max 600000)" }, + run_in_background: { type: "boolean", description: "Run the command in the background" }, + }, + required: ["command"], + }; + + run(_ctx: ToolCtx, args: JsonValue): string { + const cmd = argStr(args, "command"); + let timeoutMs = optInt(args, "timeout", DEFAULT_TIMEOUT_MS); + timeoutMs = Math.min(timeoutMs, MAX_TIMEOUT_MS); + + // Safety filter: block destructive git operations + const blocked = checkGitDestructive(cmd); + if (blocked) throw new Error(`blocked: ${blocked}`); + + const runInBackground = optBool(args, "run_in_background", false); + + if (runInBackground) { + // Background jobs handled via bgbash + const jobId = `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; + try { + const outDir = _ctx.sessionDir ? `${_ctx.sessionDir}/bash-outputs` : ""; + if (outDir) { + import("node:fs").then((fs) => { + fs.mkdirSync(outDir, { recursive: true }); + const child = Bun.spawn({ cmd: ["bash", "-c", cmd], stdout: "pipe", stderr: "pipe" }); + const read = async () => { + const out = child.stdout ? await new Response(child.stdout).text() : ""; + const err = child.stderr ? await new Response(child.stderr).text() : ""; + const code = await child.exited; + const combined = err ? `${out}\n${err}` : out; + fs.writeFileSync(`${outDir}/${jobId}`, `Exit code: ${code}\n\n${combined}`); + }; + read().catch(() => {}); + }); + } + } catch { + // Background spawn failed — still report the job id + } + return `Background job: ${jobId}`; + } + + const proc = Bun.spawnSync({ cmd: ["bash", "-c", cmd], stdout: "pipe", stderr: "pipe", timeout: timeoutMs }); + const stdout = proc.stdout.toString(); + const stderr = proc.stderr.toString(); + const code = proc.exitCode; + const elapsed = (proc as unknown as { duration?: number })?.duration ?? 0; + const elapsedStr = (elapsed / 1000).toFixed(2); + const combined = stderr ? `${stdout}\n${stderr}` : stdout; + const trimmed = combined.trim(); + + if (code === 0) { + return trimmed === "" + ? `Command completed in ${elapsedStr}s (exit code 0)` + : `${trimmed}\n\nExit code: 0 (${elapsedStr}s)`; + } + return `${trimmed}\n\nExit code: ${code} (${elapsedStr}s)`; + } +} diff --git a/apps/packages/infrastructure/src/tools/shell/credentials.ts b/apps/packages/infrastructure/src/tools/shell/credentials.ts new file mode 100644 index 0000000..5771659 --- /dev/null +++ b/apps/packages/infrastructure/src/tools/shell/credentials.ts @@ -0,0 +1,39 @@ +/** + * Credential read detection — detects commands that might exfiltrate secrets. + * Mirrors `shell_filter/credentials.rs`. NOTE: intentionally NOT wired into + * the bash tool by default. + */ + +const CREDENTIAL_PATTERNS = [ + "~/.ssh/", + ".netrc", + ".aws/credentials", + ".aws/config", + ".azure/", + ".gcp/", + ".docker/config.json", + "id_rsa", + "id_ed25519", + "known_hosts", +]; + +/** Whether a path is likely to contain credentials. */ +export function isCredentialPath(p: string): boolean { + return CREDENTIAL_PATTERNS.some((pat) => p.includes(pat)); +} + +/** Check whether a command reads credential files. */ +export function checkCredentialRead(cmd: string): string[] { + const findings: string[] = []; + const re = new RegExp( + "(?:cat|head|tail|less|more|vim?|nano|xdg-open|open|type|echo)\\s+(~?/[\\w/.@-]+)", + "gi", + ); + for (const m of cmd.matchAll(re)) { + const p = m[1] ?? ""; + if (isCredentialPath(p)) { + findings.push(`potential credential read: '${p}'`); + } + } + return findings; +} diff --git a/apps/packages/infrastructure/src/tools/shell/git.ts b/apps/packages/infrastructure/src/tools/shell/git.ts new file mode 100644 index 0000000..08d3ae3 --- /dev/null +++ b/apps/packages/infrastructure/src/tools/shell/git.ts @@ -0,0 +1,26 @@ +/** + * Git operation safety filter — blocks destructive git commands. + * Mirrors `shell_filter/git.rs`. + */ + +const DESTRUCTIVE_PATTERNS = [ + "git push --force", + "git push -f", + "git reset --hard", + "git rebase", + "git branch -d", + "git branch -D", + "git tag -d", + "git tag --delete", +]; + +/** Check whether a shell command contains a destructive git operation. */ +export function checkGitDestructive(cmd: string): string | null { + const cmdLower = cmd.toLowerCase(); + for (const pattern of DESTRUCTIVE_PATTERNS) { + if (cmdLower.includes(pattern)) { + return `destructive git operation blocked: '${pattern}'`; + } + } + return null; +} diff --git a/apps/packages/infrastructure/src/tools/spawn.ts b/apps/packages/infrastructure/src/tools/spawn.ts new file mode 100644 index 0000000..f526e19 --- /dev/null +++ b/apps/packages/infrastructure/src/tools/spawn.ts @@ -0,0 +1,84 @@ +/** + * Agent spawning tools — launch subagents and pipelines. Mirrors + * `tools/spawn.rs`. Delegates to the subagent engine (3c); degrades + * gracefully if not yet wired. + */ +import type { JsonValue } from "@zesdex/domain"; +import { type Tool, type ToolCtx } from "./mod.ts"; +import { reqArray } from "./util.ts"; + +/** Spawn multiple agents in parallel on subtasks. */ +export class SpawnAgents implements Tool { + name = "spawn_agents"; + description = "Spawn multiple agent instances to work in parallel on subtasks"; + + parameters = { + type: "object", + properties: { + agents: { + type: "array", + items: { + type: "object", + properties: { + directive: { type: "string", description: "Directive for the agent" }, + access: { type: "string", enum: ["read", "write", "full"], description: "Access tier" }, + }, + required: ["directive"], + }, + }, + }, + required: ["agents"], + }; + + async run(ctx: ToolCtx, args: JsonValue): Promise { + const agents = reqArray(args, "agents"); + try { + const { spawnAgents } = await import("../subagent/spawn_tools.ts"); + return await spawnAgents(agents, ctx); + } catch (e) { + const msg = (e as Error).message; + if (msg.includes("not yet") || msg.includes("Cannot find")) { + return `Subagent engine not yet wired in this build. ${agents.length} agent(s) requested.`; + } + throw e; + } + } +} + +/** Spawn a sequential pipeline of agent stages. */ +export class SpawnPipeline implements Tool { + name = "spawn_pipeline"; + description = "Spawn a sequential pipeline of agent stages"; + + parameters = { + type: "object", + properties: { + stages: { + type: "array", + items: { + type: "object", + properties: { + directive: { type: "string", description: "Directive for this pipeline stage" }, + access: { type: "string", enum: ["read", "write", "full"], description: "Access tier for this stage" }, + }, + required: ["directive"], + }, + }, + }, + required: ["stages"], + }; + + async run(ctx: ToolCtx, args: JsonValue): Promise { + const stages = reqArray(args, "stages"); + try { + const { spawnPipeline } = await import("../subagent/spawn_tools.ts"); + return await spawnPipeline(stages, ctx); + } catch (e) { + const msg = (e as Error).message; + if (msg.includes("not yet") || msg.includes("Cannot find")) { + return `Subagent engine not yet wired in this build. ${stages.length} stage(s) requested.`; + } + throw e; + } + } +} diff --git a/apps/packages/infrastructure/src/tools/util.ts b/apps/packages/infrastructure/src/tools/util.ts new file mode 100644 index 0000000..b9f98c0 --- /dev/null +++ b/apps/packages/infrastructure/src/tools/util.ts @@ -0,0 +1,176 @@ +/** + * Shared utility functions for tool implementations: JSON argument + * extraction, path resolution, command execution, and edit-log persistence. + * Mirrors `apps/infrastructure/src/tools/util.rs`. + */ +import * as crypto from "node:crypto"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import type { JsonValue } from "@zesdex/domain"; + +/** Extract a required string argument from a JSON args map. */ +export function argStr(args: JsonValue, name: string): string { + if (args && typeof args === "object" && !Array.isArray(args)) { + const v = (args as Record)[name]; + if (typeof v === "string") return v; + } + throw new Error(`missing required argument: ${name}`); +} + +/** Extract an optional string argument from a JSON args map. */ +export function optStr(args: JsonValue, name: string): string | undefined { + if (args && typeof args === "object" && !Array.isArray(args)) { + const v = (args as Record)[name]; + if (typeof v === "string") return v; + } + return undefined; +} + +/** Extract an optional integer argument from a JSON args map. */ +export function optInt(args: JsonValue, name: string, fallback: number): number { + if (args && typeof args === "object" && !Array.isArray(args)) { + const v = (args as Record)[name]; + if (typeof v === "number") return v; + if (typeof v === "string") { + const n = parseInt(v, 10); + if (!isNaN(n)) return n; + } + } + return fallback; +} + +/** Extract an optional boolean argument from a JSON args map. */ +export function optBool(args: JsonValue, name: string, fallback: boolean): boolean { + if (args && typeof args === "object" && !Array.isArray(args)) { + const v = (args as Record)[name]; + if (typeof v === "boolean") return v; + } + return fallback; +} + +/** Extract an optional string array argument from a JSON args map. */ +export function optStrArray(args: JsonValue, name: string): string[] { + if (args && typeof args === "object" && !Array.isArray(args)) { + const v = (args as Record)[name]; + if (Array.isArray(v)) { + return v.filter((x): x is string => typeof x === "string"); + } + } + return []; +} + +/** Extract a required array of objects from a JSON args map. */ +export function reqArray(args: JsonValue, name: string): JsonValue[] { + if (args && typeof args === "object" && !Array.isArray(args)) { + const v = (args as Record)[name]; + if (Array.isArray(v)) return v; + } + throw new Error(`missing required argument: ${name}`); +} + +/** Compute SHA-256 hex digest of a string. */ +export function sha256Hex(s: string): string { + return crypto.createHash("sha256").update(s).digest("hex"); +} + +/** + * Resolve a tool-supplied relative path to an absolute path within a workspace + * root, rejecting escapes. Supports optional `[idx]` prefix. + */ +export function resolvePath(workspaces: string[], rel: string): string { + let wsIdx = 0; + let pathPart = rel; + + // Parse optional [idx] prefix + if (rel.startsWith("[")) { + const close = rel.indexOf("]"); + if (close === -1) throw new Error("invalid workspace prefix"); + wsIdx = parseInt(rel.slice(1, close), 10); + if (isNaN(wsIdx)) throw new Error("invalid workspace index"); + pathPart = rel.slice(close + 1); + } + + const base = workspaces[wsIdx]; + if (base === undefined) { + throw new Error(`workspace index ${wsIdx} out of range`); + } + + const abs = pathPart === "" ? base : path.resolve(base, pathPart); + + // Check if resolved path is inside any workspace + const inside = workspaces.some((w) => { + const canonW = fs.existsSync(w) ? fs.realpathSync(w) : w; + const canonAbs = abs; + return canonAbs.startsWith(canonW) || canonAbs === canonW; + }); + + if (!inside) { + throw new Error(`path '${rel}' is outside all workspace roots`); + } + + return abs; +} + +/** + * After a successful write/edit tool run, compute content hash and byte + * delta, then persist an EditLogEntry. + */ +export function logWriteEditTool( + args: JsonValue, + toolName: string, + originTag: string, + sessionDir: string, + _sessionId: string, +): void { + if (!args || typeof args !== "object" || Array.isArray(args)) return; + const a = args as Record; + const reason = typeof a.reason === "string" ? a.reason : "unnamed"; + const filePath = typeof a.path === "string" ? a.path : "unknown"; + const content = typeof a.content === "string" ? a.content : typeof a.new === "string" ? a.new : ""; + const contentSha256 = sha256Hex(content); + + let bytesDelta: number; + if (toolName === "write") { + bytesDelta = Buffer.byteLength(content, "utf8"); + } else { + const oldLen = typeof a.old === "string" ? Buffer.byteLength(a.old, "utf8") : 0; + const newLen = typeof a.new === "string" ? Buffer.byteLength(a.new, "utf8") : 0; + bytesDelta = Math.abs(newLen - oldLen); + } + + const entry = { + ts: Date.now(), + tool: toolName, + path: filePath, + reason, + content_sha256: contentSha256, + bytes_delta: bytesDelta, + origin: originTag, + session_id: _sessionId, + }; + + try { + const logDir = path.join(sessionDir, "edit-log"); + fs.mkdirSync(logDir, { recursive: true }); + const logPath = path.join(logDir, "edits.jsonl"); + fs.appendFileSync(logPath, JSON.stringify(entry) + "\n"); + } catch { + // best-effort: don't fail the tool for edit-log errors + } +} + +/** + * Execute a command and return combined stdout/stderr. Throws on non-zero + * exit with the combined output. Mirrors `execute_cmd` in `util.rs`. + */ +export function executeCmd(argv: string[], cwd?: string): string { + const proc = Bun.spawnSync({ cmd: argv, cwd, stdout: "pipe", stderr: "pipe" }); + const stdout = proc.stdout.toString().trim(); + const stderr = proc.stderr.toString().trim(); + const combined = stderr === "" ? stdout : `${stdout}\n${stderr}`.trim(); + const code = proc.exitCode ?? -1; + if (proc.exitCode === 0) { + return combined; + } + throw new Error(`command failed with exit code ${code}:\n${combined}`); +} diff --git a/apps/packages/infrastructure/src/tools/utility/cd.ts b/apps/packages/infrastructure/src/tools/utility/cd.ts new file mode 100644 index 0000000..22fb162 --- /dev/null +++ b/apps/packages/infrastructure/src/tools/utility/cd.ts @@ -0,0 +1,33 @@ +/** Set the working directory for subsequent tool calls. Mirrors `utility/cd.rs`. */ +import * as fs from "node:fs"; +import type { JsonValue } from "@zesdex/domain"; +import { type Tool, type ToolCtx } from "../mod.ts"; +import { argStr, resolvePath } from "../util.ts"; + +export class Cd implements Tool { + name = "cd"; + description = "Set the working directory for subsequent tool calls"; + + parameters = { + type: "object", + properties: { + directory: { type: "string", description: "Directory path to change to (relative to workspace root)" }, + }, + required: ["directory"], + }; + + run(ctx: ToolCtx, args: JsonValue): string { + const dir = argStr(args, "directory"); + const resolved = resolvePath(ctx.workspaces, dir); + // Note: process.chdir is process-global. Tracked at a higher level in the + // real session runtime; here we perform the chdir but expose the path. + try { + process.chdir(resolved); + } catch (e) { + // Keep directory listing even if chdir unsupported; return path anyway. + void fs.existsSync(resolved); + throw e; + } + return `Changed directory to '${resolved}'`; + } +} diff --git a/apps/packages/infrastructure/src/tools/utility/dir_cache_update.ts b/apps/packages/infrastructure/src/tools/utility/dir_cache_update.ts new file mode 100644 index 0000000..ee1453d --- /dev/null +++ b/apps/packages/infrastructure/src/tools/utility/dir_cache_update.ts @@ -0,0 +1,25 @@ +/** Update the shared directory cache. Mirrors `utility/dir_cache_update.rs`. */ +import type { JsonValue } from "@zesdex/domain"; +import { type Tool, type ToolCtx } from "../mod.ts"; +import { optStrArray, resolvePath } from "../util.ts"; + +export class DirCacheUpdate implements Tool { + name = "dir_cache_update"; + description = "Update the cached directory listing"; + + parameters = { + type: "object", + properties: { + paths: { type: "array", items: { type: "string" }, description: "New list of paths for the cache" }, + }, + }; + + run(ctx: ToolCtx, args: JsonValue): string { + const paths = optStrArray(args, "paths"); + const resolved = paths.map((p) => resolvePath(ctx.workspaces, p)); + // Mutate the shared dirCache array in place (ToolCtx is a live object). + ctx.dirCache.length = 0; + ctx.dirCache.push(...resolved); + return `Directory cache updated with ${resolved.length} entries`; + } +} diff --git a/apps/packages/infrastructure/src/tools/utility/dir_list.ts b/apps/packages/infrastructure/src/tools/utility/dir_list.ts new file mode 100644 index 0000000..1f55c0e --- /dev/null +++ b/apps/packages/infrastructure/src/tools/utility/dir_list.ts @@ -0,0 +1,33 @@ +/** List directory contents. Mirrors `utility/dir_list.rs`. */ +import * as fs from "node:fs"; +import type { JsonValue } from "@zesdex/domain"; +import { type Tool, type ToolCtx } from "../mod.ts"; +import { argStr, resolvePath } from "../util.ts"; + +export class DirList implements Tool { + name = "dir_list"; + description = "List files and directories in a given path"; + + parameters = { + type: "object", + properties: { + path: { type: "string", description: "Path to list (relative to workspace root)" }, + }, + required: ["path"], + }; + + run(ctx: ToolCtx, args: JsonValue): string { + const rel = argStr(args, "path"); + const dir = resolvePath(ctx.workspaces, rel); + if (!fs.existsSync(dir)) throw new Error(`path '${rel}' does not exist`); + if (!fs.statSync(dir).isDirectory()) throw new Error(`'${rel}' is not a directory`); + + const entries = fs.readdirSync(dir, { withFileTypes: true }); + const items = entries.map((e) => { + if (e.isDirectory()) return `${e.name}/`; + return e.name; + }); + items.sort(); + return items.join("\n"); + } +} diff --git a/apps/packages/infrastructure/src/tools/utility/mod.ts b/apps/packages/infrastructure/src/tools/utility/mod.ts new file mode 100644 index 0000000..919acd4 --- /dev/null +++ b/apps/packages/infrastructure/src/tools/utility/mod.ts @@ -0,0 +1,7 @@ +/** Utility tools barrel. */ +export { Cd } from "./cd.ts"; +export { DirList } from "./dir_list.ts"; +export { DirCacheUpdate } from "./dir_cache_update.ts"; +export { Pong } from "./pong.ts"; +export { Todowrite } from "./todowrite.ts"; +export { Todofinish } from "./todofinish.ts"; diff --git a/apps/packages/infrastructure/src/tools/utility/pong.ts b/apps/packages/infrastructure/src/tools/utility/pong.ts new file mode 100644 index 0000000..ed372d5 --- /dev/null +++ b/apps/packages/infrastructure/src/tools/utility/pong.ts @@ -0,0 +1,14 @@ +/** Simple ping/pong tool. Mirrors `utility/pong.rs`. */ +import type { JsonValue } from "@zesdex/domain"; +import { type Tool, type ToolCtx } from "../mod.ts"; + +export class Pong implements Tool { + name = "pong"; + description = "Ping the agent — useful for testing connectivity"; + + parameters = { type: "object", properties: {} }; + + run(_ctx: ToolCtx, _args: JsonValue): string { + return "pong"; + } +} diff --git a/apps/packages/infrastructure/src/tools/utility/todofinish.ts b/apps/packages/infrastructure/src/tools/utility/todofinish.ts new file mode 100644 index 0000000..3c61e0e --- /dev/null +++ b/apps/packages/infrastructure/src/tools/utility/todofinish.ts @@ -0,0 +1,50 @@ +/** Mark a TODO item as finished. Mirrors `utility/todofinish.rs`. */ +import * as fs from "node:fs"; +import * as path from "node:path"; +import type { JsonValue, TurnEvent } from "@zesdex/domain"; +import { type Tool, type ToolCtx } from "../mod.ts"; +import { argStr } from "../util.ts"; + +export class Todofinish implements Tool { + name = "todofinish"; + description = "Mark a TODO item as completed"; + + parameters = { + type: "object", + properties: { + item: { type: "string", description: "TODO item text that was completed" }, + }, + required: ["item"], + }; + + run(ctx: ToolCtx, args: JsonValue): string { + const item = argStr(args, "item"); + const todoPath = path.join(ctx.sessionDir, "TODO.md"); + let content = ""; + try { + content = fs.readFileSync(todoPath, "utf8"); + } catch { + content = ""; + } + + let replaced = false; + const newLines = content.split("\n").map((line) => { + if (!line.includes(item) || !line.startsWith("- [")) return line; + replaced = true; + return line + .replace("- [high]", "- [x]") + .replace("- [medium]", "- [x]") + .replace("- [low]", "- [x]") + .replace("[ ]", "[x]"); + }); + + if (replaced) { + content = newLines.join("\n") + "\n"; + fs.writeFileSync(todoPath, content); + if (ctx.turnEvents) { + ctx.turnEvents.push({ kind: "todo_update", content } as TurnEvent); + } + } + return `TODO completed: ${item}`; + } +} diff --git a/apps/packages/infrastructure/src/tools/utility/todowrite.ts b/apps/packages/infrastructure/src/tools/utility/todowrite.ts new file mode 100644 index 0000000..2f5ec74 --- /dev/null +++ b/apps/packages/infrastructure/src/tools/utility/todowrite.ts @@ -0,0 +1,41 @@ +/** Write a TODO item. Mirrors `utility/todowrite.rs`. */ +import * as fs from "node:fs"; +import * as path from "node:path"; +import type { JsonValue, TurnEvent } from "@zesdex/domain"; +import { type Tool, type ToolCtx } from "../mod.ts"; +import { argStr, optStr } from "../util.ts"; + +export class Todowrite implements Tool { + name = "todowrite"; + description = "Add an item to the TODO list"; + + parameters = { + type: "object", + properties: { + item: { type: "string", description: "TODO item text" }, + priority: { type: "string", enum: ["high", "medium", "low"], description: "Priority level" }, + }, + required: ["item"], + }; + + run(ctx: ToolCtx, args: JsonValue): string { + const item = argStr(args, "item"); + const priority = optStr(args, "priority") ?? "medium"; + const todoLine = `- [${priority}] ${item}\n`; + + const todoPath = path.join(ctx.sessionDir, "TODO.md"); + let content = ""; + try { + content = fs.readFileSync(todoPath, "utf8"); + } catch { + content = ""; + } + content += todoLine; + fs.writeFileSync(todoPath, content); + + if (ctx.turnEvents) { + ctx.turnEvents.push({ kind: "todo_update", content } as TurnEvent); + } + return `[${priority}] TODO added: ${item}`; + } +} diff --git a/apps/packages/infrastructure/src/tools/web_search.ts b/apps/packages/infrastructure/src/tools/web_search.ts new file mode 100644 index 0000000..d5b4385 --- /dev/null +++ b/apps/packages/infrastructure/src/tools/web_search.ts @@ -0,0 +1,103 @@ +/** + * Web search & documentation scraper tool — search the web via a SearXNG + * instance. Mirrors `tools/web_search.rs`. Implements `run` as async since + * network I/O is inherently asynchronous. + */ +import type { JsonValue } from "@zesdex/domain"; +import { type Tool, type ToolCtx } from "./mod.ts"; +import { argStr, optBool, optInt, optStr } from "./util.ts"; + +const DEFAULT_SEARXNG_URL = "https://searxng.imrnes.team"; + +/** URL-encode a string for query parameters (space→+). */ +function urlencoding(input: string): string { + return encodeURIComponent(input).replace(/%20/g, "+"); +} + +async function fetchPageContent(url: string): Promise { + const resp = await fetch(url, { signal: AbortSignal.timeout(15_000) }); + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + const html = await resp.text(); + const stripped = html + .replace(//gi, " ") + .replace(//gi, " ") + .replace(/<(nav|footer|header)[\s\S]*?<\/\1>/gi, " "); + const text = stripped.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim(); + if (text === "") throw new Error("no readable content found on page"); + return text; +} + +/** Search the web for documentation, APIs, and technical information. */ +export class WebSearch implements Tool { + name = "web_search"; + description = "Search the web for documentation, APIs, and technical information. Uses SearXNG instance."; + + parameters = { + type: "object", + properties: { + query: { type: "string", description: "Search query (e.g. 'Next.js 15 app router documentation')" }, + max_results: { type: "integer", description: "Maximum number of results to return (default 5, max 10)", default: 5 }, + fetch_content: { type: "boolean", description: "Whether to fetch full page content from each result (default false)", default: false }, + categories: { type: "string", description: "Search categories (e.g. 'general', 'science', 'it', 'news'). Default: general", default: "general" }, + }, + required: ["query"], + }; + + async run(_ctx: ToolCtx, args: JsonValue): Promise { + const query = argStr(args, "query"); + const maxResults = Math.min(optInt(args, "max_results", 5), 10); + const fetchContent = optBool(args, "fetch_content", false); + const categories = optStr(args, "categories") ?? "general"; + + const searxngUrl = process.env.SEARXNG_URL || DEFAULT_SEARXNG_URL; + const searchUrl = `${searxngUrl}/search?format=json&q=${urlencoding(query)}&categories=${categories}&language=en-US`; + + let body: unknown; + try { + const resp = await fetch(searchUrl, { + signal: AbortSignal.timeout(30_000), + headers: { "User-Agent": "Zesdex/1.0 (AI Coding Agent)" }, + }); + if (!resp.ok) throw new Error(`SearXNG returned HTTP ${resp.status}`); + body = await resp.json(); + } catch (e) { + throw new Error(`search request failed: ${(e as Error).message}`); + } + + const resultsArr = (body as { results?: Array> })?.results ?? []; + const results = resultsArr + .filter((r) => typeof r.url === "string" && typeof r.title === "string") + .slice(0, maxResults); + + if (results.length === 0) { + return `No search results found for '${query}'.`; + } + + let output = `## Web Search Results for: ${query}\n\n`; + for (let i = 0; i < results.length; i++) { + const r = results[i]!; + const title = r.title as string; + const url = r.url as string; + const snippet = ((r.content as string) ?? "").trim(); + + output += `### ${i + 1}. [${title}](${url})\n\n`; + if (snippet) output += `> ${snippet}\n\n`; + + if (fetchContent && url) { + try { + const content = await fetchPageContent(url); + const truncated = content.split("\n").slice(0, 80).join("\n"); + output += `**Content:**\n\`\`\`\n${truncated}\`\`\`\n\n`; + } catch (e) { + output += `*(Content fetch failed: ${(e as Error).message})*\n\n`; + } + } + if (typeof r.engine === "string") output += `*Source: ${r.engine}*\n\n`; + } + + if (results.length < 3) { + output += "---\n*Few results. Try a more specific query or different categories.*\n"; + } + return output; + } +} diff --git a/apps/packages/infrastructure/src/tools/workflow.ts b/apps/packages/infrastructure/src/tools/workflow.ts new file mode 100644 index 0000000..e9c4436 --- /dev/null +++ b/apps/packages/infrastructure/src/tools/workflow.ts @@ -0,0 +1,121 @@ +/** + * Workflow tools — orchestrate multi-step agent workflows and hive-mind + * convergence. Mirrors `tools/workflow.rs`. + * + * Note: full workflow/hive-mind execution engines are ported in a later + * sub-phase (3d). These tools load the engine lazily; if unavailable they + * return a helpful note so the agent can still use the tool surface. + */ +import type { JsonValue } from "@zesdex/domain"; +import { type Tool, type ToolCtx } from "./mod.ts"; +import { argStr, optStr } from "./util.ts"; + +/** Execute a multi-step workflow defined in YAML. */ +export class WorkflowRun implements Tool { + name = "workflow_run"; + description = "Execute a multi-step workflow defined in YAML"; + + parameters = { + type: "object", + properties: { + workflow_yaml: { type: "string", description: "YAML workflow definition" }, + }, + required: ["workflow_yaml"], + }; + + async run(ctx: ToolCtx, args: JsonValue): Promise { + const yaml = argStr(args, "workflow_yaml"); + try { + const { executeWorkflow } = await import("../workflow/index.ts"); + return await executeWorkflow(yaml, ctx); + } catch (e) { + const msg = (e as Error).message; + if (msg.includes("not yet") || msg.includes("Cannot find")) { + return `Workflow engine not yet wired in this build. Workflow definition received (${yaml.length} chars).`; + } + throw e; + } + } +} + +/** Record a finding during workflow or hive-mind execution. */ +export class NoteFinding implements Tool { + name = "note_finding"; + description = "Record a finding during workflow or hive-mind execution"; + + parameters = { + type: "object", + properties: { + finding: { type: "string", description: "The finding text" }, + category: { type: "string", description: "Category for the finding" }, + }, + required: ["finding"], + }; + + run(ctx: ToolCtx, args: JsonValue): string { + const finding = argStr(args, "finding"); + const category = optStr(args, "category") ?? "general"; + const tagged = `[${category}] ${finding}`; + if (ctx.workflowFindings) { + ctx.workflowFindings.push(tagged); + } + return `Finding recorded: ${finding}`; + } +} + +/** Read all findings recorded so far. */ +export class ReadFindings implements Tool { + name = "read_findings"; + description = "Read all findings recorded so far in the current workflow"; + parameters = { type: "object", properties: {} }; + + run(ctx: ToolCtx, _args: JsonValue): string { + const findings = ctx.workflowFindings ?? []; + if (findings.length === 0) return "No findings recorded yet."; + return `Findings (${findings.length}):\n${findings.join("\n")}`; + } +} + +/** Orchestrate a hive-mind convergence. */ +export class HiveMind implements Tool { + name = "hive_mind"; + description = "Run a hive-mind convergence with multiple nodes across sequential cycles"; + + parameters = { + type: "object", + properties: { + cycles: { + type: "array", + items: { + type: "object", + properties: { + directives: { + type: "array", + items: { + type: "object", + properties: { + directive: { type: "string" }, + access: { type: "string", enum: ["read", "write", "full"] }, + }, + }, + }, + }, + }, + }, + }, + required: ["cycles"], + }; + + async run(ctx: ToolCtx, args: JsonValue): Promise { + try { + const { executeHiveMind } = await import("../workflow/hive_mind.ts"); + return await executeHiveMind(args, ctx); + } catch (e) { + const msg = (e as Error).message; + if (msg.includes("not yet") || msg.includes("Cannot find")) { + return "Hive-mind engine not yet wired in this build."; + } + throw e; + } + } +} diff --git a/apps/packages/infrastructure/src/workflow/hive_mind.ts b/apps/packages/infrastructure/src/workflow/hive_mind.ts new file mode 100644 index 0000000..3aaf4a4 --- /dev/null +++ b/apps/packages/infrastructure/src/workflow/hive_mind.ts @@ -0,0 +1,10 @@ +/** + * Hive-mind convergence engine — ported in sub-phase 3d. + */ +import type { JsonValue } from "@zesdex/domain"; +import type { ToolCtx } from "../tools/mod.ts"; + +/** Execute a hive-mind convergence from tool arguments. */ +export async function executeHiveMind(_args: JsonValue, _ctx: ToolCtx): Promise { + throw new Error("hive-mind engine not yet wired in this build"); +} diff --git a/apps/packages/infrastructure/src/workflow/index.ts b/apps/packages/infrastructure/src/workflow/index.ts new file mode 100644 index 0000000..f87d960 --- /dev/null +++ b/apps/packages/infrastructure/src/workflow/index.ts @@ -0,0 +1,10 @@ +/** + * Workflow execution engine — ported in sub-phase 3d. This module provides + * the execution entry points used by the workflow tools. + */ +import type { ToolCtx } from "../tools/mod.ts"; + +/** Execute a YAML workflow definition. */ +export async function executeWorkflow(_yaml: string, _ctx: ToolCtx): Promise { + throw new Error("workflow engine not yet wired in this build"); +}