From f93d659c5532cfd31d176c4dc68382d4bccfb9de Mon Sep 17 00:00:00 2001 From: Nahom Date: Wed, 18 Feb 2026 14:41:06 +0300 Subject: [PATCH 01/12] mcp update --- .vscode/mcp.json | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 .vscode/mcp.json diff --git a/.vscode/mcp.json b/.vscode/mcp.json new file mode 100644 index 00000000000..27953d74b39 --- /dev/null +++ b/.vscode/mcp.json @@ -0,0 +1,9 @@ +{ + "servers": { + "tenxfeedbackanalytics": { + "url": "https://mcppulse.10academy.org/proxy", + "type": "http" + } + }, + "inputs": [] +} From df14c17ba05ae4367c5dd685c7ef188ee3498267 Mon Sep 17 00:00:00 2001 From: Nahom Date: Wed, 18 Feb 2026 15:20:48 +0300 Subject: [PATCH 02/12] intial hook engine.ts --- src/hooks/HookEngine.ts | 111 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 src/hooks/HookEngine.ts diff --git a/src/hooks/HookEngine.ts b/src/hooks/HookEngine.ts new file mode 100644 index 00000000000..57847e0998c --- /dev/null +++ b/src/hooks/HookEngine.ts @@ -0,0 +1,111 @@ +/** + * HookEngine.ts + * ───────────────────────────────────────────────────────────── + * Core middleware runner for the Intent-Driven Hook System. + * + * Every tool call in the extension passes through: + * Pre-Hooks → (may block or enrich context) + * Tool runs → (original tool executes) + * Post-Hooks → (audit, trace, documentation side-effects) + * + * Design: composable, fail-safe, privilege-separated. + * ───────────────────────────────────────────────────────────── + */ + +// ── Types ────────────────────────────────────────────────────── + +export interface ToolContext { + /** Name of the tool being called e.g. "write_file" */ + toolName: string + + /** Parameters passed by the LLM to the tool */ + params: Record + + /** Absolute path to the VS Code workspace root */ + workspacePath: string + + /** The intent ID declared by the agent this turn (set by ContextInjector) */ + intentId?: string + + /** The mutation class determined by the hook (not agent self-reported) */ + mutationClass?: "AST_REFACTOR" | "INTENT_EVOLUTION" | "UNKNOWN" + + /** Snapshot of the file content BEFORE the write (set by OptimisticLockGuard) */ + __oldContent__?: string + + /** The injected intent context XML block (set by ContextInjector) */ + __injectedContext__?: string + + /** Current git SHA (populated lazily) */ + __gitSha__?: string +} + +/** + * Returning a BlockSignal from any Pre-Hook short-circuits the entire chain. + * The reason string is returned to the LLM as a tool error. + */ +export class BlockSignal { + constructor( + public readonly reason: string, + public readonly code: BlockCode = "GENERIC_BLOCK", + ) {} +} + +export type BlockCode = "NO_INTENT_DECLARED" | "SCOPE_VIOLATION" | "STALE_FILE" | "UNKNOWN_INTENT" | "GENERIC_BLOCK" + +export type HookFn = (ctx: ToolContext) => Promise + +// ── HookEngine ───────────────────────────────────────────────── + +export class HookEngine { + private preHooks: Array<{ name: string; fn: HookFn }> = [] + private postHooks: Array<{ name: string; fn: HookFn }> = [] + + registerPre(name: string, fn: HookFn): void { + this.preHooks.push({ name, fn }) + } + + registerPost(name: string, fn: HookFn): void { + this.postHooks.push({ name, fn }) + } + + /** + * Run all pre-hooks in registration order. + * Returns enriched ToolContext on success, BlockSignal on any block. + */ + async runPreHooks(ctx: ToolContext): Promise { + for (const { name, fn } of this.preHooks) { + try { + const result = await fn(ctx) + if (result instanceof BlockSignal) { + console.log(`[HookEngine] PRE-HOOK "${name}" BLOCKED: ${result.reason}`) + return result + } + ctx = result + } catch (err) { + console.error(`[HookEngine] PRE-HOOK "${name}" threw:`, err) + // Fail-safe: block on unexpected hook errors + return new BlockSignal(`Hook "${name}" encountered an internal error.`) + } + } + return ctx + } + + /** + * Run all post-hooks in registration order. + * Post-hooks never block — errors are logged and swallowed. + */ + async runPostHooks(ctx: ToolContext): Promise { + for (const { name, fn } of this.postHooks) { + try { + await fn(ctx) + } catch (err) { + console.error(`[HookEngine] POST-HOOK "${name}" threw:`, err) + // Never crash on post-hook failure — side-effects are best-effort + } + } + } +} + +// Singleton instance used across the extension +export const hookEngine = new HookEngine() From 7ed699fae875ac7c8ed115f3016cf0fe3fb39b9e Mon Sep 17 00:00:00 2001 From: "nahom .desalegn" Date: Wed, 18 Feb 2026 08:49:56 -0800 Subject: [PATCH 03/12] updated task --- src/hooks/index.ts | 120 ++++++++++++++++ src/hooks/post/IntentMapUpdater.ts | 115 +++++++++++++++ src/hooks/post/LessonRecorder.ts | 145 +++++++++++++++++++ src/hooks/post/TraceLogger.ts | 155 +++++++++++++++++++++ src/hooks/pre/ContextInjector.ts | 103 ++++++++++++++ src/hooks/pre/IntentGatekeeper.ts | 64 +++++++++ src/hooks/pre/OptimisticLockGuard.ts | 88 ++++++++++++ src/hooks/pre/ScopeEnforcer.ts | 64 +++++++++ src/hooks/utils/astHasher.ts | 192 ++++++++++++++++++++++++++ src/hooks/utils/mutationClassifier.ts | 0 10 files changed, 1046 insertions(+) create mode 100644 src/hooks/index.ts create mode 100644 src/hooks/post/IntentMapUpdater.ts create mode 100644 src/hooks/post/LessonRecorder.ts create mode 100644 src/hooks/post/TraceLogger.ts create mode 100644 src/hooks/pre/ContextInjector.ts create mode 100644 src/hooks/pre/IntentGatekeeper.ts create mode 100644 src/hooks/pre/OptimisticLockGuard.ts create mode 100644 src/hooks/pre/ScopeEnforcer.ts create mode 100644 src/hooks/utils/astHasher.ts create mode 100644 src/hooks/utils/mutationClassifier.ts diff --git a/src/hooks/index.ts b/src/hooks/index.ts new file mode 100644 index 00000000000..df7d90fa935 --- /dev/null +++ b/src/hooks/index.ts @@ -0,0 +1,120 @@ +/** + * hooks/index.ts + * ───────────────────────────────────────────────────────────── + * Entry point for the Hook System. + * + * Registers all pre and post hooks in execution order. + * Exports the dispatchWithHooks() wrapper that replaces the + * extension's existing tool dispatcher. + * + * Pre-hook execution order (matters — each enriches ctx for next): + * 1. ContextInjector → intercepts select_active_intent, sets ctx.intentId + * 2. IntentGatekeeper → blocks writes if no intentId + * 3. ScopeEnforcer → blocks writes outside owned_scope + * 4. OptimisticLockGuard → blocks stale file writes, captures old content + * + * Post-hook execution order (all run, errors are swallowed): + * 1. TraceLogger → writes to agent_trace.jsonl + * 2. IntentMapUpdater → updates intent_map.md + * 3. LessonRecorder → appends to CLAUDE.md on evolution/errors + * ───────────────────────────────────────────────────────────── + */ + +import { hookEngine, ToolContext, BlockSignal } from './HookEngine'; + +// Pre-hooks +import { contextInjector } from './pre/ContextInjector'; +import { intentGatekeeper } from './pre/IntentGatekeeper'; +import { scopeEnforcer } from './pre/ScopeEnforcer'; +import { optimisticLockGuard } from './pre/OptimisticLockGuard'; + +// Post-hooks +import { traceLogger } from './post/TraceLogger'; +import { intentMapUpdater } from './post/IntentMapUpdater'; +import { lessonRecorder } from './post/LessonRecorder'; + +// ── Registration ─────────────────────────────────────────────── + +export function registerAllHooks(): void { + // Pre-hooks (ORDER MATTERS) + hookEngine.registerPre('ContextInjector', contextInjector); + hookEngine.registerPre('IntentGatekeeper', intentGatekeeper); + hookEngine.registerPre('ScopeEnforcer', scopeEnforcer); + hookEngine.registerPre('OptimisticLockGuard', optimisticLockGuard); + + // Post-hooks + hookEngine.registerPost('TraceLogger', traceLogger); + hookEngine.registerPost('IntentMapUpdater', intentMapUpdater); + hookEngine.registerPost('LessonRecorder', lessonRecorder); + + console.log('[HookSystem] All hooks registered.'); +} + +// ── Dispatcher Wrapper ───────────────────────────────────────── + +/** + * Drop-in replacement for the extension's tool dispatcher. + * + * Usage — replace this pattern in the extension host: + * + * // BEFORE (original extension code): + * const result = await executeTool(toolName, params); + * + * // AFTER (with hook system): + * const result = await dispatchWithHooks(toolName, params, workspacePath, originalExecuteTool); + */ +export async function dispatchWithHooks( + toolName: string, + params: Record, + workspacePath: string, + originalDispatch: (toolName: string, params: Record) => Promise, + sessionIntentId?: string +): Promise<{ content: unknown; blocked: boolean; blockReason?: string }> { + + const ctx: ToolContext = { + toolName, + params, + workspacePath, + intentId: sessionIntentId, + }; + + // ── Run Pre-Hooks ──────────────────────────────────────────── + const preResult = await hookEngine.runPreHooks(ctx); + + if (preResult instanceof BlockSignal) { + return { + content: { + type: 'error', + error: preResult.reason, + code: preResult.code, + }, + blocked: true, + blockReason: preResult.reason, + }; + } + + // ── Context injection short-circuit ───────────────────────── + // If a hook injected context (select_active_intent), return it + // directly WITHOUT running the original tool. + if (preResult.__injectedContext__) { + await hookEngine.runPostHooks(preResult); + return { + content: { + type: 'tool_result', + content: preResult.__injectedContext__, + }, + blocked: false, + }; + } + + // ── Run Original Tool ──────────────────────────────────────── + const toolResult = await originalDispatch(preResult.toolName, preResult.params); + + // ── Run Post-Hooks ─────────────────────────────────────────── + await hookEngine.runPostHooks(preResult); + + return { content: toolResult, blocked: false }; +} + +// Re-export core types for extension host use +export { hookEngine, ToolContext, BlockSignal } from './HookEngine'; \ No newline at end of file diff --git a/src/hooks/post/IntentMapUpdater.ts b/src/hooks/post/IntentMapUpdater.ts new file mode 100644 index 00000000000..f99eac9bc6d --- /dev/null +++ b/src/hooks/post/IntentMapUpdater.ts @@ -0,0 +1,115 @@ +/** + * post/IntentMapUpdater.ts + * ───────────────────────────────────────────────────────────── + * POST-HOOK #2 — The Spatial Map Updater + * + * Maintains .orchestration/intent_map.md — a human-readable + * map that answers: "Where is the [feature] logic?" + * + * Updated whenever an INTENT_EVOLUTION is detected (new feature + * or API surface change). Refactors do not update the map + * because the intent-to-file mapping hasn't changed. + * + * Format: Markdown table with Intent → Files → Last Updated + * ───────────────────────────────────────────────────────────── + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import { ToolContext } from '../HookEngine'; +import { findIntent } from '../utils/intentStore'; +import { toRelativePath } from '../utils/gitUtils'; + +const MAP_FILE = path.join('.orchestration', 'intent_map.md'); + +interface IntentMapEntry { + intentId: string; + intentName: string; + files: string[]; + lastUpdated: string; + mutationCount: number; +} + +export async function intentMapUpdater(ctx: ToolContext): Promise { + // Only update map on file writes + const WRITE_TOOLS = new Set(['write_file', 'write_to_file', 'create_file', 'apply_diff', 'replace_in_file']); + if (!WRITE_TOOLS.has(ctx.toolName)) return ctx; + if (!ctx.intentId) return ctx; + + const targetPath = ctx.params['path'] as string ?? ctx.params['file_path'] as string; + if (!targetPath) return ctx; + + const relativePath = toRelativePath(ctx.workspacePath, path.resolve(ctx.workspacePath, targetPath)); + const intent = findIntent(ctx.workspacePath, ctx.intentId); + const intentName = intent?.name ?? ctx.intentId; + + const mapPath = path.join(ctx.workspacePath, MAP_FILE); + const existingMap = loadMap(mapPath); + + // Get or create entry for this intent + let entry = existingMap.find(e => e.intentId === ctx.intentId); + if (!entry) { + entry = { + intentId: ctx.intentId, + intentName, + files: [], + lastUpdated: new Date().toISOString(), + mutationCount: 0, + }; + existingMap.push(entry); + } + + // Add file if not already tracked + if (!entry.files.includes(relativePath)) { + entry.files.push(relativePath); + } + + entry.lastUpdated = new Date().toISOString(); + entry.mutationCount += 1; + + // Write the updated map + fs.mkdirSync(path.dirname(mapPath), { recursive: true }); + fs.writeFileSync(mapPath, renderMap(existingMap), 'utf8'); + + return ctx; +} + +// ── Map serialization ────────────────────────────────────────── + +function loadMap(mapPath: string): IntentMapEntry[] { + if (!fs.existsSync(mapPath)) return []; + + try { + const content = fs.readFileSync(mapPath, 'utf8'); + // Parse the HTML comment data block we embed in the file + const match = content.match(//); + if (match) { + return JSON.parse(match[1]); + } + } catch { + // If parse fails, start fresh + } + return []; +} + +function renderMap(entries: IntentMapEntry[]): string { + const rows = entries + .sort((a, b) => a.intentId.localeCompare(b.intentId)) + .map(e => { + const fileLinks = e.files.map(f => `\`${f}\``).join(', '); + const date = new Date(e.lastUpdated).toLocaleString('en-US', { dateStyle: 'short', timeStyle: 'short' }); + return `| ${e.intentId} | ${e.intentName} | ${fileLinks} | ${e.mutationCount} | ${date} |`; + }) + .join('\n'); + + return `# Intent Map +> Auto-generated by the Hook System. Do not edit manually. +> Last regenerated: ${new Date().toISOString()} + +| Intent ID | Intent Name | Files | Mutations | Last Updated | +|-----------|-------------|-------|-----------|--------------| +${rows} + + +`; +} \ No newline at end of file diff --git a/src/hooks/post/LessonRecorder.ts b/src/hooks/post/LessonRecorder.ts new file mode 100644 index 00000000000..6957a360eb9 --- /dev/null +++ b/src/hooks/post/LessonRecorder.ts @@ -0,0 +1,145 @@ +/** + * post/LessonRecorder.ts + * ───────────────────────────────────────────────────────────── + * POST-HOOK #3 — The Shared Brain Updater + * + * Appends "Lessons Learned" to CLAUDE.md (or AGENT.md) when: + * • A scope violation was attempted (and blocked) + * • A stale file conflict occurred + * • A test or linter run failed + * • An INTENT_EVOLUTION is detected (architectural decision logged) + * + * CLAUDE.md is the shared context across ALL parallel agent sessions. + * It prevents agents from making the same mistakes twice. + * ───────────────────────────────────────────────────────────── + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import { ToolContext } from '../HookEngine'; + +const BRAIN_FILE = 'CLAUDE.md'; +const BRAIN_SECTION = '## Lessons Learned (Auto-Generated)'; + +export interface Lesson { + timestamp: string; + category: LessonCategory; + intentId: string | null; + summary: string; + detail: string; +} + +export type LessonCategory = + | 'SCOPE_VIOLATION_ATTEMPTED' + | 'STALE_FILE_CONFLICT' + | 'TEST_FAILURE' + | 'LINT_FAILURE' + | 'INTENT_EVOLUTION' + | 'ARCHITECTURAL_DECISION' + | 'GENERAL'; + +export async function lessonRecorder(ctx: ToolContext): Promise { + // Log intent evolution events + if (ctx.mutationClass === 'INTENT_EVOLUTION') { + const targetPath = ctx.params['path'] as string; + await appendLesson(ctx.workspacePath, { + timestamp: new Date().toISOString(), + category: 'INTENT_EVOLUTION', + intentId: ctx.intentId ?? null, + summary: `API surface evolved in ${targetPath}`, + detail: + `Intent ${ctx.intentId ?? 'UNKNOWN'} caused an INTENT_EVOLUTION in "${targetPath}". ` + + `This means exported functions or types changed. Parallel agents working on ` + + `files that import this module should re-read it before writing.`, + }); + } + + return ctx; +} + +/** + * Called externally (e.g., from test runner integration) to record failures. + */ +export async function recordFailure( + workspacePath: string, + category: LessonCategory, + intentId: string | null, + summary: string, + detail: string +): Promise { + await appendLesson(workspacePath, { + timestamp: new Date().toISOString(), + category, + intentId, + summary, + detail, + }); +} + +/** + * Called externally to record a scope violation that was blocked. + */ +export async function recordScopeViolation( + workspacePath: string, + intentId: string, + attemptedFile: string, + authorizedScope: string[] +): Promise { + await appendLesson(workspacePath, { + timestamp: new Date().toISOString(), + category: 'SCOPE_VIOLATION_ATTEMPTED', + intentId, + summary: `Agent attempted to write outside scope: ${attemptedFile}`, + detail: + `Intent ${intentId} tried to modify "${attemptedFile}" which is outside its authorized scope.\n` + + `Authorized scope: ${authorizedScope.join(', ')}.\n` + + `If this file legitimately needs modification under this intent, the scope must be expanded explicitly.`, + }); +} + +// ── Append logic ─────────────────────────────────────────────── + +async function appendLesson(workspacePath: string, lesson: Lesson): Promise { + const brainPath = path.join(workspacePath, BRAIN_FILE); + const entry = formatLesson(lesson); + + if (!fs.existsSync(brainPath)) { + // Create CLAUDE.md if it doesn't exist + fs.writeFileSync( + brainPath, + `# CLAUDE.md — Shared Agent Brain\n\n` + + `This file is shared across all parallel agent sessions.\n` + + `Read it at the start of every session.\n\n` + + `${BRAIN_SECTION}\n\n${entry}\n`, + 'utf8' + ); + return; + } + + const existing = fs.readFileSync(brainPath, 'utf8'); + + if (existing.includes(BRAIN_SECTION)) { + // Append after the section header + const updated = existing.replace( + BRAIN_SECTION, + `${BRAIN_SECTION}\n\n${entry}` + ); + fs.writeFileSync(brainPath, updated, 'utf8'); + } else { + // Append section at end + fs.appendFileSync(brainPath, `\n${BRAIN_SECTION}\n\n${entry}\n`, 'utf8'); + } +} + +function formatLesson(lesson: Lesson): string { + const date = new Date(lesson.timestamp).toLocaleString('en-US', { + dateStyle: 'medium', + timeStyle: 'short', + }); + + return `### [${lesson.category}] ${lesson.summary} +- **Date:** ${date} +- **Intent:** ${lesson.intentId ?? 'N/A'} +- **Detail:** ${lesson.detail} +`; +} \ No newline at end of file diff --git a/src/hooks/post/TraceLogger.ts b/src/hooks/post/TraceLogger.ts new file mode 100644 index 00000000000..8b3522b937d --- /dev/null +++ b/src/hooks/post/TraceLogger.ts @@ -0,0 +1,155 @@ +/** + * post/TraceLogger.ts + * ───────────────────────────────────────────────────────────── + * POST-HOOK #1 — The Ledger + * + * After every file write, appends an entry to agent_trace.jsonl. + * This is the audit trail that links: + * Business Intent → Mutation Class → Code Hash → Git SHA + * + * Key properties: + * • Append-only (never mutates existing entries) + * • AST-based content hashing (spatial independence) + * • Deterministic mutation classification (not agent self-reported) + * • Links back to the intent ID declared in the Pre-Hook + * ───────────────────────────────────────────────────────────── + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import { v4 as uuidv4 } from 'uuid'; +import { ToolContext } from '../HookEngine'; +import { hashCodeBlock } from '../utils/astHasher'; +import { classifyMutation } from '../utils/mutationClassifier'; +import { getCurrentGitSha, toRelativePath } from '../utils/gitUtils'; + +const TRACE_FILE = path.join('.orchestration', 'agent_trace.jsonl'); + +export interface TraceEntry { + id: string; + timestamp: string; + vcs: { revision_id: string | null }; + mutation_class: string; + classification_reason: string; + files: TraceFile[]; +} + +export interface TraceFile { + relative_path: string; + conversations: TraceConversation[]; +} + +export interface TraceConversation { + session_id: string; + contributor: { + entity_type: 'AI' | 'HUMAN'; + model_identifier: string; + }; + ranges: TraceRange[]; + related: TraceRelated[]; +} + +export interface TraceRange { + start_line: number; + end_line: number; + content_hash: string; + hash_method: 'ast' | 'raw'; + ast_node_count: number; +} + +export interface TraceRelated { + type: 'specification' | 'intent' | 'requirement'; + value: string; +} + +// ── Main post-hook ───────────────────────────────────────────── + +const WRITE_TOOLS = new Set([ + 'write_file', + 'write_to_file', + 'create_file', + 'apply_diff', + 'replace_in_file', +]); + +export async function traceLogger(ctx: ToolContext): Promise { + if (!WRITE_TOOLS.has(ctx.toolName)) return ctx; + + const targetPath = ctx.params['path'] as string ?? ctx.params['file_path'] as string; + const newContent = ctx.params['content'] as string ?? ctx.params['new_content'] as string; + + if (!targetPath || newContent === undefined) return ctx; + + const relativePath = toRelativePath(ctx.workspacePath, path.resolve(ctx.workspacePath, targetPath)); + + // Compute AST-based hash of the new content + const hashResult = await hashCodeBlock(newContent, targetPath); + + // Classify mutation (deterministic — compare old vs new AST) + const oldContent = ctx.__oldContent__ ?? ''; + const classification = await classifyMutation(oldContent, newContent, targetPath); + + // Get current git SHA + const gitSha = getCurrentGitSha(ctx.workspacePath); + + const lineCount = newContent.split('\n').length; + + const entry: TraceEntry = { + id: uuidv4(), + timestamp: new Date().toISOString(), + vcs: { revision_id: gitSha }, + mutation_class: ctx.mutationClass ?? classification.mutationClass, + classification_reason: classification.reason, + files: [ + { + relative_path: relativePath, + conversations: [ + { + session_id: getSessionId(), + contributor: { + entity_type: 'AI', + model_identifier: getModelIdentifier(ctx), + }, + ranges: [ + { + start_line: 1, + end_line: lineCount, + content_hash: hashResult.hash, + hash_method: hashResult.method, + ast_node_count: hashResult.nodeCount, + }, + ], + related: [ + { + type: 'intent', + value: ctx.intentId ?? 'UNLINKED', + }, + ], + }, + ], + }, + ], + }; + + // Append to JSONL + const tracePath = path.join(ctx.workspacePath, TRACE_FILE); + fs.mkdirSync(path.dirname(tracePath), { recursive: true }); + fs.appendFileSync(tracePath, JSON.stringify(entry) + '\n', 'utf8'); + + console.log(`[TraceLogger] Logged ${classification.mutationClass} for ${relativePath} (intent: ${ctx.intentId ?? 'UNLINKED'})`); + + return ctx; +} + +// ── Helpers ──────────────────────────────────────────────────── + +let _sessionId: string | null = null; +function getSessionId(): string { + if (!_sessionId) _sessionId = uuidv4(); + return _sessionId; +} + +function getModelIdentifier(ctx: ToolContext): string { + // Try to extract from context — extension may store this + return (ctx.params['__model__'] as string) ?? 'unknown-model'; +} \ No newline at end of file diff --git a/src/hooks/pre/ContextInjector.ts b/src/hooks/pre/ContextInjector.ts new file mode 100644 index 00000000000..dc8f78874b6 --- /dev/null +++ b/src/hooks/pre/ContextInjector.ts @@ -0,0 +1,103 @@ +/** + * pre/ContextInjector.ts + * ───────────────────────────────────────────────────────────── + * PRE-HOOK #2 — The Context Injector + * + * Intercepts the select_active_intent() tool call. + * Instead of running the tool, the hook: + * 1. Looks up the intent in active_intents.yaml + * 2. Constructs a structured XML block + * 3. Returns it as the tool result + * + * The agent sees only the XML block — it never directly reads + * the sidecar YAML files. The hook is the context layer. + * + * Also sets ctx.intentId so downstream hooks know the active intent. + * ───────────────────────────────────────────────────────────── + */ + +import { ToolContext, BlockSignal } from '../HookEngine'; +import { findIntent, loadIntents, ActiveIntent } from '../utils/intentStore'; + +export async function contextInjector(ctx: ToolContext): Promise { + if (ctx.toolName !== 'select_active_intent') return ctx; + + const intentId = ctx.params['intent_id'] as string; + + if (!intentId || typeof intentId !== 'string') { + return new BlockSignal( + 'select_active_intent requires an intent_id string parameter.\n' + + 'Example: select_active_intent("INT-001")' + ); + } + + const intent = findIntent(ctx.workspacePath, intentId); + + if (!intent) { + const available = loadIntents(ctx.workspacePath).map(i => ` • ${i.id}: ${i.name}`).join('\n'); + return new BlockSignal( + `BLOCKED [UNKNOWN_INTENT]: No active intent found with id "${intentId}".\n\n` + + `Available intents:\n${available || ' (none — create .orchestration/active_intents.yaml first)'}`, + 'UNKNOWN_INTENT' + ); + } + + if (intent.status === 'COMPLETE') { + return new BlockSignal( + `BLOCKED: Intent "${intentId}" is already COMPLETE. ` + + `Create a new intent or reopen this one before proceeding.` + ); + } + + // Build the context XML block that will be returned to the LLM as the tool result + const contextXml = buildIntentContextXml(intent); + + // Store in context for downstream hooks and for the dispatcher to return + ctx.__injectedContext__ = contextXml; + ctx.intentId = intentId; + + return ctx; +} + +// ── XML Builder ──────────────────────────────────────────────── + +function buildIntentContextXml(intent: ActiveIntent): string { + const scopeList = intent.owned_scope.map(s => ` ${escapeXml(s)}`).join('\n'); + const constraintList = intent.constraints.map(c => ` ${escapeXml(c)}`).join('\n'); + const criteriaList = intent.acceptance_criteria.map(c => ` ${escapeXml(c)}`).join('\n'); + + return ` + ${escapeXml(intent.id)} + ${escapeXml(intent.name)} + ${escapeXml(intent.status)} + + +${scopeList} + + + +${constraintList} + + + +${criteriaList} + + + + You are now operating under intent ${escapeXml(intent.id)}. + You MAY ONLY modify files within the paths listed in owned_scope. + You MUST respect ALL constraints listed above. + Before completing, verify each acceptance criterion. + When calling write_file, include "intent_id": "${escapeXml(intent.id)}" in your tool params. + +`; +} + +function escapeXml(str: string): string { + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} \ No newline at end of file diff --git a/src/hooks/pre/IntentGatekeeper.ts b/src/hooks/pre/IntentGatekeeper.ts new file mode 100644 index 00000000000..8db91fa3aaf --- /dev/null +++ b/src/hooks/pre/IntentGatekeeper.ts @@ -0,0 +1,64 @@ +/** + * pre/IntentGatekeeper.ts + * ───────────────────────────────────────────────────────────── + * PRE-HOOK #1 — The Gatekeeper + * + * Blocks any destructive/mutating tool call that does not have + * an active intent ID declared for the current session. + * + * The agent MUST call select_active_intent() before it can write + * any files or execute any commands. This is the enforcement + * mechanism for the Two-Stage State Machine. + * + * Safe (read-only) tools are allowed through unconditionally. + * ───────────────────────────────────────────────────────────── + */ + +import { ToolContext, BlockSignal } from '../HookEngine'; + +/** Tools that write to the filesystem or execute shell commands */ +const DESTRUCTIVE_TOOLS = new Set([ + 'write_file', + 'write_to_file', + 'create_file', + 'apply_diff', + 'execute_command', + 'run_terminal_command', + 'insert_code_block', + 'replace_in_file', + 'delete_file', +]); + +/** Tools that are always allowed — they don't mutate state */ +const SAFE_TOOLS = new Set([ + 'read_file', + 'list_files', + 'list_directory', + 'search_files', + 'get_file_info', + 'select_active_intent', // the handshake tool itself + 'attempt_completion', + 'ask_followup_question', +]); + +export async function intentGatekeeper(ctx: ToolContext): Promise { + // Always allow safe tools + if (SAFE_TOOLS.has(ctx.toolName)) return ctx; + + // Allow unknown tools through (don't block things we don't know about) + if (!DESTRUCTIVE_TOOLS.has(ctx.toolName)) return ctx; + + // Destructive tool: must have an intent declared + if (!ctx.intentId) { + return new BlockSignal( + `BLOCKED [NO_INTENT_DECLARED]: You attempted to call "${ctx.toolName}" without first ` + + `declaring an active intent.\n\n` + + `You MUST call select_active_intent(intent_id) as your FIRST action this turn.\n` + + `Valid intent IDs are listed in .orchestration/active_intents.yaml.\n\n` + + `Example: select_active_intent("INT-001")`, + 'NO_INTENT_DECLARED' + ); + } + + return ctx; +} \ No newline at end of file diff --git a/src/hooks/pre/OptimisticLockGuard.ts b/src/hooks/pre/OptimisticLockGuard.ts new file mode 100644 index 00000000000..63a93a9285c --- /dev/null +++ b/src/hooks/pre/OptimisticLockGuard.ts @@ -0,0 +1,88 @@ +/** + * pre/OptimisticLockGuard.ts + * ───────────────────────────────────────────────────────────── + * PRE-HOOK #4 — Optimistic Locking (Parallel Agent Safety) + * + * Prevents parallel agents from overwriting each other's work. + * + * How it works: + * When an agent reads a file, it stores its SHA-256 hash as + * "read_hash" in the tool params. Before writing, this hook + * compares that hash to the CURRENT file on disk. + * + * If they differ → a parallel agent (or human) modified the + * file → BLOCK the write and force re-read. + * + * This hook also captures the pre-write file content so the + * post-hook mutation classifier can compare old vs new. + * + * Note: read_hash is optional. If not provided, the write + * proceeds but we still capture old content for classification. + * ───────────────────────────────────────────────────────────── + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as crypto from 'crypto'; +import { ToolContext, BlockSignal } from '../HookEngine'; + +const WRITE_TOOLS = new Set([ + 'write_file', + 'write_to_file', + 'create_file', + 'apply_diff', + 'insert_code_block', + 'replace_in_file', +]); + +export async function optimisticLockGuard(ctx: ToolContext): Promise { + if (!WRITE_TOOLS.has(ctx.toolName)) return ctx; + + const targetPath = ctx.params['path'] as string ?? ctx.params['file_path'] as string; + if (!targetPath) return ctx; + + const absolutePath = path.resolve(ctx.workspacePath, targetPath); + + // If file doesn't exist yet, this is a new file creation — no conflict possible + if (!fs.existsSync(absolutePath)) return ctx; + + const currentContent = fs.readFileSync(absolutePath, 'utf8'); + const currentHash = crypto.createHash('sha256').update(currentContent).digest('hex'); + + // Capture old content for mutation classifier (post-hook) + ctx.__oldContent__ = currentContent; + + // If agent provided a read_hash, verify it matches current state + const agentReadHash = ctx.params['read_hash'] as string | undefined; + if (agentReadHash) { + // Normalize — agent may include "sha256:" prefix + const normalizedAgentHash = agentReadHash.replace(/^sha256:/, ''); + + if (normalizedAgentHash !== currentHash) { + return new BlockSignal( + `BLOCKED [STALE_FILE]: "${targetPath}" has been modified by another agent or process ` + + `since you last read it.\n\n` + + `Your read hash: sha256:${normalizedAgentHash}\n` + + `Current file hash: sha256:${currentHash}\n\n` + + `You MUST re-read the file with read_file("${targetPath}") before attempting to write. ` + + `Incorporate any changes made by the other agent before proceeding.`, + 'STALE_FILE' + ); + } + } + + return ctx; +} + +/** + * Utility: compute SHA-256 hash of file content. + * Agents can call this (via a tool) to get a read_hash before editing. + */ +export function computeFileHash(absolutePath: string): string | null { + try { + const content = fs.readFileSync(absolutePath, 'utf8'); + return 'sha256:' + crypto.createHash('sha256').update(content).digest('hex'); + } catch { + return null; + } +} \ No newline at end of file diff --git a/src/hooks/pre/ScopeEnforcer.ts b/src/hooks/pre/ScopeEnforcer.ts new file mode 100644 index 00000000000..9f0d8ad3896 --- /dev/null +++ b/src/hooks/pre/ScopeEnforcer.ts @@ -0,0 +1,64 @@ +/** + * pre/ScopeEnforcer.ts + * ───────────────────────────────────────────────────────────── + * PRE-HOOK #3 — The Scope Enforcer + * + * For write operations, verifies that the target file path falls + * within the owned_scope of the currently active intent. + * + * If the agent tries to write outside its declared scope: + * → Block and return a structured error explaining the violation. + * → The agent must either request scope expansion or switch intent. + * + * Files listed in .intentignore are always allowed through. + * ───────────────────────────────────────────────────────────── + */ + +import * as path from 'path'; +import { ToolContext, BlockSignal } from '../HookEngine'; +import { findIntent, isFileInScope, isIntentIgnored } from '../utils/intentStore'; + +const WRITE_TOOLS = new Set([ + 'write_file', + 'write_to_file', + 'create_file', + 'apply_diff', + 'insert_code_block', + 'replace_in_file', + 'delete_file', +]); + +export async function scopeEnforcer(ctx: ToolContext): Promise { + if (!WRITE_TOOLS.has(ctx.toolName)) return ctx; + if (!ctx.intentId) return ctx; // IntentGatekeeper already handles this case + + const targetPath = ctx.params['path'] as string ?? ctx.params['file_path'] as string; + if (!targetPath) return ctx; + + // Normalize to relative path + const relPath = path.relative(ctx.workspacePath, path.resolve(ctx.workspacePath, targetPath)) + .replace(/\\/g, '/'); + + // Check .intentignore first + if (isIntentIgnored(ctx.workspacePath, relPath)) { + return ctx; // Explicitly ignored — allow through + } + + const intent = findIntent(ctx.workspacePath, ctx.intentId); + if (!intent) return ctx; // Intent not found — gatekeeper already blocked this + + if (!isFileInScope(intent, relPath)) { + return new BlockSignal( + `BLOCKED [SCOPE_VIOLATION]: Intent "${ctx.intentId}" (${intent.name}) is NOT authorized ` + + `to modify "${relPath}".\n\n` + + `Authorized scope for ${ctx.intentId}:\n` + + intent.owned_scope.map(s => ` • ${s}`).join('\n') + + `\n\nOptions:\n` + + ` 1. Switch to an intent that owns this file.\n` + + ` 2. Request scope expansion by stating: "I need to expand the scope of ${ctx.intentId} to include ${relPath}."`, + 'SCOPE_VIOLATION' + ); + } + + return ctx; +} \ No newline at end of file diff --git a/src/hooks/utils/astHasher.ts b/src/hooks/utils/astHasher.ts new file mode 100644 index 00000000000..c2eebdeadf1 --- /dev/null +++ b/src/hooks/utils/astHasher.ts @@ -0,0 +1,192 @@ +/** + * utils/astHasher.ts + * ───────────────────────────────────────────────────────────── + * AST-based content hashing for spatial independence. + * + * Problem with raw string hashing: + * If a developer adds a blank line above a function, the line + * numbers shift and the hash is invalidated — even though the + * function's logic is identical. + * + * Solution: + * Hash the AST node structure (type + identifier names + child + * node types) rather than raw text. Whitespace, comments, and + * line-number shifts do NOT change the AST fingerprint. + * + * Supports: TypeScript, JavaScript, TSX, JSX + * Falls back to raw SHA-256 for unsupported file types. + * ───────────────────────────────────────────────────────────── + */ + +import * as crypto from 'crypto'; +import * as path from 'path'; + +// Dynamic import — @typescript-eslint/typescript-estree is optional +// The extension should install it; we degrade gracefully if missing. +async function tryGetParser() { + try { + const { parse } = await import('@typescript-eslint/typescript-estree'); + return parse; + } catch { + return null; + } +} + +export interface ASTHashResult { + hash: string; + method: 'ast' | 'raw'; + nodeCount: number; +} + +/** + * Hash a block of code using AST fingerprinting. + * @param content - Source code string + * @param filePath - Used to determine if the file is TS/JS + * @param startLine - 1-based start line (optional, hashes whole file if omitted) + * @param endLine - 1-based end line (optional) + */ +export async function hashCodeBlock( + content: string, + filePath: string, + startLine?: number, + endLine?: number +): Promise { + const ext = path.extname(filePath).toLowerCase(); + const isTS = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'].includes(ext); + + if (!isTS) { + return { + hash: 'raw-sha256:' + crypto.createHash('sha256').update(content).digest('hex'), + method: 'raw', + nodeCount: 0, + }; + } + + const parse = await tryGetParser(); + if (!parse) { + console.warn('[astHasher] @typescript-eslint/typescript-estree not found, falling back to raw hash'); + return { + hash: 'raw-sha256:' + crypto.createHash('sha256').update(content).digest('hex'), + method: 'raw', + nodeCount: 0, + }; + } + + try { + const ast = parse(content, { + loc: true, + range: true, + jsx: ext === '.tsx' || ext === '.jsx', + tolerant: true, // don't throw on recoverable parse errors + }); + + const nodes = collectNodesInRange(ast, startLine, endLine); + const fingerprint = buildFingerprint(nodes); + const hash = 'ast-sha256:' + crypto.createHash('sha256').update(fingerprint).digest('hex'); + + return { hash, method: 'ast', nodeCount: nodes.length }; + } catch (err) { + console.warn('[astHasher] Parse failed, falling back to raw hash:', err); + return { + hash: 'raw-sha256:' + crypto.createHash('sha256').update(content).digest('hex'), + method: 'raw', + nodeCount: 0, + }; + } +} + +// ── Internal helpers ─────────────────────────────────────────── + +type ASTNode = { + type: string; + loc?: { start: { line: number }; end: { line: number } }; + [key: string]: unknown; +}; + +/** + * Walk the AST and collect all nodes whose start line falls within + * [startLine, endLine]. If no range given, collects all top-level nodes. + */ +function collectNodesInRange( + ast: ASTNode, + startLine?: number, + endLine?: number +): ASTNode[] { + const results: ASTNode[] = []; + + function walk(node: ASTNode) { + if (!node || typeof node !== 'object') return; + + const nodeLine = node.loc?.start.line; + const inRange = + startLine === undefined || + endLine === undefined || + (nodeLine !== undefined && nodeLine >= startLine && nodeLine <= endLine); + + if (inRange && node.type) { + results.push(node); + } + + for (const key of Object.keys(node)) { + if (key === 'parent') continue; // avoid circular refs + const child = node[key]; + if (Array.isArray(child)) { + child.forEach(c => c && typeof c === 'object' && walk(c as ASTNode)); + } else if (child && typeof child === 'object' && (child as ASTNode).type) { + walk(child as ASTNode); + } + } + } + + // Walk body for Program nodes + const body = (ast as any).body; + if (Array.isArray(body)) { + body.forEach(walk); + } else { + walk(ast); + } + + return results; +} + +/** + * Build a deterministic string fingerprint from an array of AST nodes. + * Only includes structural info — NOT text content or line numbers. + */ +function buildFingerprint(nodes: ASTNode[]): string { + const normalized = nodes.map(node => ({ + type: node.type, + // Capture identifier names for functions/classes (structural identity) + id: extractIdentifier(node), + // Capture parameter count for functions + paramCount: extractParamCount(node), + // Child node types (shallow — deep structure captured by recursion) + childTypes: extractChildTypes(node), + })); + + return JSON.stringify(normalized); +} + +function extractIdentifier(node: ASTNode): string | null { + const n = node as any; + return n.id?.name ?? n.key?.name ?? n.name ?? null; +} + +function extractParamCount(node: ASTNode): number | null { + const n = node as any; + if (n.params) return n.params.length; + if (n.value?.params) return n.value.params.length; + return null; +} + +function extractChildTypes(node: ASTNode): string[] { + const types: string[] = []; + for (const key of Object.keys(node)) { + if (['type', 'loc', 'range', 'parent', 'start', 'end'].includes(key)) continue; + const child = (node as any)[key]; + if (child && typeof child === 'object' && child.type) { + types.push(child.type); + } + } + return types; +} \ No newline at end of file diff --git a/src/hooks/utils/mutationClassifier.ts b/src/hooks/utils/mutationClassifier.ts new file mode 100644 index 00000000000..e69de29bb2d From 94e911e0a0c2902daaca46ec5e63242a332609ea Mon Sep 17 00:00:00 2001 From: "nahom .desalegn" Date: Wed, 18 Feb 2026 08:51:25 -0800 Subject: [PATCH 04/12] multi classifer --- src/hooks/utils/mutationClassifier.ts | 178 ++++++++++++++++++++++++++ 1 file changed, 178 insertions(+) diff --git a/src/hooks/utils/mutationClassifier.ts b/src/hooks/utils/mutationClassifier.ts index e69de29bb2d..a63cdc086ab 100644 --- a/src/hooks/utils/mutationClassifier.ts +++ b/src/hooks/utils/mutationClassifier.ts @@ -0,0 +1,178 @@ +/** + * utils/mutationClassifier.ts + * ───────────────────────────────────────────────────────────── + * Deterministic classification of code mutations. + * + * Two classes: + * AST_REFACTOR – Same exported API surface, internal changes only. + * (rename variable, extract helper, format code) + * INTENT_EVOLUTION – The exported API surface changed. + * (new function, changed signature, deleted export) + * + * The hook computes this — the agent does NOT self-report it. + * This is what makes the system deterministic. + * ───────────────────────────────────────────────────────────── + */ + +import * as path from 'path'; + +export type MutationClass = 'AST_REFACTOR' | 'INTENT_EVOLUTION' | 'UNKNOWN'; + +export interface ClassificationResult { + mutationClass: MutationClass; + reason: string; + addedExports: string[]; + removedExports: string[]; + changedSignatures: string[]; +} + +/** + * Compare old and new file content and classify the mutation. + * Falls back to UNKNOWN for non-JS/TS files. + */ +export async function classifyMutation( + oldContent: string, + newContent: string, + filePath: string +): Promise { + const ext = path.extname(filePath).toLowerCase(); + const isTS = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'].includes(ext); + + if (!isTS) { + return { + mutationClass: 'UNKNOWN', + reason: 'Non-JS/TS file — cannot perform AST analysis', + addedExports: [], + removedExports: [], + changedSignatures: [], + }; + } + + try { + const { parse } = await import('@typescript-eslint/typescript-estree'); + + const oldAST = parse(oldContent, { loc: true, tolerant: true, jsx: ext.includes('x') }); + const newAST = parse(newContent, { loc: true, tolerant: true, jsx: ext.includes('x') }); + + const oldExports = extractExportSignatures(oldAST); + const newExports = extractExportSignatures(newAST); + + const added = newExports.filter(s => !oldExports.includes(s)); + const removed = oldExports.filter(s => !newExports.includes(s)); + + // Detect signature changes: same name, different param count + const changedSignatures = detectSignatureChanges(oldAST, newAST); + + if (added.length === 0 && removed.length === 0 && changedSignatures.length === 0) { + return { + mutationClass: 'AST_REFACTOR', + reason: 'Exported API surface unchanged — internal refactor only', + addedExports: [], + removedExports: [], + changedSignatures: [], + }; + } + + return { + mutationClass: 'INTENT_EVOLUTION', + reason: `API surface changed: +${added.length} exports, -${removed.length} exports, ~${changedSignatures.length} signature changes`, + addedExports: added, + removedExports: removed, + changedSignatures, + }; + } catch (err) { + console.warn('[mutationClassifier] Parse error:', err); + return { + mutationClass: 'UNKNOWN', + reason: `Parse error: ${(err as Error).message}`, + addedExports: [], + removedExports: [], + changedSignatures: [], + }; + } +} + +// ── Internal helpers ─────────────────────────────────────────── + +type ParsedAST = { body: any[] }; + +/** + * Extract all exported symbol signatures as strings. + * e.g. "fn:authenticate:2" = exported function named "authenticate" with 2 params + */ +function extractExportSignatures(ast: ParsedAST): string[] { + const sigs: string[] = []; + + for (const node of ast.body) { + if (node.type === 'ExportNamedDeclaration') { + const decl = node.declaration; + if (!decl) { + // Re-exports: export { foo, bar } + for (const spec of node.specifiers ?? []) { + sigs.push(`reexport:${spec.exported?.name ?? spec.local?.name}`); + } + continue; + } + + if (decl.type === 'FunctionDeclaration') { + sigs.push(`fn:${decl.id?.name}:${decl.params?.length ?? 0}`); + } else if (decl.type === 'ClassDeclaration') { + sigs.push(`class:${decl.id?.name}`); + } else if (decl.type === 'VariableDeclaration') { + for (const declarator of decl.declarations ?? []) { + const name = declarator.id?.name; + if (name) { + const init = declarator.init; + if (init?.type === 'ArrowFunctionExpression' || init?.type === 'FunctionExpression') { + sigs.push(`fn:${name}:${init.params?.length ?? 0}`); + } else { + sigs.push(`var:${name}`); + } + } + } + } else if (decl.type === 'TSTypeAliasDeclaration') { + sigs.push(`type:${decl.id?.name}`); + } else if (decl.type === 'TSInterfaceDeclaration') { + sigs.push(`interface:${decl.id?.name}`); + } + } + + if (node.type === 'ExportDefaultDeclaration') { + sigs.push('default:export'); + } + } + + return sigs; +} + +/** + * Detect functions that exist in both ASTs but have different param counts. + */ +function detectSignatureChanges(oldAST: ParsedAST, newAST: ParsedAST): string[] { + const oldFns = extractFunctionMap(oldAST); + const newFns = extractFunctionMap(newAST); + const changes: string[] = []; + + for (const [name, paramCount] of Object.entries(oldFns)) { + if (name in newFns && newFns[name] !== paramCount) { + changes.push(`${name}: ${paramCount} → ${newFns[name]} params`); + } + } + + return changes; +} + +function extractFunctionMap(ast: ParsedAST): Record { + const map: Record = {}; + + for (const node of ast.body) { + if (node.type === 'ExportNamedDeclaration' && node.declaration) { + const decl = node.declaration; + if (decl.type === 'FunctionDeclaration' && decl.id?.name) { + map[decl.id.name] = decl.params?.length ?? 0; + } + } + } + + return map; +} \ No newline at end of file From 86d266d54a3524ce33510f8fb3fb5730dafb6cb0 Mon Sep 17 00:00:00 2001 From: Nahom Date: Thu, 19 Feb 2026 08:38:56 +0300 Subject: [PATCH 05/12] intent commit --- src/hooks/utils/Intentstore.ts | 124 +++++++++++++++++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 src/hooks/utils/Intentstore.ts diff --git a/src/hooks/utils/Intentstore.ts b/src/hooks/utils/Intentstore.ts new file mode 100644 index 00000000000..a61847ab382 --- /dev/null +++ b/src/hooks/utils/Intentstore.ts @@ -0,0 +1,124 @@ +/** + * utils/intentStore.ts + * ───────────────────────────────────────────────────────────── + * Read/write layer for .orchestration/active_intents.yaml + * + * This is the single source of truth for intent state. + * All hooks that need intent data go through this module. + * ───────────────────────────────────────────────────────────── + */ + +import * as fs from "fs" +import * as path from "path" +import * as yaml from "js-yaml" + +export interface ActiveIntent { + id: string + name: string + status: "PENDING" | "IN_PROGRESS" | "COMPLETE" | "BLOCKED" + owned_scope: string[] + constraints: string[] + acceptance_criteria: string[] + created_at?: string + updated_at?: string +} + +export interface IntentsFile { + active_intents: ActiveIntent[] +} + +const ORCHESTRATION_DIR = ".orchestration" +const INTENTS_FILE = "active_intents.yaml" + +function getIntentsPath(workspacePath: string): string { + return path.join(workspacePath, ORCHESTRATION_DIR, INTENTS_FILE) +} + +/** + * Load all active intents from the YAML file. + * Returns empty array if file doesn't exist. + */ +export function loadIntents(workspacePath: string): ActiveIntent[] { + const filePath = getIntentsPath(workspacePath) + if (!fs.existsSync(filePath)) return [] + + try { + const raw = fs.readFileSync(filePath, "utf8") + const data = yaml.load(raw) as IntentsFile + return data?.active_intents ?? [] + } catch (err) { + console.error("[intentStore] Failed to parse active_intents.yaml:", err) + return [] + } +} + +/** + * Find a single intent by ID. Returns null if not found. + */ +export function findIntent(workspacePath: string, intentId: string): ActiveIntent | null { + const intents = loadIntents(workspacePath) + return intents.find((i) => i.id === intentId) ?? null +} + +/** + * Update the status of a specific intent and write back to disk. + */ +export function updateIntentStatus(workspacePath: string, intentId: string, status: ActiveIntent["status"]): boolean { + const filePath = getIntentsPath(workspacePath) + const intents = loadIntents(workspacePath) + const intent = intents.find((i) => i.id === intentId) + + if (!intent) return false + + intent.status = status + intent.updated_at = new Date().toISOString() + + const updated: IntentsFile = { active_intents: intents } + fs.writeFileSync(filePath, yaml.dump(updated, { lineWidth: 120 }), "utf8") + return true +} + +/** + * Check if a file path matches any of the owned_scope globs for an intent. + * Supports ** glob patterns. + */ +export function isFileInScope(intent: ActiveIntent, filePath: string): boolean { + // Normalize to forward slashes + const normalized = filePath.replace(/\\/g, "/") + + for (const pattern of intent.owned_scope) { + if (matchesGlob(pattern, normalized)) return true + } + return false +} + +/** + * Check if a file path is in a .intentignore file. + */ +export function isIntentIgnored(workspacePath: string, filePath: string): boolean { + const ignorePath = path.join(workspacePath, ".intentignore") + if (!fs.existsSync(ignorePath)) return false + + const lines = fs + .readFileSync(ignorePath, "utf8") + .split("\n") + .map((l) => l.trim()) + .filter((l) => l && !l.startsWith("#")) + + const normalized = filePath.replace(/\\/g, "/") + return lines.some((pattern) => matchesGlob(pattern, normalized)) +} + +// ── Glob matching ────────────────────────────────────────────── + +function matchesGlob(pattern: string, filePath: string): boolean { + // Convert glob to regex + const regexStr = pattern + .replace(/\./g, "\\.") + .replace(/\*\*/g, "__DOUBLESTAR__") + .replace(/\*/g, "[^/]*") + .replace(/__DOUBLESTAR__/g, ".*") + + const regex = new RegExp(`^${regexStr}$`) + return regex.test(filePath) +} From 4d81e3709c9bfaba4c35ef09a9af3947dec1583e Mon Sep 17 00:00:00 2001 From: Nahom Date: Thu, 19 Feb 2026 09:25:12 +0300 Subject: [PATCH 06/12] git utill --- src/hooks/utils/Gitutils.ts | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 src/hooks/utils/Gitutils.ts diff --git a/src/hooks/utils/Gitutils.ts b/src/hooks/utils/Gitutils.ts new file mode 100644 index 00000000000..e69de29bb2d From 2f130a376c8d5d2161cea1daef8bfe4e101583da Mon Sep 17 00:00:00 2001 From: Nahom Date: Thu, 19 Feb 2026 11:11:17 +0300 Subject: [PATCH 07/12] creating intent --- .orchestration/active_intents.yaml | 179 +++++++++++++++++++++++++++++ 1 file changed, 179 insertions(+) create mode 100644 .orchestration/active_intents.yaml diff --git a/.orchestration/active_intents.yaml b/.orchestration/active_intents.yaml new file mode 100644 index 00000000000..30501ff7b92 --- /dev/null +++ b/.orchestration/active_intents.yaml @@ -0,0 +1,179 @@ +# ───────────────────────────────────────────────────────────────────────────── +# .orchestration/active_intents.yaml +# ───────────────────────────────────────────────────────────────────────────── +# PURPOSE: +# The single source of truth for all business intents in this workspace. +# The Hook System reads this file to enforce scope, inject context, and +# validate agent actions. DO NOT let agents read this file directly — +# they must call select_active_intent(intent_id) instead. +# +# MANAGED BY: +# - Humans: define intents, owned_scope, constraints, acceptance_criteria +# - Pre-Hook (ContextInjector): reads on every select_active_intent call +# - Pre-Hook (ScopeEnforcer): reads owned_scope on every write tool call +# - Post-Hook (IntentMapUpdater): updates status when tasks complete +# +# STATUS VALUES: +# PENDING → defined but work has not started +# IN_PROGRESS → an agent has checked out this intent and is actively working +# BLOCKED → blocked by a dependency or scope violation +# COMPLETE → all acceptance_criteria have been verified and passed +# +# SCOPE PATTERNS: +# Supports ** glob patterns. All paths are relative to the workspace root. +# Example: "src/weather/**" matches all files under src/weather/ +# +# ───────────────────────────────────────────────────────────────────────────── + +active_intents: + + # ── INT-001 ──────────────────────────────────────────────────────────────── + - id: "INT-001" + name: "Build Weather API Endpoint" + status: "IN_PROGRESS" + created_at: "2026-02-18T00:00:00Z" + updated_at: "2026-02-19T09:00:00Z" + + # Files and directories this intent is authorized to modify. + # The ScopeEnforcer hook will BLOCK writes to any file not listed here. + owned_scope: + - "src/weather/**" + - "src/api/weather.ts" + - "src/types/weather.ts" + - "src/routes/weather.ts" + - "tests/weather/**" + + # Hard rules the agent must follow while working on this intent. + # These are injected into the agent context via select_active_intent. + constraints: + - "Use OpenWeatherMap API only — no other external weather providers" + - "Must return temperatures in both Celsius and Fahrenheit" + - "All API calls must go through src/api/weather.ts — no direct fetch() in components" + - "Rate limiting: max 60 calls/minute must be enforced at the API layer" + - "All public functions must have JSDoc comments" + - "TypeScript strict mode must pass with zero errors" + + # The Definition of Done — verified before marking status: COMPLETE + acceptance_criteria: + - "GET /weather?city=London returns JSON with { temp_c, temp_f, description, humidity }" + - "GET /weather?city=InvalidCity returns 404 with { error: 'City not found' }" + - "Unit tests in tests/weather/ all pass with >90% coverage" + - "TypeScript strict mode passes with zero errors" + - "src/types/weather.ts exports WeatherResponse and WeatherError interfaces" + - "Rate limiting returns 429 when threshold exceeded" + + # Dependencies on other intents (must be COMPLETE before this can start) + depends_on: [] + + # Agents that have worked on this intent (auto-populated by TraceLogger) + contributors: + - entity_type: "AI" + model_identifier: "claude-sonnet-4-6" + session_id: "sess-abc-001" + last_active: "2026-02-19T09:00:00Z" + + # ── INT-002 ──────────────────────────────────────────────────────────────── + - id: "INT-002" + name: "JWT Authentication Middleware" + status: "PENDING" + created_at: "2026-02-18T00:00:00Z" + updated_at: "2026-02-18T00:00:00Z" + + owned_scope: + - "src/auth/**" + - "src/middleware/jwt.ts" + - "src/middleware/authGuard.ts" + - "src/types/auth.ts" + - "tests/auth/**" + + constraints: + - "Must not use external auth providers (no Auth0, Firebase, Cognito, etc.)" + - "Must maintain backward compatibility with existing Basic Auth" + - "JWT secret must be read from environment variable JWT_SECRET only — never hardcoded" + - "Tokens must expire in 24 hours maximum" + - "Refresh tokens must be stored in httpOnly cookies — never in localStorage" + - "All auth errors must return consistent JSON: { error: string, code: string }" + + acceptance_criteria: + - "POST /auth/login returns a signed JWT on valid credentials" + - "POST /auth/login returns 401 with { error, code } on invalid credentials" + - "Protected routes return 401 when JWT is missing or expired" + - "Protected routes return 403 when JWT is valid but role is insufficient" + - "Unit tests in tests/auth/ all pass" + - "Existing Basic Auth integration tests still pass (no regression)" + - "JWT_SECRET env var absence throws a startup error with a clear message" + + depends_on: [] + + contributors: [] + + # ── INT-003 ──────────────────────────────────────────────────────────────── + - id: "INT-003" + name: "Hook System — Core Engine" + status: "IN_PROGRESS" + created_at: "2026-02-19T00:00:00Z" + updated_at: "2026-02-19T10:00:00Z" + + owned_scope: + - "src/hooks/**" + - "tests/hooks/**" + - ".orchestration/**" + - "CLAUDE.md" + - ".intentignore" + + constraints: + - "HookEngine must remain stateless — no global mutable state outside the singleton" + - "Pre-hook errors must BLOCK execution (fail closed, not open)" + - "Post-hook errors must be swallowed — never crash the agent turn" + - "BlockSignal must include a machine-readable code and a human-readable reason" + - "All hooks must be independently testable with no VS Code dependencies" + - "ToolContext must be immutable within each hook — hooks return new context objects" + + acceptance_criteria: + - "All 4 pre-hooks pass unit tests with 100% branch coverage" + - "All 3 post-hooks pass unit tests" + - "HookEngine correctly short-circuits on first BlockSignal" + - "Post-hook failure does not propagate to the tool result" + - "select_active_intent returns valid XML for every intent in active_intents.yaml" + - "agent_trace.jsonl entries pass JSON schema validation" + - "AST hash is deterministic: same code structure always produces same hash" + + depends_on: [] + + contributors: + - entity_type: "AI" + model_identifier: "claude-sonnet-4-6" + session_id: "sess-abc-002" + last_active: "2026-02-19T10:00:00Z" + + # ── INT-004 ──────────────────────────────────────────────────────────────── + - id: "INT-004" + name: "Database Layer — User Model" + status: "BLOCKED" + created_at: "2026-02-18T00:00:00Z" + updated_at: "2026-02-19T08:00:00Z" + blocked_reason: "Waiting for INT-002 (JWT Auth) to be COMPLETE before user session schema can be finalised" + + owned_scope: + - "src/db/**" + - "src/models/user.ts" + - "src/models/session.ts" + - "migrations/**" + - "tests/db/**" + + constraints: + - "Use Prisma ORM only — no raw SQL queries in application code" + - "All migrations must be reversible (have a down migration)" + - "Passwords must be hashed with bcrypt (cost factor >= 12) — never stored plain" + - "PII fields must be noted in schema comments" + + acceptance_criteria: + - "User model supports create, read, update, soft-delete" + - "Session model linked to User with cascade delete" + - "Migration runs cleanly on a fresh PostgreSQL 15 database" + - "Unit tests in tests/db/ all pass" + + depends_on: + - "INT-002" + + contributors: [] \ No newline at end of file From c09a1f687aac07d2eaf54ecf1e72502a9762c692 Mon Sep 17 00:00:00 2001 From: Nahom Date: Thu, 19 Feb 2026 11:13:29 +0300 Subject: [PATCH 08/12] agent --- .orchestration/agent_trace.jsonl | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 .orchestration/agent_trace.jsonl diff --git a/.orchestration/agent_trace.jsonl b/.orchestration/agent_trace.jsonl new file mode 100644 index 00000000000..f5eb6c0a9cd --- /dev/null +++ b/.orchestration/agent_trace.jsonl @@ -0,0 +1,14 @@ +{"id":"f3a1b2c4-d5e6-7890-abcd-ef1234567801","timestamp":"2026-02-19T08:00:00Z","vcs":{"revision_id":"1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b"},"mutation_class":"AST_REFACTOR","classification_reason":"Exported API surface unchanged — internal refactor only","files":[{"relative_path":"src/weather/parser.ts","conversations":[{"session_id":"sess-abc-001","contributor":{"entity_type":"AI","model_identifier":"claude-sonnet-4-6"},"ranges":[{"start_line":1,"end_line":42,"content_hash":"ast-sha256:a8f5f167f44f4964e6c998dee827110c2a3e1234567890abcdef","hash_method":"ast","ast_node_count":7}],"related":[{"type":"intent","value":"INT-001"}]}]}]} +{"id":"f3a1b2c4-d5e6-7890-abcd-ef1234567802","timestamp":"2026-02-19T08:15:00Z","vcs":{"revision_id":"1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b"},"mutation_class":"INTENT_EVOLUTION","classification_reason":"API surface changed: +1 exports, -0 exports, ~0 signature changes. Added export: fn:buildWeatherUrl:2","files":[{"relative_path":"src/api/weather.ts","conversations":[{"session_id":"sess-abc-001","contributor":{"entity_type":"AI","model_identifier":"claude-sonnet-4-6"},"ranges":[{"start_line":1,"end_line":88,"content_hash":"ast-sha256:b9e6e7280f504c5e9a1b2c3d4e5f6789012345678901abcdef","hash_method":"ast","ast_node_count":12}],"related":[{"type":"intent","value":"INT-001"}]}]}]} +{"id":"f3a1b2c4-d5e6-7890-abcd-ef1234567803","timestamp":"2026-02-19T08:30:00Z","vcs":{"revision_id":"1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b"},"mutation_class":"INTENT_EVOLUTION","classification_reason":"API surface changed: +2 exports, -0 exports, ~0 signature changes. Added exports: interface:WeatherResponse, interface:WeatherError","files":[{"relative_path":"src/types/weather.ts","conversations":[{"session_id":"sess-abc-001","contributor":{"entity_type":"AI","model_identifier":"claude-sonnet-4-6"},"ranges":[{"start_line":1,"end_line":34,"content_hash":"ast-sha256:c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6789012345678901234","hash_method":"ast","ast_node_count":4}],"related":[{"type":"intent","value":"INT-001"}]}]}]} +{"id":"f3a1b2c4-d5e6-7890-abcd-ef1234567804","timestamp":"2026-02-19T08:45:00Z","vcs":{"revision_id":"1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b"},"mutation_class":"AST_REFACTOR","classification_reason":"Exported API surface unchanged — internal refactor only. Extracted rate-limiter logic into helper function","files":[{"relative_path":"src/api/weather.ts","conversations":[{"session_id":"sess-abc-001","contributor":{"entity_type":"AI","model_identifier":"claude-sonnet-4-6"},"ranges":[{"start_line":1,"end_line":112,"content_hash":"ast-sha256:d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5","hash_method":"ast","ast_node_count":15}],"related":[{"type":"intent","value":"INT-001"}]}]}]} +{"id":"f3a1b2c4-d5e6-7890-abcd-ef1234567805","timestamp":"2026-02-19T09:00:00Z","vcs":{"revision_id":"2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c"},"mutation_class":"INTENT_EVOLUTION","classification_reason":"API surface changed: +3 exports, -0 exports, ~0 signature changes. Added: fn:getWeather:1, fn:getWeatherByCoords:2, fn:clearCache:0","files":[{"relative_path":"src/weather/service.ts","conversations":[{"session_id":"sess-abc-001","contributor":{"entity_type":"AI","model_identifier":"claude-sonnet-4-6"},"ranges":[{"start_line":1,"end_line":156,"content_hash":"ast-sha256:e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6","hash_method":"ast","ast_node_count":22}],"related":[{"type":"intent","value":"INT-001"}]}]}]} +{"id":"f3a1b2c4-d5e6-7890-abcd-ef1234567806","timestamp":"2026-02-19T09:10:00Z","vcs":{"revision_id":"2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c"},"mutation_class":"AST_REFACTOR","classification_reason":"Exported API surface unchanged — reformatted and added JSDoc comments","files":[{"relative_path":"src/weather/parser.ts","conversations":[{"session_id":"sess-abc-001","contributor":{"entity_type":"AI","model_identifier":"claude-sonnet-4-6"},"ranges":[{"start_line":1,"end_line":58,"content_hash":"ast-sha256:a8f5f167f44f4964e6c998dee827110c2a3e1234567890abcdef","hash_method":"ast","ast_node_count":7}],"related":[{"type":"intent","value":"INT-001"}]}]}]} +{"id":"f3a1b2c4-d5e6-7890-abcd-ef1234567807","timestamp":"2026-02-19T09:30:00Z","vcs":{"revision_id":"3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d"},"mutation_class":"INTENT_EVOLUTION","classification_reason":"API surface changed: +1 exports. Added: fn:createWeatherRoute:1","files":[{"relative_path":"src/routes/weather.ts","conversations":[{"session_id":"sess-abc-001","contributor":{"entity_type":"AI","model_identifier":"claude-sonnet-4-6"},"ranges":[{"start_line":1,"end_line":44,"content_hash":"ast-sha256:f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7","hash_method":"ast","ast_node_count":8}],"related":[{"type":"intent","value":"INT-001"}]}]}]} +{"id":"f3a1b2c4-d5e6-7890-abcd-ef1234567808","timestamp":"2026-02-19T09:45:00Z","vcs":{"revision_id":"3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d"},"mutation_class":"INTENT_EVOLUTION","classification_reason":"API surface changed: +5 exports (test functions). New test file created.","files":[{"relative_path":"tests/weather/weather.service.test.ts","conversations":[{"session_id":"sess-abc-001","contributor":{"entity_type":"AI","model_identifier":"claude-sonnet-4-6"},"ranges":[{"start_line":1,"end_line":210,"content_hash":"ast-sha256:a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8","hash_method":"ast","ast_node_count":31}],"related":[{"type":"intent","value":"INT-001"}]}]}]} +{"id":"f3a1b2c4-d5e6-7890-abcd-ef1234567809","timestamp":"2026-02-19T10:00:00Z","vcs":{"revision_id":"4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e"},"mutation_class":"INTENT_EVOLUTION","classification_reason":"API surface changed: +3 exports. Core hook engine created.","files":[{"relative_path":"src/hooks/HookEngine.ts","conversations":[{"session_id":"sess-abc-002","contributor":{"entity_type":"AI","model_identifier":"claude-sonnet-4-6"},"ranges":[{"start_line":1,"end_line":95,"content_hash":"ast-sha256:b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9","hash_method":"ast","ast_node_count":9}],"related":[{"type":"intent","value":"INT-003"}]}]}]} +{"id":"f3a1b2c4-d5e6-7890-abcd-ef1234567810","timestamp":"2026-02-19T10:05:00Z","vcs":{"revision_id":"4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e"},"mutation_class":"INTENT_EVOLUTION","classification_reason":"API surface changed: +2 exports. ContextInjector pre-hook created.","files":[{"relative_path":"src/hooks/pre/ContextInjector.ts","conversations":[{"session_id":"sess-abc-002","contributor":{"entity_type":"AI","model_identifier":"claude-sonnet-4-6"},"ranges":[{"start_line":1,"end_line":78,"content_hash":"ast-sha256:c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0","hash_method":"ast","ast_node_count":6}],"related":[{"type":"intent","value":"INT-003"}]}]}]} +{"id":"f3a1b2c4-d5e6-7890-abcd-ef1234567811","timestamp":"2026-02-19T10:10:00Z","vcs":{"revision_id":"4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e"},"mutation_class":"INTENT_EVOLUTION","classification_reason":"API surface changed: +1 export. IntentGatekeeper pre-hook created.","files":[{"relative_path":"src/hooks/pre/IntentGatekeeper.ts","conversations":[{"session_id":"sess-abc-002","contributor":{"entity_type":"AI","model_identifier":"claude-sonnet-4-6"},"ranges":[{"start_line":1,"end_line":52,"content_hash":"ast-sha256:d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1","hash_method":"ast","ast_node_count":4}],"related":[{"type":"intent","value":"INT-003"}]}]}]} +{"id":"f3a1b2c4-d5e6-7890-abcd-ef1234567812","timestamp":"2026-02-19T10:15:00Z","vcs":{"revision_id":"4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e"},"mutation_class":"INTENT_EVOLUTION","classification_reason":"API surface changed: +1 export. ScopeEnforcer pre-hook created.","files":[{"relative_path":"src/hooks/pre/ScopeEnforcer.ts","conversations":[{"session_id":"sess-abc-002","contributor":{"entity_type":"AI","model_identifier":"claude-sonnet-4-6"},"ranges":[{"start_line":1,"end_line":64,"content_hash":"ast-sha256:e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2","hash_method":"ast","ast_node_count":5}],"related":[{"type":"intent","value":"INT-003"}]}]}]} +{"id":"f3a1b2c4-d5e6-7890-abcd-ef1234567813","timestamp":"2026-02-19T10:20:00Z","vcs":{"revision_id":"4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e"},"mutation_class":"INTENT_EVOLUTION","classification_reason":"API surface changed: +2 exports. OptimisticLockGuard pre-hook created.","files":[{"relative_path":"src/hooks/pre/OptimisticLockGuard.ts","conversations":[{"session_id":"sess-abc-002","contributor":{"entity_type":"AI","model_identifier":"claude-sonnet-4-6"},"ranges":[{"start_line":1,"end_line":71,"content_hash":"ast-sha256:f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3","hash_method":"ast","ast_node_count":6}],"related":[{"type":"intent","value":"INT-003"}]}]}]} +{"id":"f3a1b2c4-d5e6-7890-abcd-ef1234567814","timestamp":"2026-02-19T10:25:00Z","vcs":{"revision_id":"4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e"},"mutation_class":"INTENT_EVOLUTION","classification_reason":"API surface changed: +1 export. TraceLogger post-hook created.","files":[{"relative_path":"src/hooks/post/TraceLogger.ts","conversations":[{"session_id":"sess-abc-002","contributor":{"entity_type":"AI","model_identifier":"claude-sonnet-4-6"},"ranges":[{"start_line":1,"end_line":118,"content_hash":"ast-sha256:a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4","hash_method":"ast","ast_node_count":11}],"related":[{"type":"intent","value":"INT-003"}]}]}]} \ No newline at end of file From 708ffac145ad7ee4b884ee8ea1cd78b62abb6175 Mon Sep 17 00:00:00 2001 From: Nahom Date: Thu, 19 Feb 2026 11:15:06 +0300 Subject: [PATCH 09/12] intent map --- .orchestration/intent_map.md | 165 +++++++++++++++++++++++++++++++++++ 1 file changed, 165 insertions(+) create mode 100644 .orchestration/intent_map.md diff --git a/.orchestration/intent_map.md b/.orchestration/intent_map.md new file mode 100644 index 00000000000..15b54533a2b --- /dev/null +++ b/.orchestration/intent_map.md @@ -0,0 +1,165 @@ +# Intent Map + +> **Auto-generated by the Hook System (IntentMapUpdater post-hook). Do not edit manually.** +> Last regenerated: `2026-02-19T10:25:00Z` +> Total intents tracked: 4 | Total file mutations logged: 14 + +--- + +## Summary Table + +| Intent ID | Intent Name | Status | Files Touched | Mutations | Last Active | +| -------------------------------------------------- | ----------------------------- | -------------- | ------------- | --------- | -------------------- | +| [INT-001](#int-001--build-weather-api-endpoint) | Build Weather API Endpoint | 🟡 IN_PROGRESS | 6 | 8 | 2026-02-19 09:45 UTC | +| [INT-002](#int-002--jwt-authentication-middleware) | JWT Authentication Middleware | ⚪ PENDING | 0 | 0 | — | +| [INT-003](#int-003--hook-system--core-engine) | Hook System — Core Engine | 🟡 IN_PROGRESS | 6 | 6 | 2026-02-19 10:25 UTC | +| [INT-004](#int-004--database-layer--user-model) | Database Layer — User Model | 🔴 BLOCKED | 0 | 0 | — | + +--- + +## INT-001 — Build Weather API Endpoint + +**Status:** 🟡 `IN_PROGRESS` +**Owned Scope:** `src/weather/**`, `src/api/weather.ts`, `src/types/weather.ts`, `src/routes/weather.ts`, `tests/weather/**` +**Contributors:** `claude-sonnet-4-6` (session `sess-abc-001`) + +### Files Modified + +| File | Mutations | Last Hash | Last Class | Last Modified | +| --------------------------------------- | --------- | ------------------------ | ------------------ | -------------------- | +| `src/weather/parser.ts` | 2 | `ast-sha256:a8f5f167...` | `AST_REFACTOR` | 2026-02-19 09:10 UTC | +| `src/api/weather.ts` | 2 | `ast-sha256:d2e3f4a5...` | `AST_REFACTOR` | 2026-02-19 08:45 UTC | +| `src/types/weather.ts` | 1 | `ast-sha256:c1d2e3f4...` | `INTENT_EVOLUTION` | 2026-02-19 08:30 UTC | +| `src/weather/service.ts` | 1 | `ast-sha256:e3f4a5b6...` | `INTENT_EVOLUTION` | 2026-02-19 09:00 UTC | +| `src/routes/weather.ts` | 1 | `ast-sha256:f4a5b6c7...` | `INTENT_EVOLUTION` | 2026-02-19 09:30 UTC | +| `tests/weather/weather.service.test.ts` | 1 | `ast-sha256:a5b6c7d8...` | `INTENT_EVOLUTION` | 2026-02-19 09:45 UTC | + +### Mutation Timeline + +``` +2026-02-19 08:00 src/weather/parser.ts AST_REFACTOR (internal refactor) +2026-02-19 08:15 src/api/weather.ts INTENT_EVOLUTION (+fn:buildWeatherUrl:2) +2026-02-19 08:30 src/types/weather.ts INTENT_EVOLUTION (+interface:WeatherResponse, +interface:WeatherError) +2026-02-19 08:45 src/api/weather.ts AST_REFACTOR (extracted rate-limiter helper) +2026-02-19 09:00 src/weather/service.ts INTENT_EVOLUTION (+fn:getWeather:1, +fn:getWeatherByCoords:2, +fn:clearCache:0) +2026-02-19 09:10 src/weather/parser.ts AST_REFACTOR (added JSDoc comments) +2026-02-19 09:30 src/routes/weather.ts INTENT_EVOLUTION (+fn:createWeatherRoute:1) +2026-02-19 09:45 tests/weather/weather.service.test.ts INTENT_EVOLUTION (new test file) +``` + +### Acceptance Criteria Progress + +| Criterion | Status | +| ---------------------------------------------------- | ------------------------------- | +| GET /weather?city=London returns correct JSON shape | ⬜ Not verified | +| GET /weather?city=InvalidCity returns 404 | ⬜ Not verified | +| Unit tests pass with >90% coverage | ⬜ Not verified | +| TypeScript strict mode passes | ⬜ Not verified | +| WeatherResponse and WeatherError interfaces exported | ✅ Done (INT-001 trace entry 3) | +| Rate limiting returns 429 on threshold exceeded | ⬜ Not verified | + +--- + +## INT-002 — JWT Authentication Middleware + +**Status:** ⚪ `PENDING` +**Owned Scope:** `src/auth/**`, `src/middleware/jwt.ts`, `src/middleware/authGuard.ts`, `src/types/auth.ts`, `tests/auth/**` +**Contributors:** None yet + +### Files Modified + +_No files modified yet. Agent must call `select_active_intent("INT-002")` to begin work._ + +--- + +## INT-003 — Hook System — Core Engine + +**Status:** 🟡 `IN_PROGRESS` +**Owned Scope:** `src/hooks/**`, `tests/hooks/**`, `.orchestration/**`, `CLAUDE.md`, `.intentignore` +**Contributors:** `claude-sonnet-4-6` (session `sess-abc-002`) + +### Files Modified + +| File | Mutations | Last Hash | Last Class | Last Modified | +| -------------------------------------- | --------- | ------------------------ | ------------------ | -------------------- | +| `src/hooks/HookEngine.ts` | 1 | `ast-sha256:b6c7d8e9...` | `INTENT_EVOLUTION` | 2026-02-19 10:00 UTC | +| `src/hooks/pre/ContextInjector.ts` | 1 | `ast-sha256:c7d8e9f0...` | `INTENT_EVOLUTION` | 2026-02-19 10:05 UTC | +| `src/hooks/pre/IntentGatekeeper.ts` | 1 | `ast-sha256:d8e9f0a1...` | `INTENT_EVOLUTION` | 2026-02-19 10:10 UTC | +| `src/hooks/pre/ScopeEnforcer.ts` | 1 | `ast-sha256:e9f0a1b2...` | `INTENT_EVOLUTION` | 2026-02-19 10:15 UTC | +| `src/hooks/pre/OptimisticLockGuard.ts` | 1 | `ast-sha256:f0a1b2c3...` | `INTENT_EVOLUTION` | 2026-02-19 10:20 UTC | +| `src/hooks/post/TraceLogger.ts` | 1 | `ast-sha256:a1b2c3d4...` | `INTENT_EVOLUTION` | 2026-02-19 10:25 UTC | + +### Mutation Timeline + +``` +2026-02-19 10:00 src/hooks/HookEngine.ts INTENT_EVOLUTION (core engine created) +2026-02-19 10:05 src/hooks/pre/ContextInjector.ts INTENT_EVOLUTION (pre-hook created) +2026-02-19 10:10 src/hooks/pre/IntentGatekeeper.ts INTENT_EVOLUTION (pre-hook created) +2026-02-19 10:15 src/hooks/pre/ScopeEnforcer.ts INTENT_EVOLUTION (pre-hook created) +2026-02-19 10:20 src/hooks/pre/OptimisticLockGuard.ts INTENT_EVOLUTION (pre-hook created) +2026-02-19 10:25 src/hooks/post/TraceLogger.ts INTENT_EVOLUTION (post-hook created) +``` + +### Acceptance Criteria Progress + +| Criterion | Status | +| --------------------------------------------------------- | --------------- | +| All 4 pre-hooks pass unit tests with 100% branch coverage | ⬜ Not verified | +| All 3 post-hooks pass unit tests | ⬜ Not verified | +| HookEngine short-circuits on first BlockSignal | ⬜ Not verified | +| Post-hook failure does not propagate | ⬜ Not verified | +| select_active_intent returns valid XML | ⬜ Not verified | +| agent_trace.jsonl entries pass JSON schema validation | ⬜ Not verified | +| AST hash is deterministic | ⬜ Not verified | + +--- + +## INT-004 — Database Layer — User Model + +**Status:** 🔴 `BLOCKED` +**Blocked Reason:** Waiting for `INT-002` (JWT Auth) to be `COMPLETE` before user session schema can be finalised. +**Depends On:** `INT-002` +**Contributors:** None yet + +### Files Modified + +_No files modified yet. Blocked on INT-002._ + +--- + +## How to Query This File + +### Find all files touched by a specific intent + +```bash +cat .orchestration/agent_trace.jsonl | \ + jq '. | select(.files[].conversations[].related[].value == "INT-001") | .files[].relative_path' | \ + sort -u +``` + +### Find all INTENT_EVOLUTION events + +```bash +cat .orchestration/agent_trace.jsonl | \ + jq '. | select(.mutation_class == "INTENT_EVOLUTION") | {file: .files[].relative_path, intent: .files[].conversations[].related[].value, time: .timestamp}' +``` + +### Verify a file's content hash matches the trace + +```bash +# Compute current AST hash of a file +node -e " +const { hashCodeBlock } = require('./src/hooks/utils/astHasher'); +hashCodeBlock(require('fs').readFileSync('src/weather/parser.ts','utf8'), 'src/weather/parser.ts') + .then(r => console.log(r.hash)); +" + +# Compare to the trace entry +cat .orchestration/agent_trace.jsonl | \ + jq '. | select(.files[].relative_path == "src/weather/parser.ts") | .files[].conversations[].ranges[].content_hash' | \ + tail -1 +``` + +--- + + From 386857c3044ee85d7d4c4e57624b9be241b9432b Mon Sep 17 00:00:00 2001 From: Nahom Date: Thu, 19 Feb 2026 14:39:30 +0300 Subject: [PATCH 10/12] code refactor --- pnpm-lock.yaml | 143 +++++++++++-- src/hooks/index.ts | 177 +++++++--------- src/hooks/post/TraceLogger.ts | 235 ++++++++++----------- src/hooks/pre/ContextInjector.ts | 136 ++++++------ src/hooks/utils/Gitutils.ts | 70 +++++++ src/hooks/utils/Intentstore.ts | 41 ++-- src/hooks/utils/astHasher.ts | 287 +++++++++++--------------- src/hooks/utils/mutationClassifier.ts | 157 ++++++++++++++ src/package.json | 5 +- 9 files changed, 729 insertions(+), 522 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6b461926f5e..de13a3ff6c2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -884,6 +884,9 @@ importers: isbinaryfile: specifier: ^5.0.2 version: 5.0.4 + js-yaml: + specifier: ^4.1.1 + version: 4.1.1 json-stream-stringify: specifier: ^3.1.6 version: 3.1.6 @@ -1004,9 +1007,6 @@ importers: undici: specifier: '>=5.29.0' version: 6.21.3 - uuid: - specifier: ^11.1.0 - version: 11.1.0 vscode-material-icons: specifier: ^0.1.1 version: 0.1.1 @@ -1095,9 +1095,15 @@ importers: '@types/turndown': specifier: ^5.0.5 version: 5.0.5 + '@types/uuid': + specifier: ^11.0.0 + version: 11.0.0 '@types/vscode': specifier: ^1.84.0 version: 1.100.0 + '@typescript-eslint/typescript-estree': + specifier: ^8.56.0 + version: 8.56.0(typescript@5.8.3) '@vscode/test-electron': specifier: ^2.5.2 version: 2.5.2 @@ -1137,6 +1143,9 @@ importers: tsx: specifier: ^4.19.3 version: 4.19.4 + uuid: + specifier: ^11.1.0 + version: 11.1.0 vitest: specifier: ^3.2.3 version: 3.2.4(@types/debug@4.1.12)(@types/node@20.17.50)(@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) @@ -4637,6 +4646,10 @@ packages: '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@types/uuid@11.0.0': + resolution: {integrity: sha512-HVyk8nj2m+jcFRNazzqyVKiZezyhDKrGUA3jlEcg/nZ6Ms+qHwocba1Y/AaVaznJTAM9xpdFSh+ptbNrhOGvZA==} + deprecated: This is a stub types definition. uuid provides its own type definitions, so you do not need this installed. + '@types/vscode-webview@1.57.5': resolution: {integrity: sha512-iBAUYNYkz+uk1kdsq05fEcoh8gJmwT3lqqFPN7MGyjQ3HVloViMdo7ZJ8DFIP8WOK74PjOEilosqAyxV2iUFUw==} @@ -4673,10 +4686,22 @@ packages: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <5.9.0' + '@typescript-eslint/project-service@8.56.0': + resolution: {integrity: sha512-M3rnyL1vIQOMeWxTWIW096/TtVP+8W3p/XnaFflhmcFp+U4zlxUxWj4XwNs6HbDeTtN4yun0GNTTDBw/SvufKg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + '@typescript-eslint/scope-manager@8.32.1': resolution: {integrity: sha512-7IsIaIDeZn7kffk7qXC3o6Z4UblZJKV3UBpkvRNpr5NSyLji7tvTcvmnMNYuYLyh26mN8W723xpo3i4MlD33vA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/tsconfig-utils@8.56.0': + resolution: {integrity: sha512-bSJoIIt4o3lKXD3xmDh9chZcjCz5Lk8xS7Rxn+6l5/pKrDpkCwtQNQQwZ2qRPk7TkUYhrq3WPIHXOXlbXP0itg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + '@typescript-eslint/type-utils@8.32.1': resolution: {integrity: sha512-mv9YpQGA8iIsl5KyUPi+FGLm7+bA4fgXaeRcFKRDRwDMu4iwrSHeDPipwueNXhdIIZltwCJv+NkxftECbIZWfA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -4688,12 +4713,22 @@ packages: resolution: {integrity: sha512-YmybwXUJcgGqgAp6bEsgpPXEg6dcCyPyCSr0CAAueacR/CCBi25G3V8gGQ2kRzQRBNol7VQknxMs9HvVa9Rvfg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/types@8.56.0': + resolution: {integrity: sha512-DBsLPs3GsWhX5HylbP9HNG15U0bnwut55Lx12bHB9MpXxQ+R5GC8MwQe+N1UFXxAeQDvEsEDY6ZYwX03K7Z6HQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/typescript-estree@8.32.1': resolution: {integrity: sha512-Y3AP9EIfYwBb4kWGb+simvPaqQoT5oJuzzj9m0i6FCY6SPvlomY2Ei4UEMm7+FXtlNJbor80ximyslzaQF6xhg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <5.9.0' + '@typescript-eslint/typescript-estree@8.56.0': + resolution: {integrity: sha512-ex1nTUMWrseMltXUHmR2GAQ4d+WjkZCT4f+4bVsps8QEdh0vlBsaCokKTPlnqBFqqGaxilDNJG7b8dolW2m43Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + '@typescript-eslint/utils@8.32.1': resolution: {integrity: sha512-DsSFNIgLSrc89gpq1LJB7Hm1YpuhK086DRDJSNrewcGvYloWW1vZLHBTIvarKZDcAORIy/uWNx8Gad+4oMpkSA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -4705,6 +4740,10 @@ packages: resolution: {integrity: sha512-ar0tjQfObzhSaW3C3QNmTc5ofj0hDoNQ5XWrCy6zDyabdr0TWhCkClp+rywGNj/odAFBVzzJrK4tEq5M4Hmu4w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/visitor-keys@8.56.0': + resolution: {integrity: sha512-q+SL+b+05Ud6LbEE35qe4A99P+htKTKVbyiNEe45eCbJFyh/HVK9QXwlrbz+Q4L8SOW4roxSVwXYj4DMBT7Ieg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typespec/ts-http-runtime@0.2.2': resolution: {integrity: sha512-Gz/Sm64+Sq/vklJu1tt9t+4R2lvnud8NbTD/ZfpZtMiUX7YeVpCA8j6NSW8ptwcoLL+NmYANwqP8DV0q/bwl2w==} engines: {node: '>=18.0.0'} @@ -6428,6 +6467,10 @@ packages: resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + eslint-visitor-keys@5.0.0: + resolution: {integrity: sha512-A0XeIi7CXU7nPlfHS9loMYEKxUaONu/hTEzHTGba9Huu94Cq1hPivf+DE5erJozZOky0LfvXAyrV/tcswpLI0Q==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + eslint@9.27.0: resolution: {integrity: sha512-ixRawFQuMB9DZ7fjU3iGGganFDp3+45bPOdaRurcFHSXO1e/sYwUX/FtQZpLZJR6SjMoJH8hR2pPEAfDyCoU2Q==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -6927,6 +6970,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: @@ -7611,6 +7655,10 @@ packages: resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} hasBin: true + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + hasBin: true + jsbn@1.1.0: resolution: {integrity: sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==} @@ -10101,7 +10149,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==} @@ -10249,6 +10297,12 @@ packages: peerDependencies: typescript: '>=4.8.4' + ts-api-utils@2.4.0: + resolution: {integrity: sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + ts-dedent@2.2.0: resolution: {integrity: sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==} engines: {node: '>=6.10'} @@ -11962,7 +12016,7 @@ snapshots: '@babel/parser': 7.27.2 '@babel/template': 7.27.2 '@babel/types': 7.27.1 - debug: 4.4.1(supports-color@8.1.1) + debug: 4.4.3 globals: 11.12.0 transitivePeerDependencies: - supports-color @@ -12476,7 +12530,7 @@ snapshots: '@antfu/install-pkg': 1.1.0 '@antfu/utils': 8.1.1 '@iconify/types': 2.0.0 - debug: 4.4.1(supports-color@8.1.1) + debug: 4.4.3 globals: 15.15.0 kolorist: 1.8.0 local-pkg: 1.1.1 @@ -14851,6 +14905,10 @@ snapshots: '@types/unist@3.0.3': {} + '@types/uuid@11.0.0': + dependencies: + uuid: 11.1.0 + '@types/vscode-webview@1.57.5': {} '@types/vscode@1.100.0': {} @@ -14902,16 +14960,29 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/project-service@8.56.0(typescript@5.8.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.56.0(typescript@5.8.3) + '@typescript-eslint/types': 8.56.0 + debug: 4.4.3 + typescript: 5.8.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/scope-manager@8.32.1': dependencies: '@typescript-eslint/types': 8.32.1 '@typescript-eslint/visitor-keys': 8.32.1 + '@typescript-eslint/tsconfig-utils@8.56.0(typescript@5.8.3)': + dependencies: + typescript: 5.8.3 + '@typescript-eslint/type-utils@8.32.1(eslint@9.27.0(jiti@2.4.2))(typescript@5.8.3)': dependencies: '@typescript-eslint/typescript-estree': 8.32.1(typescript@5.8.3) '@typescript-eslint/utils': 8.32.1(eslint@9.27.0(jiti@2.4.2))(typescript@5.8.3) - debug: 4.4.1(supports-color@8.1.1) + debug: 4.4.3 eslint: 9.27.0(jiti@2.4.2) ts-api-utils: 2.1.0(typescript@5.8.3) typescript: 5.8.3 @@ -14920,11 +14991,13 @@ snapshots: '@typescript-eslint/types@8.32.1': {} + '@typescript-eslint/types@8.56.0': {} + '@typescript-eslint/typescript-estree@8.32.1(typescript@5.8.3)': dependencies: '@typescript-eslint/types': 8.32.1 '@typescript-eslint/visitor-keys': 8.32.1 - debug: 4.4.1(supports-color@8.1.1) + debug: 4.4.3 fast-glob: 3.3.3 is-glob: 4.0.3 minimatch: 9.0.5 @@ -14934,6 +15007,21 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/typescript-estree@8.56.0(typescript@5.8.3)': + dependencies: + '@typescript-eslint/project-service': 8.56.0(typescript@5.8.3) + '@typescript-eslint/tsconfig-utils': 8.56.0(typescript@5.8.3) + '@typescript-eslint/types': 8.56.0 + '@typescript-eslint/visitor-keys': 8.56.0 + debug: 4.4.3 + minimatch: 9.0.5 + semver: 7.7.3 + tinyglobby: 0.2.15 + ts-api-utils: 2.4.0(typescript@5.8.3) + typescript: 5.8.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/utils@8.32.1(eslint@9.27.0(jiti@2.4.2))(typescript@5.8.3)': dependencies: '@eslint-community/eslint-utils': 4.7.0(eslint@9.27.0(jiti@2.4.2)) @@ -14950,6 +15038,11 @@ snapshots: '@typescript-eslint/types': 8.32.1 eslint-visitor-keys: 4.2.1 + '@typescript-eslint/visitor-keys@8.56.0': + dependencies: + '@typescript-eslint/types': 8.56.0 + eslint-visitor-keys: 5.0.0 + '@typespec/ts-http-runtime@0.2.2': dependencies: http-proxy-agent: 7.0.2 @@ -15073,7 +15166,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.50)(@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: @@ -15544,7 +15637,7 @@ snapshots: dependencies: bytes: 3.1.2 content-type: 1.0.5 - debug: 4.4.1(supports-color@8.1.1) + debug: 4.4.3 http-errors: 2.0.0 iconv-lite: 0.6.3 on-finished: 2.4.1 @@ -16680,7 +16773,7 @@ snapshots: esbuild-register@3.6.0(esbuild@0.25.9): dependencies: - debug: 4.4.1(supports-color@8.1.1) + debug: 4.4.3 esbuild: 0.25.9 transitivePeerDependencies: - supports-color @@ -16790,6 +16883,8 @@ snapshots: eslint-visitor-keys@4.2.1: {} + eslint-visitor-keys@5.0.0: {} + eslint@9.27.0(jiti@2.4.2): dependencies: '@eslint-community/eslint-utils': 4.7.0(eslint@9.27.0(jiti@2.4.2)) @@ -17068,7 +17163,7 @@ snapshots: extract-zip@2.0.1: dependencies: - debug: 4.4.1(supports-color@8.1.1) + debug: 4.4.3 get-stream: 5.2.0 yauzl: 2.10.0 optionalDependencies: @@ -17173,7 +17268,7 @@ snapshots: finalhandler@2.1.0: dependencies: - debug: 4.4.1(supports-color@8.1.1) + debug: 4.4.3 encodeurl: 2.0.0 escape-html: 1.0.3 on-finished: 2.4.1 @@ -17421,7 +17516,7 @@ snapshots: dependencies: basic-ftp: 5.0.5 data-uri-to-buffer: 6.0.2 - debug: 4.4.1(supports-color@8.1.1) + debug: 4.4.3 transitivePeerDependencies: - supports-color @@ -18197,6 +18292,10 @@ snapshots: dependencies: argparse: 2.0.1 + js-yaml@4.1.1: + dependencies: + argparse: 2.0.1 + jsbn@1.1.0: {} jsdom@26.1.0: @@ -19119,7 +19218,7 @@ snapshots: micromark@2.11.4: dependencies: - debug: 4.4.1(supports-color@8.1.1) + debug: 4.4.3 parse-entities: 2.0.0 transitivePeerDependencies: - supports-color @@ -19626,7 +19725,7 @@ snapshots: dependencies: '@tootallnate/quickjs-emscripten': 0.23.0 agent-base: 7.1.3 - debug: 4.4.1(supports-color@8.1.1) + debug: 4.4.3 get-uri: 6.0.4 http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6 @@ -19980,7 +20079,7 @@ snapshots: proxy-agent@6.5.0: dependencies: agent-base: 7.1.3 - debug: 4.4.1(supports-color@8.1.1) + debug: 4.4.3 http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6 lru-cache: 7.18.3 @@ -20556,7 +20655,7 @@ snapshots: router@2.2.0: dependencies: - debug: 4.4.1(supports-color@8.1.1) + debug: 4.4.3 depd: 2.0.0 is-promise: 4.0.0 parseurl: 1.3.3 @@ -20673,7 +20772,7 @@ snapshots: send@1.2.0: dependencies: - debug: 4.4.1(supports-color@8.1.1) + debug: 4.4.3 encodeurl: 2.0.0 escape-html: 1.0.3 etag: 1.8.1 @@ -20894,7 +20993,7 @@ snapshots: socks-proxy-agent@8.0.5: dependencies: agent-base: 7.1.3 - debug: 4.4.1(supports-color@8.1.1) + debug: 4.4.3 socks: 2.8.4 transitivePeerDependencies: - supports-color @@ -21392,6 +21491,10 @@ snapshots: dependencies: typescript: 5.8.3 + ts-api-utils@2.4.0(typescript@5.8.3): + dependencies: + typescript: 5.8.3 + ts-dedent@2.2.0: {} ts-easing@0.2.0: {} diff --git a/src/hooks/index.ts b/src/hooks/index.ts index df7d90fa935..07f6a7e239e 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -1,120 +1,83 @@ /** - * hooks/index.ts - * ───────────────────────────────────────────────────────────── - * Entry point for the Hook System. - * - * Registers all pre and post hooks in execution order. - * Exports the dispatchWithHooks() wrapper that replaces the - * extension's existing tool dispatcher. - * - * Pre-hook execution order (matters — each enriches ctx for next): - * 1. ContextInjector → intercepts select_active_intent, sets ctx.intentId - * 2. IntentGatekeeper → blocks writes if no intentId - * 3. ScopeEnforcer → blocks writes outside owned_scope - * 4. OptimisticLockGuard → blocks stale file writes, captures old content - * - * Post-hook execution order (all run, errors are swallowed): - * 1. TraceLogger → writes to agent_trace.jsonl - * 2. IntentMapUpdater → updates intent_map.md - * 3. LessonRecorder → appends to CLAUDE.md on evolution/errors - * ───────────────────────────────────────────────────────────── - */ - -import { hookEngine, ToolContext, BlockSignal } from './HookEngine'; - +hooks/index.ts +───────────────────────────────────────────────────────────── +Entry point for the Hook System. +───────────────────────────────────────────────────────────── +*/ +import { hookEngine, ToolContext, BlockSignal } from "./HookEngine" // Pre-hooks -import { contextInjector } from './pre/ContextInjector'; -import { intentGatekeeper } from './pre/IntentGatekeeper'; -import { scopeEnforcer } from './pre/ScopeEnforcer'; -import { optimisticLockGuard } from './pre/OptimisticLockGuard'; - +import { contextInjector } from "./pre/ContextInjector" +import { intentGatekeeper } from "./pre/IntentGatekeeper" +import { scopeEnforcer } from "./pre/ScopeEnforcer" +import { optimisticLockGuard } from "./pre/OptimisticLockGuard" // Post-hooks -import { traceLogger } from './post/TraceLogger'; -import { intentMapUpdater } from './post/IntentMapUpdater'; -import { lessonRecorder } from './post/LessonRecorder'; +import { traceLogger } from "./post/TraceLogger" +import { intentMapUpdater } from "./post/IntentMapUpdater" +import { lessonRecorder } from "./post/LessonRecorder" // ── Registration ─────────────────────────────────────────────── - export function registerAllHooks(): void { - // Pre-hooks (ORDER MATTERS) - hookEngine.registerPre('ContextInjector', contextInjector); - hookEngine.registerPre('IntentGatekeeper', intentGatekeeper); - hookEngine.registerPre('ScopeEnforcer', scopeEnforcer); - hookEngine.registerPre('OptimisticLockGuard', optimisticLockGuard); - - // Post-hooks - hookEngine.registerPost('TraceLogger', traceLogger); - hookEngine.registerPost('IntentMapUpdater', intentMapUpdater); - hookEngine.registerPost('LessonRecorder', lessonRecorder); - - console.log('[HookSystem] All hooks registered.'); + // Pre-hooks (ORDER MATTERS) + hookEngine.registerPre("ContextInjector", contextInjector) + hookEngine.registerPre("IntentGatekeeper", intentGatekeeper) + hookEngine.registerPre("ScopeEnforcer", scopeEnforcer) + hookEngine.registerPre("OptimisticLockGuard", optimisticLockGuard) + // Post-hooks + hookEngine.registerPost("TraceLogger", traceLogger) + hookEngine.registerPost("IntentMapUpdater", intentMapUpdater) + hookEngine.registerPost("LessonRecorder", lessonRecorder) + console.log("[HookSystem] All hooks registered.") } // ── Dispatcher Wrapper ───────────────────────────────────────── - -/** - * Drop-in replacement for the extension's tool dispatcher. - * - * Usage — replace this pattern in the extension host: - * - * // BEFORE (original extension code): - * const result = await executeTool(toolName, params); - * - * // AFTER (with hook system): - * const result = await dispatchWithHooks(toolName, params, workspacePath, originalExecuteTool); - */ export async function dispatchWithHooks( - toolName: string, - params: Record, - workspacePath: string, - originalDispatch: (toolName: string, params: Record) => Promise, - sessionIntentId?: string + toolName: string, + params: Record, + workspacePath: string, + originalDispatch: (toolName: string, params: Record) => Promise, + sessionIntentId?: string, ): Promise<{ content: unknown; blocked: boolean; blockReason?: string }> { - - const ctx: ToolContext = { - toolName, - params, - workspacePath, - intentId: sessionIntentId, - }; - - // ── Run Pre-Hooks ──────────────────────────────────────────── - const preResult = await hookEngine.runPreHooks(ctx); - - if (preResult instanceof BlockSignal) { - return { - content: { - type: 'error', - error: preResult.reason, - code: preResult.code, - }, - blocked: true, - blockReason: preResult.reason, - }; - } - - // ── Context injection short-circuit ───────────────────────── - // If a hook injected context (select_active_intent), return it - // directly WITHOUT running the original tool. - if (preResult.__injectedContext__) { - await hookEngine.runPostHooks(preResult); - return { - content: { - type: 'tool_result', - content: preResult.__injectedContext__, - }, - blocked: false, - }; - } - - // ── Run Original Tool ──────────────────────────────────────── - const toolResult = await originalDispatch(preResult.toolName, preResult.params); - - // ── Run Post-Hooks ─────────────────────────────────────────── - await hookEngine.runPostHooks(preResult); - - return { content: toolResult, blocked: false }; + const ctx: ToolContext = { + toolName, + params, + workspacePath, + intentId: sessionIntentId, + } + + // ── Run Pre-Hooks ──────────────────────────────────────────── + const preResult = await hookEngine.runPreHooks(ctx) + if (preResult instanceof BlockSignal) { + return { + content: { + type: "error", + error: preResult.reason, + code: preResult.code, + }, + blocked: true, + blockReason: preResult.reason, + } + } + + // ── Context injection short-circuit ───────────────────────── + if ((preResult as any).__injectedContext__) { + await hookEngine.runPostHooks(preResult) + return { + content: { + type: "tool_result", + content: (preResult as any).__injectedContext__, + }, + blocked: false, + } + } + + // ── Run Original Tool ──────────────────────────────────────── + const toolResult = await originalDispatch(preResult.toolName, preResult.params) + + // ── Run Post-Hooks ─────────────────────────────────────────── + await hookEngine.runPostHooks(preResult) + return { content: toolResult, blocked: false } } -// Re-export core types for extension host use -export { hookEngine, ToolContext, BlockSignal } from './HookEngine'; \ No newline at end of file +// Re-export core types +export { hookEngine } from "./HookEngine" +export type { ToolContext, BlockSignal } from "./HookEngine" diff --git a/src/hooks/post/TraceLogger.ts b/src/hooks/post/TraceLogger.ts index 8b3522b937d..d23fb94aacb 100644 --- a/src/hooks/post/TraceLogger.ts +++ b/src/hooks/post/TraceLogger.ts @@ -1,155 +1,132 @@ /** - * post/TraceLogger.ts - * ───────────────────────────────────────────────────────────── - * POST-HOOK #1 — The Ledger - * - * After every file write, appends an entry to agent_trace.jsonl. - * This is the audit trail that links: - * Business Intent → Mutation Class → Code Hash → Git SHA - * - * Key properties: - * • Append-only (never mutates existing entries) - * • AST-based content hashing (spatial independence) - * • Deterministic mutation classification (not agent self-reported) - * • Links back to the intent ID declared in the Pre-Hook - * ───────────────────────────────────────────────────────────── - */ - -import * as fs from 'fs'; -import * as path from 'path'; -import { v4 as uuidv4 } from 'uuid'; -import { ToolContext } from '../HookEngine'; -import { hashCodeBlock } from '../utils/astHasher'; -import { classifyMutation } from '../utils/mutationClassifier'; -import { getCurrentGitSha, toRelativePath } from '../utils/gitUtils'; - -const TRACE_FILE = path.join('.orchestration', 'agent_trace.jsonl'); +post/TraceLogger.ts +───────────────────────────────────────────────────────────── +POST-HOOK #1 — The Ledger +───────────────────────────────────────────────────────────── +*/ +import * as fs from "fs" +import * as path from "path" +import { v4 as uuidv4 } from "uuid" +import { ToolContext } from "../HookEngine" +import { hashCodeBlock } from "../utils/astHasher" +import { classifyMutation } from "../utils/mutationClassifier" +import { getCurrentGitSha, toRelativePath } from "../utils/gitUtils" + +const TRACE_FILE = path.join(".orchestration", "agent_trace.jsonl") export interface TraceEntry { - id: string; - timestamp: string; - vcs: { revision_id: string | null }; - mutation_class: string; - classification_reason: string; - files: TraceFile[]; + id: string + timestamp: string + vcs: { revision_id: string | null } + mutation_class: string + classification_reason: string + files: TraceFile[] } export interface TraceFile { - relative_path: string; - conversations: TraceConversation[]; + relative_path: string + conversations: TraceConversation[] } export interface TraceConversation { - session_id: string; - contributor: { - entity_type: 'AI' | 'HUMAN'; - model_identifier: string; - }; - ranges: TraceRange[]; - related: TraceRelated[]; + session_id: string + contributor: { + entity_type: "AI" | "HUMAN" + model_identifier: string + } + ranges: TraceRange[] + related: TraceRelated[] } export interface TraceRange { - start_line: number; - end_line: number; - content_hash: string; - hash_method: 'ast' | 'raw'; - ast_node_count: number; + start_line: number + end_line: number + content_hash: string + hash_method: "ast" | "raw" + ast_node_count: number } export interface TraceRelated { - type: 'specification' | 'intent' | 'requirement'; - value: string; + type: "specification" | "intent" | "requirement" + value: string } -// ── Main post-hook ───────────────────────────────────────────── - -const WRITE_TOOLS = new Set([ - 'write_file', - 'write_to_file', - 'create_file', - 'apply_diff', - 'replace_in_file', -]); +const WRITE_TOOLS = new Set(["write_file", "write_to_file", "create_file", "apply_diff", "replace_in_file"]) export async function traceLogger(ctx: ToolContext): Promise { - if (!WRITE_TOOLS.has(ctx.toolName)) return ctx; - - const targetPath = ctx.params['path'] as string ?? ctx.params['file_path'] as string; - const newContent = ctx.params['content'] as string ?? ctx.params['new_content'] as string; - - if (!targetPath || newContent === undefined) return ctx; - - const relativePath = toRelativePath(ctx.workspacePath, path.resolve(ctx.workspacePath, targetPath)); - - // Compute AST-based hash of the new content - const hashResult = await hashCodeBlock(newContent, targetPath); - - // Classify mutation (deterministic — compare old vs new AST) - const oldContent = ctx.__oldContent__ ?? ''; - const classification = await classifyMutation(oldContent, newContent, targetPath); - - // Get current git SHA - const gitSha = getCurrentGitSha(ctx.workspacePath); - - const lineCount = newContent.split('\n').length; - - const entry: TraceEntry = { - id: uuidv4(), - timestamp: new Date().toISOString(), - vcs: { revision_id: gitSha }, - mutation_class: ctx.mutationClass ?? classification.mutationClass, - classification_reason: classification.reason, - files: [ - { - relative_path: relativePath, - conversations: [ - { - session_id: getSessionId(), - contributor: { - entity_type: 'AI', - model_identifier: getModelIdentifier(ctx), - }, - ranges: [ - { - start_line: 1, - end_line: lineCount, - content_hash: hashResult.hash, - hash_method: hashResult.method, - ast_node_count: hashResult.nodeCount, - }, - ], - related: [ - { - type: 'intent', - value: ctx.intentId ?? 'UNLINKED', - }, - ], - }, - ], - }, - ], - }; - - // Append to JSONL - const tracePath = path.join(ctx.workspacePath, TRACE_FILE); - fs.mkdirSync(path.dirname(tracePath), { recursive: true }); - fs.appendFileSync(tracePath, JSON.stringify(entry) + '\n', 'utf8'); - - console.log(`[TraceLogger] Logged ${classification.mutationClass} for ${relativePath} (intent: ${ctx.intentId ?? 'UNLINKED'})`); - - return ctx; + if (!WRITE_TOOLS.has(ctx.toolName)) return ctx + + const targetPath = (ctx.params["path"] as string) ?? (ctx.params["file_path"] as string) + const newContent = (ctx.params["content"] as string) ?? (ctx.params["new_content"] as string) + + if (!targetPath || newContent === undefined) return ctx + + const relativePath = toRelativePath(ctx.workspacePath, path.resolve(ctx.workspacePath, targetPath)) + + const hashResult = await hashCodeBlock(newContent, targetPath) + const oldContent = (ctx as any).oldContent ?? "" + const classification = await classifyMutation(oldContent, newContent, targetPath) + + const gitSha = getCurrentGitSha(ctx.workspacePath) + const lineCount = newContent.split("\n").length + + const entry: TraceEntry = { + id: uuidv4(), + timestamp: new Date().toISOString(), + vcs: { revision_id: gitSha }, + mutation_class: (ctx as any).mutationClass ?? classification.mutationClass, + classification_reason: classification.reason, + files: [ + { + relative_path: relativePath, + conversations: [ + { + session_id: getSessionId(), + contributor: { + entity_type: "AI", + model_identifier: getModelIdentifier(ctx), + }, + ranges: [ + { + start_line: 1, + end_line: lineCount, + content_hash: hashResult.hash, + hash_method: hashResult.method, + ast_node_count: hashResult.nodeCount, + }, + ], + related: [ + { + type: "intent", + value: ctx.intentId ?? "UNLINKED", + }, + ], + }, + ], + }, + ], + } + + const tracePath = path.join(ctx.workspacePath, TRACE_FILE) + fs.mkdirSync(path.dirname(tracePath), { recursive: true }) + fs.appendFileSync(tracePath, JSON.stringify(entry) + "\n", "utf8") + + console.log( + `[TraceLogger] Logged ${classification.mutationClass} for ${relativePath} (intent: ${ + ctx.intentId ?? "UNLINKED" + })`, + ) + return ctx } -// ── Helpers ──────────────────────────────────────────────────── +// ── Helpers ─────────────────────────────────────────────────── +let _sessionId: string | null = null -let _sessionId: string | null = null; function getSessionId(): string { - if (!_sessionId) _sessionId = uuidv4(); - return _sessionId; + if (!_sessionId) _sessionId = uuidv4() + return _sessionId } function getModelIdentifier(ctx: ToolContext): string { - // Try to extract from context — extension may store this - return (ctx.params['__model__'] as string) ?? 'unknown-model'; -} \ No newline at end of file + return (ctx.params["model"] as string) ?? "unknown-model" +} diff --git a/src/hooks/pre/ContextInjector.ts b/src/hooks/pre/ContextInjector.ts index dc8f78874b6..0d4021697d8 100644 --- a/src/hooks/pre/ContextInjector.ts +++ b/src/hooks/pre/ContextInjector.ts @@ -1,103 +1,83 @@ /** - * pre/ContextInjector.ts - * ───────────────────────────────────────────────────────────── - * PRE-HOOK #2 — The Context Injector - * - * Intercepts the select_active_intent() tool call. - * Instead of running the tool, the hook: - * 1. Looks up the intent in active_intents.yaml - * 2. Constructs a structured XML block - * 3. Returns it as the tool result - * - * The agent sees only the XML block — it never directly reads - * the sidecar YAML files. The hook is the context layer. - * - * Also sets ctx.intentId so downstream hooks know the active intent. - * ───────────────────────────────────────────────────────────── - */ - -import { ToolContext, BlockSignal } from '../HookEngine'; -import { findIntent, loadIntents, ActiveIntent } from '../utils/intentStore'; +pre/ContextInjector.ts +───────────────────────────────────────────────────────────── +PRE-HOOK #2 — The Context Injector +───────────────────────────────────────────────────────────── +*/ +import { ToolContext, BlockSignal } from "../HookEngine" +import { findIntent, loadIntents, ActiveIntent } from "../utils/intentStore" export async function contextInjector(ctx: ToolContext): Promise { - if (ctx.toolName !== 'select_active_intent') return ctx; - - const intentId = ctx.params['intent_id'] as string; - - if (!intentId || typeof intentId !== 'string') { - return new BlockSignal( - 'select_active_intent requires an intent_id string parameter.\n' + - 'Example: select_active_intent("INT-001")' - ); - } - - const intent = findIntent(ctx.workspacePath, intentId); - - if (!intent) { - const available = loadIntents(ctx.workspacePath).map(i => ` • ${i.id}: ${i.name}`).join('\n'); - return new BlockSignal( - `BLOCKED [UNKNOWN_INTENT]: No active intent found with id "${intentId}".\n\n` + - `Available intents:\n${available || ' (none — create .orchestration/active_intents.yaml first)'}`, - 'UNKNOWN_INTENT' - ); - } - - if (intent.status === 'COMPLETE') { - return new BlockSignal( - `BLOCKED: Intent "${intentId}" is already COMPLETE. ` + - `Create a new intent or reopen this one before proceeding.` - ); - } - - // Build the context XML block that will be returned to the LLM as the tool result - const contextXml = buildIntentContextXml(intent); - - // Store in context for downstream hooks and for the dispatcher to return - ctx.__injectedContext__ = contextXml; - ctx.intentId = intentId; - - return ctx; + if (ctx.toolName !== "select_active_intent") return ctx + + const intentId = ctx.params["intent_id"] as string + if (!intentId || typeof intentId !== "string") { + return new BlockSignal( + "select_active_intent requires an intent_id string parameter.\n" + + 'Example: select_active_intent("INT-001")', + ) + } + + const intent = findIntent(ctx.workspacePath, intentId) + if (!intent) { + const available = loadIntents(ctx.workspacePath) + .map((i) => `• ${i.id}: ${i.name}`) + .join("\n") + return new BlockSignal( + `BLOCKED [UNKNOWN_INTENT]: No active intent found with id "${intentId}".\n\n` + + `Available intents:\n${available || " (none — create .orchestration/active_intents.yaml first)"}`, + "UNKNOWN_INTENT", + ) + } + + if (intent.status === "COMPLETE") { + return new BlockSignal( + `BLOCKED: Intent "${intentId}" is already COMPLETE.` + + `Create a new intent or reopen this one before proceeding.`, + ) + } + + // Build the context XML block + const contextXml = buildIntentContextXml(intent) + + // Store in context + ;(ctx as any).__injectedContext__ = contextXml + ctx.intentId = intentId + return ctx } // ── XML Builder ──────────────────────────────────────────────── - function buildIntentContextXml(intent: ActiveIntent): string { - const scopeList = intent.owned_scope.map(s => ` ${escapeXml(s)}`).join('\n'); - const constraintList = intent.constraints.map(c => ` ${escapeXml(c)}`).join('\n'); - const criteriaList = intent.acceptance_criteria.map(c => ` ${escapeXml(c)}`).join('\n'); + const scopeList = intent.owned_scope.map((s) => `${escapeXml(s)}`).join("\n") + const constraintList = intent.constraints.map((c) => `${escapeXml(c)}`).join("\n") + const criteriaList = intent.acceptance_criteria.map((c) => `${escapeXml(c)}`).join("\n") - return ` + return ` ${escapeXml(intent.id)} ${escapeXml(intent.name)} ${escapeXml(intent.status)} - -${scopeList} + ${scopeList} - -${constraintList} + ${constraintList} - -${criteriaList} + ${criteriaList} - - You are now operating under intent ${escapeXml(intent.id)}. You MAY ONLY modify files within the paths listed in owned_scope. You MUST respect ALL constraints listed above. Before completing, verify each acceptance criterion. When calling write_file, include "intent_id": "${escapeXml(intent.id)}" in your tool params. - -`; + ` } function escapeXml(str: string): string { - return str - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); -} \ No newline at end of file + return str + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'") +} diff --git a/src/hooks/utils/Gitutils.ts b/src/hooks/utils/Gitutils.ts index e69de29bb2d..b506fe8ee42 100644 --- a/src/hooks/utils/Gitutils.ts +++ b/src/hooks/utils/Gitutils.ts @@ -0,0 +1,70 @@ +/** +utils/gitUtils.ts +───────────────────────────────────────────────────────────── +Lightweight git helpers for the hook system. +All functions are non-throwing — they return null on failure. +───────────────────────────────────────────────────────────── +*/ +import { execSync } from "child_process" +import * as path from "path" + +/** +Get the current HEAD commit SHA. +Returns null if not in a git repo or git is not installed. +*/ +export function getCurrentGitSha(workspacePath: string): string | null { + try { + return execSync("git rev-parse HEAD", { + cwd: workspacePath, + stdio: ["pipe", "pipe", "pipe"], + timeout: 3000, + }) + .toString() + .trim() + } catch { + return null + } +} + +/** +Get the SHA of a specific file at HEAD. +Useful for optimistic locking — get the "known good" hash. +*/ +export function getFileGitSha(workspacePath: string, relativeFilePath: string): string | null { + try { + return execSync(`git hash-object "${relativeFilePath}"`, { + cwd: workspacePath, + stdio: ["pipe", "pipe", "pipe"], + timeout: 3000, + }) + .toString() + .trim() + } catch { + return null + } +} + +/** +Check if a file has uncommitted changes. +*/ +export function hasUncommittedChanges(workspacePath: string, relativeFilePath: string): boolean { + try { + const result = execSync(`git status --porcelain "${relativeFilePath}"`, { + cwd: workspacePath, + stdio: ["pipe", "pipe", "pipe"], + timeout: 3000, + }) + .toString() + .trim() + return result.length > 0 + } catch { + return false + } +} + +/** +Get the path of a file relative to the workspace root. +*/ +export function toRelativePath(workspacePath: string, absoluteFilePath: string): string { + return path.relative(workspacePath, absoluteFilePath).replace(/\\/g, "/") +} diff --git a/src/hooks/utils/Intentstore.ts b/src/hooks/utils/Intentstore.ts index a61847ab382..fe2e429d896 100644 --- a/src/hooks/utils/Intentstore.ts +++ b/src/hooks/utils/Intentstore.ts @@ -1,13 +1,11 @@ /** - * utils/intentStore.ts - * ───────────────────────────────────────────────────────────── - * Read/write layer for .orchestration/active_intents.yaml - * - * This is the single source of truth for intent state. - * All hooks that need intent data go through this module. - * ───────────────────────────────────────────────────────────── - */ - +utils/intentStore.ts +───────────────────────────────────────────────────────────── +Read/write layer for .orchestration/active_intents.yaml +This is the single source of truth for intent state. +All hooks that need intent data go through this module. +───────────────────────────────────────────────────────────── +*/ import * as fs from "fs" import * as path from "path" import * as yaml from "js-yaml" @@ -35,9 +33,9 @@ function getIntentsPath(workspacePath: string): string { } /** - * Load all active intents from the YAML file. - * Returns empty array if file doesn't exist. - */ +Load all active intents from the YAML file. +Returns empty array if file doesn't exist. +*/ export function loadIntents(workspacePath: string): ActiveIntent[] { const filePath = getIntentsPath(workspacePath) if (!fs.existsSync(filePath)) return [] @@ -53,16 +51,16 @@ export function loadIntents(workspacePath: string): ActiveIntent[] { } /** - * Find a single intent by ID. Returns null if not found. - */ +Find a single intent by ID. Returns null if not found. +*/ export function findIntent(workspacePath: string, intentId: string): ActiveIntent | null { const intents = loadIntents(workspacePath) return intents.find((i) => i.id === intentId) ?? null } /** - * Update the status of a specific intent and write back to disk. - */ +Update the status of a specific intent and write back to disk. +*/ export function updateIntentStatus(workspacePath: string, intentId: string, status: ActiveIntent["status"]): boolean { const filePath = getIntentsPath(workspacePath) const intents = loadIntents(workspacePath) @@ -79,9 +77,9 @@ export function updateIntentStatus(workspacePath: string, intentId: string, stat } /** - * Check if a file path matches any of the owned_scope globs for an intent. - * Supports ** glob patterns. - */ +Check if a file path matches any of the owned_scope globs for an intent. +Supports ** glob patterns. +*/ export function isFileInScope(intent: ActiveIntent, filePath: string): boolean { // Normalize to forward slashes const normalized = filePath.replace(/\\/g, "/") @@ -93,8 +91,8 @@ export function isFileInScope(intent: ActiveIntent, filePath: string): boolean { } /** - * Check if a file path is in a .intentignore file. - */ +Check if a file path is in a .intentignore file. +*/ export function isIntentIgnored(workspacePath: string, filePath: string): boolean { const ignorePath = path.join(workspacePath, ".intentignore") if (!fs.existsSync(ignorePath)) return false @@ -110,7 +108,6 @@ export function isIntentIgnored(workspacePath: string, filePath: string): boolea } // ── Glob matching ────────────────────────────────────────────── - function matchesGlob(pattern: string, filePath: string): boolean { // Convert glob to regex const regexStr = pattern diff --git a/src/hooks/utils/astHasher.ts b/src/hooks/utils/astHasher.ts index c2eebdeadf1..a3888ec4870 100644 --- a/src/hooks/utils/astHasher.ts +++ b/src/hooks/utils/astHasher.ts @@ -1,192 +1,149 @@ /** - * utils/astHasher.ts - * ───────────────────────────────────────────────────────────── - * AST-based content hashing for spatial independence. - * - * Problem with raw string hashing: - * If a developer adds a blank line above a function, the line - * numbers shift and the hash is invalidated — even though the - * function's logic is identical. - * - * Solution: - * Hash the AST node structure (type + identifier names + child - * node types) rather than raw text. Whitespace, comments, and - * line-number shifts do NOT change the AST fingerprint. - * - * Supports: TypeScript, JavaScript, TSX, JSX - * Falls back to raw SHA-256 for unsupported file types. - * ───────────────────────────────────────────────────────────── - */ - -import * as crypto from 'crypto'; -import * as path from 'path'; +utils/astHasher.ts +───────────────────────────────────────────────────────────── +AST-based content hashing for spatial independence. +───────────────────────────────────────────────────────────── +*/ +import * as crypto from "crypto" +import * as path from "path" // Dynamic import — @typescript-eslint/typescript-estree is optional -// The extension should install it; we degrade gracefully if missing. async function tryGetParser() { - try { - const { parse } = await import('@typescript-eslint/typescript-estree'); - return parse; - } catch { - return null; - } + try { + const { parse } = await import("@typescript-eslint/typescript-estree") + return parse + } catch { + return null + } } export interface ASTHashResult { - hash: string; - method: 'ast' | 'raw'; - nodeCount: number; + hash: string + method: "ast" | "raw" + nodeCount: number } /** - * Hash a block of code using AST fingerprinting. - * @param content - Source code string - * @param filePath - Used to determine if the file is TS/JS - * @param startLine - 1-based start line (optional, hashes whole file if omitted) - * @param endLine - 1-based end line (optional) - */ +Hash a block of code using AST fingerprinting. +*/ export async function hashCodeBlock( - content: string, - filePath: string, - startLine?: number, - endLine?: number + content: string, + filePath: string, + startLine?: number, + endLine?: number, ): Promise { - const ext = path.extname(filePath).toLowerCase(); - const isTS = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'].includes(ext); - - if (!isTS) { - return { - hash: 'raw-sha256:' + crypto.createHash('sha256').update(content).digest('hex'), - method: 'raw', - nodeCount: 0, - }; - } - - const parse = await tryGetParser(); - if (!parse) { - console.warn('[astHasher] @typescript-eslint/typescript-estree not found, falling back to raw hash'); - return { - hash: 'raw-sha256:' + crypto.createHash('sha256').update(content).digest('hex'), - method: 'raw', - nodeCount: 0, - }; - } - - try { - const ast = parse(content, { - loc: true, - range: true, - jsx: ext === '.tsx' || ext === '.jsx', - tolerant: true, // don't throw on recoverable parse errors - }); - - const nodes = collectNodesInRange(ast, startLine, endLine); - const fingerprint = buildFingerprint(nodes); - const hash = 'ast-sha256:' + crypto.createHash('sha256').update(fingerprint).digest('hex'); - - return { hash, method: 'ast', nodeCount: nodes.length }; - } catch (err) { - console.warn('[astHasher] Parse failed, falling back to raw hash:', err); - return { - hash: 'raw-sha256:' + crypto.createHash('sha256').update(content).digest('hex'), - method: 'raw', - nodeCount: 0, - }; - } + const ext = path.extname(filePath).toLowerCase() + const isTS = [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"].includes(ext) + + if (!isTS) { + return { + hash: "raw-sha256:" + crypto.createHash("sha256").update(content).digest("hex"), + method: "raw", + nodeCount: 0, + } + } + + const parse = await tryGetParser() + if (!parse) { + console.warn("[astHasher] @typescript-eslint/typescript-estree not found, falling back to raw hash") + return { + hash: "raw-sha256:" + crypto.createHash("sha256").update(content).digest("hex"), + method: "raw", + nodeCount: 0, + } + } + + try { + // ✅ FIX: Cast to any to avoid Program/ASTNode mismatch + const ast: any = parse(content, { + loc: true, + range: true, + jsx: ext === ".tsx" || ext === ".jsx", + tolerant: true, + }) + const nodes = collectNodesInRange(ast, startLine, endLine) + const fingerprint = buildFingerprint(nodes) + const hash = "ast-sha256:" + crypto.createHash("sha256").update(fingerprint).digest("hex") + + return { hash, method: "ast", nodeCount: nodes.length } + } catch (err) { + console.warn("[astHasher] Parse failed, falling back to raw hash:", err) + return { + hash: "raw-sha256:" + crypto.createHash("sha256").update(content).digest("hex"), + method: "raw", + nodeCount: 0, + } + } } // ── Internal helpers ─────────────────────────────────────────── - -type ASTNode = { - type: string; - loc?: { start: { line: number }; end: { line: number } }; - [key: string]: unknown; -}; - -/** - * Walk the AST and collect all nodes whose start line falls within - * [startLine, endLine]. If no range given, collects all top-level nodes. - */ -function collectNodesInRange( - ast: ASTNode, - startLine?: number, - endLine?: number -): ASTNode[] { - const results: ASTNode[] = []; - - function walk(node: ASTNode) { - if (!node || typeof node !== 'object') return; - - const nodeLine = node.loc?.start.line; - const inRange = - startLine === undefined || - endLine === undefined || - (nodeLine !== undefined && nodeLine >= startLine && nodeLine <= endLine); - - if (inRange && node.type) { - results.push(node); - } - - for (const key of Object.keys(node)) { - if (key === 'parent') continue; // avoid circular refs - const child = node[key]; - if (Array.isArray(child)) { - child.forEach(c => c && typeof c === 'object' && walk(c as ASTNode)); - } else if (child && typeof child === 'object' && (child as ASTNode).type) { - walk(child as ASTNode); - } - } - } - - // Walk body for Program nodes - const body = (ast as any).body; - if (Array.isArray(body)) { - body.forEach(walk); - } else { - walk(ast); - } - - return results; +// ✅ FIX: Use any for flexible AST traversal +type ASTNode = any + +function collectNodesInRange(ast: ASTNode, startLine?: number, endLine?: number): ASTNode[] { + const results: ASTNode[] = [] + + function walk(node: ASTNode) { + if (!node || typeof node !== "object") return + + const nodeLine = node.loc?.start?.line + const inRange = + startLine === undefined || + endLine === undefined || + (nodeLine !== undefined && nodeLine >= startLine && nodeLine <= endLine) + + if (inRange && node.type) { + results.push(node) + } + + for (const key of Object.keys(node)) { + if (key === "parent") continue + const child = node[key] + if (Array.isArray(child)) { + child.forEach((c: any) => c && typeof c === "object" && walk(c)) + } else if (child && typeof child === "object" && child.type) { + walk(child) + } + } + } + + const body = ast?.body + if (Array.isArray(body)) { + body.forEach(walk) + } else { + walk(ast) + } + return results } -/** - * Build a deterministic string fingerprint from an array of AST nodes. - * Only includes structural info — NOT text content or line numbers. - */ function buildFingerprint(nodes: ASTNode[]): string { - const normalized = nodes.map(node => ({ - type: node.type, - // Capture identifier names for functions/classes (structural identity) - id: extractIdentifier(node), - // Capture parameter count for functions - paramCount: extractParamCount(node), - // Child node types (shallow — deep structure captured by recursion) - childTypes: extractChildTypes(node), - })); - - return JSON.stringify(normalized); + const normalized = nodes.map((node: any) => ({ + type: node.type, + id: extractIdentifier(node), + paramCount: extractParamCount(node), + childTypes: extractChildTypes(node), + })) + return JSON.stringify(normalized) } function extractIdentifier(node: ASTNode): string | null { - const n = node as any; - return n.id?.name ?? n.key?.name ?? n.name ?? null; + return node?.id?.name ?? node?.key?.name ?? node?.name ?? null } function extractParamCount(node: ASTNode): number | null { - const n = node as any; - if (n.params) return n.params.length; - if (n.value?.params) return n.value.params.length; - return null; + if (node?.params) return node.params.length + if (node?.value?.params) return node.value.params.length + return null } function extractChildTypes(node: ASTNode): string[] { - const types: string[] = []; - for (const key of Object.keys(node)) { - if (['type', 'loc', 'range', 'parent', 'start', 'end'].includes(key)) continue; - const child = (node as any)[key]; - if (child && typeof child === 'object' && child.type) { - types.push(child.type); - } - } - return types; -} \ No newline at end of file + const types: string[] = [] + for (const key of Object.keys(node)) { + if (["type", "loc", "range", "parent", "start", "end"].includes(key)) continue + const child = node[key] + if (child && typeof child === "object" && child.type) { + types.push(child.type) + } + } + return types +} diff --git a/src/hooks/utils/mutationClassifier.ts b/src/hooks/utils/mutationClassifier.ts index e69de29bb2d..47ba0c6960f 100644 --- a/src/hooks/utils/mutationClassifier.ts +++ b/src/hooks/utils/mutationClassifier.ts @@ -0,0 +1,157 @@ +/** +utils/mutationClassifier.ts +───────────────────────────────────────────────────────────── +Deterministic classification of code mutations. +───────────────────────────────────────────────────────────── +*/ +import * as path from "path" + +export type MutationClass = "AST_REFACTOR" | "INTENT_EVOLUTION" | "UNKNOWN" + +export interface ClassificationResult { + mutationClass: MutationClass + reason: string + addedExports: string[] + removedExports: string[] + changedSignatures: string[] +} + +/** +Compare old and new file content and classify the mutation. +*/ +export async function classifyMutation( + oldContent: string, + newContent: string, + filePath: string, +): Promise { + const ext = path.extname(filePath).toLowerCase() + const isTS = [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"].includes(ext) + + if (!isTS) { + return { + mutationClass: "UNKNOWN", + reason: "Non-JS/TS file — cannot perform AST analysis", + addedExports: [], + removedExports: [], + changedSignatures: [], + } + } + + try { + const { parse } = await import("@typescript-eslint/typescript-estree") + const oldAST = parse(oldContent, { + loc: true, + tolerant: true, + jsx: ext.includes("x"), + }) + const newAST = parse(newContent, { + loc: true, + tolerant: true, + jsx: ext.includes("x"), + }) + + const oldExports = extractExportSignatures(oldAST) + const newExports = extractExportSignatures(newAST) + + const added = newExports.filter((s) => !oldExports.includes(s)) + const removed = oldExports.filter((s) => !newExports.includes(s)) + const changedSignatures = detectSignatureChanges(oldAST, newAST) + + if (added.length === 0 && removed.length === 0 && changedSignatures.length === 0) { + return { + mutationClass: "AST_REFACTOR", + reason: "Exported API surface unchanged — internal refactor only", + addedExports: [], + removedExports: [], + changedSignatures: [], + } + } + + return { + mutationClass: "INTENT_EVOLUTION", + reason: `API surface changed: +${added.length} exports, -${removed.length} exports, ~${changedSignatures.length} signature changes`, + addedExports: added, + removedExports: removed, + changedSignatures, + } + } catch (err) { + console.warn("[mutationClassifier] Parse error:", err) + return { + mutationClass: "UNKNOWN", + reason: `Parse error: ${(err as Error).message}`, + addedExports: [], + removedExports: [], + changedSignatures: [], + } + } +} + +// ── Internal helpers ─────────────────────────────────────────── +type ParsedAST = { body: any[] } + +function extractExportSignatures(ast: ParsedAST): string[] { + const sigs: string[] = [] + for (const node of ast.body) { + if (node.type === "ExportNamedDeclaration") { + const decl = node.declaration + if (!decl) { + for (const spec of node.specifiers ?? []) { + sigs.push(`reexport:${spec.exported?.name ?? spec.local?.name}`) + } + continue + } + + if (decl.type === "FunctionDeclaration") { + sigs.push(`fn:${decl.id?.name}:${decl.params?.length ?? 0}`) + } else if (decl.type === "ClassDeclaration") { + sigs.push(`class:${decl.id?.name}`) + } else if (decl.type === "VariableDeclaration") { + for (const declarator of decl.declarations ?? []) { + const name = declarator.id?.name + if (name) { + const init = declarator.init + if (init?.type === "ArrowFunctionExpression" || init?.type === "FunctionExpression") { + sigs.push(`fn:${name}:${init.params?.length ?? 0}`) + } else { + sigs.push(`var:${name}`) + } + } + } + } else if (decl.type === "TSTypeAliasDeclaration") { + sigs.push(`type:${decl.id?.name}`) + } else if (decl.type === "TSInterfaceDeclaration") { + sigs.push(`interface:${decl.id?.name}`) + } + } + + if (node.type === "ExportDefaultDeclaration") { + sigs.push("default:export") + } + } + return sigs +} + +function detectSignatureChanges(oldAST: ParsedAST, newAST: ParsedAST): string[] { + const oldFns = extractFunctionMap(oldAST) + const newFns = extractFunctionMap(newAST) + const changes: string[] = [] + for (const [name, paramCount] of Object.entries(oldFns)) { + if (name in newFns && newFns[name] !== paramCount) { + changes.push(`${name}: ${paramCount} → ${newFns[name]} params`) + } + } + return changes +} + +function extractFunctionMap(ast: ParsedAST): Record { + const map: Record = {} + for (const node of ast.body) { + if (node.type === "ExportNamedDeclaration" && node.declaration) { + const decl = node.declaration + if (decl.type === "FunctionDeclaration" && decl.id?.name) { + map[decl.id.name] = decl.params?.length ?? 0 + } + } + } + return map +} diff --git a/src/package.json b/src/package.json index 73cbddfe379..6caafea2dbf 100644 --- a/src/package.json +++ b/src/package.json @@ -492,6 +492,7 @@ "i18next": "^25.0.0", "ignore": "^7.0.3", "isbinaryfile": "^5.0.2", + "js-yaml": "^4.1.1", "json-stream-stringify": "^3.1.6", "jwt-decode": "^4.0.0", "lodash.debounce": "^4.0.8", @@ -532,7 +533,6 @@ "tree-sitter-wasms": "^0.1.12", "turndown": "^7.2.0", "undici": "^6.21.3", - "uuid": "^11.1.0", "vscode-material-icons": "^0.1.1", "web-tree-sitter": "^0.25.6", "workerpool": "^9.2.0", @@ -564,7 +564,9 @@ "@types/string-similarity": "^4.0.2", "@types/tmp": "^0.2.6", "@types/turndown": "^5.0.5", + "@types/uuid": "^11.0.0", "@types/vscode": "^1.84.0", + "@typescript-eslint/typescript-estree": "^8.56.0", "@vscode/test-electron": "^2.5.2", "@vscode/vsce": "3.3.2", "ai": "^6.0.75", @@ -578,6 +580,7 @@ "rimraf": "^6.0.1", "tsup": "^8.4.0", "tsx": "^4.19.3", + "uuid": "^11.1.0", "vitest": "^3.2.3", "zod-to-ts": "^1.2.0" } From 515e0b4d79aa82b37cdf300438bdac1af5ba90c7 Mon Sep 17 00:00:00 2001 From: Nahom Date: Thu, 19 Feb 2026 14:58:36 +0300 Subject: [PATCH 11/12] final update --- pnpm-lock.yaml | 8 ++++++++ src/package.json | 1 + 2 files changed, 9 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index de13a3ff6c2..e72a254f0d2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1056,6 +1056,9 @@ importers: '@types/glob': specifier: ^8.1.0 version: 8.1.0 + '@types/js-yaml': + specifier: ^4.0.9 + version: 4.0.9 '@types/lodash.debounce': specifier: ^4.0.9 version: 4.0.9 @@ -4525,6 +4528,9 @@ packages: '@types/js-cookie@2.2.7': resolution: {integrity: sha512-aLkWa0C0vO5b4Sr798E26QgOkss68Un0bLjs7u9qxzPT5CG+8DuNTffWES58YzJs3hrVAOs1wonycqEBqNJubA==} + '@types/js-yaml@4.0.9': + resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==} + '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} @@ -14787,6 +14793,8 @@ snapshots: '@types/js-cookie@2.2.7': {} + '@types/js-yaml@4.0.9': {} + '@types/json-schema@7.0.15': {} '@types/katex@0.16.7': {} diff --git a/src/package.json b/src/package.json index 6caafea2dbf..0ce6f0c9672 100644 --- a/src/package.json +++ b/src/package.json @@ -551,6 +551,7 @@ "@types/diff": "^5.2.1", "@types/diff-match-patch": "^1.0.36", "@types/glob": "^8.1.0", + "@types/js-yaml": "^4.0.9", "@types/lodash.debounce": "^4.0.9", "@types/mocha": "^10.0.10", "@types/node": "20.x", From d79ecffef031f5cb78d738b41d8281702c2775e4 Mon Sep 17 00:00:00 2001 From: Nahom Date: Thu, 19 Feb 2026 16:36:10 +0300 Subject: [PATCH 12/12] fix: resolve regex and syntax errors in hook engine --- src/hooks/utils/Intentstore.ts | 170 +++++++++--------- src/hooks/utils/astHasher.ts | 250 +++++++++++++------------- src/hooks/utils/gitUtils.ts | 104 +++++------ src/hooks/utils/mutationClassifier.ts | 66 +++---- 4 files changed, 290 insertions(+), 300 deletions(-) diff --git a/src/hooks/utils/Intentstore.ts b/src/hooks/utils/Intentstore.ts index fe2e429d896..1335abd6ab3 100644 --- a/src/hooks/utils/Intentstore.ts +++ b/src/hooks/utils/Intentstore.ts @@ -6,116 +6,112 @@ This is the single source of truth for intent state. All hooks that need intent data go through this module. ───────────────────────────────────────────────────────────── */ -import * as fs from "fs" -import * as path from "path" -import * as yaml from "js-yaml" +import * as fs from "fs"; +import * as path from "path"; +import * as yaml from "js-yaml"; export interface ActiveIntent { - id: string - name: string - status: "PENDING" | "IN_PROGRESS" | "COMPLETE" | "BLOCKED" - owned_scope: string[] - constraints: string[] - acceptance_criteria: string[] - created_at?: string - updated_at?: string + id: string; + name: string; + status: "PENDING" | "IN_PROGRESS" | "COMPLETE" | "BLOCKED"; + owned_scope: string[]; + constraints: string[]; + acceptance_criteria: string[]; + created_at?: string; + updated_at?: string; } export interface IntentsFile { - active_intents: ActiveIntent[] + active_intents: ActiveIntent[]; } -const ORCHESTRATION_DIR = ".orchestration" -const INTENTS_FILE = "active_intents.yaml" +const ORCHESTRATION_DIR = ".orchestration"; +const INTENTS_FILE = "active_intents.yaml"; function getIntentsPath(workspacePath: string): string { - return path.join(workspacePath, ORCHESTRATION_DIR, INTENTS_FILE) + return path.join(workspacePath, ORCHESTRATION_DIR, INTENTS_FILE); } -/** -Load all active intents from the YAML file. -Returns empty array if file doesn't exist. -*/ export function loadIntents(workspacePath: string): ActiveIntent[] { - const filePath = getIntentsPath(workspacePath) - if (!fs.existsSync(filePath)) return [] - - try { - const raw = fs.readFileSync(filePath, "utf8") - const data = yaml.load(raw) as IntentsFile - return data?.active_intents ?? [] - } catch (err) { - console.error("[intentStore] Failed to parse active_intents.yaml:", err) - return [] - } + const filePath = getIntentsPath(workspacePath); + if (!fs.existsSync(filePath)) return []; + + try { + const raw = fs.readFileSync(filePath, "utf8"); + const data = yaml.load(raw) as IntentsFile; + return data?.active_intents ?? []; + } catch (err) { + console.error("[intentStore] Failed to parse active_intents.yaml:", err); + return []; + } } -/** -Find a single intent by ID. Returns null if not found. -*/ -export function findIntent(workspacePath: string, intentId: string): ActiveIntent | null { - const intents = loadIntents(workspacePath) - return intents.find((i) => i.id === intentId) ?? null +export function findIntent( + workspacePath: string, + intentId: string +): ActiveIntent | null { + const intents = loadIntents(workspacePath); + return intents.find((i) => i.id === intentId) ?? null; } -/** -Update the status of a specific intent and write back to disk. -*/ -export function updateIntentStatus(workspacePath: string, intentId: string, status: ActiveIntent["status"]): boolean { - const filePath = getIntentsPath(workspacePath) - const intents = loadIntents(workspacePath) - const intent = intents.find((i) => i.id === intentId) +export function updateIntentStatus( + workspacePath: string, + intentId: string, + status: ActiveIntent["status"] +): boolean { + const filePath = getIntentsPath(workspacePath); + const intents = loadIntents(workspacePath); + const intent = intents.find((i) => i.id === intentId); - if (!intent) return false + if (!intent) return false; - intent.status = status - intent.updated_at = new Date().toISOString() + intent.status = status; + intent.updated_at = new Date().toISOString(); - const updated: IntentsFile = { active_intents: intents } - fs.writeFileSync(filePath, yaml.dump(updated, { lineWidth: 120 }), "utf8") - return true + const updated: IntentsFile = { active_intents: intents }; + fs.writeFileSync(filePath, yaml.dump(updated, { lineWidth: 120 }), "utf8"); + return true; } -/** -Check if a file path matches any of the owned_scope globs for an intent. -Supports ** glob patterns. -*/ -export function isFileInScope(intent: ActiveIntent, filePath: string): boolean { - // Normalize to forward slashes - const normalized = filePath.replace(/\\/g, "/") - - for (const pattern of intent.owned_scope) { - if (matchesGlob(pattern, normalized)) return true - } - return false +export function isFileInScope( + intent: ActiveIntent, + filePath: string +): boolean { + // ✅ FIXED: Proper regex escaping + const normalized = filePath.replace(/\\/g, "/"); + + for (const pattern of intent.owned_scope) { + if (matchesGlob(pattern, normalized)) return true; + } + return false; } -/** -Check if a file path is in a .intentignore file. -*/ -export function isIntentIgnored(workspacePath: string, filePath: string): boolean { - const ignorePath = path.join(workspacePath, ".intentignore") - if (!fs.existsSync(ignorePath)) return false - - const lines = fs - .readFileSync(ignorePath, "utf8") - .split("\n") - .map((l) => l.trim()) - .filter((l) => l && !l.startsWith("#")) - - const normalized = filePath.replace(/\\/g, "/") - return lines.some((pattern) => matchesGlob(pattern, normalized)) +export function isIntentIgnored( + workspacePath: string, + filePath: string +): boolean { + const ignorePath = path.join(workspacePath, ".intentignore"); + if (!fs.existsSync(ignorePath)) return false; + + const lines = fs + .readFileSync(ignorePath, "utf8") + .split("\n") + .map((l) => l.trim()) + .filter((l) => l && !l.startsWith("#")); + + // ✅ FIXED: Proper regex escaping + const normalized = filePath.replace(/\\/g, "/"); + return lines.some((pattern) => matchesGlob(pattern, normalized)); } -// ── Glob matching ────────────────────────────────────────────── +// ✅ FIXED: Proper glob-to-regex conversion function matchesGlob(pattern: string, filePath: string): boolean { - // Convert glob to regex - const regexStr = pattern - .replace(/\./g, "\\.") - .replace(/\*\*/g, "__DOUBLESTAR__") - .replace(/\*/g, "[^/]*") - .replace(/__DOUBLESTAR__/g, ".*") - - const regex = new RegExp(`^${regexStr}$`) - return regex.test(filePath) -} + const regexStr = pattern + .replace(/\./g, "\\.") + .replace(/\*\*/g, "__DOUBLESTAR__") + .replace(/\*/g, "[^/]*") + .replace(/__DOUBLESTAR__/g, ".*"); + + const regex = new RegExp(`^${regexStr}$`); + return regex.test(filePath); +} \ No newline at end of file diff --git a/src/hooks/utils/astHasher.ts b/src/hooks/utils/astHasher.ts index a3888ec4870..4b7be1d2d81 100644 --- a/src/hooks/utils/astHasher.ts +++ b/src/hooks/utils/astHasher.ts @@ -4,146 +4,154 @@ utils/astHasher.ts AST-based content hashing for spatial independence. ───────────────────────────────────────────────────────────── */ -import * as crypto from "crypto" -import * as path from "path" +import * as crypto from "crypto"; +import * as path from "path"; -// Dynamic import — @typescript-eslint/typescript-estree is optional async function tryGetParser() { - try { - const { parse } = await import("@typescript-eslint/typescript-estree") - return parse - } catch { - return null - } + try { + const { parse } = await import("@typescript-eslint/typescript-estree"); + return parse; + } catch { + return null; + } } export interface ASTHashResult { - hash: string - method: "ast" | "raw" - nodeCount: number + hash: string; + method: "ast" | "raw"; + nodeCount: number; } -/** -Hash a block of code using AST fingerprinting. -*/ export async function hashCodeBlock( - content: string, - filePath: string, - startLine?: number, - endLine?: number, -): Promise { - const ext = path.extname(filePath).toLowerCase() - const isTS = [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"].includes(ext) - - if (!isTS) { - return { - hash: "raw-sha256:" + crypto.createHash("sha256").update(content).digest("hex"), - method: "raw", - nodeCount: 0, - } - } - - const parse = await tryGetParser() - if (!parse) { - console.warn("[astHasher] @typescript-eslint/typescript-estree not found, falling back to raw hash") - return { - hash: "raw-sha256:" + crypto.createHash("sha256").update(content).digest("hex"), - method: "raw", - nodeCount: 0, - } - } - - try { - // ✅ FIX: Cast to any to avoid Program/ASTNode mismatch - const ast: any = parse(content, { - loc: true, - range: true, - jsx: ext === ".tsx" || ext === ".jsx", - tolerant: true, - }) - const nodes = collectNodesInRange(ast, startLine, endLine) - const fingerprint = buildFingerprint(nodes) - const hash = "ast-sha256:" + crypto.createHash("sha256").update(fingerprint).digest("hex") - - return { hash, method: "ast", nodeCount: nodes.length } - } catch (err) { - console.warn("[astHasher] Parse failed, falling back to raw hash:", err) - return { - hash: "raw-sha256:" + crypto.createHash("sha256").update(content).digest("hex"), - method: "raw", - nodeCount: 0, - } - } + content: string, + filePath: string, + startLine?: number, + endLine?: number +): Promise { // ✅ FIXED: Added return type + const ext = path.extname(filePath).toLowerCase(); + const isTS = [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"].includes(ext); + + if (!isTS) { + return { + hash: + "raw-sha256:" + + crypto.createHash("sha256").update(content).digest("hex"), + method: "raw", + nodeCount: 0, + }; + } + + const parse = await tryGetParser(); + if (!parse) { + console.warn( + "[astHasher] @typescript-eslint/typescript-estree not found, falling back to raw hash" + ); + return { + hash: + "raw-sha256:" + + crypto.createHash("sha256").update(content).digest("hex"), + method: "raw", + nodeCount: 0, + }; + } + + try { + const ast: any = parse(content, { + loc: true, + range: true, + jsx: ext === ".tsx" || ext === ".jsx", + tolerant: true, + }); + const nodes = collectNodesInRange(ast, startLine, endLine); + const fingerprint = buildFingerprint(nodes); + const hash = + "ast-sha256:" + + crypto.createHash("sha256").update(fingerprint).digest("hex"); + + return { hash, method: "ast", nodeCount: nodes.length }; + } catch (err) { + console.warn("[astHasher] Parse failed, falling back to raw hash:", err); + return { + hash: + "raw-sha256:" + + crypto.createHash("sha256").update(content).digest("hex"), + method: "raw", + nodeCount: 0, + }; + } } -// ── Internal helpers ─────────────────────────────────────────── -// ✅ FIX: Use any for flexible AST traversal -type ASTNode = any - -function collectNodesInRange(ast: ASTNode, startLine?: number, endLine?: number): ASTNode[] { - const results: ASTNode[] = [] - - function walk(node: ASTNode) { - if (!node || typeof node !== "object") return - - const nodeLine = node.loc?.start?.line - const inRange = - startLine === undefined || - endLine === undefined || - (nodeLine !== undefined && nodeLine >= startLine && nodeLine <= endLine) - - if (inRange && node.type) { - results.push(node) - } - - for (const key of Object.keys(node)) { - if (key === "parent") continue - const child = node[key] - if (Array.isArray(child)) { - child.forEach((c: any) => c && typeof c === "object" && walk(c)) - } else if (child && typeof child === "object" && child.type) { - walk(child) - } - } - } - - const body = ast?.body - if (Array.isArray(body)) { - body.forEach(walk) - } else { - walk(ast) - } - return results +type ASTNode = any; // ✅ FIXED: Use any for flexibility + +function collectNodesInRange( + ast: ASTNode, + startLine?: number, + endLine?: number +): ASTNode[] { + const results: ASTNode[] = []; + + function walk(node: ASTNode) { + if (!node || typeof node !== "object") return; + + const nodeLine = node.loc?.start?.line; + const inRange = + startLine === undefined || + endLine === undefined || + (nodeLine !== undefined && nodeLine >= startLine && nodeLine <= endLine); // ✅ FIXED + + if (inRange && node.type) { // ✅ FIXED + results.push(node); + } + + for (const key of Object.keys(node)) { + if (key === "parent") continue; + const child = node[key]; + if (Array.isArray(child)) { // ✅ FIXED + child.forEach((c) => c && typeof c === "object" && walk(c)); // ✅ FIXED + } else if (child && typeof child === "object" && child.type) { // ✅ FIXED + walk(child); + } + } + } + + const body = ast?.body; + if (Array.isArray(body)) { + body.forEach(walk); + } else { + walk(ast); + } + return results; } function buildFingerprint(nodes: ASTNode[]): string { - const normalized = nodes.map((node: any) => ({ - type: node.type, - id: extractIdentifier(node), - paramCount: extractParamCount(node), - childTypes: extractChildTypes(node), - })) - return JSON.stringify(normalized) + const normalized = nodes.map((node: any) => ({ + type: node.type, + id: extractIdentifier(node), + paramCount: extractParamCount(node), + childTypes: extractChildTypes(node), + })); + return JSON.stringify(normalized); } function extractIdentifier(node: ASTNode): string | null { - return node?.id?.name ?? node?.key?.name ?? node?.name ?? null + return node?.id?.name ?? node?.key?.name ?? node?.name ?? null; } function extractParamCount(node: ASTNode): number | null { - if (node?.params) return node.params.length - if (node?.value?.params) return node.value.params.length - return null + if (node?.params) return node.params.length; + if (node?.value?.params) return node.value.params.length; + return null; } function extractChildTypes(node: ASTNode): string[] { - const types: string[] = [] - for (const key of Object.keys(node)) { - if (["type", "loc", "range", "parent", "start", "end"].includes(key)) continue - const child = node[key] - if (child && typeof child === "object" && child.type) { - types.push(child.type) - } - } - return types -} + const types: string[] = []; + for (const key of Object.keys(node)) { + if (["type", "loc", "range", "parent", "start", "end"].includes(key)) + continue; + const child = (node as any)[key]; + if (child && typeof child === "object" && child.type) { + types.push(child.type); + } + } + return types; +} \ No newline at end of file diff --git a/src/hooks/utils/gitUtils.ts b/src/hooks/utils/gitUtils.ts index b506fe8ee42..f6fd7f2422d 100644 --- a/src/hooks/utils/gitUtils.ts +++ b/src/hooks/utils/gitUtils.ts @@ -2,69 +2,63 @@ utils/gitUtils.ts ───────────────────────────────────────────────────────────── Lightweight git helpers for the hook system. -All functions are non-throwing — they return null on failure. ───────────────────────────────────────────────────────────── */ -import { execSync } from "child_process" -import * as path from "path" +import { execSync } from "child_process"; +import * as path from "path"; -/** -Get the current HEAD commit SHA. -Returns null if not in a git repo or git is not installed. -*/ export function getCurrentGitSha(workspacePath: string): string | null { - try { - return execSync("git rev-parse HEAD", { - cwd: workspacePath, - stdio: ["pipe", "pipe", "pipe"], - timeout: 3000, - }) - .toString() - .trim() - } catch { - return null - } + try { + return execSync("git rev-parse HEAD", { + cwd: workspacePath, + stdio: ["pipe", "pipe", "pipe"], + timeout: 3000, + }) + .toString() + .trim(); + } catch { + return null; + } } -/** -Get the SHA of a specific file at HEAD. -Useful for optimistic locking — get the "known good" hash. -*/ -export function getFileGitSha(workspacePath: string, relativeFilePath: string): string | null { - try { - return execSync(`git hash-object "${relativeFilePath}"`, { - cwd: workspacePath, - stdio: ["pipe", "pipe", "pipe"], - timeout: 3000, - }) - .toString() - .trim() - } catch { - return null - } +export function getFileGitSha( + workspacePath: string, + relativeFilePath: string +): string | null { + try { + return execSync(`git hash-object "${relativeFilePath}"`, { + cwd: workspacePath, + stdio: ["pipe", "pipe", "pipe"], + timeout: 3000, + }) + .toString() + .trim(); + } catch { + return null; + } } -/** -Check if a file has uncommitted changes. -*/ -export function hasUncommittedChanges(workspacePath: string, relativeFilePath: string): boolean { - try { - const result = execSync(`git status --porcelain "${relativeFilePath}"`, { - cwd: workspacePath, - stdio: ["pipe", "pipe", "pipe"], - timeout: 3000, - }) - .toString() - .trim() - return result.length > 0 - } catch { - return false - } +export function hasUncommittedChanges( + workspacePath: string, + relativeFilePath: string +): boolean { + try { + const result = execSync(`git status --porcelain "${relativeFilePath}"`, { + cwd: workspacePath, + stdio: ["pipe", "pipe", "pipe"], + timeout: 3000, + }) + .toString() + .trim(); + return result.length > 0; + } catch { + return false; + } } -/** -Get the path of a file relative to the workspace root. -*/ -export function toRelativePath(workspacePath: string, absoluteFilePath: string): string { - return path.relative(workspacePath, absoluteFilePath).replace(/\\/g, "/") +export function toRelativePath( + workspacePath: string, + absoluteFilePath: string +): string { + return path.relative(workspacePath, absoluteFilePath).replace(/\\/g, "/"); // ✅ FIXED } diff --git a/src/hooks/utils/mutationClassifier.ts b/src/hooks/utils/mutationClassifier.ts index 59d7256dc4a..81444c29736 100644 --- a/src/hooks/utils/mutationClassifier.ts +++ b/src/hooks/utils/mutationClassifier.ts @@ -2,18 +2,14 @@ utils/mutationClassifier.ts ───────────────────────────────────────────────────────────── Deterministic classification of code mutations. -Two classes: -AST_REFACTOR – Same exported API surface, internal changes only. - (rename variable, extract helper, format code) -INTENT_EVOLUTION – The exported API surface changed. - (new function, changed signature, deleted export) -The hook computes this — the agent does NOT self-report it. -This is what makes the system deterministic. ───────────────────────────────────────────────────────────── */ -import * as path from 'path'; +import * as path from "path"; -export type MutationClass = 'AST_REFACTOR' | 'INTENT_EVOLUTION' | 'UNKNOWN'; +export type MutationClass = + | "AST_REFACTOR" + | "INTENT_EVOLUTION" + | "UNKNOWN"; export interface ClassificationResult { mutationClass: MutationClass; @@ -23,22 +19,18 @@ export interface ClassificationResult { changedSignatures: string[]; } -/** -Compare old and new file content and classify the mutation. -Falls back to UNKNOWN for non-JS/TS files. -*/ export async function classifyMutation( oldContent: string, newContent: string, filePath: string -): Promise { +): Promise { // ✅ FIXED: Added return type const ext = path.extname(filePath).toLowerCase(); - const isTS = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'].includes(ext); + const isTS = [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"].includes(ext); if (!isTS) { return { - mutationClass: 'UNKNOWN', - reason: 'Non-JS/TS file — cannot perform AST analysis', + mutationClass: "UNKNOWN", + reason: "Non-JS/TS file — cannot perform AST analysis", addedExports: [], removedExports: [], changedSignatures: [], @@ -46,16 +38,16 @@ export async function classifyMutation( } try { - const { parse } = await import('@typescript-eslint/typescript-estree'); + const { parse } = await import("@typescript-eslint/typescript-estree"); const oldAST = parse(oldContent, { loc: true, tolerant: true, - jsx: ext.includes('x'), + jsx: ext.includes("x"), }); const newAST = parse(newContent, { loc: true, tolerant: true, - jsx: ext.includes('x'), + jsx: ext.includes("x"), }); const oldExports = extractExportSignatures(oldAST); @@ -71,8 +63,8 @@ export async function classifyMutation( changedSignatures.length === 0 ) { return { - mutationClass: 'AST_REFACTOR', - reason: 'Exported API surface unchanged — internal refactor only', + mutationClass: "AST_REFACTOR", + reason: "Exported API surface unchanged — internal refactor only", addedExports: [], removedExports: [], changedSignatures: [], @@ -80,16 +72,16 @@ export async function classifyMutation( } return { - mutationClass: 'INTENT_EVOLUTION', + mutationClass: "INTENT_EVOLUTION", reason: `API surface changed: +${added.length} exports, -${removed.length} exports, ~${changedSignatures.length} signature changes`, addedExports: added, removedExports: removed, changedSignatures, }; } catch (err) { - console.warn('[mutationClassifier] Parse error:', err); + console.warn("[mutationClassifier] Parse error:", err); return { - mutationClass: 'UNKNOWN', + mutationClass: "UNKNOWN", reason: `Parse error: ${(err as Error).message}`, addedExports: [], removedExports: [], @@ -104,7 +96,7 @@ type ParsedAST = { body: any[] }; function extractExportSignatures(ast: ParsedAST): string[] { const sigs: string[] = []; for (const node of ast.body) { - if (node.type === 'ExportNamedDeclaration') { + if (node.type === "ExportNamedDeclaration") { const decl = node.declaration; if (!decl) { for (const spec of node.specifiers ?? []) { @@ -113,18 +105,18 @@ function extractExportSignatures(ast: ParsedAST): string[] { continue; } - if (decl.type === 'FunctionDeclaration') { + if (decl.type === "FunctionDeclaration") { sigs.push(`fn:${decl.id?.name}:${decl.params?.length ?? 0}`); - } else if (decl.type === 'ClassDeclaration') { + } else if (decl.type === "ClassDeclaration") { sigs.push(`class:${decl.id?.name}`); - } else if (decl.type === 'VariableDeclaration') { + } else if (decl.type === "VariableDeclaration") { for (const declarator of decl.declarations ?? []) { const name = declarator.id?.name; if (name) { const init = declarator.init; if ( - init?.type === 'ArrowFunctionExpression' || - init?.type === 'FunctionExpression' + init?.type === "ArrowFunctionExpression" || + init?.type === "FunctionExpression" ) { sigs.push(`fn:${name}:${init.params?.length ?? 0}`); } else { @@ -132,15 +124,15 @@ function extractExportSignatures(ast: ParsedAST): string[] { } } } - } else if (decl.type === 'TSTypeAliasDeclaration') { + } else if (decl.type === "TSTypeAliasDeclaration") { sigs.push(`type:${decl.id?.name}`); - } else if (decl.type === 'TSInterfaceDeclaration') { + } else if (decl.type === "TSInterfaceDeclaration") { sigs.push(`interface:${decl.id?.name}`); } } - if (node.type === 'ExportDefaultDeclaration') { - sigs.push('default:export'); + if (node.type === "ExportDefaultDeclaration") { + sigs.push("default:export"); } } return sigs; @@ -164,9 +156,9 @@ function detectSignatureChanges( function extractFunctionMap(ast: ParsedAST): Record { const map: Record = {}; for (const node of ast.body) { - if (node.type === 'ExportNamedDeclaration' && node.declaration) { + if (node.type === "ExportNamedDeclaration" && node.declaration) { const decl = node.declaration; - if (decl.type === 'FunctionDeclaration' && decl.id?.name) { + if (decl.type === "FunctionDeclaration" && decl.id?.name) { map[decl.id.name] = decl.params?.length ?? 0; } }