Merge pull request #2 from bethel4/hooks

hooks file updated
This commit is contained in:
kokeb 2026-02-18 11:22:33 -08:00 committed by GitHub
commit 95fd3d0179
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 516 additions and 0 deletions

View file

21
src/hooks/content-hash.ts Normal file
View file

@ -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)
}

View file

@ -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<IntentContext | null> {
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) => ` <constraint>${escapeXml(c)}</constraint>`).join("\n")
: " <constraint>None specified</constraint>"
const scopeXml =
context.owned_scope.length > 0
? context.owned_scope.map((s) => ` <scope>${escapeXml(s)}</scope>`).join("\n")
: " <scope>No scope restriction</scope>"
const criteriaXml =
(context.acceptance_criteria?.length ?? 0 > 0)
? context.acceptance_criteria!.map((a) => ` <criterion>${escapeXml(a)}</criterion>`).join("\n")
: " <criterion>None specified</criterion>"
return `<intent_context>
<id>${escapeXml(context.id)}</id>
<name>${escapeXml(context.name)}</name>
<status>${escapeXml(context.status)}</status>
<constraints>
${constraintsXml}
</constraints>
<owned_scope>
${scopeXml}
</owned_scope>
<acceptance_criteria>
${criteriaXml}
</acceptance_criteria>
</intent_context>`
}
function escapeXml(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;")
}
/**
* 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<string[]> {
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 []
}
}

View file

@ -0,0 +1,13 @@
/**
* Orchestration hooks for IntentCode 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"

View file

@ -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.`

55
src/hooks/middleware.ts Normal file
View file

@ -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<string, unknown>): Promise<HookResult> {
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<string, unknown>, _result: unknown): Promise<void> {
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?.(),
})
}
}

71
src/hooks/post-hook.ts Normal file
View file

@ -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<void> {
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)
}

89
src/hooks/pre-hook.ts Normal file
View file

@ -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<string, unknown>): Promise<HookResult> {
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<boolean> {
return new Promise((resolve) => {
vscode.window
.showWarningMessage(`Approve destructive action: ${toolName}?`, { modal: true }, "Approve", "Reject")
.then((choice) => resolve(choice === "Approve"))
})
}
}

39
src/hooks/scope.ts Normal file
View file

@ -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)
}

View file

@ -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

View file

@ -0,0 +1,94 @@
/**
* Orchestration hook types for IntentCode 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