From 9c38728d678f88b8288a472ff626550e7c941f0f Mon Sep 17 00:00:00 2001 From: Bethel Yohannes Date: Wed, 18 Feb 2026 18:18:00 +0300 Subject: [PATCH 1/8] created the hooks folder --- src/hooks/ middleware.ts | 0 src/hooks/ post-hook.ts | 0 src/hooks/ pre-hook.ts | 0 src/hooks/context-loader.ts | 0 src/hooks/index.ts | 0 src/hooks/scope-enforcer.ts | 0 src/hooks/trace-serializer.ts | 0 src/hooks/types.ts | 0 8 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 src/hooks/ middleware.ts create mode 100644 src/hooks/ post-hook.ts create mode 100644 src/hooks/ pre-hook.ts create mode 100644 src/hooks/context-loader.ts create mode 100644 src/hooks/index.ts create mode 100644 src/hooks/scope-enforcer.ts create mode 100644 src/hooks/trace-serializer.ts create mode 100644 src/hooks/types.ts diff --git a/src/hooks/ middleware.ts b/src/hooks/ middleware.ts new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/hooks/ post-hook.ts b/src/hooks/ post-hook.ts new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/hooks/ pre-hook.ts b/src/hooks/ pre-hook.ts new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/hooks/context-loader.ts b/src/hooks/context-loader.ts new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/hooks/index.ts b/src/hooks/index.ts new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/hooks/scope-enforcer.ts b/src/hooks/scope-enforcer.ts new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/hooks/trace-serializer.ts b/src/hooks/trace-serializer.ts new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/hooks/types.ts b/src/hooks/types.ts new file mode 100644 index 00000000000..e69de29bb2d From 56653f5dfdf6a6a4a98dd67fe2a559210bd8fa16 Mon Sep 17 00:00:00 2001 From: Bethel Yohannes Date: Wed, 18 Feb 2026 22:04:03 +0300 Subject: [PATCH 2/8] hooks file updated --- src/hooks/ middleware.ts | 0 src/hooks/ pre-hook.ts | 0 src/hooks/content-hash.ts | 21 +++++ src/hooks/context-loader.ts | 105 +++++++++++++++++++++++++ src/hooks/index.ts | 13 +++ src/hooks/intent-prompt-snippet.ts | 6 ++ src/hooks/middleware.ts | 55 +++++++++++++ src/hooks/post-hook.ts | 71 +++++++++++++++++ src/hooks/pre-hook.ts | 89 +++++++++++++++++++++ src/hooks/scope.ts | 39 +++++++++ src/hooks/select-active-intent-tool.ts | 23 ++++++ src/hooks/types.ts | 94 ++++++++++++++++++++++ 12 files changed, 516 insertions(+) delete mode 100644 src/hooks/ middleware.ts delete mode 100644 src/hooks/ pre-hook.ts create mode 100644 src/hooks/content-hash.ts create mode 100644 src/hooks/intent-prompt-snippet.ts create mode 100644 src/hooks/middleware.ts create mode 100644 src/hooks/post-hook.ts create mode 100644 src/hooks/pre-hook.ts create mode 100644 src/hooks/scope.ts create mode 100644 src/hooks/select-active-intent-tool.ts diff --git a/src/hooks/ middleware.ts b/src/hooks/ middleware.ts deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/src/hooks/ pre-hook.ts b/src/hooks/ pre-hook.ts deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/src/hooks/content-hash.ts b/src/hooks/content-hash.ts new file mode 100644 index 00000000000..538666b1b35 --- /dev/null +++ b/src/hooks/content-hash.ts @@ -0,0 +1,21 @@ +import crypto from "crypto" + +const HASH_PREFIX = "sha256:" + +/** + * Compute a SHA-256 content hash for spatial independence. + * If lines move, the hash of the content block remains valid. + */ +export function contentHash(content: string): string { + const hash = crypto.createHash("sha256").update(content, "utf8").digest("hex") + return `${HASH_PREFIX}${hash}` +} + +/** + * Extract a logical code block (e.g. by line range) and return its hash. + */ +export function contentHashForRange(fullContent: string, startLine: number, endLine: number): string { + const lines = fullContent.split("\n") + const slice = lines.slice(Math.max(0, startLine - 1), endLine).join("\n") + return contentHash(slice) +} diff --git a/src/hooks/context-loader.ts b/src/hooks/context-loader.ts index e69de29bb2d..9d8a2f15d3e 100644 --- a/src/hooks/context-loader.ts +++ b/src/hooks/context-loader.ts @@ -0,0 +1,105 @@ +import fs from "fs/promises" +import path from "path" +import yaml from "yaml" + +import type { IntentContext, ActiveIntentsDoc } from "./types" + +const ORCHESTRATION_DIR = ".orchestration" +const ACTIVE_INTENTS_FILE = "active_intents.yaml" +const AGENT_TRACE_FILE = "agent_trace.jsonl" + +/** + * Loads intent context from .orchestration/active_intents.yaml for the given intent ID. + * Used by the Pre-Hook when the agent calls select_active_intent. + */ +export async function loadIntentContext(cwd: string, intentId: string): Promise { + const filePath = path.join(cwd, ORCHESTRATION_DIR, ACTIVE_INTENTS_FILE) + try { + const raw = await fs.readFile(filePath, "utf-8") + const doc = yaml.parse(raw) as ActiveIntentsDoc + const intent = doc?.active_intents?.find((i) => i.id === intentId) + if (!intent) return null + return { + id: intent.id, + name: intent.name, + status: intent.status, + constraints: intent.constraints ?? [], + owned_scope: intent.owned_scope ?? [], + acceptance_criteria: intent.acceptance_criteria, + } + } catch { + return null + } +} + +/** + * Build an XML block to inject as the tool result for select_active_intent. + */ +export function buildIntentContextXml(context: IntentContext): string { + const constraintsXml = + context.constraints.length > 0 + ? context.constraints.map((c) => ` ${escapeXml(c)}`).join("\n") + : " None specified" + const scopeXml = + context.owned_scope.length > 0 + ? context.owned_scope.map((s) => ` ${escapeXml(s)}`).join("\n") + : " No scope restriction" + const criteriaXml = + (context.acceptance_criteria?.length ?? 0 > 0) + ? context.acceptance_criteria!.map((a) => ` ${escapeXml(a)}`).join("\n") + : " None specified" + + return ` + ${escapeXml(context.id)} + ${escapeXml(context.name)} + ${escapeXml(context.status)} + +${constraintsXml} + + +${scopeXml} + + +${criteriaXml} + +` +} + +function escapeXml(s: string): string { + return s + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'") +} + +/** + * Read recent agent trace lines for an intent (optional: for "related history" in context). + */ +export async function readRecentTraceForIntent(cwd: string, intentId: string, limit: number = 20): Promise { + const filePath = path.join(cwd, ORCHESTRATION_DIR, AGENT_TRACE_FILE) + try { + const raw = await fs.readFile(filePath, "utf-8") + const lines = raw.trim().split("\n").filter(Boolean) + const related: string[] = [] + for (let i = lines.length - 1; i >= 0 && related.length < limit; i--) { + const entry = JSON.parse(lines[i]) as { + files?: Array<{ conversations?: Array<{ related?: Array<{ value: string }> }> }> + } + for (const f of entry.files ?? []) { + for (const conv of f.conversations ?? []) { + for (const r of conv.related ?? []) { + if (r.value === intentId) { + related.push(lines[i]) + break + } + } + } + } + } + return related + } catch { + return [] + } +} diff --git a/src/hooks/index.ts b/src/hooks/index.ts index e69de29bb2d..d4a9dc04bb0 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -0,0 +1,13 @@ +/** + * Orchestration hooks for Intent–Code traceability (TRP1 Challenge). + * Clean middleware: Pre-Hook (context + scope + gatekeeper), Post-Hook (agent_trace.jsonl). + */ + +export * from "./types" +export * from "./content-hash" +export * from "./context-loader" +export * from "./scope" +export * from "./pre-hook" +export * from "./post-hook" +export * from "./middleware" +export * from "./select-active-intent-tool" diff --git a/src/hooks/intent-prompt-snippet.ts b/src/hooks/intent-prompt-snippet.ts new file mode 100644 index 00000000000..63b8f6ad3a2 --- /dev/null +++ b/src/hooks/intent-prompt-snippet.ts @@ -0,0 +1,6 @@ +/** + * System prompt snippet to enforce the Intent-Driven protocol. + * Prepend or inject this when orchestration is enabled so the agent + * must call select_active_intent before writing code. + */ +export const INTENT_DRIVEN_PROMPT_SNIPPET = `You are an Intent-Driven Architect. You CANNOT write code immediately. Your first action MUST be to analyze the user request, identify which requirement or task it maps to, and call select_active_intent(intent_id) with a valid ID from .orchestration/active_intents.yaml to load the necessary context. Only after you receive the intent context (constraints, scope, acceptance criteria) may you proceed to use write_to_file or other editing tools. If no intent matches the request, you must ask the user to add one to active_intents.yaml or clarify the task.` diff --git a/src/hooks/middleware.ts b/src/hooks/middleware.ts new file mode 100644 index 00000000000..1b2f437ce4e --- /dev/null +++ b/src/hooks/middleware.ts @@ -0,0 +1,55 @@ +import type { HookResult } from "./types" +import type { PreHook } from "./pre-hook" +import { appendAgentTrace } from "./post-hook" + +export interface HookMiddlewareOptions { + preHook: PreHook + getActiveIntentId: () => string | null + getCwd: () => string + getSessionLogId?: () => string | undefined + getModelId?: () => string | undefined + getVcsRevisionId?: () => string | undefined +} + +/** + * Hook Engine: strict middleware boundary around tool execution. + * 1. Pre-Hook: intercept, validate intent/scope, optionally inject result for select_active_intent. + * 2. Execute: delegate to original tool (caller performs this). + * 3. Post-Hook: on write_to_file success, append to agent_trace.jsonl. + */ +export class HookMiddleware { + constructor(private options: HookMiddlewareOptions) {} + + /** + * Run pre-hook only. Returns HookResult; if blocked, caller must not execute the tool + * and should push toolResult with result.error. If injectResult is set, caller should + * push that as the tool result (for select_active_intent) and skip calling the real tool. + */ + async preToolUse(toolName: string, params: Record): Promise { + return this.options.preHook.intercept(toolName, params) + } + + /** + * Run post-hook after a successful write_to_file. Call this from the host after + * the file has been written. + */ + async postToolUse(toolName: string, params: Record, _result: unknown): Promise { + if (toolName !== "write_to_file") return + const pathParam = params.path + const contentParam = params.content + if (typeof pathParam !== "string" || typeof contentParam !== "string") return + + const intentId = this.options.getActiveIntentId() + const mutationClass = (params.mutation_class as "AST_REFACTOR" | "INTENT_EVOLUTION" | "NEW_FILE") ?? "UNKNOWN" + + await appendAgentTrace(this.options.getCwd(), { + relativePath: pathParam, + content: contentParam, + intentId, + mutationClass, + sessionLogId: this.options.getSessionLogId?.(), + modelIdentifier: this.options.getModelId?.(), + vcsRevisionId: this.options.getVcsRevisionId?.(), + }) + } +} diff --git a/src/hooks/post-hook.ts b/src/hooks/post-hook.ts new file mode 100644 index 00000000000..16c8e13a6e8 --- /dev/null +++ b/src/hooks/post-hook.ts @@ -0,0 +1,71 @@ +import fs from "fs/promises" +import path from "path" +import { randomUUID } from "crypto" + +import type { AgentTraceEntry, AgentTraceFileEntry, AgentTraceConversation, MutationClass } from "./types" +import { contentHash, contentHashForRange } from "./content-hash" + +const ORCHESTRATION_DIR = ".orchestration" +const AGENT_TRACE_FILE = "agent_trace.jsonl" + +export interface PostHookWriteParams { + relativePath: string + content: string + intentId: string | null + mutationClass?: MutationClass + sessionLogId?: string + modelIdentifier?: string + vcsRevisionId?: string +} + +/** + * Post-Hook: after a successful write_to_file, append an entry to agent_trace.jsonl + * linking the file (and content hash) to the intent. + */ +export async function appendAgentTrace(cwd: string, params: PostHookWriteParams): Promise { + const { + relativePath, + content, + intentId, + mutationClass = "UNKNOWN", + sessionLogId, + modelIdentifier = "unknown", + vcsRevisionId, + } = params + + const dir = path.join(cwd, ORCHESTRATION_DIR) + await fs.mkdir(dir, { recursive: true }) + const tracePath = path.join(dir, AGENT_TRACE_FILE) + + const lines = content.split("\n") + const fullRangeHash = contentHash(content) + const conversation: AgentTraceConversation = { + url: sessionLogId, + contributor: { entity_type: "AI", model_identifier: modelIdentifier }, + ranges: [{ start_line: 1, end_line: lines.length, content_hash: fullRangeHash }], + related: intentId ? [{ type: "specification", value: intentId }] : [], + } + + const fileEntry: AgentTraceFileEntry = { + relative_path: relativePath, + conversations: [conversation], + } + + const entry: AgentTraceEntry = { + id: randomUUID(), + timestamp: new Date().toISOString(), + vcs: vcsRevisionId ? { revision_id: vcsRevisionId } : undefined, + files: [fileEntry], + } + + const line = JSON.stringify(entry) + "\n" + await fs.appendFile(tracePath, line) +} + +/** + * Compute content hash for a modified block (e.g. after apply_diff). + * Use when you have start_line/end_line and full file content. + */ +export function computeRangeHash(content: string, startLine: number, endLine: number): string { + return contentHashForRange(content, startLine, endLine) +} diff --git a/src/hooks/pre-hook.ts b/src/hooks/pre-hook.ts new file mode 100644 index 00000000000..6a47559e1a9 --- /dev/null +++ b/src/hooks/pre-hook.ts @@ -0,0 +1,89 @@ +import * as vscode from "vscode" +import path from "path" + +import type { HookResult, IntentContext, MutationClass } from "./types" +import { DESTRUCTIVE_TOOLS } from "./types" +import { loadIntentContext, buildIntentContextXml } from "./context-loader" +import { pathInScope } from "./scope" + +export interface PreHookOptions { + cwd: string + /** Current intent ID set by select_active_intent (per task/session) */ + getActiveIntentId: () => string | null + setActiveIntentId: (id: string | null) => void + /** Optional: path to .intentignore-style patterns (one per line) */ + intentIgnorePath?: string + /** Require intent for destructive tools only; if false, require for all tools */ + requireIntentForDestructiveOnly?: boolean +} + +/** + * Pre-Hook: intercepts tool execution to enforce intent context and scope. + * - select_active_intent: load context, return XML, set active intent. + * - Destructive tools: require active intent; optional HITL; scope check for write_to_file. + */ +export class PreHook { + constructor(private options: PreHookOptions) {} + + async intercept(toolName: string, params: Record): Promise { + const { cwd, getActiveIntentId, setActiveIntentId } = this.options + + // —— select_active_intent: the Handshake —— + if (toolName === "select_active_intent") { + const intentId = typeof params.intent_id === "string" ? params.intent_id.trim() : null + if (!intentId) { + return { blocked: true, error: "You must provide a valid intent_id when calling select_active_intent." } + } + const context = await loadIntentContext(cwd, intentId) + if (!context) { + return { + blocked: true, + error: `You must cite a valid active Intent ID. Intent "${intentId}" was not found in .orchestration/active_intents.yaml.`, + } + } + setActiveIntentId(intentId) + const xml = buildIntentContextXml(context) + return { blocked: false, injectResult: xml } + } + + const isDestructive = (DESTRUCTIVE_TOOLS as readonly string[]).includes(toolName) + const requireIntent = this.options.requireIntentForDestructiveOnly ? isDestructive : true + + if (requireIntent) { + const activeId = getActiveIntentId() + if (!activeId) { + return { + blocked: true, + error: "You must select an active intent first. Call select_active_intent(intent_id) with a valid ID from .orchestration/active_intents.yaml before writing code or running destructive commands.", + } + } + + // Scope enforcement for write_to_file + if (toolName === "write_to_file" && params.path) { + const relPath = String(params.path) + const context = await loadIntentContext(cwd, activeId) + if (context && context.owned_scope.length > 0 && !pathInScope(relPath, context.owned_scope, cwd)) { + return { + blocked: true, + error: `Scope Violation: ${activeId} is not authorized to edit "${relPath}". Request scope expansion in active_intents.yaml or choose another intent.`, + } + } + } + } + + // Optional: HITL for destructive tools (can be wired via askApproval in host) + return { blocked: false } + } + + /** + * Optional: prompt for Human-in-the-Loop approval on destructive actions. + * Call this from the host when askApproval is invoked for destructive tools. + */ + static async askApprovalDestructive(toolName: string, message: string): Promise { + return new Promise((resolve) => { + vscode.window + .showWarningMessage(`Approve destructive action: ${toolName}?`, { modal: true }, "Approve", "Reject") + .then((choice) => resolve(choice === "Approve")) + }) + } +} diff --git a/src/hooks/scope.ts b/src/hooks/scope.ts new file mode 100644 index 00000000000..9ee969a4216 --- /dev/null +++ b/src/hooks/scope.ts @@ -0,0 +1,39 @@ +import path from "path" + +/** + * Check if a relative file path is within the owned_scope of the active intent. + * owned_scope entries are glob-like (e.g. "src/auth/**", "src/middleware/jwt.ts"). + */ +export function pathInScope(relativePath: string, ownedScope: string[], _cwd: string): boolean { + if (!ownedScope || ownedScope.length === 0) return true + const normalized = path.normalize(relativePath).replace(/\\/g, "/") + for (const pattern of ownedScope) { + const p = path.normalize(pattern).replace(/\\/g, "/") + if (p.endsWith("/**")) { + const prefix = p.slice(0, -3) + if (normalized === prefix || normalized.startsWith(prefix + "/")) return true + } else if (p.includes("*")) { + if (simpleGlobMatch(normalized, p)) return true + } else { + if (normalized === p || normalized.endsWith("/" + p)) return true + } + } + return false +} + +function simpleGlobMatch(path: string, pattern: string): boolean { + const re = new RegExp( + "^" + + pattern + .split("/") + .map((seg) => + seg + .replace(/[.+^${}()|[\]\\]/g, "\\$&") + .replace(/\*\*/g, ".*") + .replace(/\*/g, "[^/]*"), + ) + .join("/") + + "$", + ) + return re.test(path) +} diff --git a/src/hooks/select-active-intent-tool.ts b/src/hooks/select-active-intent-tool.ts new file mode 100644 index 00000000000..f0facf16954 --- /dev/null +++ b/src/hooks/select-active-intent-tool.ts @@ -0,0 +1,23 @@ +import type OpenAI from "openai" + +const SELECT_ACTIVE_INTENT_DESCRIPTION = `Select the active intent (requirement/task) before making code changes. You MUST call this tool first when the user asks you to implement, refactor, or change code. It loads the intent's constraints, scope, and acceptance criteria from .orchestration/active_intents.yaml. Do not write code or use write_to_file until you have called select_active_intent with a valid intent_id.` + +export const selectActiveIntentToolDefinition = { + type: "function" as const, + function: { + name: "select_active_intent", + description: SELECT_ACTIVE_INTENT_DESCRIPTION, + strict: true, + parameters: { + type: "object", + properties: { + intent_id: { + type: "string", + description: "The intent ID from .orchestration/active_intents.yaml (e.g. INT-001)", + }, + }, + required: ["intent_id"], + additionalProperties: false, + }, + }, +} satisfies OpenAI.Chat.ChatCompletionTool diff --git a/src/hooks/types.ts b/src/hooks/types.ts index e69de29bb2d..3b41f12a54e 100644 --- a/src/hooks/types.ts +++ b/src/hooks/types.ts @@ -0,0 +1,94 @@ +/** + * Orchestration hook types for Intent–Code traceability (TRP1). + * Data models and schemas for .orchestration/ sidecar storage. + */ + +export interface ActiveIntent { + id: string + name: string + status: "PENDING" | "IN_PROGRESS" | "DONE" | "BLOCKED" + owned_scope?: string[] + constraints?: string[] + acceptance_criteria?: string[] +} + +export interface ActiveIntentsDoc { + active_intents: ActiveIntent[] +} + +export interface IntentContext { + id: string + name: string + status: string + constraints: string[] + owned_scope: string[] + acceptance_criteria?: string[] +} + +export type MutationClass = "AST_REFACTOR" | "INTENT_EVOLUTION" | "NEW_FILE" | "UNKNOWN" + +export interface HookResult { + blocked: boolean + error?: string + /** For select_active_intent: XML or text to inject as tool result */ + injectResult?: string +} + +/** Per-file entry in agent_trace.jsonl */ +export interface AgentTraceFileEntry { + relative_path: string + conversations: AgentTraceConversation[] +} + +export interface AgentTraceConversation { + url?: string + contributor: { + entity_type: "AI" | "human" + model_identifier?: string + } + ranges: Array<{ + start_line: number + end_line: number + content_hash: string + }> + related: Array<{ type: string; value: string }> +} + +export interface AgentTraceEntry { + id: string + timestamp: string + vcs?: { revision_id: string } + files: AgentTraceFileEntry[] +} + +/** Safe = read-only; Destructive = write, delete, execute */ +export type CommandClass = "safe" | "destructive" + +export const DESTRUCTIVE_TOOLS = [ + "write_to_file", + "apply_diff", + "edit", + "search_and_replace", + "search_replace", + "edit_file", + "apply_patch", + "execute_command", + "new_task", + "generate_image", +] as const + +export const SAFE_TOOLS = [ + "read_file", + "list_files", + "codebase_search", + "search_files", + "read_command_output", + "use_mcp_tool", + "access_mcp_resource", + "ask_followup_question", + "switch_mode", + "update_todo_list", + "attempt_completion", + "run_slash_command", + "skill", +] as const From 085f30872af9ebab7ecdf6daf2ce95110cb912cb Mon Sep 17 00:00:00 2001 From: Bethel Yohannes Date: Sat, 21 Feb 2026 11:40:17 +0300 Subject: [PATCH 3/8] created test file for the hook and check if the hooks (pre and post) hooks are working --- .orchestration/active_intents.yaml | 12 ++ .orchestration/agent_trace.jsonl | 3 + scripts/test-hooks.ts | 255 +++++++++++++++++++++++++++++ src/hooks/ post-hook.ts | 0 src/hooks/context-loader.ts | 2 +- src/hooks/pre-hook.ts | 32 ++-- 6 files changed, 288 insertions(+), 16 deletions(-) create mode 100644 .orchestration/active_intents.yaml create mode 100644 .orchestration/agent_trace.jsonl create mode 100644 scripts/test-hooks.ts delete mode 100644 src/hooks/ post-hook.ts diff --git a/.orchestration/active_intents.yaml b/.orchestration/active_intents.yaml new file mode 100644 index 00000000000..87492967f5f --- /dev/null +++ b/.orchestration/active_intents.yaml @@ -0,0 +1,12 @@ +active_intents: + - id: "INT-001" + name: "Build Weather API" + status: "IN_PROGRESS" + owned_scope: + - "src/api/**" + constraints: + - "Use REST conventions" + - "Return JSON" + acceptance_criteria: + - "GET /weather returns 200 with location and temperature" + diff --git a/.orchestration/agent_trace.jsonl b/.orchestration/agent_trace.jsonl new file mode 100644 index 00000000000..48c0a714028 --- /dev/null +++ b/.orchestration/agent_trace.jsonl @@ -0,0 +1,3 @@ +{"id":"df1a0b04-1993-45f4-b3cb-c0b4fcb2dba2","timestamp":"2026-02-21T08:37:15.794Z","vcs":{"revision_id":"abc123"},"files":[{"relative_path":"src/api/weather.ts","conversations":[{"url":"session-1","contributor":{"entity_type":"AI","model_identifier":"test-model"},"ranges":[{"start_line":1,"end_line":2,"content_hash":"sha256:2e4ec5cf14cfeb55ef32cb663ffd2c4fe6ab22bb2d2fe77ee8e284fe62866cac"}],"related":[{"type":"specification","value":"INT-001"}]}]}]} +{"id":"7f9541c8-7fd4-4cef-ae44-c51f191b942a","timestamp":"2026-02-21T08:37:15.797Z","files":[{"relative_path":"src/api/forecast.ts","conversations":[{"contributor":{"entity_type":"AI","model_identifier":"unknown"},"ranges":[{"start_line":1,"end_line":1,"content_hash":"sha256:c47272be41ffa9d8a897b81b3f9f6d5e13fbb5d6dd4609a7b20c282950f11842"}],"related":[{"type":"specification","value":"INT-001"}]}]}]} +{"id":"da60914f-8e16-4c59-8c5d-2c673793287d","timestamp":"2026-02-21T08:37:15.803Z","vcs":{"revision_id":"rev-1"},"files":[{"relative_path":"src/api/demo.ts","conversations":[{"url":"log-1","contributor":{"entity_type":"AI","model_identifier":"model-1"},"ranges":[{"start_line":1,"end_line":1,"content_hash":"sha256:bd994c26b795571c7c1e0357cba348f1d0238b648b748b9ec7a4d0de264d212f"}],"related":[{"type":"specification","value":"INT-001"}]}]}]} diff --git a/scripts/test-hooks.ts b/scripts/test-hooks.ts new file mode 100644 index 00000000000..f7176b69a5c --- /dev/null +++ b/scripts/test-hooks.ts @@ -0,0 +1,255 @@ +/** + * Run each hook component and assert expected behavior. + * Run from repo root: pnpm tsx scripts/test-hooks.ts + */ +import fs from "fs/promises" +import path from "path" + +import { contentHash, contentHashForRange } from "../src/hooks/content-hash" +import { loadIntentContext, buildIntentContextXml } from "../src/hooks/context-loader" +import { pathInScope } from "../src/hooks/scope" +import { PreHook } from "../src/hooks/pre-hook" +import { HookMiddleware } from "../src/hooks/middleware" +import { appendAgentTrace } from "../src/hooks/post-hook" + +const cwd = process.cwd() + +function assert(condition: boolean, message: string): void { + if (!condition) throw new Error(`FAIL: ${message}`) +} + +async function run(name: string, fn: () => Promise | void): Promise { + try { + await fn() + console.log(` OK ${name}`) + } catch (e) { + console.error(` FAIL ${name}`) + throw e + } +} + +async function main() { + console.log("=== 1. content-hash ===\n") + + await run("contentHash returns sha256: prefix", async () => { + const h = contentHash("hello") + assert(h.startsWith("sha256:"), "prefix") + assert(h.length === 71, "length 7 prefix + 64 hex") + }) + + await run("contentHash is deterministic", async () => { + const a = contentHash("same") + const b = contentHash("same") + assert(a === b, "same input => same hash") + }) + + await run("contentHashForRange hashes line range", async () => { + const text = "line1\nline2\nline3\nline4" + const h = contentHashForRange(text, 2, 3) + assert(h.startsWith("sha256:"), "prefix") + // line2\nline3 + const expected = contentHash("line2\nline3") + assert(h === expected, "range hash matches manual slice") + }) + + console.log("\n=== 2. context-loader ===\n") + + await run("loadIntentContext returns null when file missing", async () => { + const ctx = await loadIntentContext("/nonexistent", "INT-001") + assert(ctx === null, "missing dir => null") + }) + + await run("loadIntentContext loads INT-001 from .orchestration/active_intents.yaml", async () => { + const ctx = await loadIntentContext(cwd, "INT-001") + assert(ctx !== null, "context exists") + assert(ctx!.id === "INT-001", "id") + assert(ctx!.name === "Build Weather API", "name") + assert(Array.isArray(ctx!.owned_scope) && ctx!.owned_scope!.includes("src/api/**"), "owned_scope") + assert(Array.isArray(ctx!.constraints) && ctx!.constraints!.length > 0, "constraints") + }) + + await run("loadIntentContext returns null for unknown intent", async () => { + const ctx = await loadIntentContext(cwd, "INT-999") + assert(ctx === null, "unknown id => null") + }) + + await run("buildIntentContextXml produces valid XML block", async () => { + const ctx = await loadIntentContext(cwd, "INT-001") + assert(ctx !== null, "context exists") + const xml = buildIntentContextXml(ctx!) + assert(xml.includes(""), "root tag") + assert(xml.includes("INT-001"), "id") + assert(xml.includes(""), "constraints") + assert(xml.includes(""), "scope") + }) + + console.log("\n=== 3. scope ===\n") + + await run("pathInScope: empty scope => allowed", async () => { + assert(pathInScope("any/file.ts", [], cwd) === true, "empty scope allows all") + }) + + await run("pathInScope: src/api/** matches src/api/weather.ts", async () => { + assert(pathInScope("src/api/weather.ts", ["src/api/**"], cwd) === true, "in scope") + }) + + await run("pathInScope: src/api/** does not match src/other/file.ts", async () => { + assert(pathInScope("src/other/unauthorized.ts", ["src/api/**"], cwd) === false, "out of scope") + }) + + await run("pathInScope: exact file matches", async () => { + assert(pathInScope("src/middleware/jwt.ts", ["src/middleware/jwt.ts"], cwd) === true, "exact match") + }) + + console.log("\n=== 4. pre-hook (PreHook) ===\n") + + let activeIntentId: string | null = null + const preHook = new PreHook({ + cwd, + getActiveIntentId: () => activeIntentId, + setActiveIntentId: (id) => { + activeIntentId = id + }, + requireIntentForDestructiveOnly: true, + }) + + await run("PreHook: write_to_file with no intent => blocked", async () => { + activeIntentId = null + const r = await preHook.intercept("write_to_file", { path: "src/api/x.ts", content: "x" }) + assert(r.blocked === true, "blocked") + assert(r.error != null && r.error.includes("select an active intent"), "error message") + }) + + await run("PreHook: select_active_intent with valid ID => injectResult XML", async () => { + const r = await preHook.intercept("select_active_intent", { intent_id: "INT-001" }) + assert(r.blocked === false, "not blocked") + assert(r.injectResult != null && r.injectResult.includes(""), "XML injected") + assert(activeIntentId === "INT-001", "active intent set") + }) + + await run("PreHook: select_active_intent with invalid ID => blocked", async () => { + const r = await preHook.intercept("select_active_intent", { intent_id: "INT-999" }) + assert(r.blocked === true, "blocked") + assert(r.error != null && r.error.includes("INT-999"), "error mentions id") + }) + + await run("PreHook: write_to_file in owned_scope => allowed", async () => { + activeIntentId = "INT-001" + const r = await preHook.intercept("write_to_file", { + path: "src/api/weather.ts", + content: "// code", + }) + assert(r.blocked === false, "not blocked") + }) + + await run("PreHook: write_to_file outside owned_scope => blocked", async () => { + activeIntentId = "INT-001" + const r = await preHook.intercept("write_to_file", { + path: "src/other/unauthorized.ts", + content: "// bad", + }) + assert(r.blocked === true, "blocked") + assert(r.error != null && r.error.includes("Scope Violation"), "scope violation message") + }) + + await run("PreHook: read_file (safe) without intent => allowed", async () => { + activeIntentId = null + const r = await preHook.intercept("read_file", { path: "src/api/x.ts" }) + assert(r.blocked === false, "safe tool allowed without intent") + }) + + // TDD: path traversal must be blocked (test first, then implement) + await run("PreHook: write_to_file with path traversal (..) => blocked", async () => { + activeIntentId = "INT-001" + const r = await preHook.intercept("write_to_file", { + path: "src/api/../../../etc/escape.ts", + content: "// path traversal", + }) + assert(r.blocked === true, "blocked") + assert(r.error != null && r.error.toLowerCase().includes("path"), "error mentions path/traversal") + }) + + console.log("\n=== 5. post-hook (appendAgentTrace) ===\n") + + const tracePath = path.join(cwd, ".orchestration", "agent_trace.jsonl") + // Start fresh for this test + try { + await fs.unlink(tracePath) + } catch { + // ignore if missing + } + + await run("appendAgentTrace creates .orchestration/agent_trace.jsonl", async () => { + await appendAgentTrace(cwd, { + relativePath: "src/api/weather.ts", + content: "// weather API\nconst x = 1;", + intentId: "INT-001", + mutationClass: "INTENT_EVOLUTION", + sessionLogId: "session-1", + modelIdentifier: "test-model", + vcsRevisionId: "abc123", + }) + const raw = await fs.readFile(tracePath, "utf-8") + const line = raw.trim().split("\n")[0] + assert(line != null, "at least one line") + const entry = JSON.parse(line!) + assert(entry.id != null, "id") + assert(entry.timestamp != null, "timestamp") + assert(entry.vcs?.revision_id === "abc123", "vcs") + assert(entry.files?.length === 1, "one file") + assert(entry.files[0].relative_path === "src/api/weather.ts", "path") + const conv = entry.files[0].conversations[0] + assert(conv.contributor?.entity_type === "AI", "contributor") + assert(conv.ranges?.[0]?.content_hash?.startsWith("sha256:"), "content_hash") + assert(conv.related?.[0]?.value === "INT-001", "related intent") + }) + + await run("appendAgentTrace appends second entry", async () => { + await appendAgentTrace(cwd, { + relativePath: "src/api/forecast.ts", + content: "// forecast", + intentId: "INT-001", + }) + const raw = await fs.readFile(tracePath, "utf-8") + const lines = raw.trim().split("\n").filter(Boolean) + assert(lines.length >= 2, "two or more lines") + }) + + console.log("\n=== 6. middleware (full flow) ===\n") + + activeIntentId = null // reset so we simulate: select_intent then write + const middleware = new HookMiddleware({ + preHook, + getActiveIntentId: () => activeIntentId, + getCwd: () => cwd, + getSessionLogId: () => "log-1", + getModelId: () => "model-1", + getVcsRevisionId: () => "rev-1", + }) + + await run( + "Middleware: preToolUse(select_active_intent) then preToolUse(write_to_file) then postToolUse", + async () => { + const r1 = await middleware.preToolUse("select_active_intent", { intent_id: "INT-001" }) + assert(!r1.blocked && r1.injectResult != null, "select ok") + const r2 = await middleware.preToolUse("write_to_file", { + path: "src/api/demo.ts", + content: "// demo", + }) + assert(!r2.blocked, "write allowed") + await middleware.postToolUse("write_to_file", { path: "src/api/demo.ts", content: "// demo" }, {}) + const raw = await fs.readFile(tracePath, "utf-8") + const lastLine = raw.trim().split("\n").filter(Boolean).pop() + assert(lastLine != null, "new line") + const entry = JSON.parse(lastLine!) + assert(entry.files[0].relative_path === "src/api/demo.ts", "demo.ts traced") + }, + ) + + console.log("\n=== All hook checks passed. ===\n") +} + +main().catch((err) => { + console.error(err) + process.exit(1) +}) diff --git a/src/hooks/ post-hook.ts b/src/hooks/ post-hook.ts deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/src/hooks/context-loader.ts b/src/hooks/context-loader.ts index 9d8a2f15d3e..62063c4fc8a 100644 --- a/src/hooks/context-loader.ts +++ b/src/hooks/context-loader.ts @@ -45,7 +45,7 @@ export function buildIntentContextXml(context: IntentContext): string { ? context.owned_scope.map((s) => ` ${escapeXml(s)}`).join("\n") : " No scope restriction" const criteriaXml = - (context.acceptance_criteria?.length ?? 0 > 0) + (context.acceptance_criteria?.length ?? 0) > 0 ? context.acceptance_criteria!.map((a) => ` ${escapeXml(a)}`).join("\n") : " None specified" diff --git a/src/hooks/pre-hook.ts b/src/hooks/pre-hook.ts index 6a47559e1a9..be650829cf1 100644 --- a/src/hooks/pre-hook.ts +++ b/src/hooks/pre-hook.ts @@ -1,11 +1,18 @@ -import * as vscode from "vscode" import path from "path" -import type { HookResult, IntentContext, MutationClass } from "./types" +import type { HookResult } from "./types" import { DESTRUCTIVE_TOOLS } from "./types" import { loadIntentContext, buildIntentContextXml } from "./context-loader" import { pathInScope } from "./scope" +/** Block paths that escape workspace (.. or absolute outside cwd). */ +function isPathTraversal(relPath: string, cwd: string): boolean { + const normalized = path.normalize(relPath) + if (normalized.includes("..")) return true + const resolved = path.resolve(cwd, relPath) + return !resolved.startsWith(cwd) +} + export interface PreHookOptions { cwd: string /** Current intent ID set by select_active_intent (per task/session) */ @@ -61,6 +68,13 @@ export class PreHook { // Scope enforcement for write_to_file if (toolName === "write_to_file" && params.path) { const relPath = String(params.path) + // Path traversal: block paths that escape workspace (e.g. .. or absolute) + if (isPathTraversal(relPath, cwd)) { + return { + blocked: true, + error: `Path traversal not allowed: "${relPath}" would escape the workspace. Use a path relative to the workspace only.`, + } + } const context = await loadIntentContext(cwd, activeId) if (context && context.owned_scope.length > 0 && !pathInScope(relPath, context.owned_scope, cwd)) { return { @@ -71,19 +85,7 @@ export class PreHook { } } - // Optional: HITL for destructive tools (can be wired via askApproval in host) + // Optional: HITL for destructive tools (wire via askApproval in host / extension) return { blocked: false } } - - /** - * Optional: prompt for Human-in-the-Loop approval on destructive actions. - * Call this from the host when askApproval is invoked for destructive tools. - */ - static async askApprovalDestructive(toolName: string, message: string): Promise { - return new Promise((resolve) => { - vscode.window - .showWarningMessage(`Approve destructive action: ${toolName}?`, { modal: true }, "Approve", "Reject") - .then((choice) => resolve(choice === "Approve")) - }) - } } From 5f225d946f3cdcf98d2435bfa162e7bfbddc8dda Mon Sep 17 00:00:00 2001 From: Bethel Yohannes Date: Sat, 21 Feb 2026 13:01:30 +0300 Subject: [PATCH 4/8] Reasoning loop implemntation or handshake done and teste as well --- .orchestration/agent_trace.jsonl | 6 +-- scripts/test_phase1.js | 47 ++++++++++++++++++++ src/api/weather.ts | 1 + src/core/prompts/system.ts | 3 ++ src/core/prompts/tools/native-tools/index.ts | 2 + src/hooks/context-loader.ts | 32 ++++++++++++- src/hooks/pre-hook.ts | 7 ++- 7 files changed, 90 insertions(+), 8 deletions(-) create mode 100644 scripts/test_phase1.js create mode 100644 src/api/weather.ts diff --git a/.orchestration/agent_trace.jsonl b/.orchestration/agent_trace.jsonl index 48c0a714028..297c6c17351 100644 --- a/.orchestration/agent_trace.jsonl +++ b/.orchestration/agent_trace.jsonl @@ -1,3 +1,3 @@ -{"id":"df1a0b04-1993-45f4-b3cb-c0b4fcb2dba2","timestamp":"2026-02-21T08:37:15.794Z","vcs":{"revision_id":"abc123"},"files":[{"relative_path":"src/api/weather.ts","conversations":[{"url":"session-1","contributor":{"entity_type":"AI","model_identifier":"test-model"},"ranges":[{"start_line":1,"end_line":2,"content_hash":"sha256:2e4ec5cf14cfeb55ef32cb663ffd2c4fe6ab22bb2d2fe77ee8e284fe62866cac"}],"related":[{"type":"specification","value":"INT-001"}]}]}]} -{"id":"7f9541c8-7fd4-4cef-ae44-c51f191b942a","timestamp":"2026-02-21T08:37:15.797Z","files":[{"relative_path":"src/api/forecast.ts","conversations":[{"contributor":{"entity_type":"AI","model_identifier":"unknown"},"ranges":[{"start_line":1,"end_line":1,"content_hash":"sha256:c47272be41ffa9d8a897b81b3f9f6d5e13fbb5d6dd4609a7b20c282950f11842"}],"related":[{"type":"specification","value":"INT-001"}]}]}]} -{"id":"da60914f-8e16-4c59-8c5d-2c673793287d","timestamp":"2026-02-21T08:37:15.803Z","vcs":{"revision_id":"rev-1"},"files":[{"relative_path":"src/api/demo.ts","conversations":[{"url":"log-1","contributor":{"entity_type":"AI","model_identifier":"model-1"},"ranges":[{"start_line":1,"end_line":1,"content_hash":"sha256:bd994c26b795571c7c1e0357cba348f1d0238b648b748b9ec7a4d0de264d212f"}],"related":[{"type":"specification","value":"INT-001"}]}]}]} +{"id":"02a7c124-4730-4e49-8783-08f861f640ba","timestamp":"2026-02-21T09:48:48.707Z","vcs":{"revision_id":"abc123"},"files":[{"relative_path":"src/api/weather.ts","conversations":[{"url":"session-1","contributor":{"entity_type":"AI","model_identifier":"test-model"},"ranges":[{"start_line":1,"end_line":2,"content_hash":"sha256:2e4ec5cf14cfeb55ef32cb663ffd2c4fe6ab22bb2d2fe77ee8e284fe62866cac"}],"related":[{"type":"specification","value":"INT-001"}]}]}]} +{"id":"e55e9867-663a-412e-b341-af61f85dbedd","timestamp":"2026-02-21T09:48:48.716Z","files":[{"relative_path":"src/api/forecast.ts","conversations":[{"contributor":{"entity_type":"AI","model_identifier":"unknown"},"ranges":[{"start_line":1,"end_line":1,"content_hash":"sha256:c47272be41ffa9d8a897b81b3f9f6d5e13fbb5d6dd4609a7b20c282950f11842"}],"related":[{"type":"specification","value":"INT-001"}]}]}]} +{"id":"3609a24b-418e-4e1a-a554-2e3cf9db1be9","timestamp":"2026-02-21T09:48:48.737Z","vcs":{"revision_id":"rev-1"},"files":[{"relative_path":"src/api/demo.ts","conversations":[{"url":"log-1","contributor":{"entity_type":"AI","model_identifier":"model-1"},"ranges":[{"start_line":1,"end_line":1,"content_hash":"sha256:bd994c26b795571c7c1e0357cba348f1d0238b648b748b9ec7a4d0de264d212f"}],"related":[{"type":"specification","value":"INT-001"}]}]}]} diff --git a/scripts/test_phase1.js b/scripts/test_phase1.js new file mode 100644 index 00000000000..0f4c0bb6bd3 --- /dev/null +++ b/scripts/test_phase1.js @@ -0,0 +1,47 @@ +const fs = require("fs") +const path = require("path") + +// Load active intents +const intentsPath = path.join(__dirname, "..", ".orchestration", "active_intents.yaml") +const yaml = require("js-yaml") +const intents = yaml.load(fs.readFileSync(intentsPath, "utf8")).active_intents + +// Simple PreHook simulation +function preHook(intentId, targetFile) { + const intent = intents.find((i) => i.id === intentId) + if (!intent) { + throw new Error("You must select a valid active Intent ID before writing code.") + } + + const allowed = intent.owned_scope.some((scope) => targetFile.startsWith(scope.replace("**", ""))) + if (!allowed) { + throw new Error(`Scope Violation: ${intentId} is not authorized to edit ${targetFile}`) + } + + console.log(`PreHook Passed: ${targetFile} is within scope for ${intentId}`) +} + +// Simulate AI writing a file +function writeFile(intentId, filePath, content) { + preHook(intentId, filePath) + fs.writeFileSync(filePath, content) + console.log(`File written successfully: ${filePath}`) +} + +// === TEST CASES === +try { + // Valid case + writeFile("INT-001", "src/api/weather.ts", "// Weather API code here") + + // Invalid scope + writeFile("INT-001", "src/db/db.ts", "// DB code here") +} catch (err) { + console.error("Error:", err.message) +} + +try { + // Invalid intent + writeFile("INT-999", "src/api/weather.ts", "// Should fail") +} catch (err) { + console.error("Error:", err.message) +} diff --git a/src/api/weather.ts b/src/api/weather.ts new file mode 100644 index 00000000000..a442555ef28 --- /dev/null +++ b/src/api/weather.ts @@ -0,0 +1 @@ +// Weather API code here diff --git a/src/core/prompts/system.ts b/src/core/prompts/system.ts index 0d6071644a9..de103ea8c37 100644 --- a/src/core/prompts/system.ts +++ b/src/core/prompts/system.ts @@ -10,6 +10,7 @@ import { isEmpty } from "../../utils/object" import { McpHub } from "../../services/mcp/McpHub" import { CodeIndexManager } from "../../services/code-index/manager" import { SkillsManager } from "../../services/skills/SkillsManager" +import { INTENT_DRIVEN_PROMPT_SNIPPET } from "../../hooks/intent-prompt-snippet" import type { SystemPromptSettings } from "./types" import { @@ -84,6 +85,8 @@ async function generatePrompt( const basePrompt = `${roleDefinition} +${INTENT_DRIVEN_PROMPT_SNIPPET} + ${markdownFormattingSection()} ${getSharedToolUseSection()}${toolsCatalog} diff --git a/src/core/prompts/tools/native-tools/index.ts b/src/core/prompts/tools/native-tools/index.ts index 758914d2d65..624d1db0841 100644 --- a/src/core/prompts/tools/native-tools/index.ts +++ b/src/core/prompts/tools/native-tools/index.ts @@ -1,4 +1,5 @@ import type OpenAI from "openai" +import { selectActiveIntentToolDefinition } from "../../../../hooks/select-active-intent-tool" import accessMcpResource from "./access_mcp_resource" import { apply_diff } from "./apply_diff" import applyPatch from "./apply_patch" @@ -47,6 +48,7 @@ export function getNativeTools(options: NativeToolsOptions = {}): OpenAI.Chat.Ch } return [ + selectActiveIntentToolDefinition, accessMcpResource, apply_diff, applyPatch, diff --git a/src/hooks/context-loader.ts b/src/hooks/context-loader.ts index 62063c4fc8a..6c3591cf700 100644 --- a/src/hooks/context-loader.ts +++ b/src/hooks/context-loader.ts @@ -34,8 +34,9 @@ export async function loadIntentContext(cwd: string, intentId: string): Promise< /** * Build an XML block to inject as the tool result for select_active_intent. + * Optionally include related agent trace entries for consolidated context. */ -export function buildIntentContextXml(context: IntentContext): string { +export function buildIntentContextXml(context: IntentContext, relatedTracePaths: string[] = []): string { const constraintsXml = context.constraints.length > 0 ? context.constraints.map((c) => ` ${escapeXml(c)}`).join("\n") @@ -48,6 +49,10 @@ export function buildIntentContextXml(context: IntentContext): string { (context.acceptance_criteria?.length ?? 0) > 0 ? context.acceptance_criteria!.map((a) => ` ${escapeXml(a)}`).join("\n") : " None specified" + const traceXml = + relatedTracePaths.length > 0 + ? relatedTracePaths.map((p) => ` ${escapeXml(p)}`).join("\n") + : " None yet" return ` ${escapeXml(context.id)} @@ -62,9 +67,34 @@ ${scopeXml} ${criteriaXml} + +${traceXml} + ` } +/** + * Load intent from active_intents.yaml, gather related agent_trace entries for that intent, + * and return a consolidated XML context block (for Pre-Hook injection). + */ +export async function buildConsolidatedIntentContextXml(cwd: string, intentId: string): Promise { + const context = await loadIntentContext(cwd, intentId) + if (!context) return null + const traceLines = await readRecentTraceForIntent(cwd, intentId, 20) + const paths = new Set() + for (const line of traceLines) { + try { + const entry = JSON.parse(line) as { files?: Array<{ relative_path?: string }> } + for (const f of entry.files ?? []) { + if (f.relative_path) paths.add(f.relative_path) + } + } catch { + // skip malformed lines + } + } + return buildIntentContextXml(context, [...paths]) +} + function escapeXml(s: string): string { return s .replace(/&/g, "&") diff --git a/src/hooks/pre-hook.ts b/src/hooks/pre-hook.ts index be650829cf1..e8dc7315c67 100644 --- a/src/hooks/pre-hook.ts +++ b/src/hooks/pre-hook.ts @@ -2,7 +2,7 @@ import path from "path" import type { HookResult } from "./types" import { DESTRUCTIVE_TOOLS } from "./types" -import { loadIntentContext, buildIntentContextXml } from "./context-loader" +import { loadIntentContext, buildConsolidatedIntentContextXml } from "./context-loader" import { pathInScope } from "./scope" /** Block paths that escape workspace (.. or absolute outside cwd). */ @@ -41,15 +41,14 @@ export class PreHook { if (!intentId) { return { blocked: true, error: "You must provide a valid intent_id when calling select_active_intent." } } - const context = await loadIntentContext(cwd, intentId) - if (!context) { + const xml = await buildConsolidatedIntentContextXml(cwd, intentId) + if (!xml) { return { blocked: true, error: `You must cite a valid active Intent ID. Intent "${intentId}" was not found in .orchestration/active_intents.yaml.`, } } setActiveIntentId(intentId) - const xml = buildIntentContextXml(context) return { blocked: false, injectResult: xml } } From 04965a7cf031fcd8acb3e31f4f87b67dca06dd86 Mon Sep 17 00:00:00 2001 From: Bethel Yohannes Date: Sat, 21 Feb 2026 14:55:40 +0300 Subject: [PATCH 5/8] Hook Middleware & Security Boundary and test done --- scripts/pre-hook.spec.ts | 62 ++++++++++++++++ scripts/pre-hook.test.ts | 72 +++++++++++++++++++ .../presentAssistantMessage.ts | 4 ++ src/core/prompts/responses.ts | 21 ++++++ src/core/task/Task.ts | 11 +++ src/core/tools/BaseTool.ts | 9 +++ src/core/tools/WriteToFileTool.ts | 7 ++ src/hooks/index.ts | 1 + src/hooks/intent-ignore.ts | 54 ++++++++++++++ src/hooks/pre-hook.ts | 64 ++++++++++++++--- src/hooks/scope.ts | 21 ++++++ src/hooks/types.ts | 8 +++ 12 files changed, 326 insertions(+), 8 deletions(-) create mode 100644 scripts/pre-hook.spec.ts create mode 100644 scripts/pre-hook.test.ts create mode 100644 src/hooks/intent-ignore.ts diff --git a/scripts/pre-hook.spec.ts b/scripts/pre-hook.spec.ts new file mode 100644 index 00000000000..2254917394d --- /dev/null +++ b/scripts/pre-hook.spec.ts @@ -0,0 +1,62 @@ +import { describe, it, expect, beforeEach } from "vitest" +import { PreHook } from "../pre-hook" + +describe("PreHook", () => { + let activeIntentId: string | null + let preHook: PreHook + + beforeEach(() => { + activeIntentId = null + preHook = new PreHook({ + cwd: process.cwd(), + getActiveIntentId: () => activeIntentId, + setActiveIntentId: (id) => { + activeIntentId = id + }, + requireIntentForDestructiveOnly: true, + }) + }) + + it("blocks destructive write without active intent", async () => { + activeIntentId = null + const res = await preHook.intercept("write_to_file", { + path: "src/api/weather.ts", + content: "// test", + }) + expect(res.blocked).toBe(true) + expect(res.error).toEqual(expect.stringMatching(/select an active intent/i)) + }) + + it("blocks write outside owned scope (scope violation)", async () => { + activeIntentId = "INT-001" + const res = await preHook.intercept("write_to_file", { + path: "src/db/db.ts", + content: "// should be blocked", + }) + expect(res.blocked).toBe(true) + expect(res.error).toEqual(expect.stringMatching(/scope violation/i)) + }) + + it("recovery loop: retry after select_active_intent succeeds", async () => { + // initial attempt without intent + activeIntentId = null + const attempt1 = await preHook.intercept("write_to_file", { + path: "src/api/weather.ts", + content: "// first", + }) + expect(attempt1.blocked).toBe(true) + + // select active intent (handshake) + const handshake = await preHook.intercept("select_active_intent", { intent_id: "INT-001" }) + expect(handshake.blocked).toBe(false) + expect(handshake.injectResult).toEqual(expect.stringContaining("")) + expect(activeIntentId).toBe("INT-001") + + // retry write (in-owned-scope path) + const attempt2 = await preHook.intercept("write_to_file", { + path: "src/api/weather.ts", + content: "// second", + }) + expect(attempt2.blocked).toBe(false) + }) +}) diff --git a/scripts/pre-hook.test.ts b/scripts/pre-hook.test.ts new file mode 100644 index 00000000000..13181d963b2 --- /dev/null +++ b/scripts/pre-hook.test.ts @@ -0,0 +1,72 @@ +import test from "node:test" +import assert from "node:assert/strict" +import { PreHook } from "../src/hooks/pre-hook" + +const cwd = process.cwd() + +test("blocks destructive write without active intent", async () => { + let activeIntentId: string | null = null + const preHook = new PreHook({ + cwd, + getActiveIntentId: () => activeIntentId, + setActiveIntentId: (id) => { + activeIntentId = id + }, + requireIntentForDestructiveOnly: true, + }) + + const res = await preHook.intercept("write_to_file", { + path: "src/api/weather.ts", + content: "// test", + }) + assert.equal(res.blocked, true) + assert.match(String(res.error), /select an active intent/i) +}) + +test("blocks write outside owned scope (scope violation)", async () => { + let activeIntentId: string | null = "INT-001" + const preHook = new PreHook({ + cwd, + getActiveIntentId: () => activeIntentId, + setActiveIntentId: (id) => { + activeIntentId = id + }, + requireIntentForDestructiveOnly: true, + }) + + const res = await preHook.intercept("write_to_file", { + path: "src/db/db.ts", + content: "// should be blocked", + }) + assert.equal(res.blocked, true) + assert.match(String(res.error), /scope violation/i) +}) + +test("recovery loop: retry after select_active_intent succeeds", async () => { + let activeIntentId: string | null = null + const preHook = new PreHook({ + cwd, + getActiveIntentId: () => activeIntentId, + setActiveIntentId: (id) => { + activeIntentId = id + }, + requireIntentForDestructiveOnly: true, + }) + + const attempt1 = await preHook.intercept("write_to_file", { + path: "src/api/weather.ts", + content: "// first", + }) + assert.equal(attempt1.blocked, true) + + const handshake = await preHook.intercept("select_active_intent", { intent_id: "INT-001" }) + assert.equal(handshake.blocked, false) + assert.ok(typeof handshake.injectResult === "string" && handshake.injectResult.includes("")) + assert.equal(activeIntentId, "INT-001") + + const attempt2 = await preHook.intercept("write_to_file", { + path: "src/api/weather.ts", + content: "// second", + }) + assert.equal(attempt2.blocked, false) +}) diff --git a/src/core/assistant-message/presentAssistantMessage.ts b/src/core/assistant-message/presentAssistantMessage.ts index 7f5862be154..ced072d1f08 100644 --- a/src/core/assistant-message/presentAssistantMessage.ts +++ b/src/core/assistant-message/presentAssistantMessage.ts @@ -40,6 +40,7 @@ import { codebaseSearchTool } from "../tools/CodebaseSearchTool" import { formatResponse } from "../prompts/responses" import { sanitizeToolUseId } from "../../utils/tool-id" +import { HookMiddleware, PreHook } from "../../hooks" /** * Processes and presents assistant message content to the user interface. @@ -676,6 +677,9 @@ export async function presentAssistantMessage(cline: Task) { } switch (block.name) { + case "select_active_intent": + // Handled entirely by pre-hook (injectResult pushed above) + break case "write_to_file": await checkpointSaveAndMark(cline) await writeToFileTool.handle(cline, block as ToolUse<"write_to_file">, { diff --git a/src/core/prompts/responses.ts b/src/core/prompts/responses.ts index 60b5b4123ac..2a66d3f3add 100644 --- a/src/core/prompts/responses.ts +++ b/src/core/prompts/responses.ts @@ -30,6 +30,27 @@ export const formatResponse = { error, }), + /** Standardized JSON for autonomous recovery when user rejects a destructive tool. */ + toolErrorUserRejected: (toolName?: string) => + JSON.stringify({ + status: "error", + type: "user_rejected", + message: "The user rejected this operation.", + tool: toolName, + suggestion: "Do not retry the same operation; try a different approach or ask the user for permission.", + }), + + /** Standardized JSON for scope violation so the LLM can request scope expansion. */ + toolErrorScopeViolation: (intentId: string, filename: string) => + JSON.stringify({ + status: "error", + type: "scope_violation", + message: `Scope Violation: ${intentId} is not authorized to edit [${filename}]. Request scope expansion.`, + intent_id: intentId, + path: filename, + suggestion: "Request scope expansion in .orchestration/active_intents.yaml or choose another intent.", + }), + rooIgnoreError: (path: string) => JSON.stringify({ status: "error", diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 6ba57e98ac3..22b42cbb1ff 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -266,6 +266,8 @@ export class Task extends EventEmitter implements TaskLike { providerRef: WeakRef private readonly globalStoragePath: string + /** Active intent ID set by select_active_intent (per task/session). Used by Hook Engine for scope enforcement. */ + private _activeIntentId: string | null = null abort: boolean = false currentRequestAbortController?: AbortController skipPrevResponseIdOnce: boolean = false @@ -4670,6 +4672,15 @@ export class Task extends EventEmitter implements TaskLike { return this.workspacePath } + /** Active intent ID for Hook Engine (select_active_intent / scope enforcement). */ + public getActiveIntentId(): string | null { + return this._activeIntentId + } + + public setActiveIntentId(id: string | null): void { + this._activeIntentId = id + } + /** * Provides convenient access to high-level message operations. * Uses lazy initialization - the MessageManager is only created when first accessed. diff --git a/src/core/tools/BaseTool.ts b/src/core/tools/BaseTool.ts index 7d574068a97..19f137556df 100644 --- a/src/core/tools/BaseTool.ts +++ b/src/core/tools/BaseTool.ts @@ -3,6 +3,13 @@ import type { ToolName } from "@roo-code/types" import { Task } from "../task/Task" import type { ToolUse, HandleError, PushToolResult, AskApproval, NativeToolArgs } from "../../shared/tools" +/** Params passed to onWriteToFileSuccess after a successful write_to_file (for Hook Engine post-hook). */ +export interface WriteToFileSuccessParams { + path: string + content: string + mutation_class?: string +} + /** * Callbacks passed to tool execution */ @@ -11,6 +18,8 @@ export interface ToolCallbacks { handleError: HandleError pushToolResult: PushToolResult toolCallId?: string + /** Called after successful write_to_file for Hook Engine agent_trace / post-hook. */ + onWriteToFileSuccess?: (params: WriteToFileSuccessParams) => void | Promise } /** diff --git a/src/core/tools/WriteToFileTool.ts b/src/core/tools/WriteToFileTool.ts index c8455ef3d97..3fe0d4166c0 100644 --- a/src/core/tools/WriteToFileTool.ts +++ b/src/core/tools/WriteToFileTool.ts @@ -179,6 +179,13 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { pushToolResult(message) + const mutationClass = (params as Record).mutation_class as string | undefined + await callbacks.onWriteToFileSuccess?.({ + path: relPath, + content: newContent, + mutation_class: mutationClass, + }) + await task.diffViewProvider.reset() this.resetPartialState() diff --git a/src/hooks/index.ts b/src/hooks/index.ts index d4a9dc04bb0..ea52530019b 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -7,6 +7,7 @@ export * from "./types" export * from "./content-hash" export * from "./context-loader" export * from "./scope" +export * from "./intent-ignore" export * from "./pre-hook" export * from "./post-hook" export * from "./middleware" diff --git a/src/hooks/intent-ignore.ts b/src/hooks/intent-ignore.ts new file mode 100644 index 00000000000..82d39e285fd --- /dev/null +++ b/src/hooks/intent-ignore.ts @@ -0,0 +1,54 @@ +import fs from "fs/promises" +import path from "path" + +import { pathMatchesAnyPattern } from "./scope" + +const DEFAULT_INTENT_IGNORE_NAME = ".intentignore" +const ORCHESTRATION_DIR = ".orchestration" +const INTENT_PREFIX = "intent:" + +export interface IntentIgnoreResult { + pathPatterns: string[] + excludedIntentIds: string[] +} + +/** + * Load .intentignore-style file: path patterns (one per line) and optional + * "intent:ID" lines to exclude specific intents from receiving changes. + * Convention: lines starting with "intent:" are intent IDs to exclude; all other + * non-empty, non-comment lines are path glob patterns that no write may touch. + * + * @param cwd - Workspace root + * @param intentIgnorePath - Optional path relative to cwd (e.g. ".orchestration/.intentignore") + */ +export async function loadIntentIgnore(cwd: string, intentIgnorePath?: string): Promise { + const filePath = intentIgnorePath + ? path.resolve(cwd, intentIgnorePath) + : path.join(cwd, ORCHESTRATION_DIR, DEFAULT_INTENT_IGNORE_NAME) + try { + const raw = await fs.readFile(filePath, "utf-8") + const pathPatterns: string[] = [] + const excludedIntentIds: string[] = [] + for (const line of raw.split("\n")) { + const trimmed = line.trim() + if (!trimmed || trimmed.startsWith("#")) continue + if (trimmed.startsWith(INTENT_PREFIX)) { + excludedIntentIds.push(trimmed.slice(INTENT_PREFIX.length).trim()) + } else { + pathPatterns.push(trimmed) + } + } + return { pathPatterns, excludedIntentIds } + } catch { + return { pathPatterns: [], excludedIntentIds: [] } + } +} + +export function isPathIgnored(relativePath: string, pathPatterns: string[]): boolean { + return pathMatchesAnyPattern(relativePath, pathPatterns) +} + +export function isIntentExcluded(intentId: string | null, excludedIntentIds: string[]): boolean { + if (!intentId) return false + return excludedIntentIds.some((id) => id === intentId) +} diff --git a/src/hooks/pre-hook.ts b/src/hooks/pre-hook.ts index e8dc7315c67..8b85180786c 100644 --- a/src/hooks/pre-hook.ts +++ b/src/hooks/pre-hook.ts @@ -27,11 +27,19 @@ export interface PreHookOptions { /** * Pre-Hook: intercepts tool execution to enforce intent context and scope. * - select_active_intent: load context, return XML, set active intent. - * - Destructive tools: require active intent; optional HITL; scope check for write_to_file. + * - Destructive tools: require active intent; .intentignore exclusion; scope check for write_to_file; optional UI-blocking approval. */ export class PreHook { + private intentIgnoreCache: IntentIgnoreResult | null = null + constructor(private options: PreHookOptions) {} + private async getIntentIgnore(): Promise { + if (this.intentIgnoreCache) return this.intentIgnoreCache + this.intentIgnoreCache = await loadIntentIgnore(this.options.cwd, this.options.intentIgnorePath) + return this.intentIgnoreCache + } + async intercept(toolName: string, params: Record): Promise { const { cwd, getActiveIntentId, setActiveIntentId } = this.options @@ -54,9 +62,9 @@ export class PreHook { const isDestructive = (DESTRUCTIVE_TOOLS as readonly string[]).includes(toolName) const requireIntent = this.options.requireIntentForDestructiveOnly ? isDestructive : true + const activeId = getActiveIntentId() if (requireIntent) { - const activeId = getActiveIntentId() if (!activeId) { return { blocked: true, @@ -64,27 +72,67 @@ export class PreHook { } } - // Scope enforcement for write_to_file - if (toolName === "write_to_file" && params.path) { - const relPath = String(params.path) - // Path traversal: block paths that escape workspace (e.g. .. or absolute) + const ignore = await this.getIntentIgnore() + if (isIntentExcluded(activeId, ignore.excludedIntentIds)) { + return { + blocked: true, + error: formatResponse.toolError( + `Intent ${activeId} is listed in .intentignore and cannot be modified. Choose another intent or ask the user to update .intentignore.`, + ), + } + } + + // Scope and .intentignore path checks for file-writing tools + const filePathParam = + toolName === "write_to_file" + ? params.path + : [ + "edit", + "search_and_replace", + "search_replace", + "edit_file", + "apply_patch", + "apply_diff", + ].includes(toolName) + ? (params.file_path ?? params.path) + : undefined + if (filePathParam) { + const relPath = String(filePathParam) if (isPathTraversal(relPath, cwd)) { return { blocked: true, error: `Path traversal not allowed: "${relPath}" would escape the workspace. Use a path relative to the workspace only.`, } } + if (isPathIgnored(relPath, ignore.pathPatterns)) { + return { + blocked: true, + error: formatResponse.toolError( + `Path "${relPath}" is excluded by .intentignore. You are not authorized to edit it.`, + ), + } + } const context = await loadIntentContext(cwd, activeId) if (context && context.owned_scope.length > 0 && !pathInScope(relPath, context.owned_scope, cwd)) { return { blocked: true, - error: `Scope Violation: ${activeId} is not authorized to edit "${relPath}". Request scope expansion in active_intents.yaml or choose another intent.`, + error: formatResponse.toolErrorScopeViolation(activeId, relPath), } } } } - // Optional: HITL for destructive tools (wire via askApproval in host / extension) + // UI-blocking authorization for destructive tools (e.g. showWarningMessage Approve/Reject) + if (isDestructive && this.options.confirmDestructive) { + const approved = await this.options.confirmDestructive(toolName, params) + if (!approved) { + return { + blocked: true, + error: formatResponse.toolErrorUserRejected(toolName), + } + } + } + return { blocked: false } } } diff --git a/src/hooks/scope.ts b/src/hooks/scope.ts index 9ee969a4216..5c54738b556 100644 --- a/src/hooks/scope.ts +++ b/src/hooks/scope.ts @@ -37,3 +37,24 @@ function simpleGlobMatch(path: string, pattern: string): boolean { ) return re.test(path) } + +/** + * Check if a relative path matches any of the given glob-like patterns. + * Used by .intentignore to exclude paths from edits. + */ +export function pathMatchesAnyPattern(relativePath: string, patterns: string[]): boolean { + if (!patterns || patterns.length === 0) return false + const normalized = path.normalize(relativePath).replace(/\\/g, "/") + for (const pattern of patterns) { + const p = path.normalize(pattern).replace(/\\/g, "/") + if (p.endsWith("/**")) { + const prefix = p.slice(0, -3) + if (normalized === prefix || normalized.startsWith(prefix + "/")) return true + } else if (p.includes("*")) { + if (simpleGlobMatch(normalized, p)) return true + } else { + if (normalized === p || normalized.endsWith("/" + p)) return true + } + } + return false +} diff --git a/src/hooks/types.ts b/src/hooks/types.ts index 3b41f12a54e..5fde102df2b 100644 --- a/src/hooks/types.ts +++ b/src/hooks/types.ts @@ -64,6 +64,14 @@ export interface AgentTraceEntry { /** Safe = read-only; Destructive = write, delete, execute */ export type CommandClass = "safe" | "destructive" +/** + * Classify a tool name as Safe (read) or Destructive (write, delete, execute). + * Used by the Hook Engine for authorization and UI-blocking. + */ +export function classifyCommand(toolName: string): CommandClass { + return (DESTRUCTIVE_TOOLS as readonly string[]).includes(toolName) ? "destructive" : "safe" +} + export const DESTRUCTIVE_TOOLS = [ "write_to_file", "apply_diff", From e226d1e2a5b33971b67313d63ca87c219d3bc470 Mon Sep 17 00:00:00 2001 From: Bethel Yohannes Date: Sat, 21 Feb 2026 15:50:07 +0300 Subject: [PATCH 6/8] schema trigger implemntation done --- .../assistant-message/NativeToolCallParser.ts | 12 +++++++ .../presentAssistantMessage.ts | 32 ++++++++++++++++++- .../tools/native-tools/write_to_file.ts | 24 ++++++++++++-- src/core/tools/BaseTool.ts | 1 + src/core/tools/WriteToFileTool.ts | 2 ++ src/hooks/content-hash.ts | 13 ++++++-- src/hooks/format.ts | 31 ++++++++++++++++++ src/hooks/middleware.ts | 6 +++- src/hooks/post-hook.ts | 9 +++++- src/hooks/pre-hook.ts | 11 ++++--- src/shared/tools.ts | 10 +++++- 11 files changed, 139 insertions(+), 12 deletions(-) create mode 100644 src/hooks/format.ts diff --git a/src/core/assistant-message/NativeToolCallParser.ts b/src/core/assistant-message/NativeToolCallParser.ts index e0ea1383f17..e49e67838b5 100644 --- a/src/core/assistant-message/NativeToolCallParser.ts +++ b/src/core/assistant-message/NativeToolCallParser.ts @@ -468,6 +468,12 @@ export class NativeToolCallParser { nativeArgs = { path: partialArgs.path, content: partialArgs.content, + intent_id: partialArgs.intent_id, + mutation_class: partialArgs.mutation_class as + | "AST_REFACTOR" + | "INTENT_EVOLUTION" + | "NEW_FILE" + | undefined, } } break @@ -905,6 +911,12 @@ export class NativeToolCallParser { nativeArgs = { path: args.path, content: args.content, + intent_id: args.intent_id, + mutation_class: args.mutation_class as + | "AST_REFACTOR" + | "INTENT_EVOLUTION" + | "NEW_FILE" + | undefined, } as NativeArgsFor } break diff --git a/src/core/assistant-message/presentAssistantMessage.ts b/src/core/assistant-message/presentAssistantMessage.ts index ced072d1f08..d1cc063a848 100644 --- a/src/core/assistant-message/presentAssistantMessage.ts +++ b/src/core/assistant-message/presentAssistantMessage.ts @@ -676,18 +676,48 @@ export async function presentAssistantMessage(cline: Task) { } } + // Hook Engine: Post-Hook for write_to_file appends to agent_trace.jsonl + const preHook = new PreHook({ + cwd: cline.cwd, + getActiveIntentId: () => cline.getActiveIntentId(), + setActiveIntentId: (id) => cline.setActiveIntentId(id), + requireIntentForDestructiveOnly: true, + }) + const hookMiddleware = new HookMiddleware({ + preHook, + getActiveIntentId: () => cline.getActiveIntentId(), + getCwd: () => cline.cwd, + getReqId: () => cline.taskId, + getSessionLogId: () => undefined, + getModelId: () => cline.api.getModel()?.id, + getVcsRevisionId: () => undefined, + }) + switch (block.name) { case "select_active_intent": // Handled entirely by pre-hook (injectResult pushed above) break - case "write_to_file": + case "write_to_file": { await checkpointSaveAndMark(cline) await writeToFileTool.handle(cline, block as ToolUse<"write_to_file">, { askApproval, handleError, pushToolResult, + onWriteToFileSuccess: async (p) => { + await hookMiddleware.postToolUse( + "write_to_file", + { + path: p.path, + content: p.content, + intent_id: p.intent_id, + mutation_class: p.mutation_class, + }, + {}, + ) + }, }) break + } case "update_todo_list": await updateTodoListTool.handle(cline, block as ToolUse<"update_todo_list">, { askApproval, diff --git a/src/core/prompts/tools/native-tools/write_to_file.ts b/src/core/prompts/tools/native-tools/write_to_file.ts index b9e9b313a22..35c8d2c791d 100644 --- a/src/core/prompts/tools/native-tools/write_to_file.ts +++ b/src/core/prompts/tools/native-tools/write_to_file.ts @@ -1,5 +1,7 @@ import type OpenAI from "openai" +const MUTATION_CLASS_ENUM = ["AST_REFACTOR", "INTENT_EVOLUTION", "NEW_FILE"] as const + const WRITE_TO_FILE_DESCRIPTION = `Request to write content to a file. This tool is primarily used for creating new files or for scenarios where a complete rewrite of an existing file is intentionally required. If the file exists, it will be overwritten. If it doesn't exist, it will be created. This tool will automatically create any directories needed to write the file. **Important:** You should prefer using other editing tools over write_to_file when making changes to existing files, since write_to_file is slower and cannot handle large files. Use write_to_file primarily for new file creation. @@ -8,13 +10,22 @@ When using this tool, use it directly with the desired content. You do not need When creating a new project, organize all new files within a dedicated project directory unless the user specifies otherwise. Structure the project logically, adhering to best practices for the specific type of project being created. +**Traceability:** You MUST provide intent_id (the active intent from select_active_intent) and mutation_class: +- AST_REFACTOR: Syntax/structural change, same intent (e.g. rename, format, extract function). +- INTENT_EVOLUTION: New feature or behavior change tied to the intent. +- NEW_FILE: Creating a file that did not exist before. + Example: Writing a configuration file -{ "path": "frontend-config.json", "content": "{\\n \\"apiEndpoint\\": \\"https://api.example.com\\",\\n \\"theme\\": {\\n \\"primaryColor\\": \\"#007bff\\"\\n }\\n}" }` +{ "path": "frontend-config.json", "content": "{\\n \\"apiEndpoint\\": \\"https://api.example.com\\",\\n \\"theme\\": {\\n \\"primaryColor\\": \\"#007bff\\"\\n }\\n}", "intent_id": "INT-001", "mutation_class": "INTENT_EVOLUTION" }` const PATH_PARAMETER_DESCRIPTION = `The path of the file to write to (relative to the current workspace directory)` const CONTENT_PARAMETER_DESCRIPTION = `The content to write to the file. ALWAYS provide the COMPLETE intended content of the file, without any truncation or omissions. You MUST include ALL parts of the file, even if they haven't been modified. Do NOT include line numbers in the content.` +const INTENT_ID_DESCRIPTION = `The ID of the active intent (from select_active_intent) that this write serves. Required for traceability.` + +const MUTATION_CLASS_DESCRIPTION = `Semantic classification: AST_REFACTOR (syntax change, same intent), INTENT_EVOLUTION (new feature/behavior), or NEW_FILE (creating a new file).` + export default { type: "function", function: { @@ -32,8 +43,17 @@ export default { type: "string", description: CONTENT_PARAMETER_DESCRIPTION, }, + intent_id: { + type: "string", + description: INTENT_ID_DESCRIPTION, + }, + mutation_class: { + type: "string", + enum: MUTATION_CLASS_ENUM, + description: MUTATION_CLASS_DESCRIPTION, + }, }, - required: ["path", "content"], + required: ["path", "content", "intent_id", "mutation_class"], additionalProperties: false, }, }, diff --git a/src/core/tools/BaseTool.ts b/src/core/tools/BaseTool.ts index 19f137556df..916e0e595a5 100644 --- a/src/core/tools/BaseTool.ts +++ b/src/core/tools/BaseTool.ts @@ -7,6 +7,7 @@ import type { ToolUse, HandleError, PushToolResult, AskApproval, NativeToolArgs export interface WriteToFileSuccessParams { path: string content: string + intent_id?: string mutation_class?: string } diff --git a/src/core/tools/WriteToFileTool.ts b/src/core/tools/WriteToFileTool.ts index 3fe0d4166c0..d511f9d8776 100644 --- a/src/core/tools/WriteToFileTool.ts +++ b/src/core/tools/WriteToFileTool.ts @@ -180,9 +180,11 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { pushToolResult(message) const mutationClass = (params as Record).mutation_class as string | undefined + const intentId = (params as Record).intent_id as string | undefined await callbacks.onWriteToFileSuccess?.({ path: relPath, content: newContent, + intent_id: intentId, mutation_class: mutationClass, }) diff --git a/src/hooks/content-hash.ts b/src/hooks/content-hash.ts index 538666b1b35..59dbc35bac3 100644 --- a/src/hooks/content-hash.ts +++ b/src/hooks/content-hash.ts @@ -3,14 +3,23 @@ import crypto from "crypto" const HASH_PREFIX = "sha256:" /** - * Compute a SHA-256 content hash for spatial independence. - * If lines move, the hash of the content block remains valid. + * Generate a SHA-256 hash of string content (spatial hashing utility). + * Returns a prefixed hex string (e.g. "sha256:abc123...") for traceability. + * Content hash remains valid even if line positions change. */ export function contentHash(content: string): string { const hash = crypto.createHash("sha256").update(content, "utf8").digest("hex") return `${HASH_PREFIX}${hash}` } +/** + * Alias for contentHash - generates SHA-256 hash of string content. + * Use for spatial hashing in agent trace and diff operations. + */ +export function sha256Hash(content: string): string { + return contentHash(content) +} + /** * Extract a logical code block (e.g. by line range) and return its hash. */ diff --git a/src/hooks/format.ts b/src/hooks/format.ts new file mode 100644 index 00000000000..1f8361b10a2 --- /dev/null +++ b/src/hooks/format.ts @@ -0,0 +1,31 @@ +/** + * Minimal tool error formatters for pre-hook. + * Avoids importing from core/prompts/responses to prevent vscode dependency in Node scripts. + */ +export const toolErrorFormat = { + toolError: (error?: string) => + JSON.stringify({ + status: "error", + message: "The tool execution failed", + error, + }), + + toolErrorScopeViolation: (intentId: string, filename: string) => + JSON.stringify({ + status: "error", + type: "scope_violation", + message: `Scope Violation: ${intentId} is not authorized to edit [${filename}]. Request scope expansion.`, + intent_id: intentId, + path: filename, + suggestion: "Request scope expansion in .orchestration/active_intents.yaml or choose another intent.", + }), + + toolErrorUserRejected: (toolName?: string) => + JSON.stringify({ + status: "error", + type: "user_rejected", + message: "The user rejected this operation.", + tool: toolName, + suggestion: "Do not retry the same operation; try a different approach or ask the user for permission.", + }), +} diff --git a/src/hooks/middleware.ts b/src/hooks/middleware.ts index 1b2f437ce4e..7ff0819f6c8 100644 --- a/src/hooks/middleware.ts +++ b/src/hooks/middleware.ts @@ -6,6 +6,8 @@ export interface HookMiddlewareOptions { preHook: PreHook getActiveIntentId: () => string | null getCwd: () => string + /** REQ-ID from Phase 1 - injected into agent_trace related array */ + getReqId?: () => string | undefined getSessionLogId?: () => string | undefined getModelId?: () => string | undefined getVcsRevisionId?: () => string | undefined @@ -39,7 +41,8 @@ export class HookMiddleware { const contentParam = params.content if (typeof pathParam !== "string" || typeof contentParam !== "string") return - const intentId = this.options.getActiveIntentId() + const intentId = + (typeof params.intent_id === "string" ? params.intent_id.trim() : null) || this.options.getActiveIntentId() const mutationClass = (params.mutation_class as "AST_REFACTOR" | "INTENT_EVOLUTION" | "NEW_FILE") ?? "UNKNOWN" await appendAgentTrace(this.options.getCwd(), { @@ -47,6 +50,7 @@ export class HookMiddleware { content: contentParam, intentId, mutationClass, + reqId: this.options.getReqId?.(), sessionLogId: this.options.getSessionLogId?.(), modelIdentifier: this.options.getModelId?.(), vcsRevisionId: this.options.getVcsRevisionId?.(), diff --git a/src/hooks/post-hook.ts b/src/hooks/post-hook.ts index 16c8e13a6e8..08575010984 100644 --- a/src/hooks/post-hook.ts +++ b/src/hooks/post-hook.ts @@ -13,6 +13,8 @@ export interface PostHookWriteParams { content: string intentId: string | null mutationClass?: MutationClass + /** REQ-ID from Phase 1 - injected into related array for traceability */ + reqId?: string sessionLogId?: string modelIdentifier?: string vcsRevisionId?: string @@ -28,6 +30,7 @@ export async function appendAgentTrace(cwd: string, params: PostHookWriteParams) content, intentId, mutationClass = "UNKNOWN", + reqId, sessionLogId, modelIdentifier = "unknown", vcsRevisionId, @@ -39,11 +42,15 @@ export async function appendAgentTrace(cwd: string, params: PostHookWriteParams) const lines = content.split("\n") const fullRangeHash = contentHash(content) + const related: Array<{ type: string; value: string }> = [] + if (intentId) related.push({ type: "specification", value: intentId }) + if (reqId) related.push({ type: "request", value: reqId }) + const conversation: AgentTraceConversation = { url: sessionLogId, contributor: { entity_type: "AI", model_identifier: modelIdentifier }, ranges: [{ start_line: 1, end_line: lines.length, content_hash: fullRangeHash }], - related: intentId ? [{ type: "specification", value: intentId }] : [], + related, } const fileEntry: AgentTraceFileEntry = { diff --git a/src/hooks/pre-hook.ts b/src/hooks/pre-hook.ts index 8b85180786c..4b4f297739f 100644 --- a/src/hooks/pre-hook.ts +++ b/src/hooks/pre-hook.ts @@ -3,7 +3,10 @@ import path from "path" import type { HookResult } from "./types" import { DESTRUCTIVE_TOOLS } from "./types" import { loadIntentContext, buildConsolidatedIntentContextXml } from "./context-loader" +import { loadIntentIgnore, isPathIgnored, isIntentExcluded } from "./intent-ignore" +import type { IntentIgnoreResult } from "./intent-ignore" import { pathInScope } from "./scope" +import { toolErrorFormat } from "./format" /** Block paths that escape workspace (.. or absolute outside cwd). */ function isPathTraversal(relPath: string, cwd: string): boolean { @@ -76,7 +79,7 @@ export class PreHook { if (isIntentExcluded(activeId, ignore.excludedIntentIds)) { return { blocked: true, - error: formatResponse.toolError( + error: toolErrorFormat.toolError( `Intent ${activeId} is listed in .intentignore and cannot be modified. Choose another intent or ask the user to update .intentignore.`, ), } @@ -107,7 +110,7 @@ export class PreHook { if (isPathIgnored(relPath, ignore.pathPatterns)) { return { blocked: true, - error: formatResponse.toolError( + error: toolErrorFormat.toolError( `Path "${relPath}" is excluded by .intentignore. You are not authorized to edit it.`, ), } @@ -116,7 +119,7 @@ export class PreHook { if (context && context.owned_scope.length > 0 && !pathInScope(relPath, context.owned_scope, cwd)) { return { blocked: true, - error: formatResponse.toolErrorScopeViolation(activeId, relPath), + error: toolErrorFormat.toolErrorScopeViolation(activeId, relPath), } } } @@ -128,7 +131,7 @@ export class PreHook { if (!approved) { return { blocked: true, - error: formatResponse.toolErrorUserRejected(toolName), + error: toolErrorFormat.toolErrorUserRejected(toolName), } } } diff --git a/src/shared/tools.ts b/src/shared/tools.ts index 491ba693611..f09bed46884 100644 --- a/src/shared/tools.ts +++ b/src/shared/tools.ts @@ -80,6 +80,9 @@ export const toolParamNames = [ // read_file legacy format parameter (backward compatibility) "files", "line_ranges", + // write_to_file traceability (AI-Native Git Layer) + "intent_id", + "mutation_class", ] as const export type ToolParamName = (typeof toolParamNames)[number] @@ -114,7 +117,12 @@ export type NativeToolArgs = { switch_mode: { mode_slug: string; reason: string } update_todo_list: { todos: string } use_mcp_tool: { server_name: string; tool_name: string; arguments?: Record } - write_to_file: { path: string; content: string } + write_to_file: { + path: string + content: string + intent_id?: string + mutation_class?: "AST_REFACTOR" | "INTENT_EVOLUTION" | "NEW_FILE" + } // Add more tools as they are migrated to native protocol } From 3c9f6fe492a7383ff06c05567bd04616b6f90f65 Mon Sep 17 00:00:00 2001 From: Bethel Yohannes Date: Sat, 21 Feb 2026 16:11:49 +0300 Subject: [PATCH 7/8] created test scenrio and tested it for semntic tracking ledger --- .orchestration/agent_trace.jsonl | 8 ++- docs/testing-traceability.md | 78 +++++++++++++++++++++ package.json | 2 + scripts/test-hooks.ts | 17 +++++ scripts/test-trace-scenario.ts | 114 +++++++++++++++++++++++++++++++ 5 files changed, 216 insertions(+), 3 deletions(-) create mode 100644 docs/testing-traceability.md create mode 100644 scripts/test-trace-scenario.ts diff --git a/.orchestration/agent_trace.jsonl b/.orchestration/agent_trace.jsonl index 297c6c17351..75a357fc264 100644 --- a/.orchestration/agent_trace.jsonl +++ b/.orchestration/agent_trace.jsonl @@ -1,3 +1,5 @@ -{"id":"02a7c124-4730-4e49-8783-08f861f640ba","timestamp":"2026-02-21T09:48:48.707Z","vcs":{"revision_id":"abc123"},"files":[{"relative_path":"src/api/weather.ts","conversations":[{"url":"session-1","contributor":{"entity_type":"AI","model_identifier":"test-model"},"ranges":[{"start_line":1,"end_line":2,"content_hash":"sha256:2e4ec5cf14cfeb55ef32cb663ffd2c4fe6ab22bb2d2fe77ee8e284fe62866cac"}],"related":[{"type":"specification","value":"INT-001"}]}]}]} -{"id":"e55e9867-663a-412e-b341-af61f85dbedd","timestamp":"2026-02-21T09:48:48.716Z","files":[{"relative_path":"src/api/forecast.ts","conversations":[{"contributor":{"entity_type":"AI","model_identifier":"unknown"},"ranges":[{"start_line":1,"end_line":1,"content_hash":"sha256:c47272be41ffa9d8a897b81b3f9f6d5e13fbb5d6dd4609a7b20c282950f11842"}],"related":[{"type":"specification","value":"INT-001"}]}]}]} -{"id":"3609a24b-418e-4e1a-a554-2e3cf9db1be9","timestamp":"2026-02-21T09:48:48.737Z","vcs":{"revision_id":"rev-1"},"files":[{"relative_path":"src/api/demo.ts","conversations":[{"url":"log-1","contributor":{"entity_type":"AI","model_identifier":"model-1"},"ranges":[{"start_line":1,"end_line":1,"content_hash":"sha256:bd994c26b795571c7c1e0357cba348f1d0238b648b748b9ec7a4d0de264d212f"}],"related":[{"type":"specification","value":"INT-001"}]}]}]} +{"id":"2e408f80-f271-4f66-a728-76117c9301ca","timestamp":"2026-02-21T13:09:40.095Z","vcs":{"revision_id":"abc123"},"files":[{"relative_path":"src/api/weather.ts","conversations":[{"url":"session-1","contributor":{"entity_type":"AI","model_identifier":"test-model"},"ranges":[{"start_line":1,"end_line":2,"content_hash":"sha256:2e4ec5cf14cfeb55ef32cb663ffd2c4fe6ab22bb2d2fe77ee8e284fe62866cac"}],"related":[{"type":"specification","value":"INT-001"}]}]}]} +{"id":"58f39b6a-cb85-4a08-9f17-c41db429f9f7","timestamp":"2026-02-21T13:09:40.116Z","files":[{"relative_path":"src/api/forecast.ts","conversations":[{"contributor":{"entity_type":"AI","model_identifier":"unknown"},"ranges":[{"start_line":1,"end_line":1,"content_hash":"sha256:c47272be41ffa9d8a897b81b3f9f6d5e13fbb5d6dd4609a7b20c282950f11842"}],"related":[{"type":"specification","value":"INT-001"}]}]}]} +{"id":"adcb313c-4db0-4168-bf96-b3e9df9a3346","timestamp":"2026-02-21T13:09:40.120Z","files":[{"relative_path":"src/api/trace.ts","conversations":[{"contributor":{"entity_type":"AI","model_identifier":"unknown"},"ranges":[{"start_line":1,"end_line":1,"content_hash":"sha256:a3d4bb671e8b19ed161b408c183193afc9fa361f573c5187dded809930d14413"}],"related":[{"type":"specification","value":"INT-001"},{"type":"request","value":"REQ-12345"}]}]}]} +{"id":"52e92ed0-7aff-490e-9b3a-f3704926e858","timestamp":"2026-02-21T13:09:40.140Z","vcs":{"revision_id":"rev-1"},"files":[{"relative_path":"src/api/demo.ts","conversations":[{"url":"log-1","contributor":{"entity_type":"AI","model_identifier":"model-1"},"ranges":[{"start_line":1,"end_line":1,"content_hash":"sha256:bd994c26b795571c7c1e0357cba348f1d0238b648b748b9ec7a4d0de264d212f"}],"related":[{"type":"specification","value":"INT-001"}]}]}]} +{"id":"35b7b5a9-263a-48a0-bad0-89743ebf0368","timestamp":"2026-02-21T13:10:04.649Z","files":[{"relative_path":"src/api/weather.ts","conversations":[{"url":"session-scenario","contributor":{"entity_type":"AI","model_identifier":"scenario-runner"},"ranges":[{"start_line":1,"end_line":4,"content_hash":"sha256:6ecdce6218d02c1a1f3864f4a0e7829f1c0887e3f5a9c57d77331e473841c30c"}],"related":[{"type":"specification","value":"INT-001"},{"type":"request","value":"REQ-scenario-1771679404634"}]}]}]} diff --git a/docs/testing-traceability.md b/docs/testing-traceability.md new file mode 100644 index 00000000000..5c1f4f671ca --- /dev/null +++ b/docs/testing-traceability.md @@ -0,0 +1,78 @@ +# Testing the AI-Native Git Layer (Traceability) + +How to test the semantic tracking ledger and `agent_trace.jsonl` **in the terminal**. + +## Prerequisites + +- From the **repo root**: `.orchestration/active_intents.yaml` must exist (it does in this repo). +- Node/pnpm: `pnpm install` already run. + +## 1. Run the full hook test suite + +Runs content-hash, context-loader, scope, pre-hook, post-hook, and middleware: + +```bash +pnpm test:hooks +``` + +Or without pnpm: + +```bash +npx tsx scripts/test-hooks.ts +``` + +## 2. Run the trace scenario (one flow end-to-end) + +Simulates: **select_active_intent** → **write_to_file** (pre-hook) → **post-hook** appends to `agent_trace.jsonl`, then prints the last trace entry. + +```bash +pnpm test:trace-scenario +``` + +Or: + +```bash +npx tsx scripts/test-trace-scenario.ts +``` + +You should see: + +- Intent `INT-001` loaded +- `write_to_file` allowed (in scope) +- A new line in `.orchestration/agent_trace.jsonl` +- The last entry printed with `intent_id`, `content_hash`, and **REQ-ID** in `related` + +## 3. Inspect the trace file yourself + +After running the scenario (or after real agent writes): + +```bash +# Last 3 trace entries (pretty-printed) +tail -n 3 .orchestration/agent_trace.jsonl | while read line; do echo "$line" | jq .; done + +# Or just the raw last line +tail -n 1 .orchestration/agent_trace.jsonl +``` + +## 4. Create your own scenario + +Copy `scripts/test-trace-scenario.ts` and change: + +- `intent_id` (must exist in `.orchestration/active_intents.yaml`) +- `path` (must be under that intent’s `owned_scope`, e.g. `src/api/**`) +- `content` and `mutation_class` (`AST_REFACTOR` | `INTENT_EVOLUTION` | `NEW_FILE`) + +Then run: + +```bash +pnpm exec tsx scripts/your-scenario.ts +``` + +## Summary + +| Command | What it does | +| -------------------------- | ----------------------------------------------------------------------- | +| `pnpm test:hooks` | All hook unit-style checks (content-hash, pre/post-hook, middleware). | +| `pnpm test:trace-scenario` | One full flow: select intent → write → append trace → print last entry. | + +Both run in the terminal with no VS Code or extension required. diff --git a/package.json b/package.json index de8dff751cb..5043923f7e3 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,8 @@ "lint": "turbo lint --log-order grouped --output-logs new-only", "check-types": "turbo check-types --log-order grouped --output-logs new-only", "test": "turbo test --log-order grouped --output-logs new-only", + "test:hooks": "pnpm exec tsx scripts/test-hooks.ts", + "test:trace-scenario": "pnpm exec tsx scripts/test-trace-scenario.ts", "format": "turbo format --log-order grouped --output-logs new-only", "build": "turbo build --log-order grouped --output-logs new-only", "bundle": "turbo bundle --log-order grouped --output-logs new-only", diff --git a/scripts/test-hooks.ts b/scripts/test-hooks.ts index f7176b69a5c..545fe863073 100644 --- a/scripts/test-hooks.ts +++ b/scripts/test-hooks.ts @@ -215,6 +215,23 @@ async function main() { assert(lines.length >= 2, "two or more lines") }) + await run("appendAgentTrace injects REQ-ID into related array", async () => { + await appendAgentTrace(cwd, { + relativePath: "src/api/trace.ts", + content: "// trace", + intentId: "INT-001", + reqId: "REQ-12345", + }) + const raw = await fs.readFile(tracePath, "utf-8") + const lines = raw.trim().split("\n").filter(Boolean) + const lastLine = lines[lines.length - 1] + assert(lastLine != null, "last line exists") + const entry = JSON.parse(lastLine) + const conv = entry.files[0].conversations[0] + const reqRelated = conv.related?.find((r: { type: string }) => r.type === "request") + assert(reqRelated != null && reqRelated.value === "REQ-12345", "REQ-ID in related array") + }) + console.log("\n=== 6. middleware (full flow) ===\n") activeIntentId = null // reset so we simulate: select_intent then write diff --git a/scripts/test-trace-scenario.ts b/scripts/test-trace-scenario.ts new file mode 100644 index 00000000000..9e9e5b3f6ad --- /dev/null +++ b/scripts/test-trace-scenario.ts @@ -0,0 +1,114 @@ +#!/usr/bin/env npx tsx +/** + * Scenario test for the AI-Native Git Layer (agent_trace.jsonl). + * + * Run from repo root: + * pnpm tsx scripts/test-trace-scenario.ts + * or + * npx tsx scripts/test-trace-scenario.ts + * + * This script simulates: select_active_intent → write_to_file → post-hook appends to agent_trace.jsonl. + * It then prints the last trace entry so you can verify intent_id, mutation_class, content_hash, and REQ-ID. + */ +import fs from "fs/promises" +import path from "path" + +import { PreHook } from "../src/hooks/pre-hook" +import { HookMiddleware } from "../src/hooks/middleware" +import { contentHash } from "../src/hooks/content-hash" + +const cwd = process.cwd() +const tracePath = path.join(cwd, ".orchestration", "agent_trace.jsonl") + +async function main() { + console.log("=== Traceability scenario (terminal test) ===\n") + + let activeIntentId: string | null = null + const preHook = new PreHook({ + cwd, + getActiveIntentId: () => activeIntentId, + setActiveIntentId: (id) => { + activeIntentId = id + }, + requireIntentForDestructiveOnly: true, + }) + + const middleware = new HookMiddleware({ + preHook, + getActiveIntentId: () => activeIntentId, + getCwd: () => cwd, + getReqId: () => "REQ-scenario-" + Date.now(), + getSessionLogId: () => "session-scenario", + getModelId: () => "scenario-runner", + getVcsRevisionId: () => undefined, + }) + + // Step 1: Select intent (like the agent would) + console.log("1. select_active_intent(INT-001) ...") + const selectResult = await middleware.preToolUse("select_active_intent", { intent_id: "INT-001" }) + if (selectResult.blocked) { + console.error(" FAIL: select_active_intent blocked:", selectResult.error) + process.exit(1) + } + console.log(" OK – intent context loaded\n") + + // Step 2: Simulate write_to_file with intent_id and mutation_class + const writeParams = { + path: "src/api/weather.ts", + content: "// Weather API\nconst getWeather = () => ({ temp: 72 })\nexport { getWeather }\n", + intent_id: "INT-001", + mutation_class: "INTENT_EVOLUTION" as const, + } + console.log("2. write_to_file (pre-hook check) ...") + const preWrite = await middleware.preToolUse("write_to_file", writeParams) + if (preWrite.blocked) { + console.error(" FAIL: write_to_file blocked:", preWrite.error) + process.exit(1) + } + console.log(" OK – within scope\n") + + // Step 3: Post-hook appends to agent_trace.jsonl (like after real write) + console.log("3. postToolUse (append to agent_trace.jsonl) ...") + await middleware.postToolUse("write_to_file", writeParams, {}) + console.log(" OK – trace entry appended\n") + + // Step 4: Read and display the last trace entry + const raw = await fs.readFile(tracePath, "utf-8") + const lines = raw.trim().split("\n").filter(Boolean) + const lastLine = lines[lines.length - 1] + if (!lastLine) { + console.error(" FAIL: no lines in agent_trace.jsonl") + process.exit(1) + } + + const entry = JSON.parse(lastLine) + const expectedHash = contentHash(writeParams.content) + + console.log("4. Last trace entry (from .orchestration/agent_trace.jsonl):\n") + console.log(JSON.stringify(entry, null, 2)) + console.log("\n--- Checks ---") + console.log( + " intent (specification):", + entry.files[0].conversations[0].related?.find((r: { type: string }) => r.type === "specification")?.value ?? + "missing", + ) + console.log( + " REQ-ID (request): ", + entry.files[0].conversations[0].related?.find((r: { type: string }) => r.type === "request")?.value ?? + "missing", + ) + console.log( + " content_hash in ranges: ", + entry.files[0].conversations[0].ranges[0]?.content_hash?.startsWith("sha256:") ? "yes" : "no", + ) + console.log( + " expected hash match: ", + entry.files[0].conversations[0].ranges[0]?.content_hash === expectedHash ? "yes" : "no", + ) + console.log("\n=== Scenario done. ===\n") +} + +main().catch((err) => { + console.error(err) + process.exit(1) +}) From 659a7bedbeec94acd99e85680091986a918907a3 Mon Sep 17 00:00:00 2001 From: Bethel Yohannes Date: Sat, 21 Feb 2026 22:20:31 +0300 Subject: [PATCH 8/8] test and final step --- .orchestration/agent_trace.jsonl | 9 +- package.json | 3 +- packages/types/src/tool.ts | 1 + pnpm-lock.yaml | 172 +++++++++++++++--- scripts/test_post_hook.ts | 25 +++ .../assistant-message/NativeToolCallParser.ts | 18 ++ .../presentAssistantMessage.ts | 21 +++ .../native-tools/append_lesson_learned.ts | 33 ++++ src/core/prompts/tools/native-tools/index.ts | 2 + src/core/task/Task.ts | 12 ++ src/core/tools/AppendLessonLearnedTool.ts | 80 ++++++++ src/core/tools/ReadFileTool.ts | 6 + src/core/tools/WriteToFileTool.ts | 16 ++ src/shared/tools.ts | 3 + test-guardrails.js | 57 ++++++ test-trace.js | 23 +++ todo-demo/.orchestration/active_intents.yaml | 12 ++ todo-demo/.orchestration/agent_trace.jsonl | 1 + todo-demo/src/components/TodoList.tsx | 32 ++++ .../.orchestration/active_intents.yaml | 11 ++ 20 files changed, 506 insertions(+), 31 deletions(-) create mode 100644 scripts/test_post_hook.ts create mode 100644 src/core/prompts/tools/native-tools/append_lesson_learned.ts create mode 100644 src/core/tools/AppendLessonLearnedTool.ts create mode 100644 test-guardrails.js create mode 100644 test-trace.js create mode 100644 todo-demo/.orchestration/active_intents.yaml create mode 100644 todo-demo/.orchestration/agent_trace.jsonl create mode 100644 todo-demo/src/components/TodoList.tsx create mode 100644 weather-api-demo/.orchestration/active_intents.yaml diff --git a/.orchestration/agent_trace.jsonl b/.orchestration/agent_trace.jsonl index 75a357fc264..8690f4fb4d7 100644 --- a/.orchestration/agent_trace.jsonl +++ b/.orchestration/agent_trace.jsonl @@ -1,5 +1,4 @@ -{"id":"2e408f80-f271-4f66-a728-76117c9301ca","timestamp":"2026-02-21T13:09:40.095Z","vcs":{"revision_id":"abc123"},"files":[{"relative_path":"src/api/weather.ts","conversations":[{"url":"session-1","contributor":{"entity_type":"AI","model_identifier":"test-model"},"ranges":[{"start_line":1,"end_line":2,"content_hash":"sha256:2e4ec5cf14cfeb55ef32cb663ffd2c4fe6ab22bb2d2fe77ee8e284fe62866cac"}],"related":[{"type":"specification","value":"INT-001"}]}]}]} -{"id":"58f39b6a-cb85-4a08-9f17-c41db429f9f7","timestamp":"2026-02-21T13:09:40.116Z","files":[{"relative_path":"src/api/forecast.ts","conversations":[{"contributor":{"entity_type":"AI","model_identifier":"unknown"},"ranges":[{"start_line":1,"end_line":1,"content_hash":"sha256:c47272be41ffa9d8a897b81b3f9f6d5e13fbb5d6dd4609a7b20c282950f11842"}],"related":[{"type":"specification","value":"INT-001"}]}]}]} -{"id":"adcb313c-4db0-4168-bf96-b3e9df9a3346","timestamp":"2026-02-21T13:09:40.120Z","files":[{"relative_path":"src/api/trace.ts","conversations":[{"contributor":{"entity_type":"AI","model_identifier":"unknown"},"ranges":[{"start_line":1,"end_line":1,"content_hash":"sha256:a3d4bb671e8b19ed161b408c183193afc9fa361f573c5187dded809930d14413"}],"related":[{"type":"specification","value":"INT-001"},{"type":"request","value":"REQ-12345"}]}]}]} -{"id":"52e92ed0-7aff-490e-9b3a-f3704926e858","timestamp":"2026-02-21T13:09:40.140Z","vcs":{"revision_id":"rev-1"},"files":[{"relative_path":"src/api/demo.ts","conversations":[{"url":"log-1","contributor":{"entity_type":"AI","model_identifier":"model-1"},"ranges":[{"start_line":1,"end_line":1,"content_hash":"sha256:bd994c26b795571c7c1e0357cba348f1d0238b648b748b9ec7a4d0de264d212f"}],"related":[{"type":"specification","value":"INT-001"}]}]}]} -{"id":"35b7b5a9-263a-48a0-bad0-89743ebf0368","timestamp":"2026-02-21T13:10:04.649Z","files":[{"relative_path":"src/api/weather.ts","conversations":[{"url":"session-scenario","contributor":{"entity_type":"AI","model_identifier":"scenario-runner"},"ranges":[{"start_line":1,"end_line":4,"content_hash":"sha256:6ecdce6218d02c1a1f3864f4a0e7829f1c0887e3f5a9c57d77331e473841c30c"}],"related":[{"type":"specification","value":"INT-001"},{"type":"request","value":"REQ-scenario-1771679404634"}]}]}]} +{"id":"e7b89cb4-6121-45f5-bc17-b43d641f6cc3","timestamp":"2026-02-21T18:32:36.210Z","vcs":{"revision_id":"abc123"},"files":[{"relative_path":"src/api/weather.ts","conversations":[{"url":"session-1","contributor":{"entity_type":"AI","model_identifier":"test-model"},"ranges":[{"start_line":1,"end_line":2,"content_hash":"sha256:2e4ec5cf14cfeb55ef32cb663ffd2c4fe6ab22bb2d2fe77ee8e284fe62866cac"}],"related":[{"type":"specification","value":"INT-001"}]}]}]} +{"id":"c446252d-db42-480f-bde7-1336b81a03cd","timestamp":"2026-02-21T18:32:36.223Z","files":[{"relative_path":"src/api/forecast.ts","conversations":[{"contributor":{"entity_type":"AI","model_identifier":"unknown"},"ranges":[{"start_line":1,"end_line":1,"content_hash":"sha256:c47272be41ffa9d8a897b81b3f9f6d5e13fbb5d6dd4609a7b20c282950f11842"}],"related":[{"type":"specification","value":"INT-001"}]}]}]} +{"id":"c5b3ffd3-d912-41dd-95c6-bcf76fbe08a3","timestamp":"2026-02-21T18:32:36.226Z","files":[{"relative_path":"src/api/trace.ts","conversations":[{"contributor":{"entity_type":"AI","model_identifier":"unknown"},"ranges":[{"start_line":1,"end_line":1,"content_hash":"sha256:a3d4bb671e8b19ed161b408c183193afc9fa361f573c5187dded809930d14413"}],"related":[{"type":"specification","value":"INT-001"},{"type":"request","value":"REQ-12345"}]}]}]} +{"id":"26635dad-41f9-4ced-b73a-a555f2d3071e","timestamp":"2026-02-21T18:32:36.257Z","vcs":{"revision_id":"rev-1"},"files":[{"relative_path":"src/api/demo.ts","conversations":[{"url":"log-1","contributor":{"entity_type":"AI","model_identifier":"model-1"},"ranges":[{"start_line":1,"end_line":1,"content_hash":"sha256:bd994c26b795571c7c1e0357cba348f1d0238b648b748b9ec7a4d0de264d212f"}],"related":[{"type":"specification","value":"INT-001"}]}]}]} diff --git a/package.json b/package.json index 5043923f7e3..15c23a02deb 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "@dotenvx/dotenvx": "^1.34.0", "@roo-code/config-typescript": "workspace:^", "@types/glob": "^9.0.0", - "@types/node": "^24.1.0", + "@types/node": "^24.2.1", "@vscode/vsce": "3.3.2", "esbuild": "^0.25.0", "eslint": "^9.27.0", @@ -47,6 +47,7 @@ "ovsx": "0.10.4", "prettier": "^3.4.2", "rimraf": "^6.0.1", + "ts-node": "^10.9.2", "tsx": "^4.19.3", "turbo": "^2.5.6", "typescript": "5.8.3" diff --git a/packages/types/src/tool.ts b/packages/types/src/tool.ts index 4f90b63e9fc..dd4feaf46e3 100644 --- a/packages/types/src/tool.ts +++ b/packages/types/src/tool.ts @@ -45,6 +45,7 @@ export const toolNames = [ "run_slash_command", "skill", "generate_image", + "append_lesson_learned", "custom_tool", ] as const diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6b461926f5e..56f355558e7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -33,7 +33,7 @@ importers: specifier: ^9.0.0 version: 9.0.0 '@types/node': - specifier: ^24.1.0 + specifier: ^24.2.1 version: 24.2.1 '@vscode/vsce': specifier: 3.3.2 @@ -71,6 +71,9 @@ importers: rimraf: specifier: ^6.0.1 version: 6.0.1 + ts-node: + specifier: ^10.9.2 + version: 10.9.2(@types/node@24.2.1)(typescript@5.8.3) tsx: specifier: ^4.19.3 version: 4.19.4 @@ -420,7 +423,7 @@ importers: version: 3.4.0 tailwindcss-animate: specifier: ^1.0.7 - version: 1.0.7(tailwindcss@3.4.17) + version: 1.0.7(tailwindcss@3.4.17(ts-node@10.9.2(@types/node@20.17.57)(typescript@5.8.3))) tldts: specifier: ^6.1.86 version: 6.1.86 @@ -436,7 +439,7 @@ importers: version: link:../../packages/config-typescript '@tailwindcss/typography': specifier: ^0.5.19 - version: 0.5.19(tailwindcss@3.4.17) + version: 0.5.19(tailwindcss@3.4.17(ts-node@10.9.2(@types/node@20.17.57)(typescript@5.8.3))) '@types/node': specifier: 20.x version: 20.17.57 @@ -457,7 +460,7 @@ importers: version: 8.5.6 tailwindcss: specifier: ^3.4.17 - version: 3.4.17 + version: 3.4.17(ts-node@10.9.2(@types/node@20.17.57)(typescript@5.8.3)) vitest: specifier: ^4.0.18 version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@20.17.57)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.0) @@ -1992,6 +1995,10 @@ packages: '@corex/deepmerge@4.0.43': resolution: {integrity: sha512-N8uEMrMPL0cu/bdboEWpQYb/0i2K5Qn8eCsxzOmxSggJbbQte7ljMRoXm917AbntqTGOzdTu+vP3KOOzoC70HQ==} + '@cspotcode/source-map-support@0.8.1': + resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} + engines: {node: '>=12'} + '@csstools/color-helpers@5.0.2': resolution: {integrity: sha512-JqWH1vsgdGcw2RR6VliXXdA0/59LttzlU8UlRT/iUUsEeWfYq8I+K0yhihEUTTHLRm1EXvpsCx3083EU15ecsA==} engines: {node: '>=18'} @@ -2529,6 +2536,9 @@ packages: '@jridgewell/trace-mapping@0.3.25': resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==} + '@jridgewell/trace-mapping@0.3.9': + resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + '@kwsites/file-exists@1.1.1': resolution: {integrity: sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw==} @@ -4347,6 +4357,18 @@ packages: peerDependencies: typescript: '>=5.7.2' + '@tsconfig/node10@1.0.12': + resolution: {integrity: sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==} + + '@tsconfig/node12@1.0.11': + resolution: {integrity: sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==} + + '@tsconfig/node14@1.0.3': + resolution: {integrity: sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==} + + '@tsconfig/node16@1.0.4': + resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==} + '@tybys/wasm-util@0.9.0': resolution: {integrity: sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw==} @@ -4883,10 +4905,9 @@ packages: peerDependencies: acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 - acorn@8.14.1: - resolution: {integrity: sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==} + acorn-walk@8.3.5: + resolution: {integrity: sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==} engines: {node: '>=0.4.0'} - hasBin: true acorn@8.15.0: resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} @@ -4984,6 +5005,9 @@ packages: resolution: {integrity: sha512-ZcbTaIqJOfCc03QwD468Unz/5Ir8ATtvAHsK+FdXbDIbGfihqh9mrvdcYunQzqn4HrvWWaFyaxJhGZagaJJpPQ==} engines: {node: '>= 14'} + arg@4.1.3: + resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==} + arg@5.0.2: resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} @@ -5614,6 +5638,9 @@ packages: resolution: {integrity: sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g==} engines: {node: '>= 14'} + create-require@1.1.1: + resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} + cross-fetch@4.0.0: resolution: {integrity: sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g==} @@ -6020,6 +6047,10 @@ packages: resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + diff@4.0.4: + resolution: {integrity: sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==} + engines: {node: '>=0.3.1'} + diff@5.2.0: resolution: {integrity: sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A==} engines: {node: '>=0.3.1'} @@ -6927,6 +6958,7 @@ packages: glob@11.1.0: resolution: {integrity: sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==} engines: {node: 20 || >=22} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true global-agent@3.0.0: @@ -8085,6 +8117,9 @@ packages: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} engines: {node: '>=10'} + make-error@1.3.6: + resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==} + mammoth@1.9.1: resolution: {integrity: sha512-4S2v1eP4Yo4so0zGNicJKcP93su3wDPcUk+xvkjSG75nlNjSkDJu8BhWQ+e54BROM0HfA6nPzJn12S6bq2Ko6w==} engines: {node: '>=12.0.0'} @@ -9016,6 +9051,7 @@ packages: prebuild-install@7.1.3: resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} engines: {node: '>=10'} + deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available. hasBin: true prelude-ls@1.2.1: @@ -10101,7 +10137,7 @@ packages: tar@7.4.3: resolution: {integrity: sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==} engines: {node: '>=18'} - deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exhorbitant rates) by contacting i@izs.me + deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me term-size@2.2.1: resolution: {integrity: sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg==} @@ -10259,6 +10295,20 @@ packages: ts-interface-checker@0.1.13: resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + ts-node@10.9.2: + resolution: {integrity: sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==} + hasBin: true + peerDependencies: + '@swc/core': '>=1.2.50' + '@swc/wasm': '>=1.2.50' + '@types/node': '*' + typescript: '>=2.7' + peerDependenciesMeta: + '@swc/core': + optional: true + '@swc/wasm': + optional: true + tslib@1.14.1: resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==} @@ -10582,6 +10632,9 @@ packages: resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} hasBin: true + v8-compile-cache-lib@3.0.1: + resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} + v8-to-istanbul@9.3.0: resolution: {integrity: sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==} engines: {node: '>=10.12.0'} @@ -11063,6 +11116,10 @@ packages: yazl@2.5.1: resolution: {integrity: sha512-phENi2PLiHnHb6QBVot+dJnaAZ0xosj7p3fWl+znIjBDlnMI2PsZCJZ306BPTFOaHf5qdDEI8x5qFrSOBN5vrw==} + yn@3.1.1: + resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==} + engines: {node: '>=6'} + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -12198,6 +12255,10 @@ snapshots: '@corex/deepmerge@4.0.43': {} + '@cspotcode/source-map-support@0.8.1': + dependencies: + '@jridgewell/trace-mapping': 0.3.9 + '@csstools/color-helpers@5.0.2': {} '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': @@ -12653,7 +12714,12 @@ snapshots: '@jridgewell/trace-mapping@0.3.25': dependencies: '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.0 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@jridgewell/trace-mapping@0.3.9': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 '@kwsites/file-exists@1.1.1': dependencies: @@ -14456,10 +14522,10 @@ snapshots: postcss: 8.5.4 tailwindcss: 4.1.8 - '@tailwindcss/typography@0.5.19(tailwindcss@3.4.17)': + '@tailwindcss/typography@0.5.19(tailwindcss@3.4.17(ts-node@10.9.2(@types/node@20.17.57)(typescript@5.8.3)))': dependencies: postcss-selector-parser: 6.0.10 - tailwindcss: 3.4.17 + tailwindcss: 3.4.17(ts-node@10.9.2(@types/node@20.17.57)(typescript@5.8.3)) '@tailwindcss/vite@4.1.6(vite@6.3.6(@types/node@20.17.57)(jiti@2.4.2)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.0))': dependencies: @@ -14528,6 +14594,14 @@ snapshots: dependencies: typescript: 5.8.3 + '@tsconfig/node10@1.0.12': {} + + '@tsconfig/node12@1.0.11': {} + + '@tsconfig/node14@1.0.3': {} + + '@tsconfig/node16@1.0.4': {} + '@tybys/wasm-util@0.9.0': dependencies: tslib: 2.8.1 @@ -15073,7 +15147,7 @@ snapshots: sirv: 3.0.1 tinyglobby: 0.2.14 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.2.1)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.0) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@20.17.57)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.0) '@vitest/utils@3.2.4': dependencies: @@ -15210,15 +15284,13 @@ snapshots: mime-types: 3.0.1 negotiator: 1.0.0 - acorn-jsx@5.3.2(acorn@8.14.1): - dependencies: - acorn: 8.14.1 - acorn-jsx@5.3.2(acorn@8.15.0): dependencies: acorn: 8.15.0 - acorn@8.14.1: {} + acorn-walk@8.3.5: + dependencies: + acorn: 8.15.0 acorn@8.15.0: {} @@ -15338,6 +15410,8 @@ snapshots: tar-stream: 3.1.7 zip-stream: 6.0.1 + arg@4.1.3: {} + arg@5.0.2: {} argparse@1.0.10: @@ -15997,6 +16071,8 @@ snapshots: crc-32: 1.2.2 readable-stream: 4.7.0 + create-require@1.1.1: {} + cross-fetch@4.0.0: dependencies: node-fetch: 2.7.0 @@ -16385,6 +16461,8 @@ snapshots: diff-sequences@29.6.3: {} + diff@4.0.4: {} + diff@5.2.0: {} dijkstrajs@1.0.3: {} @@ -16876,8 +16954,8 @@ snapshots: espree@10.3.0: dependencies: - acorn: 8.14.1 - acorn-jsx: 5.3.2(acorn@8.14.1) + acorn: 8.15.0 + acorn-jsx: 5.3.2(acorn@8.15.0) eslint-visitor-keys: 4.2.0 espree@10.4.0: @@ -18671,6 +18749,8 @@ snapshots: dependencies: semver: 7.7.3 + make-error@1.3.6: {} + mammoth@1.9.1: dependencies: '@xmldom/xmldom': 0.8.10 @@ -19219,7 +19299,7 @@ snapshots: mlly@1.7.4: dependencies: - acorn: 8.14.1 + acorn: 8.15.0 pathe: 2.0.3 pkg-types: 1.3.1 ufo: 1.6.1 @@ -19796,12 +19876,13 @@ snapshots: camelcase-css: 2.0.1 postcss: 8.5.6 - postcss-load-config@4.0.2(postcss@8.5.6): + postcss-load-config@4.0.2(postcss@8.5.6)(ts-node@10.9.2(@types/node@20.17.57)(typescript@5.8.3)): dependencies: lilconfig: 3.1.3 yaml: 2.8.0 optionalDependencies: postcss: 8.5.6 + ts-node: 10.9.2(@types/node@20.17.57)(typescript@5.8.3) postcss-load-config@6.0.1(jiti@2.4.2)(postcss@8.5.6)(tsx@4.19.4)(yaml@2.8.0): dependencies: @@ -21203,15 +21284,15 @@ snapshots: tailwind-merge@3.4.0: {} - tailwindcss-animate@1.0.7(tailwindcss@3.4.17): + tailwindcss-animate@1.0.7(tailwindcss@3.4.17(ts-node@10.9.2(@types/node@20.17.57)(typescript@5.8.3))): dependencies: - tailwindcss: 3.4.17 + tailwindcss: 3.4.17(ts-node@10.9.2(@types/node@20.17.57)(typescript@5.8.3)) tailwindcss-animate@1.0.7(tailwindcss@4.1.6): dependencies: tailwindcss: 4.1.6 - tailwindcss@3.4.17: + tailwindcss@3.4.17(ts-node@10.9.2(@types/node@20.17.57)(typescript@5.8.3)): dependencies: '@alloc/quick-lru': 5.2.0 arg: 5.0.2 @@ -21230,7 +21311,7 @@ snapshots: postcss: 8.5.6 postcss-import: 15.1.0(postcss@8.5.6) postcss-js: 4.0.1(postcss@8.5.6) - postcss-load-config: 4.0.2(postcss@8.5.6) + postcss-load-config: 4.0.2(postcss@8.5.6)(ts-node@10.9.2(@types/node@20.17.57)(typescript@5.8.3)) postcss-nested: 6.2.0(postcss@8.5.6) postcss-selector-parser: 6.1.2 resolve: 1.22.10 @@ -21398,6 +21479,43 @@ snapshots: ts-interface-checker@0.1.13: {} + ts-node@10.9.2(@types/node@20.17.57)(typescript@5.8.3): + dependencies: + '@cspotcode/source-map-support': 0.8.1 + '@tsconfig/node10': 1.0.12 + '@tsconfig/node12': 1.0.11 + '@tsconfig/node14': 1.0.3 + '@tsconfig/node16': 1.0.4 + '@types/node': 20.17.57 + acorn: 8.15.0 + acorn-walk: 8.3.5 + arg: 4.1.3 + create-require: 1.1.1 + diff: 4.0.4 + make-error: 1.3.6 + typescript: 5.8.3 + v8-compile-cache-lib: 3.0.1 + yn: 3.1.1 + optional: true + + ts-node@10.9.2(@types/node@24.2.1)(typescript@5.8.3): + dependencies: + '@cspotcode/source-map-support': 0.8.1 + '@tsconfig/node10': 1.0.12 + '@tsconfig/node12': 1.0.11 + '@tsconfig/node14': 1.0.3 + '@tsconfig/node16': 1.0.4 + '@types/node': 24.2.1 + acorn: 8.15.0 + acorn-walk: 8.3.5 + arg: 4.1.3 + create-require: 1.1.1 + diff: 4.0.4 + make-error: 1.3.6 + typescript: 5.8.3 + v8-compile-cache-lib: 3.0.1 + yn: 3.1.1 + tslib@1.14.1: {} tslib@2.6.2: {} @@ -21749,6 +21867,8 @@ snapshots: uuid@9.0.1: {} + v8-compile-cache-lib@3.0.1: {} + v8-to-istanbul@9.3.0: dependencies: '@jridgewell/trace-mapping': 0.3.25 @@ -22422,6 +22542,8 @@ snapshots: dependencies: buffer-crc32: 0.2.13 + yn@3.1.1: {} + yocto-queue@0.1.0: {} yocto-queue@1.2.1: {} diff --git a/scripts/test_post_hook.ts b/scripts/test_post_hook.ts new file mode 100644 index 00000000000..109ef805c73 --- /dev/null +++ b/scripts/test_post_hook.ts @@ -0,0 +1,25 @@ +import path from "path" +import { appendAgentTrace } from "./post-hook" // adjust path if needed +import fs from "fs/promises" + +async function runTest() { + const cwd = process.cwd() + const testFile = "src/test_file.txt" + + // Ensure the src folder exists + await fs.mkdir(path.join(cwd, "src"), { recursive: true }) + + // Call your Post-Hook + await appendAgentTrace(cwd, { + relativePath: testFile, + content: "console.log('hello world')", + intentId: "INT-001", + mutationClass: "CREATE", + reqId: "REQ-123", + sessionLogId: "SESSION-456", + }) + + console.log("✅ Test completed. Check .orchestration/agent_trace.jsonl and intent_map.md") +} + +runTest() diff --git a/src/core/assistant-message/NativeToolCallParser.ts b/src/core/assistant-message/NativeToolCallParser.ts index e49e67838b5..3687a1bf284 100644 --- a/src/core/assistant-message/NativeToolCallParser.ts +++ b/src/core/assistant-message/NativeToolCallParser.ts @@ -533,6 +533,15 @@ export class NativeToolCallParser { } break + case "append_lesson_learned": + if (partialArgs.lesson !== undefined || partialArgs.file_path !== undefined) { + nativeArgs = { + lesson: partialArgs.lesson, + file_path: partialArgs.file_path, + } + } + break + case "search_files": if (partialArgs.path !== undefined || partialArgs.regex !== undefined) { nativeArgs = { @@ -921,6 +930,15 @@ export class NativeToolCallParser { } break + case "append_lesson_learned": + if (args.lesson !== undefined) { + nativeArgs = { + lesson: args.lesson, + file_path: args.file_path, + } as NativeArgsFor + } + break + case "use_mcp_tool": if (args.server_name !== undefined && args.tool_name !== undefined) { nativeArgs = { diff --git a/src/core/assistant-message/presentAssistantMessage.ts b/src/core/assistant-message/presentAssistantMessage.ts index d1cc063a848..57af7908fdf 100644 --- a/src/core/assistant-message/presentAssistantMessage.ts +++ b/src/core/assistant-message/presentAssistantMessage.ts @@ -34,6 +34,7 @@ import { updateTodoListTool } from "../tools/UpdateTodoListTool" import { runSlashCommandTool } from "../tools/RunSlashCommandTool" import { skillTool } from "../tools/SkillTool" import { generateImageTool } from "../tools/GenerateImageTool" +import { appendLessonLearnedTool } from "../tools/AppendLessonLearnedTool" import { applyDiffTool as applyDiffToolClass } from "../tools/ApplyDiffTool" import { isValidToolName, validateToolUse } from "../tools/validateToolUse" import { codebaseSearchTool } from "../tools/CodebaseSearchTool" @@ -384,6 +385,8 @@ export async function presentAssistantMessage(cline: Task) { return `[${block.name} for '${block.params.skill}'${block.params.args ? ` with args: ${block.params.args}` : ""}]` case "generate_image": return `[${block.name} for '${block.params.path}']` + case "append_lesson_learned": + return `[${block.name}]` default: return `[${block.name}]` } @@ -604,6 +607,17 @@ export async function presentAssistantMessage(cline: Task) { stateExperiments, includedTools, ) + + // Hook Engine: Pre-Hook validation for intent enforcement and guardrails + const preHookResult = await hookMiddleware.preToolUse(block.name, block.params) + if (preHookResult.blocked) { + pushToolResult(preHookResult.error || "Tool blocked by pre-hook") + break + } + if (preHookResult.injectResult) { + pushToolResult(preHookResult.injectResult) + break + } } catch (error) { cline.consecutiveMistakeCount++ // For validation errors (unknown tool, tool not allowed for mode), we need to: @@ -875,6 +889,13 @@ export async function presentAssistantMessage(cline: Task) { pushToolResult, }) break + case "append_lesson_learned": + await appendLessonLearnedTool.handle(cline, block as ToolUse<"append_lesson_learned">, { + askApproval, + handleError, + pushToolResult, + }) + break case "generate_image": await checkpointSaveAndMark(cline) await generateImageTool.handle(cline, block as ToolUse<"generate_image">, { diff --git a/src/core/prompts/tools/native-tools/append_lesson_learned.ts b/src/core/prompts/tools/native-tools/append_lesson_learned.ts new file mode 100644 index 00000000000..e570539d527 --- /dev/null +++ b/src/core/prompts/tools/native-tools/append_lesson_learned.ts @@ -0,0 +1,33 @@ +import type OpenAI from "openai" + +const APPEND_LESSON_LEARNED_DESCRIPTION = `Append a "Lesson Learned" entry to CLAUDE.md (or another file). Use this when a verification step (linter, test, or build) fails so that future sessions can avoid the same mistake. + +Call this after a failed verification: record what went wrong and how to fix or avoid it. The lesson is appended under a "## Lessons Learned" section.` + +const LESSON_PARAMETER_DESCRIPTION = `The lesson text to record (e.g. what failed, why, and how to fix or avoid it).` + +const FILE_PATH_PARAMETER_DESCRIPTION = `Optional. File to append to. Defaults to CLAUDE.md. Use a path relative to the workspace.` + +export default { + type: "function", + function: { + name: "append_lesson_learned", + description: APPEND_LESSON_LEARNED_DESCRIPTION, + strict: true, + parameters: { + type: "object", + properties: { + lesson: { + type: "string", + description: LESSON_PARAMETER_DESCRIPTION, + }, + file_path: { + type: "string", + description: FILE_PATH_PARAMETER_DESCRIPTION, + }, + }, + required: ["lesson"], + additionalProperties: false, + }, + }, +} satisfies OpenAI.Chat.ChatCompletionTool diff --git a/src/core/prompts/tools/native-tools/index.ts b/src/core/prompts/tools/native-tools/index.ts index 624d1db0841..cb64b9b621d 100644 --- a/src/core/prompts/tools/native-tools/index.ts +++ b/src/core/prompts/tools/native-tools/index.ts @@ -21,6 +21,7 @@ import searchFiles from "./search_files" import switchMode from "./switch_mode" import updateTodoList from "./update_todo_list" import writeToFile from "./write_to_file" +import appendLessonLearned from "./append_lesson_learned" export { getMcpServerTools } from "./mcp_server" export { convertOpenAIToolToAnthropic, convertOpenAIToolsToAnthropic } from "./converters" @@ -70,6 +71,7 @@ export function getNativeTools(options: NativeToolsOptions = {}): OpenAI.Chat.Ch switchMode, updateTodoList, writeToFile, + appendLessonLearned, ] satisfies OpenAI.Chat.ChatCompletionTool[] } diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 22b42cbb1ff..14dd974d859 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -268,6 +268,8 @@ export class Task extends EventEmitter implements TaskLike { private readonly globalStoragePath: string /** Active intent ID set by select_active_intent (per task/session). Used by Hook Engine for scope enforcement. */ private _activeIntentId: string | null = null + /** Per-file content hashes from read_file (path -> sha256 hash). Used for optimistic locking on write_to_file. */ + private _fileReadHashes: Map = new Map() abort: boolean = false currentRequestAbortController?: AbortController skipPrevResponseIdOnce: boolean = false @@ -4681,6 +4683,16 @@ export class Task extends EventEmitter implements TaskLike { this._activeIntentId = id } + /** Record content hash for a file read by read_file (optimistic locking: write compares disk hash to this). */ + public recordFileReadHash(relativePath: string, contentHash: string): void { + this._fileReadHashes.set(relativePath, contentHash) + } + + /** Get the content hash recorded when the agent last read this file (if any). */ + public getFileReadHash(relativePath: string): string | undefined { + return this._fileReadHashes.get(relativePath) + } + /** * Provides convenient access to high-level message operations. * Uses lazy initialization - the MessageManager is only created when first accessed. diff --git a/src/core/tools/AppendLessonLearnedTool.ts b/src/core/tools/AppendLessonLearnedTool.ts new file mode 100644 index 00000000000..574870631e1 --- /dev/null +++ b/src/core/tools/AppendLessonLearnedTool.ts @@ -0,0 +1,80 @@ +import path from "path" +import fs from "fs/promises" + +import { Task } from "../task/Task" +import { formatResponse } from "../prompts/responses" +import { getReadablePath } from "../../utils/path" +import type { ToolUse } from "../../shared/tools" + +import { BaseTool, ToolCallbacks } from "./BaseTool" + +const DEFAULT_LESSONS_FILE = "CLAUDE.md" +const LESSONS_HEADER = "## Lessons Learned" + +interface AppendLessonLearnedParams { + lesson: string + file_path?: string +} + +/** + * Appends a "Lesson Learned" entry to CLAUDE.md (or file_path). + * Used when a verification step (linter/test) fails so the agent can record what went wrong. + */ +export class AppendLessonLearnedTool extends BaseTool<"append_lesson_learned"> { + readonly name = "append_lesson_learned" as const + + async execute(params: AppendLessonLearnedParams, task: Task, callbacks: ToolCallbacks): Promise { + const { pushToolResult, handleError } = callbacks + const relPath = params.file_path ?? DEFAULT_LESSONS_FILE + const lesson = params.lesson?.trim() + + if (!lesson) { + task.consecutiveMistakeCount++ + task.recordToolError("append_lesson_learned") + pushToolResult(await task.sayAndCreateMissingParamError("append_lesson_learned", "lesson")) + return + } + + const absolutePath = path.resolve(task.cwd, relPath) + + try { + let content: string + try { + content = await fs.readFile(absolutePath, "utf-8") + } catch (err: unknown) { + if ((err as NodeJS.ErrnoException)?.code === "ENOENT") { + content = "" + } else { + throw err + } + } + + const timestamp = new Date().toISOString().slice(0, 10) + const entry = `\n- **${timestamp}**: ${lesson.replace(/\n/g, " ")}\n` + + if (!content.includes(LESSONS_HEADER)) { + content = content.trimEnd() + if (content) content += "\n\n" + content += `${LESSONS_HEADER}\n${entry}` + } else { + const headerIndex = content.indexOf(LESSONS_HEADER) + const afterHeader = content.indexOf("\n", headerIndex) + 1 + content = content.slice(0, afterHeader) + entry + content.slice(afterHeader) + } + + await fs.writeFile(absolutePath, content, "utf-8") + + const readablePath = getReadablePath(task.cwd, relPath) + pushToolResult(`Appended lesson to ${readablePath}.`) + } catch (error) { + await handleError("append_lesson_learned", error as Error) + pushToolResult( + formatResponse.toolError( + `Failed to append lesson to ${getReadablePath(task.cwd, relPath)}: ${(error as Error).message}`, + ), + ) + } + } +} + +export const appendLessonLearnedTool = new AppendLessonLearnedTool() diff --git a/src/core/tools/ReadFileTool.ts b/src/core/tools/ReadFileTool.ts index 8ad6a3b33d1..4366880c3d3 100644 --- a/src/core/tools/ReadFileTool.ts +++ b/src/core/tools/ReadFileTool.ts @@ -24,6 +24,7 @@ import { extractTextFromFile, addLineNumbers, getSupportedBinaryFormats } from " import { readWithIndentation, readWithSlice } from "../../integrations/misc/indentation-reader" import { DEFAULT_LINE_LIMIT } from "../prompts/tools/native-tools/read_file" import type { ToolUse, PushToolResult } from "../../shared/tools" +import { contentHash } from "../../hooks/content-hash" import { DEFAULT_MAX_IMAGE_FILE_SIZE_MB, @@ -219,6 +220,8 @@ export class ReadFileTool extends BaseTool<"read_file"> { const result = this.processTextFile(fileContent, entry) await task.fileContextTracker.trackFileContext(relPath, "read_tool" as RecordSource) + // Optimistic locking: record hash so write_to_file can detect parallel edits + task.recordFileReadHash(relPath, contentHash(fileContent)) updateFileResult(relPath, { nativeContent: `File: ${relPath}\n${result}`, @@ -393,6 +396,7 @@ export class ReadFileTool extends BaseTool<"read_file"> { const lineCount = content.split("\n").length await task.fileContextTracker.trackFileContext(relPath, "read_tool" as RecordSource) + task.recordFileReadHash(relPath, contentHash(content)) updateFileResult(relPath, { nativeContent: @@ -798,6 +802,8 @@ export class ReadFileTool extends BaseTool<"read_file"> { // Track file in context await task.fileContextTracker.trackFileContext(relPath, "read_tool") + // Optimistic locking: record full-file hash for write_to_file stale detection + task.recordFileReadHash(relPath, contentHash(rawContent)) } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error) results.push(`File: ${relPath}\nError: ${errorMsg}`) diff --git a/src/core/tools/WriteToFileTool.ts b/src/core/tools/WriteToFileTool.ts index d511f9d8776..07bad47943e 100644 --- a/src/core/tools/WriteToFileTool.ts +++ b/src/core/tools/WriteToFileTool.ts @@ -17,6 +17,7 @@ import { convertNewFileToUnifiedDiff, computeDiffStats, sanitizeUnifiedDiff } fr import type { ToolUse } from "../../shared/tools" import { BaseTool, ToolCallbacks } from "./BaseTool" +import { contentHash } from "../../hooks/content-hash" interface WriteToFileParams { path: string @@ -67,6 +68,21 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { task.diffViewProvider.editType = fileExists ? "modify" : "create" } + // Optimistic locking: if we have a read-hash for this file, ensure disk hasn't changed (parallel agent or human edit) + if (fileExists) { + const expectedHash = task.getFileReadHash(relPath) + if (expectedHash !== undefined) { + const currentContent = await fs.readFile(absolutePath, "utf-8") + const currentHash = contentHash(currentContent) + if (currentHash !== expectedHash) { + const staleError = `Stale File: The file "${relPath}" was modified since you read it (by another agent or the user). Your write is blocked to avoid overwriting those changes. Re-read the file with read_file and then apply your edits.` + pushToolResult(formatResponse.toolError(staleError)) + await task.diffViewProvider.reset() + return + } + } + } + // Create parent directories early for new files to prevent ENOENT errors // in subsequent operations (e.g., diffViewProvider.open, fs.readFile) if (!fileExists) { diff --git a/src/shared/tools.ts b/src/shared/tools.ts index f09bed46884..f38649fac4e 100644 --- a/src/shared/tools.ts +++ b/src/shared/tools.ts @@ -83,6 +83,7 @@ export const toolParamNames = [ // write_to_file traceability (AI-Native Git Layer) "intent_id", "mutation_class", + "lesson", // append_lesson_learned ] as const export type ToolParamName = (typeof toolParamNames)[number] @@ -123,6 +124,7 @@ export type NativeToolArgs = { intent_id?: string mutation_class?: "AST_REFACTOR" | "INTENT_EVOLUTION" | "NEW_FILE" } + append_lesson_learned: { lesson: string; file_path?: string } // Add more tools as they are migrated to native protocol } @@ -296,6 +298,7 @@ export const TOOL_DISPLAY_NAMES: Record = { run_slash_command: "run slash command", skill: "load skill", generate_image: "generate images", + append_lesson_learned: "append lessons learned", custom_tool: "use custom tools", } as const diff --git a/test-guardrails.js b/test-guardrails.js new file mode 100644 index 00000000000..a8e4f10818f --- /dev/null +++ b/test-guardrails.js @@ -0,0 +1,57 @@ +const { PreHook } = require("../src/hooks/pre-hook") + +async function testGuardrails() { + const cwd = process.cwd() + let activeIntentId = null + + const preHook = new PreHook({ + cwd, + getActiveIntentId: () => activeIntentId, + setActiveIntentId: (id) => { + activeIntentId = id + }, + requireIntentForDestructiveOnly: true, + }) + + console.log("🧪 Testing Guardrails in Demo Workspace\n") + + // Test 1: No intent ID + console.log("1. Testing write_to_file WITHOUT intent ID:") + const result1 = await preHook.intercept("write_to_file", { + path: "src/config/app.ts", + content: "// config", + }) + console.log(" Blocked:", result1.blocked) + console.log(" Error:", result1.error?.substring(0, 100) + "...") + + // Test 2: Select valid intent + console.log("\n2. Testing select_active_intent with INT-001:") + const result2 = await preHook.intercept("select_active_intent", { + intent_id: "INT-001", + }) + console.log(" Blocked:", result2.blocked) + console.log(" InjectResult:", result2.injectResult ? "XML injected" : "None") + activeIntentId = "INT-001" + + // Test 3: Scope violation + console.log("\n3. Testing write_to_file with scope violation:") + const result3 = await preHook.intercept("write_to_file", { + path: "src/db/database.ts", + content: "// database", + }) + console.log(" Blocked:", result3.blocked) + console.log(" Error:", result3.error?.substring(0, 100) + "...") + + // Test 4: Valid scope + console.log("\n4. Testing write_to_file in valid scope:") + const result4 = await preHook.intercept("write_to_file", { + path: "src/api/weather.ts", + content: "// weather API", + }) + console.log(" Blocked:", result4.blocked) + console.log(" Error:", result4.error || "None") + + console.log("\n✅ Guardrail tests completed!") +} + +testGuardrails().catch(console.error) diff --git a/test-trace.js b/test-trace.js new file mode 100644 index 00000000000..4d7a94a374f --- /dev/null +++ b/test-trace.js @@ -0,0 +1,23 @@ +import { appendAgentTrace } from "../src/hooks/post-hook" + +async function testTrace() { + const cwd = process.cwd() + + console.log("🧪 Testing Trace Generation in Demo Workspace\n") + + await appendAgentTrace(cwd, { + relativePath: "src/api/weather.ts", + content: "// weather API\nexport function getWeather() {\n return { temp: 72, condition: 'sunny' };\n}", + intentId: "INT-001", + mutationClass: "NEW_FILE", + reqId: "REQ-123", + sessionLogId: "SESSION-456", + modelIdentifier: "test-model", + vcsRevisionId: "abc123", + }) + + console.log("✅ Trace entry created!") + console.log("📁 Check .orchestration/agent_trace.jsonl") +} + +testTrace().catch(console.error) diff --git a/todo-demo/.orchestration/active_intents.yaml b/todo-demo/.orchestration/active_intents.yaml new file mode 100644 index 00000000000..563eacd624f --- /dev/null +++ b/todo-demo/.orchestration/active_intents.yaml @@ -0,0 +1,12 @@ +active_intents: + - id: "INT-001" + name: "Build Todo App" + status: "IN_PROGRESS" + owned_scope: + - "src/**" + constraints: + - "Use TypeScript" + - "Keep components simple" + acceptance_criteria: + - "Add todo functionality works" + - "Delete todo functionality works" diff --git a/todo-demo/.orchestration/agent_trace.jsonl b/todo-demo/.orchestration/agent_trace.jsonl new file mode 100644 index 00000000000..9ba7ce37d5d --- /dev/null +++ b/todo-demo/.orchestration/agent_trace.jsonl @@ -0,0 +1 @@ +{"id": "trace-001", "timestamp": "2025-02-21T21:50:00.000Z", "files": [{"relative_path": "src/components/TodoList.tsx", "conversations": [{"url": "session-1", "contributor": {"entity_type": "AI", "model_identifier": "test-model"}, "ranges": [{"start_line": 1, "end_line": 15, "content_hash": "sha256:abc123def456789"}], "related": [{"type": "specification", "value": "INT-001"}]}]}]} diff --git a/todo-demo/src/components/TodoList.tsx b/todo-demo/src/components/TodoList.tsx new file mode 100644 index 00000000000..3aad033dd98 --- /dev/null +++ b/todo-demo/src/components/TodoList.tsx @@ -0,0 +1,32 @@ +import React from "react" + +interface Todo { + id: string + text: string + completed: boolean +} + +interface TodoListProps { + todos: Todo[] +} + +const TodoList: React.FC = ({ todos }) => { + return ( +
+

Todo List

+ {todos.length === 0 ? ( +

No todos yet!

+ ) : ( +
    + {todos.map((todo) => ( +
  • + {todo.text} +
  • + ))} +
+ )} +
+ ) +} + +export default TodoList diff --git a/weather-api-demo/.orchestration/active_intents.yaml b/weather-api-demo/.orchestration/active_intents.yaml new file mode 100644 index 00000000000..ff3740978d6 --- /dev/null +++ b/weather-api-demo/.orchestration/active_intents.yaml @@ -0,0 +1,11 @@ +active_intents: + - id: "INT-001" + name: "Build Weather API" + status: "IN_PROGRESS" + owned_scope: + - "src/api/**" + constraints: + - "Use REST conventions" + - "Return JSON" + acceptance_criteria: + - "GET /weather returns 200"