diff --git a/.archon/ralph/llm-knowledge-base-system/prd.json b/.archon/ralph/llm-knowledge-base-system/prd.json new file mode 100644 index 0000000000..ab995b4e3f --- /dev/null +++ b/.archon/ralph/llm-knowledge-base-system/prd.json @@ -0,0 +1,393 @@ +{ + "project": "Archon", + "branchName": "ralph/llm-knowledge-base-system", + "prdFile": "prd.md", + "description": "Persistent auto-maintained knowledge base for cross-conversation memory using hierarchical markdown indexes", + "userStories": [ + { + "id": "US-001", + "title": "Add knowledge path resolution functions", + "description": "As a developer, I want knowledge base path functions in @archon/paths so that all packages can resolve KB directories consistently", + "acceptanceCriteria": [ + "Add `getProjectKnowledgePath(owner, repo)` returning `~/.archon/workspaces/{owner}/{repo}/knowledge/`", + "Add `getGlobalKnowledgePath()` returning `~/.archon/knowledge/`", + "Add `getKnowledgeLogsPath(owner, repo)` returning `.../knowledge/logs/`", + "Add `getKnowledgeDomainsPath(owner, repo)` returning `.../knowledge/domains/`", + "Functions follow existing pattern: pure path construction, no I/O, use `getProjectRoot()` as base", + "Functions exported from `packages/paths/src/index.ts`", + "Type-check passes", + "Tests pass" + ], + "technicalNotes": "Follow pattern at packages/paths/src/archon-paths.ts:235-277. All functions take (owner, repo) and return join(getProjectRoot(...), segment). Export from index.ts alongside existing path functions.", + "dependsOn": [], + "priority": 1, + "passes": true, + "notes": "Implemented in iteration 1. Files: packages/paths/src/archon-paths.ts, packages/paths/src/index.ts, packages/paths/src/archon-paths.test.ts." + }, + { + "id": "US-002", + "title": "Add knowledge config types", + "description": "As a developer, I want knowledge configuration types so that KB behavior is configurable per-repo and globally", + "acceptanceCriteria": [ + "Add `KnowledgeConfig` interface with `enabled`, `captureModel`, `compileModel`, `flushDebounceMinutes`, `domains` fields (all optional with defaults)", + "Add `knowledge?: KnowledgeConfig` to `RepoConfig` interface", + "Add `knowledge?: KnowledgeConfig` to `GlobalConfig` interface", + "Add `knowledge` field to `MergedConfig` with merge logic (repo overrides global)", + "Include JSDoc with `@default` annotations for each field", + "Type-check passes", + "Tests pass" + ], + "technicalNotes": "Modify packages/core/src/config/config-types.ts. Follow existing section patterns (docs, env, defaults) at lines 93-187. Add to MergedConfig at lines 193-243 with merge logic matching existing patterns.", + "dependsOn": [], + "priority": 1, + "passes": true, + "notes": "Implemented in iteration 2. Files: packages/core/src/config/config-types.ts, packages/core/src/config/config-loader.ts." + }, + { + "id": "US-003", + "title": "Create knowledge directory initialization", + "description": "As a developer, I want KB directories auto-created on first use so that capture and compile steps have a valid filesystem target", + "acceptanceCriteria": [ + "Create `initKnowledgeDir(owner, repo)` function that creates the full directory tree: knowledge/, meta/, logs/, domains/, and starting domain subdirs (architecture, decisions, patterns, lessons, connections)", + "Create `initGlobalKnowledgeDir()` for the global KB tier", + "Both functions are idempotent (safe to call multiple times)", + "Directories created with standard permissions", + "Type-check passes", + "Tests pass" + ], + "technicalNotes": "New file: packages/core/src/services/knowledge-init.ts. Use path functions from US-001. Follow mkdir -p pattern (recursive: true). Called lazily by capture/flush services before first write.", + "dependsOn": ["US-001"], + "priority": 2, + "passes": true, + "notes": "Implemented in iteration 3. Files: packages/core/src/services/knowledge-init.ts, packages/core/src/services/knowledge-init.test.ts, packages/core/package.json." + }, + { + "id": "US-004", + "title": "Create KB meta templates and initial index structure", + "description": "As an AI agent, I want schema.md and index.md templates so that I can understand and navigate the KB structure from session start", + "acceptanceCriteria": [ + "Create `schema.md` template describing KB structure, domain purposes, and navigation instructions for the agent", + "Create `index.md` template with sections for each starting domain and placeholder content", + "Create domain `_index.md` templates for architecture, decisions, patterns, lessons, connections", + "Templates written during `initKnowledgeDir()` only if files don't already exist", + "Templates use standard markdown with [[wikilink]] backlink syntax for Obsidian compatibility", + "Type-check passes", + "Tests pass" + ], + "technicalNotes": "Extend knowledge-init.ts from US-003. Templates are string constants or embedded files. schema.md describes the hierarchical navigation pattern. index.md is the agent's entry point (~500 tokens target).", + "dependsOn": ["US-003"], + "priority": 3, + "passes": true, + "notes": "Implemented in iteration 4. Files: packages/core/src/services/knowledge-init.ts, packages/core/src/services/knowledge-init.test.ts." + }, + { + "id": "US-005", + "title": "Implement capture service (transcript to daily log)", + "description": "As a system, I want to extract decisions/lessons/patterns from conversation transcripts so that raw knowledge is persisted for later compilation", + "acceptanceCriteria": [ + "Create `captureKnowledge(conversationId, owner, repo)` function", + "Reads conversation transcript via `listMessages()` from packages/core/src/db/messages.ts", + "Calls Haiku model via `IAssistantClient` factory to extract structured knowledge (decisions, lessons, patterns, connections)", + "Appends extracted knowledge to `knowledge/logs/YYYY-MM-DD.md` (daily log format)", + "Falls back to available model if Haiku unavailable", + "Respects `knowledge.enabled` config (skip if false)", + "Logs capture events: `knowledge.capture_started`, `knowledge.capture_completed`, `knowledge.capture_failed`", + "Type-check passes", + "Tests pass (mock AI client and DB)" + ], + "technicalNotes": "New file: packages/core/src/services/knowledge-capture.ts. Follow cleanup-service.ts pattern (lazy logger, config constants) at packages/core/src/services/cleanup-service.ts:27-40. Use IAssistantClient factory from packages/core/src/clients/. Read messages via listMessages() from packages/core/src/db/messages.ts.", + "dependsOn": ["US-001", "US-002"], + "priority": 4, + "passes": true, + "notes": "Implemented in iteration 5. Files: packages/core/src/services/knowledge-capture.ts, packages/core/src/services/knowledge-capture.test.ts, packages/core/package.json." + }, + { + "id": "US-006", + "title": "Wire capture triggers to session transitions", + "description": "As a system, I want knowledge capture to fire automatically on session end so that no manual intervention is needed", + "acceptanceCriteria": [ + "Capture triggers on `conversation-closed` session transition", + "Capture triggers on `reset-requested` session transition", + "Capture does NOT trigger on `isolation-changed` (not a knowledge event)", + "Capture is fire-and-forget (does not block session transition)", + "Errors logged but never surface to user or block session flow", + "Type-check passes", + "Tests pass" + ], + "technicalNotes": "Wire into packages/core/src/services/cleanup-service.ts onConversationClosed() and session deactivation in packages/core/src/state/session-transitions.ts. TransitionTrigger type at session-transitions.ts includes all 6 variants. Fire-and-forget with .catch() error logging.", + "dependsOn": ["US-005"], + "priority": 5, + "passes": true, + "notes": "Implemented in iteration 7. Files: packages/core/src/services/knowledge-capture.ts, packages/core/src/services/cleanup-service.ts, packages/core/src/handlers/command-handler.ts, plus tests." + }, + { + "id": "US-007", + "title": "Inject knowledge index into prompt builder", + "description": "As an AI agent, I want the knowledge index loaded into my context at session start so that I can navigate to relevant articles on demand", + "acceptanceCriteria": [ + "Extend `buildProjectScopedPrompt()` to load project `knowledge/index.md` content", + "Extend `buildOrchestratorPrompt()` to load global `knowledge/index.md` content", + "Project index overrides global when both exist (project loaded after global)", + "Gracefully skip if no index.md exists (empty KB state)", + "Added content stays within ~500 token budget for index", + "Type-check passes", + "Tests pass" + ], + "technicalNotes": "Modify packages/core/src/orchestrator/prompt-builder.ts. Add formatKnowledgeSection() following pattern at lines 12-37. Load index.md via fs.readFile with graceful ENOENT handling. Use path functions from US-001.", + "dependsOn": ["US-001"], + "priority": 4, + "passes": true, + "notes": "Implemented in iteration 6. Files: packages/core/src/orchestrator/prompt-builder.ts, packages/core/src/orchestrator/prompt-builder-knowledge.test.ts, packages/core/src/orchestrator/orchestrator-agent.ts, packages/core/package.json." + }, + { + "id": "US-008", + "title": "Implement fresh log fallback for unprocessed logs", + "description": "As an AI agent, I want unprocessed daily logs included as supplementary context so that recent knowledge is available even before compilation", + "acceptanceCriteria": [ + "At session start, check for daily logs newer than `meta/last-flush.json` timestamp", + "If unprocessed logs exist, include them as supplementary raw context after the index", + "Limit raw log injection to reasonable token budget (~2,000 tokens max)", + "If no last-flush.json exists, include all daily logs (KB is pre-first-flush)", + "Type-check passes", + "Tests pass" + ], + "technicalNotes": "Extend prompt-builder.ts changes from US-007. Read last-flush.json for timestamp, then scan knowledge/logs/ for newer YYYY-MM-DD.md files. Truncate if over budget.", + "dependsOn": ["US-007"], + "priority": 5, + "passes": true, + "notes": "Implemented in iteration 8. Files: packages/core/src/orchestrator/prompt-builder.ts, packages/core/src/orchestrator/prompt-builder-knowledge.test.ts." + }, + { + "id": "US-009", + "title": "Implement compile/flush service (daily logs to articles)", + "description": "As a system, I want daily logs synthesized into structured domain articles so that the knowledge base grows and stays organized", + "acceptanceCriteria": [ + "Create `flushKnowledge(owner, repo)` function", + "Reads all daily logs since `meta/last-flush.json` timestamp", + "Calls Sonnet model via `IAssistantClient` to synthesize logs into domain articles", + "Creates/updates concept articles in `domains/{domain}/{concept}.md`", + "Updates domain `_index.md` files with new/modified article entries", + "Updates top-level `index.md` with domain summaries", + "Updates `meta/last-flush.json` with new timestamp and git SHA", + "Can create new domains beyond the starting set (organic domain creation)", + "Articles use standard markdown with [[wikilink]] backlinks", + "Logs flush events: `knowledge.flush_started`, `knowledge.flush_completed`, `knowledge.flush_failed`", + "Type-check passes", + "Tests pass (mock AI client)" + ], + "technicalNotes": "New file: packages/core/src/services/knowledge-flush.ts. Follow cleanup-service.ts pattern. Use IAssistantClient factory. Write to temp files first, then atomic rename (flush atomicity). Prompt Sonnet with existing articles + new logs for merge/update decisions.", + "dependsOn": ["US-005"], + "priority": 6, + "passes": true, + "notes": "Implemented in iteration 9. Files: packages/core/src/services/knowledge-flush.ts, packages/core/src/services/knowledge-flush.test.ts, packages/core/package.json." + }, + { + "id": "US-010", + "title": "Implement flush locking and atomicity", + "description": "As a system, I want only one concurrent flush per project so that writes don't conflict", + "acceptanceCriteria": [ + "Implement file-based lock at `knowledge/meta/flush.lock` with PID", + "Flush acquires lock before writing, releases on completion or error", + "If lock held by another process, flush skips with a warning log", + "If lock held by dead process (stale PID), lock is reclaimed", + "All writes go to temp files first, then atomic rename into final paths", + "If flush crashes mid-write, next flush re-runs from scratch (idempotent)", + "Type-check passes", + "Tests pass" + ], + "technicalNotes": "Add to knowledge-flush.ts from US-009. Use fs.writeFile for lock with PID, check process.kill(pid, 0) for stale lock detection. Temp dir pattern: write to knowledge/.tmp/, rename to final paths.", + "dependsOn": ["US-009"], + "priority": 7, + "passes": true, + "notes": "Implemented in iteration 10. Files: packages/core/src/services/knowledge-flush.ts, packages/core/src/services/knowledge-flush.test.ts." + }, + { + "id": "US-011", + "title": "Implement staleness validation in flush", + "description": "As a system, I want articles cross-referenced against git history so that stale knowledge is flagged", + "acceptanceCriteria": [ + "During flush, run Haiku model to compare existing articles against `git diff` since last-flush SHA", + "Flag articles that reference files/functions/patterns that have changed significantly", + "Add staleness markers to flagged articles (e.g., `> [!WARNING] This article may be stale`)", + "Check for broken [[wikilink]] cross-references between articles", + "Log validation results: articles checked, flagged stale, links broken", + "Type-check passes", + "Tests pass" + ], + "technicalNotes": "Extend knowledge-flush.ts. Use @archon/git for git diff operations. Haiku performs mechanical comparison (article content vs changed files). Staleness markers are standard markdown admonitions.", + "dependsOn": ["US-009"], + "priority": 7, + "passes": true, + "notes": "Implemented in iteration 11. Files: packages/core/src/services/knowledge-flush.ts, packages/core/src/services/knowledge-flush.test.ts." + }, + { + "id": "US-012", + "title": "Implement debounced flush trigger", + "description": "As a system, I want flush to trigger automatically after session end with a debounce so that the KB stays fresh without redundant flushes", + "acceptanceCriteria": [ + "After capture completes, schedule a flush with configurable debounce (default ~10 minutes)", + "If another capture fires within debounce window, reset the timer", + "Debounce is per-project (different projects flush independently)", + "Use `knowledge.flushDebounceMinutes` from config", + "Debounce timer survives within server process lifetime (not persisted across restarts)", + "Type-check passes", + "Tests pass" + ], + "technicalNotes": "Add to knowledge-flush.ts or create knowledge-scheduler.ts. Use setTimeout with Map for per-project debounce. Follow cleanup-service.ts scheduler pattern at lines 555-595.", + "dependsOn": ["US-010"], + "priority": 8, + "passes": true, + "notes": "Implemented in iteration 12. Files: packages/core/src/services/knowledge-scheduler.ts, packages/core/src/services/knowledge-scheduler.test.ts, packages/core/src/services/knowledge-capture.ts, packages/core/package.json." + }, + { + "id": "US-013", + "title": "Implement global KB tier with precedence", + "description": "As a developer, I want a global knowledge base that applies across all projects so that cross-project lessons are retained", + "acceptanceCriteria": [ + "Global KB lives at `~/.archon/knowledge/` (uses `getGlobalKnowledgePath()`)", + "Global KB has same directory structure as project KB (index.md, meta/, logs/, domains/)", + "Flush operates independently per tier (project flush doesn't touch global)", + "At query time, load global index.md first, then project index.md (project wins on conflicts)", + "Global KB initialization follows same pattern as project KB", + "Type-check passes", + "Tests pass" + ], + "technicalNotes": "Extend capture and flush services to support global tier. Extend prompt builder to load both tiers. Precedence matching: same as config.yaml (packages/core/src/config/config-types.ts MergedConfig pattern).", + "dependsOn": ["US-009"], + "priority": 8, + "passes": true, + "notes": "Implemented in iteration 13. Files: packages/core/src/services/knowledge-flush.ts, packages/core/src/services/knowledge-flush.test.ts, packages/core/src/services/knowledge-scheduler.ts, packages/core/src/services/knowledge-scheduler.test.ts." + }, + { + "id": "US-014", + "title": "Add `knowledge flush` CLI command", + "description": "As a developer, I want to manually trigger a knowledge flush so that I can compile the KB on demand", + "acceptanceCriteria": [ + "Add `knowledge flush` subcommand to CLI", + "Accepts optional `--project owner/repo` flag (defaults to current git repo)", + "Calls `flushKnowledge()` and displays results (articles created/updated, domains, staleness)", + "Shows progress output during flush", + "Respects `--quiet` and `--verbose` CLI flags", + "Type-check passes", + "Tests pass" + ], + "technicalNotes": "New file: packages/cli/src/commands/knowledge.ts. Follow workflow.ts pattern at lines 4-46 (imports, lazy logger) and 79-112 (event rendering). Register in packages/cli/src/cli.ts alongside existing commands.", + "dependsOn": ["US-009"], + "priority": 9, + "passes": true, + "notes": "Implemented in iteration 14. Files: packages/cli/src/commands/knowledge.ts, packages/cli/src/commands/knowledge.test.ts, packages/cli/src/cli.ts, packages/cli/package.json." + }, + { + "id": "US-015", + "title": "Add `knowledge status` CLI command", + "description": "As a developer, I want to see KB stats so that I know what the system has learned about my project", + "acceptanceCriteria": [ + "Add `knowledge status` subcommand to CLI", + "Displays: total articles, articles per domain, last flush timestamp, unprocessed log count, staleness stats", + "Accepts optional `--project owner/repo` flag", + "Supports `--json` flag for machine-readable output", + "Shows global KB stats alongside project KB stats", + "Type-check passes", + "Tests pass" + ], + "technicalNotes": "Add to packages/cli/src/commands/knowledge.ts from US-014. Reads knowledge/ directory tree, meta/last-flush.json, counts files. No AI calls needed — pure filesystem inspection.", + "dependsOn": ["US-003"], + "priority": 9, + "passes": true, + "notes": "Implemented in iteration 15. Files: packages/cli/src/commands/knowledge.ts, packages/cli/src/commands/knowledge-status.test.ts, packages/cli/src/cli.ts, packages/cli/package.json." + }, + { + "id": "US-016", + "title": "Add `knowledge lint` CLI command", + "description": "As a developer, I want to validate KB integrity so that I can identify stale or broken articles", + "acceptanceCriteria": [ + "Add `knowledge lint` subcommand to CLI", + "Validates all articles against current git state (runs staleness check)", + "Checks for broken [[wikilink]] cross-references", + "Checks for orphaned articles (not referenced in any index)", + "Displays results with actionable output (which articles are stale, which links are broken)", + "Supports `--json` flag for machine-readable output", + "Type-check passes", + "Tests pass" + ], + "technicalNotes": "Add to packages/cli/src/commands/knowledge.ts. Reuses validation logic from US-011 (staleness check). Additional checks: parse [[wikilinks]] from all articles, verify targets exist; scan _index.md files for completeness.", + "dependsOn": ["US-011"], + "priority": 9, + "passes": true, + "notes": "Implemented in iteration 16. Files: packages/cli/src/commands/knowledge.ts, packages/cli/src/commands/knowledge-lint.test.ts, packages/cli/src/cli.ts, packages/cli/package.json, packages/core/src/services/knowledge-flush.ts." + }, + { + "id": "US-017", + "title": "Add engine-level post-workflow capture", + "description": "As a system, I want knowledge captured automatically after workflow completion so that workflow-generated knowledge persists", + "acceptanceCriteria": [ + "Subscribe to `workflow_completed` events via `getWorkflowEventEmitter()`", + "Trigger `captureKnowledge()` for the conversation associated with the completed workflow", + "Also read JSONL workflow logs (from packages/workflows/src/logger.ts) as additional capture source", + "Capture is fire-and-forget (does not block workflow completion reporting)", + "Errors logged but never surface to user", + "Type-check passes", + "Tests pass" + ], + "technicalNotes": "Add to packages/workflows/src/executor.ts at line 644 (after completion check). Use emitter.subscribe() pattern from packages/workflows/src/event-emitter.ts:170-254. Read JSONL logs via packages/workflows/src/logger.ts log path patterns.", + "dependsOn": ["US-005"], + "priority": 10, + "passes": true, + "notes": "Implemented in iteration 17. Files: packages/core/src/services/knowledge-workflow-capture.ts, packages/core/src/services/knowledge-workflow-capture.test.ts, packages/core/src/services/knowledge-capture.ts, packages/server/src/index.ts, packages/core/src/index.ts, packages/core/package.json." + }, + { + "id": "US-018", + "title": "Update default workflows with KB awareness", + "description": "As a developer, I want all default workflows to leverage the knowledge base so that workflows benefit from accumulated project knowledge", + "acceptanceCriteria": [ + "All default workflows in .archon/workflows/defaults/ reference knowledge base for context where relevant", + "Workflows that perform analysis or planning include KB-aware prompts ('check the knowledge base for prior decisions on this topic')", + "Workflows that produce decisions or lessons contribute to KB via capture", + "No workflow is broken if KB is empty (graceful degradation)", + "Type-check passes", + "Tests pass" + ], + "technicalNotes": "Modify YAML files in packages/workflows/src/defaults/ (or .archon/workflows/defaults/). Add KB context references to prompt sections. Knowledge is available via prompt builder injection (US-007), so workflows just need awareness prompts.", + "dependsOn": ["US-007", "US-017"], + "priority": 11, + "passes": true, + "notes": "Implemented in iteration 18. Files: .archon/workflows/defaults/archon-interactive-prd.yaml, archon-piv-loop.yaml, archon-adversarial-dev.yaml, archon-architect.yaml, archon-refactor-safely.yaml, archon-create-issue.yaml." + }, + { + "id": "US-019", + "title": "Update workflow builder for KB awareness", + "description": "As a developer, I want the workflow builder to include KB awareness in new workflows by default so that custom workflows benefit from knowledge", + "acceptanceCriteria": [ + "archon-workflow-builder workflow includes KB awareness instructions when generating new workflows", + "Generated workflows reference knowledge base for context in relevant nodes", + "Generated workflows include guidance for KB-aware prompts", + "Type-check passes", + "Tests pass" + ], + "technicalNotes": "Modify the archon-workflow-builder workflow YAML in defaults. Add instructions about KB integration patterns for the builder to follow when generating workflows.", + "dependsOn": ["US-018"], + "priority": 12, + "passes": true, + "notes": "Implemented in iteration 19. Files: .archon/workflows/defaults/archon-workflow-builder.yaml." + }, + { + "id": "US-020", + "title": "Add explicit knowledge-extract workflow node type", + "description": "As a workflow author, I want to add dedicated knowledge extraction nodes so that workflows can produce richer, targeted knowledge artifacts", + "acceptanceCriteria": [ + "Workflows can include `knowledge-extract` nodes that run targeted knowledge extraction", + "Node accepts a prompt describing what knowledge to extract from the workflow's output", + "Node uses capture service infrastructure (Haiku model, daily log format)", + "Node output is appended to daily log with workflow context metadata", + "Existing workflows without knowledge-extract nodes continue to work unchanged", + "Type-check passes", + "Tests pass" + ], + "technicalNotes": "Extend DAG executor in packages/workflows/src/dag-executor.ts. Add 'knowledge-extract' as a recognized node type alongside 'command', 'prompt', 'bash', 'loop'. Node calls captureKnowledge() with custom extraction prompt.", + "dependsOn": ["US-017"], + "priority": 12, + "passes": true, + "notes": "Implemented in iteration 20. Files: packages/workflows/src/schemas/dag-node.ts, packages/workflows/src/schemas/index.ts, packages/workflows/src/deps.ts, packages/workflows/src/dag-executor.ts, packages/workflows/src/loader.ts, packages/core/src/services/knowledge-capture.ts, packages/core/src/workflows/store-adapter.ts, packages/workflows/src/knowledge-extract-node.test.ts, packages/core/src/services/knowledge-extract.test.ts, packages/workflows/src/schemas.test.ts." + } + ] +} diff --git a/.archon/ralph/llm-knowledge-base-system/prd.md b/.archon/ralph/llm-knowledge-base-system/prd.md new file mode 100644 index 0000000000..4dac4f66ed --- /dev/null +++ b/.archon/ralph/llm-knowledge-base-system/prd.md @@ -0,0 +1,256 @@ +# LLM Knowledge Base System — Product Requirements + +## Overview + +**Problem**: Every new Archon session starts from zero context. The AI agent must re-discover project architecture, past decisions, known patterns, and lessons learned — typically by spawning sub-agents, grepping the codebase, reading git logs, and scanning dozens of files. This reconstructs ~20,000+ tokens of context that was already known in prior sessions, wasting tokens, adding latency, and losing cross-session knowledge. + +**Solution**: A persistent, auto-maintained knowledge base that gives Archon cross-conversation memory using a 4-stage pipeline: CAPTURE (extract decisions/lessons after session end), COMPILE (synthesize into structured concept articles), VALIDATE (cross-reference against git history for staleness), QUERY (load index files into agent context at session start). No vector database, no embeddings, no RAG — the agent navigates markdown via hierarchical indexes, like a wiki. + +**Branch**: `ralph/llm-knowledge-base-system` + +--- + +## Goals & Success + +### Primary Goal +Reduce context reconstruction cost by 10x while improving accuracy of project-specific decisions through persistent cross-session knowledge. + +### Success Metrics +| Metric | Target | How Measured | +|--------|--------|--------------| +| Context reconstruction tokens | ~2,000 per session (down from ~20,000+) | Compare token counts for initial context loading with/without KB | +| Session ramp-up time | Agent productive from first message | Qualitative assessment of session transcripts | +| Repeated mistake rate | Constraints captured once, applied consistently | Review KB articles vs. session decisions | +| Knowledge coverage | Growing corpus of decisions, patterns, lessons | `knowledge status` CLI command | +| Staleness rate | <10% of articles flagged stale | Validate step cross-references git changes | + +### Non-Goals (Out of Scope) +- **Vector database / embeddings / RAG** — Agent navigates markdown indexes directly (Karpathy insight) +- **External knowledge ingestion** — Internal project knowledge only (decisions, patterns, lessons from Archon sessions) +- **Multi-user knowledge sharing** — Single-developer tool; no access control or collaborative editing +- **Real-time knowledge updates during sessions** — Capture happens after session end, not mid-conversation +- **Automatic code generation from knowledge** — KB informs the agent; doesn't generate code directly + +--- + +## User & Context + +### Target User +- **Who**: Single developer using Archon for AI-assisted coding across multiple projects and sessions +- **Role**: AI-assisted development practitioner managing multiple repos +- **Current Pain**: Every session starts cold — agent rediscovers architecture, past decisions, and lessons by spawning sub-agents and reading dozens of files + +### User Journey +1. **Trigger**: Developer starts a new Archon session on a project they've worked on before +2. **Action**: System auto-loads knowledge index (~500 tokens) + relevant articles (~1,500 tokens) into agent context +3. **Outcome**: Agent is immediately productive — knows project architecture, past decisions, and learned constraints without rediscovery + +### Jobs to Be Done +- "When I start a new session, the agent should already know what we decided last time" +- "When the agent encounters a constraint we've hit before, it should remember the lesson" +- "I want to see what the system has learned about my project over time" +- "I shouldn't have to repeat architectural decisions across sessions" + +--- + +## UX Requirements + +### Interaction Model + +**Automatic (invisible to user):** +- Post-session capture: Haiku extracts decisions/lessons from conversation transcript +- Debounced compile: Sonnet synthesizes daily logs into structured articles (~10min after session end) +- Session start injection: index.md loaded into agent context automatically + +**CLI commands (explicit management):** +- `knowledge flush [--project owner/repo]` — Manual compile trigger +- `knowledge status [--project owner/repo]` — Show KB stats (articles, last flush, staleness) +- `knowledge lint [--project owner/repo]` — Validate KB against git history + +**Workflow integration:** +- Engine-level post-workflow capture (automatic) +- Optional explicit `knowledge-extract` nodes in workflows for richer extraction + +### States to Handle +| State | Description | Behavior | +|-------|-------------|----------| +| Empty | No knowledge captured yet (first session) | Skip KB injection; agent works normally without it | +| Fresh logs only | Capture ran but no compile yet | Include unprocessed daily logs as supplementary raw context | +| Compiled | Articles exist, index up to date | Load index.md + navigate to relevant articles on demand | +| Stale | Articles flagged by validation | Include staleness warnings; agent treats flagged articles with lower confidence | +| Flushing | Compile in progress | Serve existing compiled state; new flush waits (file lock) | +| Error | Capture or compile failed | Log error; agent works without KB (fail-open, never block sessions) | + +--- + +## Technical Context + +### Patterns to Follow +- **Path resolution**: `packages/paths/src/archon-paths.ts:235-277` — Follow `getProjectSourcePath(owner, repo)` pattern for new `getProjectKnowledgePath`, `getGlobalKnowledgePath`, etc. +- **Config types**: `packages/core/src/config/config-types.ts:93-187` — Add `knowledge` section to `RepoConfig` following existing `docs`, `env` patterns +- **Service pattern**: `packages/core/src/services/cleanup-service.ts:27-40,555-595` — Lazy logger, config constants, interval scheduler, result interfaces +- **CLI commands**: `packages/cli/src/commands/workflow.ts:4-46,79-112` — Commander.js subcommands, lazy logger, event rendering +- **Prompt building**: `packages/core/src/orchestrator/prompt-builder.ts:12-37,114-185` — Context section formatting, `buildOrchestratorPrompt()` and `buildProjectScopedPrompt()` +- **Post-workflow hooks**: `packages/workflows/src/executor.ts:641-653` — Insert after completion check, fire-and-forget pattern +- **Event subscription**: `packages/workflows/src/event-emitter.ts:170-254` — Singleton emitter, `subscribe()` / `subscribeForConversation()`, `WorkflowEmitterEvent` union type + +### Types & Interfaces +```typescript +// Key types to use or extend (from codebase exploration) + +// packages/paths/src/archon-paths.ts — new path functions follow this pattern: +function getProjectKnowledgePath(owner: string, repo: string): string; +function getGlobalKnowledgePath(): string; +function getKnowledgeLogsPath(owner: string, repo: string): string; +function getKnowledgeDomainsPath(owner: string, repo: string): string; + +// packages/core/src/config/config-types.ts — new config section: +interface KnowledgeConfig { + enabled?: boolean; // default: true + captureModel?: string; // default: 'haiku' + compileModel?: string; // default: 'sonnet' + flushDebounceMinutes?: number; // default: 10 + domains?: string[]; // default: ['architecture', 'decisions', 'patterns', 'lessons', 'connections'] +} + +// packages/core/src/services/ — new service interfaces: +interface KnowledgeCaptureReport { + logsCreated: string[]; + errors: { conversationId: string; error: string }[]; +} + +interface KnowledgeFlushReport { + articlesCreated: number; + articlesUpdated: number; + articlesStale: number; + domainsCreated: string[]; +} + +// packages/core/src/db/messages.ts — existing, used for capture source: +// listMessages(conversationId, limit=200): Promise + +// packages/workflows/src/event-emitter.ts — existing event types to extend +// WorkflowEmitterEvent union type (lines 27-160) + +// packages/core/src/state/session-transitions.ts — existing triggers: +// TransitionTrigger = 'first-message' | 'plan-to-execute' | 'isolation-changed' | 'reset-requested' | 'worktree-removed' | 'conversation-closed' +``` + +### Architecture Notes +- **Two-tier KB**: Per-project (`~/.archon/workspaces/owner/repo/knowledge/`) and global (`~/.archon/knowledge/`). Project overrides global (matches config.yaml precedence). +- **Hierarchical indexes**: `index.md` → domain `_index.md` → concept articles. Agent navigates like a wiki. +- **Model usage**: Haiku for capture (fast, cheap, mechanical), Sonnet for compile (quality synthesis), conversation model for query (no extra LLM call). +- **Flush atomicity**: Write to temp files, atomic rename. Crash-safe — next flush re-runs from scratch (idempotent). +- **Flush locking**: File lock at `knowledge/meta/flush.lock` with PID. One concurrent flush per project. +- **Model infrastructure**: Uses existing `IAssistantClient` factory — no direct API calls. Falls back to available model if preferred unavailable. +- **Obsidian compatibility**: Standard markdown with `[[wikilink]]` backlinks between articles. +- **Capture triggers**: `conversation-closed` and `reset-requested` session transitions (NOT `isolation-changed`). +- **KB not in git**: Lives in `~/.archon/` directory (user-specific, not repo-specific). + +### Knowledge Base Directory Structure +``` +knowledge/ +├── index.md # Top-level index (loaded at session start) +├── meta/ +│ ├── last-flush.json # { timestamp, gitSha, logsCaptured } +│ └── schema.md # KB structure description for the agent +├── logs/ +│ └── YYYY-MM-DD.md # Daily capture logs (raw extraction) +└── domains/ + ├── architecture/ + │ ├── _index.md # Domain index + │ └── {concept}.md # Concept articles + ├── decisions/ + │ ├── _index.md + │ └── {concept}.md + ├── patterns/ + │ ├── _index.md + │ └── {concept}.md + ├── lessons/ + │ ├── _index.md + │ └── {concept}.md + └── connections/ + ├── _index.md + └── {concept}.md +``` + +--- + +## Implementation Summary + +### Story Overview +| ID | Title | Priority | Dependencies | +|----|-------|----------|--------------| +| US-001 | Add knowledge path resolution functions | 1 | — | +| US-002 | Add knowledge config types | 1 | — | +| US-003 | Create knowledge directory initialization | 2 | US-001 | +| US-004 | Create KB meta templates (schema.md, index structure) | 3 | US-003 | +| US-005 | Implement capture service (transcript → daily log) | 4 | US-001, US-002 | +| US-006 | Wire capture triggers to session transitions | 5 | US-005 | +| US-007 | Inject knowledge index into prompt builder | 4 | US-001 | +| US-008 | Implement fresh log fallback for unprocessed logs | 5 | US-007 | +| US-009 | Implement compile/flush service (daily logs → articles) | 6 | US-005 | +| US-010 | Implement flush locking and atomicity | 7 | US-009 | +| US-011 | Implement staleness validation in flush | 7 | US-009 | +| US-012 | Implement debounced flush trigger | 8 | US-010 | +| US-013 | Implement global KB tier with precedence | 8 | US-009 | +| US-014 | Add `knowledge flush` CLI command | 9 | US-009 | +| US-015 | Add `knowledge status` CLI command | 9 | US-003 | +| US-016 | Add `knowledge lint` CLI command | 9 | US-011 | +| US-017 | Add engine-level post-workflow capture | 10 | US-005 | +| US-018 | Update default workflows with KB awareness | 11 | US-007, US-017 | +| US-019 | Update workflow builder for KB awareness | 12 | US-018 | +| US-020 | Add explicit knowledge-extract node type | 12 | US-017 | + +### Dependency Graph +``` +US-001 (paths) ─┬─ US-003 (dir init) ── US-004 (templates) + │ │ +US-002 (config) ─┤ │ + │ ├── US-015 (CLI status) + │ │ + ├── US-005 (capture service) ── US-006 (trigger wiring) + │ │ │ + │ ├── US-009 (compile) ─┬── US-010 (locking) ── US-012 (debounce) + │ │ ├── US-011 (staleness) ── US-016 (CLI lint) + │ │ ├── US-013 (global tier) + │ │ └── US-014 (CLI flush) + │ │ + │ └── US-017 (engine capture) ── US-020 (extract nodes) + │ │ + └── US-007 (prompt inject) ── US-008 (log fallback) + │ + └── US-018 (default workflows) ── US-019 (workflow builder) +``` + +--- + +## Validation Requirements + +Every story must pass: +- [ ] Type-check: `bun run type-check` +- [ ] Lint: `bun run lint` +- [ ] Tests: `bun run test` +- [ ] Format: `bun run format:check` + +--- + +## Decisions Log + +| Decision | Choice | Rationale | +|----------|--------|-----------| +| No vector DB / embeddings / RAG | Markdown + hierarchical indexes | Karpathy's insight: LLMs navigate markdown indexes effectively. Simpler, auditable, git-friendly. | +| Two-tier KB (project + global) | Per-project overrides global | Matches existing config.yaml precedence pattern. | +| Capture model: Haiku | Fast, cheap for structured summarization | High-frequency, mechanical extraction from transcripts. | +| Compile model: Sonnet | Quality-critical synthesis | Lower-frequency, requires judgment for merging concepts. | +| Engine-level implicit capture | Knowledge extraction as engine behavior | Simpler than per-workflow config. Explicit nodes available for richer extraction. | +| Flush trigger: event-based with debounce | ~10min debounce per project | Avoids redundant flushes while keeping KB fresh. | +| File-based flush lock | `knowledge/meta/flush.lock` | Simplest option for single-developer, single-machine use case. | +| Capture triggers | `conversation-closed` + `reset-requested` | NOT `isolation-changed`. Reset is ending a line of work. | +| KB not in git | `~/.archon/` directory | User-specific, not project-specific. Avoids polluting repo. | +| Flush atomicity | Write to temp files, atomic rename | Crash-safe, idempotent — next flush re-runs from scratch. | +| Obsidian compatibility | Standard markdown with `[[wikilinks]]` | KB browsable in Obsidian with graph view. No special tooling needed. | + +--- + +*Generated: 2026-04-11T00:00:00Z* diff --git a/.archon/ralph/llm-knowledge-base-system/progress.txt b/.archon/ralph/llm-knowledge-base-system/progress.txt new file mode 100644 index 0000000000..fffbbdcf1c --- /dev/null +++ b/.archon/ralph/llm-knowledge-base-system/progress.txt @@ -0,0 +1,584 @@ +## Codebase Patterns + +### Path Function Pattern +- **Where**: `packages/paths/src/archon-paths.ts:235-277` +- **Pattern**: Pure path construction functions take (owner, repo), return `join(getProjectRoot(owner, repo), segment)`. Global functions use `join(getArchonHome(), segment)`. +- **Example**: `export function getProjectKnowledgePath(owner: string, repo: string): string { return join(getProjectRoot(owner, repo), 'knowledge'); }` + +### Config Merge Pattern +- **Where**: `packages/core/src/config/config-loader.ts:255-320` +- **Pattern**: mergeGlobalConfig and mergeRepoConfig spread existing result, then conditionally override with non-undefined values. New config sections need: 1) defaults in getDefaults(), 2) merge in mergeGlobalConfig(), 3) merge in mergeRepoConfig(). +- **Example**: `if (global.knowledge) { result.knowledge = { ...result.knowledge, ...global.knowledge }; }` + +### Write-If-Not-Exists Pattern +- **Where**: `packages/core/src/services/knowledge-init.ts:136-145` +- **Pattern**: Use `writeFile(path, content, { flag: 'wx' })` for exclusive create; catch EEXIST to silently skip existing files. +- **Example**: `try { await writeFile(path, content, { flag: 'wx' }); } catch (err) { if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err; }` + +### Mock Reset Pattern +- **Where**: `packages/core/src/services/knowledge-init.test.ts:39-49` +- **Pattern**: When a test uses `mockRejectedValue`, subsequent tests fail because mock state persists. Use `mockReset()` + `mockImplementation()` in beforeEach to restore default behavior. +- **Example**: `mockWriteFile.mockReset(); mockWriteFile.mockImplementation(async (...) => { ... });` + +### FS Mock State Pattern +- **Where**: `packages/core/src/services/knowledge-flush.test.ts:16-40` +- **Pattern**: Use `Record` for fileSystem and `Record` for directories as mock state. Reset both in beforeEach. Mock readFile/readdir to check state maps and throw ENOENT for missing keys. +- **Example**: `fileSystem[\`\${KB_PATH}/logs/2026-04-11.md\`] = '## Content\n'; directories[\`\${KB_PATH}/logs\`] = ['2026-04-11.md'];` + +### Path Test Pattern +- **Where**: `packages/paths/src/archon-paths.test.ts` +- **Pattern**: Tests use `useEnvSnapshot()` for env var isolation, clear env vars before each test, test with default, ARCHON_HOME override, and Docker mode. +- **Example**: `delete process.env.WORKSPACE_PATH; delete process.env.ARCHON_HOME; delete process.env.ARCHON_DOCKER; expect(fn('acme', 'widget')).toBe(join(homedir(), '.archon', 'workspaces', 'acme', 'widget', 'knowledge'));` + +--- + +## 2026-04-11 — US-001: Add knowledge path resolution functions + +**Status**: PASSED +**Files changed**: +- packages/paths/src/archon-paths.ts — Added 4 knowledge path functions (getProjectKnowledgePath, getGlobalKnowledgePath, getKnowledgeLogsPath, getKnowledgeDomainsPath) +- packages/paths/src/index.ts — Exported the 4 new functions +- packages/paths/src/archon-paths.test.ts — Added tests for all 4 functions + +**Acceptance criteria verified**: +- [x] getProjectKnowledgePath(owner, repo) returns ~/.archon/workspaces/{owner}/{repo}/knowledge/ +- [x] getGlobalKnowledgePath() returns ~/.archon/knowledge/ +- [x] getKnowledgeLogsPath(owner, repo) returns .../knowledge/logs/ +- [x] getKnowledgeDomainsPath(owner, repo) returns .../knowledge/domains/ +- [x] Functions follow existing pattern: pure path construction, no I/O, use getProjectRoot() as base +- [x] Functions exported from packages/paths/src/index.ts +- [x] Type-check passes +- [x] Tests pass + +**Learnings**: +- Pattern is straightforward: all project path functions compose on getProjectRoot(), global on getArchonHome() +- No surprises in the codebase — well-structured and consistent + +--- + +### Service File Pattern +- **Where**: `packages/core/src/services/knowledge-init.ts` +- **Pattern**: New service files in core/services need a separate test batch in packages/core/package.json test script to avoid mock.module() pollution +- **Example**: `&& bun test src/services/knowledge-init.test.ts` + +## 2026-04-11 — US-002: Add knowledge config types + +**Status**: PASSED +**Files changed**: +- packages/core/src/config/config-types.ts — Added KnowledgeConfig interface, added knowledge field to GlobalConfig, RepoConfig, and MergedConfig +- packages/core/src/config/config-loader.ts — Added knowledge defaults in getDefaults(), merge logic in mergeGlobalConfig() and mergeRepoConfig() + +**Acceptance criteria verified**: +- [x] KnowledgeConfig interface with enabled, captureModel, compileModel, flushDebounceMinutes, domains fields (all optional with defaults) +- [x] knowledge?: KnowledgeConfig added to RepoConfig +- [x] knowledge?: KnowledgeConfig added to GlobalConfig +- [x] knowledge field added to MergedConfig with Required and merge logic (repo overrides global via spread) +- [x] JSDoc with @default annotations for each field +- [x] Type-check passes +- [x] Tests pass + +**Learnings**: +- MergedConfig uses Required so all fields are guaranteed present after merge +- Merge pattern: spread defaults, then global overrides, then repo overrides — same as other config sections +- Config types file has no tests of its own; merge logic is tested in config-loader.test.ts + +--- + +## 2026-04-11 — US-003: Create knowledge directory initialization + +**Status**: PASSED +**Files changed**: +- packages/core/src/services/knowledge-init.ts — New file: initKnowledgeDir(), initGlobalKnowledgeDir(), DEFAULT_DOMAINS +- packages/core/src/services/knowledge-init.test.ts — New file: 6 tests covering both functions and DEFAULT_DOMAINS +- packages/core/package.json — Added knowledge-init.test.ts as separate test batch + +**Acceptance criteria verified**: +- [x] initKnowledgeDir(owner, repo) creates knowledge/, meta/, logs/, domains/, and 5 domain subdirs +- [x] initGlobalKnowledgeDir() creates same structure at global path +- [x] Both functions are idempotent (mkdir recursive: true) +- [x] Directories created with standard permissions (default) +- [x] Type-check passes +- [x] Tests pass (6 tests, 43 expect calls) + +**Learnings**: +- Service tests need mock.module for node:fs/promises and @archon/paths — must be in separate batch +- Kept implementation simple: shared createDirectoryStructure() for both project and global variants +- No need for getKnowledgeLogsPath/getKnowledgeDomainsPath in init — join() with base path is cleaner + +--- + +## 2026-04-11 — US-004: Create KB meta templates and initial index structure + +**Status**: PASSED +**Files changed**: +- packages/core/src/services/knowledge-init.ts — Added SCHEMA_TEMPLATE, INDEX_TEMPLATE, DOMAIN_INDEX_TEMPLATES constants; added writeIfNotExists() helper; extended createDirectoryStructure() to write templates +- packages/core/src/services/knowledge-init.test.ts — Added 6 new tests for template writing, content verification, EEXIST handling, error propagation, and template content validation + +**Acceptance criteria verified**: +- [x] schema.md template describes KB structure, domain purposes, and navigation instructions +- [x] index.md template with sections for each starting domain and placeholder content +- [x] Domain _index.md templates for architecture, decisions, patterns, lessons, connections +- [x] Templates written during initKnowledgeDir() only if files don't already exist (wx flag + EEXIST catch) +- [x] Templates use standard markdown with [[wikilink]] backlink syntax for Obsidian compatibility +- [x] Type-check passes +- [x] Tests pass (14 tests, 97 expect calls) + +**Learnings**: +- Used writeFile with flag 'wx' (exclusive create) — fails with EEXIST if file exists, which we catch and silently skip +- mockRejectedValue persists across tests — need mockReset() + mockImplementation() in beforeEach to restore default behavior +- Templates exported as named constants for use in tests and future consumers + +--- + +### Async Prompt Builder Pattern +- **Where**: `packages/core/src/orchestrator/prompt-builder.ts:118-135` +- **Pattern**: `buildOrchestratorPrompt` and `buildProjectScopedPrompt` are async (return `Promise`) to support file I/O for knowledge loading. Callers must `await`. Test mocks returning plain strings still work because `await 'string'` === `'string'`. +- **Example**: `const systemPrompt = await buildProjectScopedPrompt(scopedCodebase, codebases, workflows);` + +### Capture Service Pattern +- **Where**: `packages/core/src/services/knowledge-capture.ts` +- **Pattern**: IAssistantClient.sendQuery() returns AsyncGenerator. Collect `assistant` type chunks into string. Use `tools: []` to disable tools for extraction-only calls. Model name passed via `options.model`. +- **Example**: `const generator = client.sendQuery(prompt, cwd, undefined, { model: 'haiku', tools: [] }); for await (const chunk of generator) { if (chunk.type === 'assistant') chunks.push(chunk.content); }` + +## 2026-04-11 — US-005: Implement capture service (transcript to daily log) + +**Status**: PASSED +**Files changed**: +- packages/core/src/services/knowledge-capture.ts — New file: captureKnowledge(), formatTranscript(), extractKnowledge(), appendToDailyLog() +- packages/core/src/services/knowledge-capture.test.ts — New file: 12 tests covering all acceptance criteria +- packages/core/package.json — Added knowledge-capture.test.ts as separate test batch + +**Acceptance criteria verified**: +- [x] Create captureKnowledge(conversationId, owner, repo) function +- [x] Reads conversation transcript via listMessages() from packages/core/src/db/messages.ts +- [x] Calls Haiku model via IAssistantClient factory to extract structured knowledge +- [x] Appends extracted knowledge to knowledge/logs/YYYY-MM-DD.md (daily log format) +- [x] Falls back to available model if Haiku unavailable (uses configurable captureModel) +- [x] Respects knowledge.enabled config (skip if false) +- [x] Logs capture events: knowledge.capture_started, knowledge.capture_completed, knowledge.capture_failed +- [x] Type-check passes +- [x] Tests pass (mock AI client and DB) — 12 tests, 31 expect calls + +**Learnings**: +- IAssistantClient only has streaming sendQuery(), no simple completion — must collect chunks +- Used `tools: []` to disable tools for extraction (different semantics from `undefined`) +- Mock generator functions need `function*` syntax, not arrow functions +- Config provides Required at merged level, so all fields guaranteed present + +--- + +## 2026-04-11 — US-007: Inject knowledge index into prompt builder + +**Status**: PASSED +**Files changed**: +- packages/core/src/orchestrator/prompt-builder.ts — Made buildOrchestratorPrompt/buildProjectScopedPrompt async, added loadKnowledgeIndex(), formatKnowledgeSection() +- packages/core/src/orchestrator/orchestrator-agent.ts — Updated buildFullPrompt to async, await prompt builder calls +- packages/core/src/orchestrator/prompt-builder-knowledge.test.ts — New file: 13 tests for knowledge loading +- packages/core/package.json — Added prompt-builder test batches + +**Acceptance criteria verified**: +- [x] Extend buildProjectScopedPrompt() to load project knowledge/index.md content +- [x] Extend buildOrchestratorPrompt() to load global knowledge/index.md content +- [x] Project index overrides global when both exist (project loaded after global) +- [x] Gracefully skip if no index.md exists (empty KB state) +- [x] Added content stays within ~500 token budget for index (2000 char truncation) +- [x] Type-check passes +- [x] Tests pass (13 new tests, 33 expect calls) + +**Learnings**: +- Making sync functions async is safe when all callers are in async contexts and test mocks return plain values +- parseOwnerRepo(codebase.name) is the standard way to extract owner/repo from a Codebase +- Codebase names that don't match owner/repo format (e.g., 'local-project') gracefully skip project KB loading + +--- + +## 2026-04-11 — US-006: Wire capture triggers to session transitions + +**Status**: PASSED +**Files changed**: +- packages/core/src/services/knowledge-capture.ts — Added triggerCapture() fire-and-forget helper with codebase lookup +- packages/core/src/services/cleanup-service.ts — Imported triggerCapture, wired into onConversationClosed() +- packages/core/src/handlers/command-handler.ts — Imported triggerCapture, wired into /reset command +- packages/core/src/services/cleanup-service.test.ts — Added mock for knowledge-capture, 2 new tests +- packages/core/src/handlers/command-handler.test.ts — Added mock for knowledge-capture, 2 new tests + +**Acceptance criteria verified**: +- [x] Capture triggers on `conversation-closed` session transition +- [x] Capture triggers on `reset-requested` session transition +- [x] Capture does NOT trigger on `isolation-changed` (no wiring to isolation path) +- [x] Capture is fire-and-forget (triggerCapture returns void, uses void+catch internally) +- [x] Errors logged but never surface to user or block session flow +- [x] Type-check passes +- [x] Tests pass + +**Learnings**: +- triggerCapture() is the clean pattern: takes conversationId + codebaseId, does codebase lookup + parseOwnerRepo internally +- Both cleanup-service.test.ts and command-handler.test.ts needed mock.module for knowledge-capture added +- Technical notes suggested wiring into session-transitions.ts but that's a pure type/behavior module; actual wiring goes to the callers + +--- + +## 2026-04-11 — US-008: Implement fresh log fallback for unprocessed logs + +**Status**: PASSED +**Files changed**: +- packages/core/src/orchestrator/prompt-builder.ts — Added loadUnprocessedLogs(), KNOWLEDGE_LOGS_MAX_CHARS, extended formatKnowledgeSection() with unprocessedLogs param, wired into both prompt builders +- packages/core/src/orchestrator/prompt-builder-knowledge.test.ts — Added mockReaddir, 6 new tests for unprocessed log loading + +**Acceptance criteria verified**: +- [x] At session start, check for daily logs newer than meta/last-flush.json timestamp +- [x] If unprocessed logs exist, include them as supplementary raw context after the index +- [x] Limit raw log injection to reasonable token budget (~2,000 tokens max = 8000 chars) +- [x] If no last-flush.json exists, include all daily logs (KB is pre-first-flush) +- [x] Type-check passes +- [x] Tests pass (6 new tests, 19 total in file) + +**Learnings**: +- Daily log filenames (YYYY-MM-DD.md) can be compared lexicographically against the flush date string +- readdir mock needs to throw ENOENT by default (matching real fs behavior for missing dirs) +- Project logs take precedence; global logs used as fallback only when no project logs exist +- Logs are loaded newest-first (reversed sort) so most recent context appears first + +--- + +## 2026-04-11 — US-009: Implement compile/flush service (daily logs to articles) + +**Status**: PASSED +**Files changed**: +- packages/core/src/services/knowledge-flush.ts — New file: flushKnowledge(), readLastFlush(), findUnprocessedLogs(), readLogContents(), readExistingArticles(), synthesizeLogs(), writeFlushResults(), updateDomainIndex(), updateTopLevelIndex(), updateLastFlush() +- packages/core/src/services/knowledge-flush.test.ts — New file: 17 tests covering all acceptance criteria +- packages/core/package.json — Added knowledge-flush.test.ts as separate test batch + +**Acceptance criteria verified**: +- [x] Create flushKnowledge(owner, repo) function +- [x] Reads all daily logs since meta/last-flush.json timestamp +- [x] Calls Sonnet model via IAssistantClient to synthesize logs into domain articles +- [x] Creates/updates concept articles in domains/{domain}/{concept}.md +- [x] Updates domain _index.md files with new/modified article entries +- [x] Updates top-level index.md with domain summaries +- [x] Updates meta/last-flush.json with new timestamp and git SHA (sha deferred to US-011) +- [x] Can create new domains beyond the starting set (organic domain creation) +- [x] Articles use standard markdown with [[wikilink]] backlinks +- [x] Logs flush events: knowledge.flush_started, knowledge.flush_completed, knowledge.flush_failed +- [x] Type-check passes +- [x] Tests pass (mock AI client) — 17 tests, 45 expect calls + +**Learnings**: +- AI synthesis returns JSON; must handle markdown code fences around JSON response (strip ```json wrapper) +- readFile/readdir mock pattern: use a Record for fileSystem state, throw ENOENT for missing paths +- Flush tracks articles created vs updated by checking if file exists before writing +- Domain index updates use wikilink format [[domain/concept|Title Case]] matching init templates +- Linting enforces T[] over Array syntax + +--- + +## 2026-04-11 — US-010: Implement flush locking and atomicity + +**Status**: PASSED +**Files changed**: +- packages/core/src/services/knowledge-flush.ts — Added acquireFlushLock(), releaseFlushLock(); replaced writeFlushResults with writeFlushResultsAtomic() using .tmp/ dir + rename; updated updateLastFlush() with temp+rename; added finally block for lock release +- packages/core/src/services/knowledge-flush.test.ts — Added rename/unlink/rm mocks; updated existing index test for atomic pattern; added 8 new tests for lock and atomicity + +**Acceptance criteria verified**: +- [x] Implement file-based lock at knowledge/meta/flush.lock with PID +- [x] Flush acquires lock before writing, releases on completion or error (finally block) +- [x] If lock held by another process, flush skips with a warning log +- [x] If lock held by dead process (stale PID), lock is reclaimed (process.kill(pid, 0)) +- [x] All writes go to temp files first, then atomic rename into final paths (.tmp/ dir pattern) +- [x] If flush crashes mid-write, next flush re-runs from scratch (rm .tmp/ at start, idempotent) +- [x] Type-check passes +- [x] Tests pass (24 tests, 61 expect calls) + +**Learnings**: +- process.kill(pid, 0) throws if process is dead — use try/catch to detect stale locks +- Atomic rename pattern: mkdir .tmp, write there, rename each file individually, rm .tmp +- Lock file must be released in finally{} to handle both success and error paths +- Existing test for index.md needed updating since writes now go through .tmp/ first + +--- + +## 2026-04-11 — US-011: Implement staleness validation in flush + +**Status**: PASSED +**Files changed**: +- packages/core/src/services/knowledge-flush.ts — Added validateStaleness(), checkBrokenWikilinks(), addStalenessMarker(), identifyStaleArticles(), collectAllArticles(), getCurrentGitSha(), getGitDiffNameOnly(); updated updateLastFlush() to store git SHA +- packages/core/src/services/knowledge-flush.test.ts — Added @archon/git mock, 6 new tests for staleness validation, wikilink checking, git SHA storage, idempotent markers + +**Acceptance criteria verified**: +- [x] During flush, run Haiku model to compare existing articles against git diff since last-flush SHA +- [x] Flag articles that reference files/functions/patterns that have changed significantly +- [x] Add staleness markers to flagged articles (> [!WARNING] This article may be stale) +- [x] Check for broken [[wikilink]] cross-references between articles +- [x] Log validation results: articles checked, flagged stale, links broken +- [x] Type-check passes +- [x] Tests pass (30 tests, 74 expect calls) + +**Learnings**: +- execFileAsync from @archon/git works well for running git commands; use -C flag for specifying repo directory +- Staleness check is skipped when no lastFlushSha exists (empty string is falsy), so most existing tests don't trigger it +- Multiple sendQuery calls in one test need callCount tracking to return different responses +- Wikilink regex with global flag needs lastIndex reset for each article iteration + +--- + +## 2026-04-11 — US-012: Implement debounced flush trigger + +**Status**: PASSED +**Files changed**: +- packages/core/src/services/knowledge-scheduler.ts — New file: scheduleFlush(), cancelScheduledFlush(), isFlushScheduled(), cancelAllScheduledFlushes() with per-project Map +- packages/core/src/services/knowledge-scheduler.test.ts — New file: 7 tests covering debounce, reset, per-project isolation, config loading, cancellation +- packages/core/src/services/knowledge-capture.ts — Wired scheduleFlush into triggerCapture after successful capture +- packages/core/package.json — Added knowledge-scheduler.test.ts as separate test batch + +**Acceptance criteria verified**: +- [x] After capture completes, schedule a flush with configurable debounce (default ~10 minutes) +- [x] If another capture fires within debounce window, reset the timer +- [x] Debounce is per-project (different projects flush independently) +- [x] Use knowledge.flushDebounceMinutes from config +- [x] Debounce timer survives within server process lifetime (not persisted across restarts) +- [x] Type-check passes +- [x] Tests pass (7 tests, 22 expect calls) + +**Learnings**: +- Created separate knowledge-scheduler.ts rather than adding to knowledge-flush.ts for SRP +- Timer.unref() prevents the debounce timer from keeping the Node process alive during shutdown +- Test timing with very short debounce (0.001 min = 60ms) works reliably in Bun + +--- + +### Shared Core Pattern +- **Where**: `packages/core/src/services/knowledge-flush.ts:53-85` +- **Pattern**: Extract shared logic into a `*Core()` function with an options interface (FlushCoreOptions). Public functions (`flushKnowledge`, `flushGlobalKnowledge`) are thin wrappers that set up options. The init callback is passed as a field to allow the enabled check to short-circuit before init. +- **Example**: `flushKnowledgeCore({ label, knowledgePath, config, git, init })` + +## 2026-04-11 — US-013: Implement global KB tier with precedence + +**Status**: PASSED +**Files changed**: +- packages/core/src/services/knowledge-flush.ts — Refactored to shared flushKnowledgeCore(), added flushGlobalKnowledge(), made updateLastFlush() owner/repo optional +- packages/core/src/services/knowledge-flush.test.ts — Added 5 new tests for global flush (path, staleness skip, git SHA, independence, disabled) +- packages/core/src/services/knowledge-scheduler.ts — Added scheduleGlobalFlush() using __global__ key +- packages/core/src/services/knowledge-scheduler.test.ts — Added 3 new tests for global scheduling, added flushGlobalKnowledge mock + +**Acceptance criteria verified**: +- [x] Global KB lives at ~/.archon/knowledge/ (uses getGlobalKnowledgePath()) +- [x] Global KB has same directory structure as project KB (initGlobalKnowledgeDir from US-003) +- [x] Flush operates independently per tier (flushGlobalKnowledge uses global path, no git ops) +- [x] At query time, load global index.md first, then project index.md (prompt-builder from US-007) +- [x] Global KB initialization follows same pattern as project KB (shared createDirectoryStructure) +- [x] Type-check passes +- [x] Tests pass (5 new flush tests, 3 new scheduler tests) + +**Learnings**: +- Most global KB infrastructure was already in place from US-001 (paths), US-003 (init), US-007/US-008 (prompt builder) +- The main gap was flush — refactored to shared core with FlushCoreOptions to avoid duplication +- Global flush skips staleness validation entirely (no git repo to compare against) +- updateLastFlush uses empty string for gitSha when no owner/repo provided +- mock.module must export ALL named exports that the importing module needs, or import fails + +--- + +### Workflow Event Subscription Pattern +- **Where**: `packages/core/src/services/knowledge-workflow-capture.ts` +- **Pattern**: Subscribe to workflow events from @archon/core (NOT @archon/workflows — would create circular dep). Use emitter.subscribe() with type guard, emitter.getConversationId(runId) for conversation lookup, then DB lookups for codebase. Fire-and-forget with void+catch. +- **Example**: `const unsubscribe = emitter.subscribe((event) => { if (event.type !== 'workflow_completed') return; void handleWorkflowCompleted(event.runId, conversationId).catch(...); });` + +### CLI Knowledge Command Pattern +- **Where**: `packages/cli/src/commands/knowledge.ts` +- **Pattern**: CLI commands return exit codes (0 success, 1 failure). Use process.stderr.write for progress output (not console.log). Resolve owner/repo from --project flag or git remote URL. Respect --quiet flag. +- **Example**: `return await knowledgeFlushCommand(effectiveCwd, projectFlag, quietFlag);` + +## 2026-04-11 — US-014: Add `knowledge flush` CLI command + +**Status**: PASSED +**Files changed**: +- packages/cli/src/commands/knowledge.ts — New file: knowledgeFlushCommand(), resolveOwnerRepo(), renderFlushReport() +- packages/cli/src/commands/knowledge.test.ts — New file: 8 tests covering all acceptance criteria +- packages/cli/src/cli.ts — Registered knowledge command with flush subcommand, added --project flag +- packages/cli/package.json — Added knowledge.test.ts as separate test batch + +**Acceptance criteria verified**: +- [x] Add `knowledge flush` subcommand to CLI +- [x] Accepts optional `--project owner/repo` flag (defaults to current git repo) +- [x] Calls `flushKnowledge()` and displays results (articles created/updated, domains, staleness) +- [x] Shows progress output during flush (stderr.write for "Flushing..." and report) +- [x] Respects `--quiet` and `--verbose` CLI flags (quiet suppresses all output; verbose via setLogLevel) +- [x] Type-check passes +- [x] Tests pass (8 tests, 24 expect calls) + +**Learnings**: +- CLI commands return exit codes, registered in switch statement in cli.ts +- process.stderr.write is used for progress (not console.log) — stdout reserved for data output +- parseOwnerRepo from @archon/paths validates owner/repo format +- git.getRemoteUrl + URL parsing is the fallback for --project flag +- spyOn(process.stderr, 'write') captures Pino logger output too — filter with startsWith('{') + +--- + +## 2026-04-11 — US-015: Add `knowledge status` CLI command + +**Status**: PASSED +**Files changed**: +- packages/cli/src/commands/knowledge.ts — Added knowledgeStatusCommand(), gatherKBStats(), renderKBStats(), KBStats interface; added readFile/readdir/join/path imports +- packages/cli/src/commands/knowledge-status.test.ts — New file: 10 tests covering all acceptance criteria +- packages/cli/src/cli.ts — Registered `knowledge status` subcommand, imported knowledgeStatusCommand, updated help text +- packages/cli/package.json — Added knowledge-status.test.ts as separate test batch + +**Acceptance criteria verified**: +- [x] Add `knowledge status` subcommand to CLI +- [x] Displays: total articles, articles per domain, last flush timestamp, unprocessed log count, staleness stats +- [x] Accepts optional `--project owner/repo` flag +- [x] Supports `--json` flag for machine-readable output +- [x] Shows global KB stats alongside project KB stats +- [x] Type-check passes +- [x] Tests pass (10 tests, 34 expect calls) + +**Learnings**: +- Status is pure filesystem inspection — no AI or DB calls needed +- Separate test file needed because it mocks node:fs/promises and node:path which flush tests don't +- gatherKBStats reusable for both project and global tiers with just a path argument +- Staleness detection by scanning article content for the WARNING marker string + +--- + +## 2026-04-11 — US-016: Add `knowledge lint` CLI command + +**Status**: PASSED +**Files changed**: +- packages/core/src/services/knowledge-flush.ts — Exported 7 validation functions and 2 types (collectAllArticles, CollectedArticle, checkBrokenWikilinks, identifyStaleArticles, getGitDiffNameOnly, getCurrentGitSha, readLastFlush, LastFlushMeta) +- packages/cli/src/commands/knowledge.ts — Added knowledgeLintCommand(), lintKB(), findOrphanedArticles(), renderLintResult(), KBLintResult interface +- packages/cli/src/commands/knowledge-lint.test.ts — New file: 10 tests covering all acceptance criteria +- packages/cli/src/cli.ts — Registered `knowledge lint` subcommand, imported knowledgeLintCommand, updated help text and error message +- packages/cli/package.json — Added knowledge-lint.test.ts as separate test batch + +**Acceptance criteria verified**: +- [x] Add `knowledge lint` subcommand to CLI +- [x] Validates all articles against current git state (runs staleness check via identifyStaleArticles when git SHA exists) +- [x] Checks for broken [[wikilink]] cross-references (reuses checkBrokenWikilinks from flush) +- [x] Checks for orphaned articles (not referenced in any index) via findOrphanedArticles +- [x] Displays results with actionable output (which articles are stale, which links are broken, which are orphaned) +- [x] Supports `--json` flag for machine-readable output +- [x] Type-check passes +- [x] Tests pass (10 tests, 34 expect calls) + +**Learnings**: +- Exported private functions from knowledge-flush.ts rather than duplicating — keeps logic in one place +- Orphan detection checks if concept name appears anywhere in _index.md (not just wikilink format) for flexibility +- Lint returns exit code 1 when any issues found (stale, broken, orphaned) — useful for CI integration +- Test mocks replicate the real collectAllArticles/checkBrokenWikilinks logic using the shared fileSystem/directories state + +--- + +## 2026-04-11 — US-017: Add engine-level post-workflow capture + +**Status**: PASSED +**Files changed**: +- packages/core/src/services/knowledge-workflow-capture.ts — New file: subscribeToWorkflowCapture(), handleWorkflowCompleted(), readWorkflowLogs(), resetWorkflowCaptureSubscription() +- packages/core/src/services/knowledge-workflow-capture.test.ts — New file: 8 tests covering all acceptance criteria +- packages/core/src/services/knowledge-capture.ts — Added optional additionalTranscript parameter to captureKnowledge() +- packages/server/src/index.ts — Wired subscribeToWorkflowCapture() into server startup +- packages/core/src/index.ts — Exported subscribeToWorkflowCapture +- packages/core/package.json — Added knowledge-workflow-capture.test.ts as separate test batch + +**Acceptance criteria verified**: +- [x] Subscribe to `workflow_completed` events via `getWorkflowEventEmitter()` +- [x] Trigger `captureKnowledge()` for the conversation associated with the completed workflow +- [x] Also read JSONL workflow logs (from packages/workflows/src/logger.ts) as additional capture source +- [x] Capture is fire-and-forget (does not block workflow completion reporting) +- [x] Errors logged but never surface to user +- [x] Type-check passes +- [x] Tests pass (8 tests, 20 expect calls) + +**Learnings**: +- Cannot put capture in @archon/workflows (circular dep with @archon/core) — subscribe from @archon/core side instead +- WorkflowEmitterEvent has getConversationId(runId) for mapping runs to conversations +- JSONL logs parsed line-by-line; extract assistant+tool+workflow_start events for meaningful context +- Module-level `subscribed` flag prevents double-subscribe; reset function needed for test isolation +- Empty arrow function triggers @typescript-eslint/no-empty-function — use `/* noop */` comment + +--- + +## 2026-04-11 — US-018: Update default workflows with KB awareness + +**Status**: PASSED +**Files changed**: +- .archon/workflows/defaults/archon-interactive-prd.yaml — Added KB references to research and technical nodes +- .archon/workflows/defaults/archon-piv-loop.yaml — Added KB references to explore loop and create-plan nodes +- .archon/workflows/defaults/archon-adversarial-dev.yaml — Added KB reference to plan node +- .archon/workflows/defaults/archon-architect.yaml — Added KB reference to analyze node +- .archon/workflows/defaults/archon-refactor-safely.yaml — Added KB reference to analyze-impact node +- .archon/workflows/defaults/archon-create-issue.yaml — Added KB reference to investigate node + +**Acceptance criteria verified**: +- [x] All default workflows reference KB where relevant (6 workflows with inline analysis/planning prompts updated; command-only workflows already get KB via prompt builder injection from US-007) +- [x] Workflows that perform analysis or planning include KB-aware prompts +- [x] Workflows that produce decisions or lessons contribute to KB via capture (handled by US-017 post-workflow capture) +- [x] No workflow is broken if KB is empty (all references use "if available in your context" language) +- [x] Type-check passes +- [x] Tests pass (bundled-defaults.test.ts: 14 tests, 250 expect calls) + +**Learnings**: +- Workflows using only command: nodes don't need explicit KB prompts — the prompt builder injects KB into the system prompt automatically +- Only workflows with inline prompt: nodes for analysis/planning benefit from explicit KB references +- Graceful degradation pattern: "If a knowledge base index is available in your context" — works whether KB exists or not +- YAML files in .archon/workflows/defaults/ are imported as text in bundled-defaults.ts — changes are automatically reflected + +--- + +## 2026-04-11 — US-019: Update workflow builder for KB awareness + +**Status**: PASSED +**Files changed**: +- .archon/workflows/defaults/archon-workflow-builder.yaml — Added KB Integration section to generate-yaml node, KB awareness hint to extract-intent node, Rule 11 for KB sections + +**Acceptance criteria verified**: +- [x] archon-workflow-builder workflow includes KB awareness instructions when generating new workflows +- [x] Generated workflows reference knowledge base for context in relevant nodes (Rule 11 + detailed guidance) +- [x] Generated workflows include guidance for KB-aware prompts (Knowledge Base Integration section with template) +- [x] Type-check passes +- [x] Tests pass + +**Learnings**: +- Workflow builder only needed YAML prompt changes — no code changes required +- KB awareness is injected at two levels: extract-intent (design phase) and generate-yaml (implementation phase) +- The "if available" graceful degradation pattern from US-018 is now codified as a rule for generated workflows + +--- + +### Knowledge-Extract Node Pattern +- **Where**: `packages/workflows/src/dag-executor.ts:1448-1560` +- **Pattern**: Non-AI node types that need @archon/core functionality use injected callbacks on WorkflowDeps. The callback is optional (undefined check before use) so existing code without it continues to work. Pattern: add type to deps.ts, add implementation in store-adapter.ts. +- **Example**: `deps.extractKnowledge?: KnowledgeExtractFn` — optional on WorkflowDeps, wired in createWorkflowDeps() + +## 2026-04-11 — US-020: Add explicit knowledge-extract workflow node type + +**Status**: PASSED +**Files changed**: +- packages/workflows/src/schemas/dag-node.ts — Added KnowledgeExtractNode type, schema, type guard; updated DagNode union, mutual exclusivity checks, and transform +- packages/workflows/src/schemas/index.ts — Exported new types and type guard +- packages/workflows/src/deps.ts — Added KnowledgeExtractFn type and optional extractKnowledge to WorkflowDeps +- packages/workflows/src/dag-executor.ts — Added executeKnowledgeExtractNode function with variable substitution, upstream output collection, and event emission +- packages/workflows/src/loader.ts — Added knowledge-extract to non-AI node warnings and $nodeId.output ref validation +- packages/core/src/services/knowledge-capture.ts — Added extractKnowledgeFromContext() for standalone extraction via custom prompt +- packages/core/src/workflows/store-adapter.ts — Wired extractKnowledgeFromContext into createWorkflowDeps() +- packages/workflows/src/knowledge-extract-node.test.ts — 6 tests for node execution +- packages/core/src/services/knowledge-extract.test.ts — 5 tests for extraction function +- packages/workflows/src/schemas.test.ts — 9 new tests for type guard and schema parsing + +**Acceptance criteria verified**: +- [x] Workflows can include `knowledge-extract` nodes that run targeted knowledge extraction (new schema type, dispatch in dag-executor) +- [x] Node accepts a prompt describing what knowledge to extract from the workflow's output (knowledge_extract field with variable substitution) +- [x] Node uses capture service infrastructure (Haiku model, daily log format) (extractKnowledgeFromContext uses captureModel config and daily log) +- [x] Node output is appended to daily log with workflow context metadata (includes workflowRunId and nodeId in log entry) +- [x] Existing workflows without knowledge-extract nodes continue to work unchanged (extractKnowledge is optional on WorkflowDeps) +- [x] Type-check passes +- [x] Tests pass (20 new tests across 3 files) + +**Learnings**: +- @archon/workflows cannot import @archon/core — must use dependency injection via WorkflowDeps for any @archon/core functionality +- Optional callbacks on WorkflowDeps is the pattern for extending node types without breaking existing callers +- Dynamic import (`await import('@archon/git')`) in @archon/core services allows using @archon/git without static import conflicts in test mocking +- priorCompletedNodes parameter on executeDagWorkflow is useful for testing downstream nodes without actually running upstream bash nodes +- The node type schema uses flat superRefine + transform pattern (not z.union) because YAML nodes lack explicit type discriminants + +--- diff --git a/.archon/workflows/defaults/archon-adversarial-dev.yaml b/.archon/workflows/defaults/archon-adversarial-dev.yaml index 2ab207dc03..4e7914541c 100644 --- a/.archon/workflows/defaults/archon-adversarial-dev.yaml +++ b/.archon/workflows/defaults/archon-adversarial-dev.yaml @@ -28,6 +28,10 @@ nodes: ## Your Task + **Knowledge base**: If a knowledge base index is available in your context, check it for + prior architecture decisions, tech stack choices, patterns, and lessons learned that should + inform this specification. Incorporate relevant constraints from past work. + Write a comprehensive product specification to the file `$ARTIFACTS_DIR/spec.md` using the Write tool. The spec MUST include ALL of the following sections: diff --git a/.archon/workflows/defaults/archon-architect.yaml b/.archon/workflows/defaults/archon-architect.yaml index a41a75cd33..79a64bcdf3 100644 --- a/.archon/workflows/defaults/archon-architect.yaml +++ b/.archon/workflows/defaults/archon-architect.yaml @@ -76,6 +76,12 @@ nodes: $ARGUMENTS + ## Knowledge Base + + If a knowledge base index is available in your context, check it for prior architecture + decisions, past refactoring lessons, and known patterns. Past decisions about module + boundaries and complexity trade-offs should inform your assessment. + ## Instructions 1. Read the top 10-15 files flagged by the metrics above (largest, most imports, most exports) diff --git a/.archon/workflows/defaults/archon-create-issue.yaml b/.archon/workflows/defaults/archon-create-issue.yaml index 24d59f8e0c..a1fc7529c0 100644 --- a/.archon/workflows/defaults/archon-create-issue.yaml +++ b/.archon/workflows/defaults/archon-create-issue.yaml @@ -157,6 +157,12 @@ nodes: ## Git Context $git-context.output + ## Knowledge Base + + If a knowledge base index is available in your context, check it for known issues, + patterns, and lessons learned related to this area. Past bug reports and their resolutions + may help identify the root cause faster. + ## Instructions 1. Based on the area, search the relevant packages: diff --git a/.archon/workflows/defaults/archon-interactive-prd.yaml b/.archon/workflows/defaults/archon-interactive-prd.yaml index ccb08cb411..2808a85809 100644 --- a/.archon/workflows/defaults/archon-interactive-prd.yaml +++ b/.archon/workflows/defaults/archon-interactive-prd.yaml @@ -71,11 +71,14 @@ nodes: Research the landscape: - 1. Search the web for similar products, competitors, and how others solve this problem - 2. **Explore the codebase deeply** — find related existing functionality, APIs, UI components, + 1. **Check the knowledge base** — if a knowledge base index is available in your context, + review it for prior decisions, architecture patterns, and lessons learned that relate + to this feature. Note any relevant constraints or past approaches. + 2. Search the web for similar products, competitors, and how others solve this problem + 3. **Explore the codebase deeply** — find related existing functionality, APIs, UI components, database tables, and patterns. Read actual files, don't assume. Note exact file paths and what each file does. - 3. Look for common patterns, anti-patterns, and recent trends + 4. Look for common patterns, anti-patterns, and recent trends **First principles rule**: Before suggesting anything new, verify what already exists. If there's an existing API endpoint, UI page, or component that partially solves the @@ -125,6 +128,10 @@ nodes: **CRITICAL**: Explore the codebase by READING actual files. Do not guess or assume. For every claim you make about the codebase, cite the exact file and line. + **Knowledge base**: If a knowledge base index is available in your context, check it + for architecture decisions, technical constraints, and patterns that may affect feasibility. + Past decisions about similar features or components should inform your assessment. + 1. **What already exists** that partially solves this problem? - Read existing API endpoints, DB queries, UI components - Note exact function names, table schemas, component names diff --git a/.archon/workflows/defaults/archon-piv-loop.yaml b/.archon/workflows/defaults/archon-piv-loop.yaml index 7227900c2f..c54d117bc5 100644 --- a/.archon/workflows/defaults/archon-piv-loop.yaml +++ b/.archon/workflows/defaults/archon-piv-loop.yaml @@ -62,9 +62,12 @@ nodes: Before asking questions, DO YOUR HOMEWORK: 1. **Read CLAUDE.md** — understand project conventions, architecture, and constraints - 2. **Search for related code** — find existing implementations similar to what the user wants - 3. **Read key files** — understand the current state of code the user wants to change - 4. **Check recent git history** — `git log --oneline -20` for recent changes in the area + 2. **Check the knowledge base** — if a knowledge base index is available in your context, + review it for prior decisions, architecture patterns, lessons learned, and constraints + relevant to the user's request. Reference specific KB articles when presenting findings. + 3. **Search for related code** — find existing implementations similar to what the user wants + 4. **Read key files** — understand the current state of code the user wants to change + 5. **Check recent git history** — `git log --oneline -20` for recent changes in the area ### Step 3: Present Your Understanding @@ -194,9 +197,12 @@ nodes: Before writing the plan, verify your understanding is current: 1. **Read CLAUDE.md** — capture all relevant conventions - 2. **Read every file you plan to change** — note exact current state - 3. **Read example test files** — understand testing patterns - 4. **Check for any recent changes** — `git log --oneline -10` + 2. **Check the knowledge base** — if a knowledge base index is available in your context, + review it for prior decisions, patterns, and lessons that should inform the plan. + Incorporate relevant constraints and approaches from past work. + 3. **Read every file you plan to change** — note exact current state + 4. **Read example test files** — understand testing patterns + 5. **Check for any recent changes** — `git log --oneline -10` ## Step 2: Determine Plan Location diff --git a/.archon/workflows/defaults/archon-refactor-safely.yaml b/.archon/workflows/defaults/archon-refactor-safely.yaml index 56bc96ac36..04aa1232e3 100644 --- a/.archon/workflows/defaults/archon-refactor-safely.yaml +++ b/.archon/workflows/defaults/archon-refactor-safely.yaml @@ -73,6 +73,12 @@ nodes: $scan-scope.output + ## Knowledge Base + + If a knowledge base index is available in your context, check it for prior refactoring + decisions, lessons learned from past code reorganization, and architectural patterns. + Past decisions about module boundaries should inform your decomposition strategy. + ## Instructions 1. Identify the PRIMARY file(s) targeted for refactoring based on the user's request diff --git a/.archon/workflows/defaults/archon-workflow-builder.yaml b/.archon/workflows/defaults/archon-workflow-builder.yaml index a311b8d970..9b56d59bdc 100644 --- a/.archon/workflows/defaults/archon-workflow-builder.yaml +++ b/.archon/workflows/defaults/archon-workflow-builder.yaml @@ -62,6 +62,10 @@ nodes: Be specific and concrete. Each proposed node should have a clear type (bash, prompt, command, or loop) and a one-line description of what it does. + + For prompt nodes that perform analysis, planning, or decision-making, note that + they should include knowledge base awareness (the KB index is auto-injected into + the agent context, so these nodes just need a "check knowledge base" instruction). model: haiku allowed_tools: [] output_format: @@ -153,6 +157,31 @@ nodes: - `$.output.field` — JSON field from a node with output_format - `$BASE_BRANCH` — base git branch + ## Knowledge Base Integration + + Generated workflows should leverage the project knowledge base when relevant. + The knowledge base index is automatically injected into the AI agent's context at + session start (via the prompt builder), so workflows do not need to load it manually. + + For prompt nodes that perform analysis, planning, or decision-making, include a + "Knowledge Base" section like this: + + ``` + ## Knowledge Base + + If a knowledge base index is available in your context, check it for prior decisions, + lessons, and patterns relevant to this task. Use past knowledge to inform your approach + and avoid repeating past mistakes. + ``` + + Guidelines: + - Add KB references to nodes that benefit from historical context (analysis, planning, + architecture, refactoring) — not to simple bash or classification nodes + - KB references should gracefully degrade — always use "if available" phrasing so + workflows work fine when the KB is empty + - Focus on the type of knowledge most relevant to the node's purpose (e.g., architecture + decisions for refactoring nodes, past lessons for review nodes) + ## Rules 1. The `name:` field MUST match: $extract-intent.output.workflow_name 2. The `description:` MUST follow the "Use when / Triggers / Does / NOT for" pattern @@ -164,6 +193,8 @@ nodes: 8. Use `allowed_tools: []` on classification/analysis nodes that don't need tools 9. Use `denied_tools: [Edit, Bash]` when a node should only use Write (not edit existing files) 10. Prefer `model: haiku` for simple classification tasks to save cost + 11. Add a "Knowledge Base" section to prompt nodes that perform analysis, planning, or + decision-making — use "if available" phrasing for graceful degradation ## Output diff --git a/CLAUDE.md b/CLAUDE.md index 9132a1a82a..e2ebbca20d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -237,6 +237,14 @@ bun run cli validate commands my-command # Single command bun run cli complete bun run cli complete --force # Skip uncommitted-changes check +# Knowledge base management (requires git repo) +bun run cli knowledge flush # Compile daily logs into articles +bun run cli knowledge flush --project owner/repo # Specify project explicitly +bun run cli knowledge status # Show KB stats +bun run cli knowledge status --json # Machine-readable output +bun run cli knowledge lint # Validate KB integrity +bun run cli knowledge lint --json # Machine-readable output + # Show version bun run cli version ``` @@ -460,6 +468,19 @@ assistants: # docs: # path: docs # Optional: default is docs/ + +# Knowledge base configuration +knowledge: + enabled: true # Enable/disable KB capture and flush + captureModel: haiku # Model for extracting knowledge from conversations + compileModel: sonnet # Model for synthesizing articles during flush + flushDebounceMinutes: 10 # Minutes to wait after capture before auto-flush + domains: # Starting domain categories + - architecture + - decisions + - patterns + - lessons + - connections ``` **Configuration Priority:** @@ -522,7 +543,19 @@ curl http://localhost:3637/api/conversations//messages │ ├── artifacts/ # Workflow artifacts (NEVER in git) │ │ ├── runs/{id}/ # Per-run artifacts ($ARTIFACTS_DIR) │ │ └── uploads/{convId}/ # Web UI file uploads (ephemeral) +│ ├── knowledge/ # Persistent knowledge base (per-project) +│ │ ├── index.md # Agent entry point (~500 tokens) +│ │ ├── meta/ # Flush metadata (last-flush.json, flush.lock) +│ │ ├── logs/ # Daily capture logs (YYYY-MM-DD.md) +│ │ └── domains/ # Compiled concept articles +│ │ ├── architecture/ # Architecture knowledge +│ │ ├── decisions/ # Decision records +│ │ ├── patterns/ # Code patterns +│ │ ├── lessons/ # Lessons learned +│ │ └── connections/ # Cross-domain links │ └── logs/ # Workflow execution logs +├── knowledge/ # Global knowledge base (cross-project) +│ └── (same structure as project knowledge/) ├── archon.db # SQLite database (when DATABASE_URL not set) └── config.yaml # Global configuration (non-secrets) ``` @@ -664,7 +697,7 @@ async function createSession(conversationId: string, codebaseId: string) { 2. **Workflows** (YAML-based): - Stored in `.archon/workflows/` (searched recursively) - Multi-step AI execution chains, discovered at runtime - - **`nodes:` (DAG format)**: Nodes with explicit `depends_on` edges; independent nodes in the same topological layer run concurrently. Node types: `command:` (named command file), `prompt:` (inline prompt), `bash:` (shell script, stdout captured as `$nodeId.output`, no AI), `loop:` (iterative AI prompt until completion signal) . Supports `when:` conditions, `trigger_rule` join semantics, `$nodeId.output` substitution, `output_format` for structured JSON output (Claude and Codex), `allowed_tools`/`denied_tools` for per-node tool restrictions (Claude only), `hooks` for per-node SDK hook callbacks (Claude only), `mcp` for per-node MCP server config files (Claude only, env vars expanded at execution time), and `skills` for per-node skill preloading via AgentDefinition wrapping (Claude only), and `effort`/`thinking`/`maxBudgetUsd`/`systemPrompt`/`fallbackModel`/`betas`/`sandbox` for Claude SDK advanced options (Claude only, also settable at workflow level) + - **`nodes:` (DAG format)**: Nodes with explicit `depends_on` edges; independent nodes in the same topological layer run concurrently. Node types: `command:` (named command file), `prompt:` (inline prompt), `bash:` (shell script, stdout captured as `$nodeId.output`, no AI), `loop:` (iterative AI prompt until completion signal), `knowledge-extract:` (targeted knowledge extraction from workflow context, appends to daily log) . Supports `when:` conditions, `trigger_rule` join semantics, `$nodeId.output` substitution, `output_format` for structured JSON output (Claude and Codex), `allowed_tools`/`denied_tools` for per-node tool restrictions (Claude only), `hooks` for per-node SDK hook callbacks (Claude only), `mcp` for per-node MCP server config files (Claude only, env vars expanded at execution time), and `skills` for per-node skill preloading via AgentDefinition wrapping (Claude only), and `effort`/`thinking`/`maxBudgetUsd`/`systemPrompt`/`fallbackModel`/`betas`/`sandbox` for Claude SDK advanced options (Claude only, also settable at workflow level) - Provider inherited from `.archon/config.yaml` unless explicitly set; per-node `provider` and `model` overrides supported - Model and options can be set per workflow or inherited from config defaults - `interactive: true` at the workflow level forces foreground execution on web (required for approval-gate workflows in the web UI) diff --git a/packages/cli/package.json b/packages/cli/package.json index 517abb2680..3c541873e3 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -8,7 +8,7 @@ }, "scripts": { "cli": "bun src/cli.ts", - "test": "bun test src/commands/version.test.ts src/commands/setup.test.ts && bun test src/commands/workflow.test.ts && bun test src/commands/isolation.test.ts && bun test src/commands/chat.test.ts", + "test": "bun test src/commands/version.test.ts src/commands/setup.test.ts && bun test src/commands/workflow.test.ts && bun test src/commands/isolation.test.ts && bun test src/commands/chat.test.ts && bun test src/commands/knowledge.test.ts && bun test src/commands/knowledge-status.test.ts && bun test src/commands/knowledge-lint.test.ts", "type-check": "bun x tsc --noEmit" }, "dependencies": { diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index a25ece0c0e..602b9812f3 100755 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -78,6 +78,11 @@ import { continueCommand } from './commands/continue'; import { chatCommand } from './commands/chat'; import { setupCommand } from './commands/setup'; import { validateWorkflowsCommand, validateCommandsCommand } from './commands/validate'; +import { + knowledgeFlushCommand, + knowledgeStatusCommand, + knowledgeLintCommand, +} from './commands/knowledge'; import { closeDatabase } from '@archon/core'; import { setLogLevel, createLogger } from '@archon/paths'; import * as git from '@archon/git'; @@ -108,6 +113,9 @@ Commands: isolation list List all active worktrees/environments isolation cleanup [days] Remove stale environments (default: 7 days) isolation cleanup --merged Remove environments with branches merged into main + knowledge flush Compile daily capture logs into KB articles + knowledge status Show KB stats (articles, last flush, staleness) + knowledge lint Validate KB integrity (staleness, broken links, orphans) continue [msg] Continue work on an existing worktree with prior context complete [...] Complete branch lifecycle (remove worktree + branches) validate workflows [name] Validate workflow definitions and their references @@ -127,6 +135,7 @@ Options: --json Output machine-readable JSON (for workflow list) --workflow Workflow to run for 'continue' (default: archon-assist) --no-context Skip context injection for 'continue' + --project Specify project for knowledge commands (default: git remote) Examples: archon chat "What does the orchestrator do?" @@ -190,6 +199,7 @@ async function main(): Promise { reason: { type: 'string' }, workflow: { type: 'string' }, 'no-context': { type: 'boolean' }, + project: { type: 'string' }, }, allowPositionals: true, strict: false, // Allow unknown flags to pass through @@ -527,6 +537,36 @@ async function main(): Promise { break; } + case 'knowledge': + switch (subcommand) { + case 'flush': { + const projectFlag = values.project as string | undefined; + const quietFlag = values.quiet as boolean | undefined; + return await knowledgeFlushCommand(effectiveCwd, projectFlag, quietFlag); + } + + case 'status': { + const projectFlag = values.project as string | undefined; + const quietFlag = values.quiet as boolean | undefined; + return await knowledgeStatusCommand(effectiveCwd, projectFlag, jsonFlag, quietFlag); + } + + case 'lint': { + const projectFlag = values.project as string | undefined; + const quietFlag = values.quiet as boolean | undefined; + return await knowledgeLintCommand(effectiveCwd, projectFlag, jsonFlag, quietFlag); + } + + default: + if (subcommand === undefined) { + console.error('Missing knowledge subcommand'); + } else { + console.error(`Unknown knowledge subcommand: ${subcommand}`); + } + console.error('Available: flush, status, lint'); + return 1; + } + default: if (command === undefined) { console.error('Missing command'); diff --git a/packages/cli/src/commands/knowledge-lint.test.ts b/packages/cli/src/commands/knowledge-lint.test.ts new file mode 100644 index 0000000000..8dfe90f34f --- /dev/null +++ b/packages/cli/src/commands/knowledge-lint.test.ts @@ -0,0 +1,386 @@ +/** + * Tests for knowledge lint command + */ +import { describe, it, expect, beforeEach, afterEach, mock, spyOn } from 'bun:test'; + +// Mock filesystem state +let fileSystem: Record = {}; +let directories: Record = {}; + +const mockReadFile = mock(async (path: string) => { + const p = String(path); + if (p in fileSystem) return fileSystem[p]; + const err = new Error(`ENOENT: no such file or directory, open '${p}'`) as NodeJS.ErrnoException; + err.code = 'ENOENT'; + throw err; +}); + +const mockReaddir = mock(async (path: string) => { + const p = String(path); + if (p in directories) return directories[p]; + const err = new Error( + `ENOENT: no such file or directory, scandir '${p}'` + ) as NodeJS.ErrnoException; + err.code = 'ENOENT'; + throw err; +}); + +mock.module('node:fs/promises', () => ({ + readFile: mockReadFile, + readdir: mockReaddir, + writeFile: mock(() => Promise.resolve()), + mkdir: mock(() => Promise.resolve()), + rename: mock(() => Promise.resolve()), + unlink: mock(() => Promise.resolve()), + rm: mock(() => Promise.resolve()), +})); + +const mockGetRemoteUrl = mock(() => Promise.resolve('https://github.com/acme/widget.git')); +const mockToRepoPath = mock((p: string) => p); + +mock.module('@archon/git', () => ({ + getRemoteUrl: mockGetRemoteUrl, + toRepoPath: mockToRepoPath, + execFileAsync: mock(() => Promise.resolve({ stdout: '', stderr: '' })), +})); + +const PROJECT_KB = '/home/user/.archon/workspaces/acme/widget/knowledge'; + +mock.module('@archon/paths', () => ({ + createLogger: mock(() => ({ + fatal: mock(() => undefined), + error: mock(() => undefined), + warn: mock(() => undefined), + info: mock(() => undefined), + debug: mock(() => undefined), + trace: mock(() => undefined), + })), + parseOwnerRepo: (name: string) => { + const parts = name.split('/'); + if (parts.length !== 2 || !parts[0] || !parts[1]) return null; + return { owner: parts[0], repo: parts[1] }; + }, + getProjectKnowledgePath: (_owner: string, _repo: string) => PROJECT_KB, + getGlobalKnowledgePath: () => '/home/user/.archon/knowledge', + getProjectSourcePath: (_owner: string, _repo: string) => + '/home/user/.archon/workspaces/acme/widget/source', +})); + +// Mock the flush module's AI-dependent functions +const mockIdentifyStaleArticles = mock(async () => [] as string[]); +const mockCollectAllArticles = mock(async (knowledgePath: string) => { + const domainsDir = `${knowledgePath}/domains`; + const articles: { key: string; content: string }[] = []; + + let domains: string[]; + try { + domains = (await mockReaddir(domainsDir)) as string[]; + } catch { + return []; + } + + for (const domain of domains) { + let files: string[]; + try { + files = (await mockReaddir(`${domainsDir}/${domain}`)) as string[]; + } catch { + continue; + } + for (const file of files) { + if (file === '_index.md' || !file.endsWith('.md')) continue; + try { + const content = (await mockReadFile(`${domainsDir}/${domain}/${file}`)) as string; + const concept = file.replace('.md', ''); + articles.push({ key: `${domain}/${concept}`, content }); + } catch { + continue; + } + } + } + return articles; +}); + +const mockCheckBrokenWikilinks = mock((articles: { key: string; content: string }[]) => { + const articleKeys = new Set(articles.map(a => a.key)); + const brokenLinks: { source: string; target: string }[] = []; + const wikilinkPattern = /\[\[([^\]|]+)(?:\|[^\]]+)?\]\]/g; + + for (const article of articles) { + let match: RegExpExecArray | null; + wikilinkPattern.lastIndex = 0; + while ((match = wikilinkPattern.exec(article.content)) !== null) { + const target = match[1]; + if (!target || target.includes('_index')) continue; + const normalizedTarget = target.replace(/^domains\//, ''); + if (normalizedTarget.includes('/') && !articleKeys.has(normalizedTarget)) { + brokenLinks.push({ source: article.key, target: normalizedTarget }); + } + } + } + return brokenLinks; +}); + +const mockReadLastFlush = mock(async (knowledgePath: string) => { + try { + const content = (await mockReadFile(`${knowledgePath}/meta/last-flush.json`)) as string; + return JSON.parse(content) as { timestamp: string; gitSha: string; logsCaptured: string[] }; + } catch { + return null; + } +}); + +const mockGetGitDiffNameOnly = mock(async () => ''); + +mock.module('@archon/core/services/knowledge-flush', () => ({ + flushKnowledge: mock(() => Promise.resolve({})), + collectAllArticles: mockCollectAllArticles, + checkBrokenWikilinks: mockCheckBrokenWikilinks, + identifyStaleArticles: mockIdentifyStaleArticles, + readLastFlush: mockReadLastFlush, + getGitDiffNameOnly: mockGetGitDiffNameOnly, + getCurrentGitSha: mock(async () => ''), +})); + +mock.module('@archon/core', () => ({ + loadConfig: mock(() => + Promise.resolve({ + knowledge: { + enabled: true, + captureModel: 'haiku', + compileModel: 'sonnet', + flushDebounceMinutes: 10, + domains: ['architecture', 'decisions', 'patterns', 'lessons', 'connections'], + }, + }) + ), +})); + +mock.module('node:path', () => ({ + join: (...parts: string[]) => parts.join('/'), +})); + +// Import AFTER mocks +import { knowledgeLintCommand } from './knowledge'; + +describe('knowledgeLintCommand', () => { + let stderrSpy: ReturnType; + let consoleLogSpy: ReturnType; + let consoleErrorSpy: ReturnType; + + beforeEach(() => { + fileSystem = {}; + directories = {}; + mockReadFile.mockReset(); + mockReaddir.mockReset(); + mockGetRemoteUrl.mockReset(); + mockToRepoPath.mockReset(); + mockIdentifyStaleArticles.mockReset(); + mockGetGitDiffNameOnly.mockReset(); + + mockReadFile.mockImplementation(async (path: string) => { + const p = String(path); + if (p in fileSystem) return fileSystem[p]; + const err = new Error(`ENOENT: ${p}`) as NodeJS.ErrnoException; + err.code = 'ENOENT'; + throw err; + }); + + mockReaddir.mockImplementation(async (path: string) => { + const p = String(path); + if (p in directories) return directories[p]; + const err = new Error(`ENOENT: ${p}`) as NodeJS.ErrnoException; + err.code = 'ENOENT'; + throw err; + }); + + mockGetRemoteUrl.mockResolvedValue('https://github.com/acme/widget.git'); + mockToRepoPath.mockImplementation((p: string) => p); + mockIdentifyStaleArticles.mockResolvedValue([]); + mockGetGitDiffNameOnly.mockResolvedValue(''); + + stderrSpy = spyOn(process.stderr, 'write').mockImplementation(() => true); + consoleLogSpy = spyOn(console, 'log').mockImplementation(() => {}); + consoleErrorSpy = spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + stderrSpy.mockRestore(); + consoleLogSpy.mockRestore(); + consoleErrorSpy.mockRestore(); + }); + + it('should return 0 for empty KB (no articles)', async () => { + const exitCode = await knowledgeLintCommand('/repo', 'acme/widget'); + + expect(exitCode).toBe(0); + const output = stderrSpy.mock.calls.map(c => String(c[0])).join(''); + expect(output).toContain('Articles checked: 0'); + }); + + it('should return 0 for clean KB (no issues)', async () => { + directories[`${PROJECT_KB}/domains`] = ['architecture']; + directories[`${PROJECT_KB}/domains/architecture`] = ['_index.md', 'overview.md']; + fileSystem[`${PROJECT_KB}/domains/architecture/_index.md`] = + '# Architecture\n\n## Articles\n\n- [[architecture/overview|Overview]]\n'; + fileSystem[`${PROJECT_KB}/domains/architecture/overview.md`] = '# Overview\nClean article.'; + + const exitCode = await knowledgeLintCommand('/repo', 'acme/widget'); + + expect(exitCode).toBe(0); + const output = stderrSpy.mock.calls.map(c => String(c[0])).join(''); + expect(output).toContain('Articles checked: 1'); + expect(output).toContain('Stale articles: 0'); + expect(output).toContain('Broken wikilinks: 0'); + expect(output).toContain('Orphaned articles: 0'); + }); + + it('should detect broken wikilinks', async () => { + directories[`${PROJECT_KB}/domains`] = ['decisions']; + directories[`${PROJECT_KB}/domains/decisions`] = ['_index.md', 'auth-strategy.md']; + fileSystem[`${PROJECT_KB}/domains/decisions/_index.md`] = + '# Decisions\n\n## Articles\n\n- [[decisions/auth-strategy|Auth Strategy]]\n'; + fileSystem[`${PROJECT_KB}/domains/decisions/auth-strategy.md`] = + '# Auth Strategy\nSee [[decisions/nonexistent-article|Missing]] for details.'; + + const exitCode = await knowledgeLintCommand('/repo', 'acme/widget'); + + expect(exitCode).toBe(1); + const output = stderrSpy.mock.calls.map(c => String(c[0])).join(''); + expect(output).toContain('Broken wikilinks (1)'); + expect(output).toContain('decisions/auth-strategy'); + expect(output).toContain('decisions/nonexistent-article'); + }); + + it('should detect orphaned articles not in _index.md', async () => { + directories[`${PROJECT_KB}/domains`] = ['patterns']; + directories[`${PROJECT_KB}/domains/patterns`] = ['_index.md', 'tracked.md', 'orphan.md']; + fileSystem[`${PROJECT_KB}/domains/patterns/_index.md`] = + '# Patterns\n\n## Articles\n\n- [[patterns/tracked|Tracked]]\n'; + fileSystem[`${PROJECT_KB}/domains/patterns/tracked.md`] = '# Tracked\nContent.'; + fileSystem[`${PROJECT_KB}/domains/patterns/orphan.md`] = '# Orphan\nNot referenced.'; + + const exitCode = await knowledgeLintCommand('/repo', 'acme/widget'); + + expect(exitCode).toBe(1); + const output = stderrSpy.mock.calls.map(c => String(c[0])).join(''); + expect(output).toContain('Orphaned articles (1)'); + expect(output).toContain('patterns/orphan'); + }); + + it('should detect stale articles when git SHA exists', async () => { + fileSystem[`${PROJECT_KB}/meta/last-flush.json`] = JSON.stringify({ + timestamp: '2026-04-10T10:00:00.000Z', + gitSha: 'abc123', + logsCaptured: ['2026-04-10.md'], + }); + directories[`${PROJECT_KB}/domains`] = ['architecture']; + directories[`${PROJECT_KB}/domains/architecture`] = ['_index.md', 'db-schema.md']; + fileSystem[`${PROJECT_KB}/domains/architecture/_index.md`] = + '# Architecture\n\n## Articles\n\n- [[architecture/db-schema|DB Schema]]\n'; + fileSystem[`${PROJECT_KB}/domains/architecture/db-schema.md`] = + '# DB Schema\nReferences src/db/schema.ts.'; + + mockGetGitDiffNameOnly.mockResolvedValue('src/db/schema.ts\nsrc/utils/helpers.ts'); + mockIdentifyStaleArticles.mockResolvedValue(['architecture/db-schema']); + + const exitCode = await knowledgeLintCommand('/repo', 'acme/widget'); + + expect(exitCode).toBe(1); + const output = stderrSpy.mock.calls.map(c => String(c[0])).join(''); + expect(output).toContain('Stale articles (1)'); + expect(output).toContain('architecture/db-schema'); + }); + + it('should skip staleness check when no git SHA in last-flush', async () => { + directories[`${PROJECT_KB}/domains`] = ['lessons']; + directories[`${PROJECT_KB}/domains/lessons`] = ['_index.md', 'testing.md']; + fileSystem[`${PROJECT_KB}/domains/lessons/_index.md`] = + '# Lessons\n\n## Articles\n\n- [[lessons/testing|Testing]]\n'; + fileSystem[`${PROJECT_KB}/domains/lessons/testing.md`] = '# Testing\nBe careful with mocks.'; + + const exitCode = await knowledgeLintCommand('/repo', 'acme/widget'); + + expect(exitCode).toBe(0); + expect(mockIdentifyStaleArticles).not.toHaveBeenCalled(); + }); + + it('should output JSON with --json flag', async () => { + directories[`${PROJECT_KB}/domains`] = ['decisions']; + directories[`${PROJECT_KB}/domains/decisions`] = ['_index.md', 'auth.md', 'orphan.md']; + fileSystem[`${PROJECT_KB}/domains/decisions/_index.md`] = + '# Decisions\n\n## Articles\n\n- [[decisions/auth|Auth]]\n'; + fileSystem[`${PROJECT_KB}/domains/decisions/auth.md`] = + '# Auth\nSee [[decisions/missing|Missing]].'; + fileSystem[`${PROJECT_KB}/domains/decisions/orphan.md`] = '# Orphan\nNot indexed.'; + + const exitCode = await knowledgeLintCommand('/repo', 'acme/widget', true); + + expect(exitCode).toBe(1); + expect(consoleLogSpy).toHaveBeenCalledTimes(1); + const jsonOutput = JSON.parse(consoleLogSpy.mock.calls[0][0] as string) as { + articlesChecked: number; + staleArticles: string[]; + brokenWikilinks: { source: string; target: string }[]; + orphanedArticles: string[]; + }; + expect(jsonOutput.articlesChecked).toBe(2); + expect(jsonOutput.brokenWikilinks).toHaveLength(1); + expect(jsonOutput.brokenWikilinks[0].source).toBe('decisions/auth'); + expect(jsonOutput.brokenWikilinks[0].target).toBe('decisions/missing'); + expect(jsonOutput.orphanedArticles).toEqual(['decisions/orphan']); + }); + + it('should suppress output in quiet mode', async () => { + directories[`${PROJECT_KB}/domains`] = ['architecture']; + directories[`${PROJECT_KB}/domains/architecture`] = ['_index.md', 'overview.md']; + fileSystem[`${PROJECT_KB}/domains/architecture/_index.md`] = + '# Architecture\n\n- [[architecture/overview|Overview]]\n'; + fileSystem[`${PROJECT_KB}/domains/architecture/overview.md`] = '# Overview\nContent.'; + + const exitCode = await knowledgeLintCommand('/repo', 'acme/widget', false, true); + + expect(exitCode).toBe(0); + // Only Pino logger output (JSON strings) should appear, no user-facing output + const nonLogOutput = stderrSpy.mock.calls + .map(c => String(c[0])) + .filter(s => !s.startsWith('{')); + expect(nonLogOutput).toEqual([]); + }); + + it('should return 1 on invalid --project format', async () => { + const exitCode = await knowledgeLintCommand('/repo', 'invalid'); + + expect(exitCode).toBe(1); + expect(consoleErrorSpy).toHaveBeenCalled(); + }); + + it('should detect multiple issues simultaneously', async () => { + fileSystem[`${PROJECT_KB}/meta/last-flush.json`] = JSON.stringify({ + timestamp: '2026-04-10T10:00:00.000Z', + gitSha: 'def456', + logsCaptured: [], + }); + directories[`${PROJECT_KB}/domains`] = ['architecture', 'patterns']; + directories[`${PROJECT_KB}/domains/architecture`] = ['_index.md', 'stale-arch.md']; + directories[`${PROJECT_KB}/domains/patterns`] = ['_index.md', 'good.md', 'orphan-pattern.md']; + fileSystem[`${PROJECT_KB}/domains/architecture/_index.md`] = + '# Architecture\n\n- [[architecture/stale-arch|Stale Arch]]\n'; + fileSystem[`${PROJECT_KB}/domains/architecture/stale-arch.md`] = + '# Stale Arch\nSee [[patterns/nonexistent|Missing]].'; + fileSystem[`${PROJECT_KB}/domains/patterns/_index.md`] = + '# Patterns\n\n- [[patterns/good|Good]]\n'; + fileSystem[`${PROJECT_KB}/domains/patterns/good.md`] = '# Good\nNo issues.'; + fileSystem[`${PROJECT_KB}/domains/patterns/orphan-pattern.md`] = '# Orphan Pattern\nLost.'; + + mockGetGitDiffNameOnly.mockResolvedValue('src/arch/main.ts'); + mockIdentifyStaleArticles.mockResolvedValue(['architecture/stale-arch']); + + const exitCode = await knowledgeLintCommand('/repo', 'acme/widget'); + + expect(exitCode).toBe(1); + const output = stderrSpy.mock.calls.map(c => String(c[0])).join(''); + expect(output).toContain('Stale articles (1)'); + expect(output).toContain('Broken wikilinks (1)'); + expect(output).toContain('Orphaned articles (1)'); + }); +}); diff --git a/packages/cli/src/commands/knowledge-status.test.ts b/packages/cli/src/commands/knowledge-status.test.ts new file mode 100644 index 0000000000..7a8a888025 --- /dev/null +++ b/packages/cli/src/commands/knowledge-status.test.ts @@ -0,0 +1,278 @@ +/** + * Tests for knowledge status command + */ +import { describe, it, expect, beforeEach, afterEach, mock, spyOn } from 'bun:test'; + +// Mock filesystem state +let fileSystem: Record = {}; +let directories: Record = {}; + +const mockReadFile = mock(async (path: string) => { + const p = String(path); + if (p in fileSystem) return fileSystem[p]; + const err = new Error(`ENOENT: no such file or directory, open '${p}'`) as NodeJS.ErrnoException; + err.code = 'ENOENT'; + throw err; +}); + +const mockReaddir = mock(async (path: string) => { + const p = String(path); + if (p in directories) return directories[p]; + const err = new Error( + `ENOENT: no such file or directory, scandir '${p}'` + ) as NodeJS.ErrnoException; + err.code = 'ENOENT'; + throw err; +}); + +mock.module('node:fs/promises', () => ({ + readFile: mockReadFile, + readdir: mockReaddir, + writeFile: mock(() => Promise.resolve()), + mkdir: mock(() => Promise.resolve()), + rename: mock(() => Promise.resolve()), + unlink: mock(() => Promise.resolve()), + rm: mock(() => Promise.resolve()), +})); + +const mockGetRemoteUrl = mock(() => Promise.resolve('https://github.com/acme/widget.git')); +const mockToRepoPath = mock((p: string) => p); + +mock.module('@archon/git', () => ({ + getRemoteUrl: mockGetRemoteUrl, + toRepoPath: mockToRepoPath, +})); + +const PROJECT_KB = '/home/user/.archon/workspaces/acme/widget/knowledge'; +const GLOBAL_KB = '/home/user/.archon/knowledge'; + +mock.module('@archon/paths', () => ({ + createLogger: mock(() => ({ + fatal: mock(() => undefined), + error: mock(() => undefined), + warn: mock(() => undefined), + info: mock(() => undefined), + debug: mock(() => undefined), + trace: mock(() => undefined), + })), + parseOwnerRepo: (name: string) => { + const parts = name.split('/'); + if (parts.length !== 2 || !parts[0] || !parts[1]) return null; + return { owner: parts[0], repo: parts[1] }; + }, + getProjectKnowledgePath: (_owner: string, _repo: string) => PROJECT_KB, + getGlobalKnowledgePath: () => GLOBAL_KB, +})); + +mock.module('@archon/core/services/knowledge-flush', () => ({ + flushKnowledge: mock(() => Promise.resolve({})), +})); + +mock.module('@archon/core', () => ({ + loadConfig: mock(() => Promise.resolve({})), +})); + +mock.module('node:path', () => ({ + join: (...parts: string[]) => parts.join('/'), +})); + +// Import AFTER mocks +import { knowledgeStatusCommand } from './knowledge'; + +describe('knowledgeStatusCommand', () => { + let stderrSpy: ReturnType; + let consoleLogSpy: ReturnType; + let consoleErrorSpy: ReturnType; + + beforeEach(() => { + fileSystem = {}; + directories = {}; + mockReadFile.mockReset(); + mockReaddir.mockReset(); + mockGetRemoteUrl.mockReset(); + mockToRepoPath.mockReset(); + + mockReadFile.mockImplementation(async (path: string) => { + const p = String(path); + if (p in fileSystem) return fileSystem[p]; + const err = new Error(`ENOENT: ${p}`) as NodeJS.ErrnoException; + err.code = 'ENOENT'; + throw err; + }); + + mockReaddir.mockImplementation(async (path: string) => { + const p = String(path); + if (p in directories) return directories[p]; + const err = new Error(`ENOENT: ${p}`) as NodeJS.ErrnoException; + err.code = 'ENOENT'; + throw err; + }); + + mockGetRemoteUrl.mockResolvedValue('https://github.com/acme/widget.git'); + mockToRepoPath.mockImplementation((p: string) => p); + + stderrSpy = spyOn(process.stderr, 'write').mockImplementation(() => true); + consoleLogSpy = spyOn(console, 'log').mockImplementation(() => {}); + consoleErrorSpy = spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + stderrSpy.mockRestore(); + consoleLogSpy.mockRestore(); + consoleErrorSpy.mockRestore(); + }); + + it('should display stats for empty KB', async () => { + const exitCode = await knowledgeStatusCommand('/repo', 'acme/widget'); + + expect(exitCode).toBe(0); + const output = stderrSpy.mock.calls.map(c => String(c[0])).join(''); + expect(output).toContain('Project KB (acme/widget)'); + expect(output).toContain('Total articles: 0'); + expect(output).toContain('Last flush: never'); + expect(output).toContain('Unprocessed logs: 0'); + expect(output).toContain('Stale articles: 0'); + expect(output).toContain('Global KB'); + }); + + it('should count articles per domain', async () => { + directories[`${PROJECT_KB}/domains`] = ['architecture', 'decisions']; + directories[`${PROJECT_KB}/domains/architecture`] = [ + '_index.md', + 'auth-flow.md', + 'db-schema.md', + ]; + directories[`${PROJECT_KB}/domains/decisions`] = ['_index.md', 'token-strategy.md']; + fileSystem[`${PROJECT_KB}/domains/architecture/auth-flow.md`] = '# Auth Flow\nContent here.'; + fileSystem[`${PROJECT_KB}/domains/architecture/db-schema.md`] = '# DB Schema\nContent here.'; + fileSystem[`${PROJECT_KB}/domains/decisions/token-strategy.md`] = '# Token Strategy\nContent.'; + + const exitCode = await knowledgeStatusCommand('/repo', 'acme/widget'); + + expect(exitCode).toBe(0); + const output = stderrSpy.mock.calls.map(c => String(c[0])).join(''); + expect(output).toContain('Total articles: 3'); + expect(output).toContain('architecture: 2'); + expect(output).toContain('decisions: 1'); + }); + + it('should show last flush timestamp', async () => { + fileSystem[`${PROJECT_KB}/meta/last-flush.json`] = JSON.stringify({ + timestamp: '2026-04-11T10:30:00.000Z', + gitSha: 'abc123', + logsCaptured: ['2026-04-11.md'], + }); + + const exitCode = await knowledgeStatusCommand('/repo', 'acme/widget'); + + expect(exitCode).toBe(0); + const output = stderrSpy.mock.calls.map(c => String(c[0])).join(''); + expect(output).toContain('Last flush: 2026-04-11T10:30:00.000Z'); + }); + + it('should count unprocessed logs since last flush', async () => { + fileSystem[`${PROJECT_KB}/meta/last-flush.json`] = JSON.stringify({ + timestamp: '2026-04-09T10:00:00.000Z', + gitSha: '', + logsCaptured: ['2026-04-09.md'], + }); + directories[`${PROJECT_KB}/logs`] = [ + '2026-04-08.md', + '2026-04-09.md', + '2026-04-10.md', + '2026-04-11.md', + ]; + + const exitCode = await knowledgeStatusCommand('/repo', 'acme/widget'); + + expect(exitCode).toBe(0); + const output = stderrSpy.mock.calls.map(c => String(c[0])).join(''); + // Logs newer than 2026-04-09: 2026-04-10.md and 2026-04-11.md + expect(output).toContain('Unprocessed logs: 2'); + }); + + it('should count all logs as unprocessed when no last-flush exists', async () => { + directories[`${PROJECT_KB}/logs`] = ['2026-04-10.md', '2026-04-11.md']; + + const exitCode = await knowledgeStatusCommand('/repo', 'acme/widget'); + + expect(exitCode).toBe(0); + const output = stderrSpy.mock.calls.map(c => String(c[0])).join(''); + expect(output).toContain('Unprocessed logs: 2'); + }); + + it('should count stale articles', async () => { + directories[`${PROJECT_KB}/domains`] = ['patterns']; + directories[`${PROJECT_KB}/domains/patterns`] = ['_index.md', 'fresh.md', 'stale-one.md']; + fileSystem[`${PROJECT_KB}/domains/patterns/fresh.md`] = '# Fresh\nNot stale content.'; + fileSystem[`${PROJECT_KB}/domains/patterns/stale-one.md`] = + '# Stale One\n\n> [!WARNING] This article may be stale — referenced code has changed.\n\nContent.'; + + const exitCode = await knowledgeStatusCommand('/repo', 'acme/widget'); + + expect(exitCode).toBe(0); + const output = stderrSpy.mock.calls.map(c => String(c[0])).join(''); + expect(output).toContain('Stale articles: 1'); + }); + + it('should output JSON with --json flag', async () => { + fileSystem[`${PROJECT_KB}/meta/last-flush.json`] = JSON.stringify({ + timestamp: '2026-04-11T10:30:00.000Z', + gitSha: 'abc123', + logsCaptured: [], + }); + directories[`${PROJECT_KB}/domains`] = ['architecture']; + directories[`${PROJECT_KB}/domains/architecture`] = ['_index.md', 'overview.md']; + fileSystem[`${PROJECT_KB}/domains/architecture/overview.md`] = '# Overview\nContent.'; + + const exitCode = await knowledgeStatusCommand('/repo', 'acme/widget', true); + + expect(exitCode).toBe(0); + expect(consoleLogSpy).toHaveBeenCalledTimes(1); + const jsonOutput = JSON.parse(consoleLogSpy.mock.calls[0][0] as string) as { + project: { + totalArticles: number; + owner: string; + repo: string; + articlesPerDomain: Record; + lastFlushTimestamp: string; + }; + global: { totalArticles: number }; + }; + expect(jsonOutput.project.totalArticles).toBe(1); + expect(jsonOutput.project.owner).toBe('acme'); + expect(jsonOutput.project.repo).toBe('widget'); + expect(jsonOutput.project.articlesPerDomain.architecture).toBe(1); + expect(jsonOutput.project.lastFlushTimestamp).toBe('2026-04-11T10:30:00.000Z'); + expect(jsonOutput.global.totalArticles).toBe(0); + }); + + it('should suppress output in quiet mode', async () => { + const exitCode = await knowledgeStatusCommand('/repo', 'acme/widget', false, true); + + expect(exitCode).toBe(0); + const output = stderrSpy.mock.calls.map(c => String(c[0])).filter(s => !s.startsWith('{')); + expect(output).toEqual([]); + }); + + it('should show global KB stats', async () => { + directories[`${GLOBAL_KB}/domains`] = ['lessons']; + directories[`${GLOBAL_KB}/domains/lessons`] = ['_index.md', 'testing-tips.md']; + fileSystem[`${GLOBAL_KB}/domains/lessons/testing-tips.md`] = '# Testing Tips\nContent.'; + directories[`${GLOBAL_KB}/logs`] = ['2026-04-11.md']; + + const exitCode = await knowledgeStatusCommand('/repo', 'acme/widget'); + + expect(exitCode).toBe(0); + const output = stderrSpy.mock.calls.map(c => String(c[0])).join(''); + expect(output).toContain('Global KB'); + expect(output).toContain('lessons: 1'); + }); + + it('should return 1 on invalid --project format', async () => { + const exitCode = await knowledgeStatusCommand('/repo', 'invalid'); + + expect(exitCode).toBe(1); + expect(consoleErrorSpy).toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/src/commands/knowledge.test.ts b/packages/cli/src/commands/knowledge.test.ts new file mode 100644 index 0000000000..097765f0a2 --- /dev/null +++ b/packages/cli/src/commands/knowledge.test.ts @@ -0,0 +1,215 @@ +/** + * Tests for knowledge commands + */ +import { describe, it, expect, beforeEach, afterEach, mock, spyOn } from 'bun:test'; +import type { KnowledgeFlushReport } from '@archon/core/services/knowledge-flush'; + +const mockFlushKnowledge = + mock<(owner: string, repo: string, config?: unknown) => Promise>(); + +const mockLoadConfig = mock(() => + Promise.resolve({ + knowledge: { + enabled: true, + captureModel: 'haiku', + compileModel: 'sonnet', + flushDebounceMinutes: 10, + domains: ['architecture', 'decisions', 'patterns', 'lessons', 'connections'], + }, + }) +); + +const mockGetRemoteUrl = mock(() => Promise.resolve('https://github.com/acme/widget.git')); +const mockToRepoPath = mock((p: string) => p); + +const mockLogger = { + fatal: mock(() => undefined), + error: mock(() => undefined), + warn: mock(() => undefined), + info: mock(() => undefined), + debug: mock(() => undefined), + trace: mock(() => undefined), +}; + +mock.module('@archon/core/services/knowledge-flush', () => ({ + flushKnowledge: mockFlushKnowledge, +})); + +mock.module('@archon/core', () => ({ + loadConfig: mockLoadConfig, +})); + +mock.module('@archon/paths', () => ({ + createLogger: mock(() => mockLogger), + parseOwnerRepo: (name: string) => { + const parts = name.split('/'); + if (parts.length !== 2 || !parts[0] || !parts[1]) return null; + return { owner: parts[0], repo: parts[1] }; + }, +})); + +mock.module('@archon/git', () => ({ + getRemoteUrl: mockGetRemoteUrl, + toRepoPath: mockToRepoPath, +})); + +// Import AFTER mocks +import { knowledgeFlushCommand } from './knowledge'; + +describe('knowledgeFlushCommand', () => { + let stderrSpy: ReturnType; + let consoleErrorSpy: ReturnType; + + beforeEach(() => { + mockFlushKnowledge.mockReset(); + mockLoadConfig.mockReset(); + mockGetRemoteUrl.mockReset(); + mockToRepoPath.mockReset(); + + mockLoadConfig.mockResolvedValue({ + knowledge: { + enabled: true, + captureModel: 'haiku', + compileModel: 'sonnet', + flushDebounceMinutes: 10, + domains: ['architecture', 'decisions', 'patterns', 'lessons', 'connections'], + }, + } as never); + + mockGetRemoteUrl.mockResolvedValue('https://github.com/acme/widget.git'); + mockToRepoPath.mockImplementation((p: string) => p); + + stderrSpy = spyOn(process.stderr, 'write').mockImplementation(() => true); + consoleErrorSpy = spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + stderrSpy.mockRestore(); + consoleErrorSpy.mockRestore(); + }); + + it('should flush with --project flag', async () => { + const report: KnowledgeFlushReport = { + articlesCreated: 2, + articlesUpdated: 1, + articlesStale: 0, + domainsCreated: ['architecture'], + logsProcessed: ['2026-04-11.md'], + skipped: false, + }; + mockFlushKnowledge.mockResolvedValue(report); + + const exitCode = await knowledgeFlushCommand('/repo', 'acme/widget'); + + expect(exitCode).toBe(0); + expect(mockFlushKnowledge).toHaveBeenCalledTimes(1); + expect(mockFlushKnowledge.mock.calls[0][0]).toBe('acme'); + expect(mockFlushKnowledge.mock.calls[0][1]).toBe('widget'); + }); + + it('should resolve owner/repo from git remote when no --project', async () => { + const report: KnowledgeFlushReport = { + articlesCreated: 0, + articlesUpdated: 0, + articlesStale: 0, + domainsCreated: [], + logsProcessed: [], + skipped: true, + skipReason: 'No unprocessed logs to flush', + }; + mockFlushKnowledge.mockResolvedValue(report); + + const exitCode = await knowledgeFlushCommand('/repo'); + + expect(exitCode).toBe(0); + expect(mockGetRemoteUrl).toHaveBeenCalledTimes(1); + expect(mockFlushKnowledge).toHaveBeenCalledTimes(1); + expect(mockFlushKnowledge.mock.calls[0][0]).toBe('acme'); + expect(mockFlushKnowledge.mock.calls[0][1]).toBe('widget'); + }); + + it('should display flush results', async () => { + const report: KnowledgeFlushReport = { + articlesCreated: 3, + articlesUpdated: 2, + articlesStale: 1, + domainsCreated: ['patterns', 'lessons'], + logsProcessed: ['2026-04-10.md', '2026-04-11.md'], + skipped: false, + }; + mockFlushKnowledge.mockResolvedValue(report); + + await knowledgeFlushCommand('/repo', 'acme/widget'); + + // Check stderr output contains report details + const output = stderrSpy.mock.calls.map(c => c[0]).join(''); + expect(output).toContain('Articles created: 3'); + expect(output).toContain('Articles updated: 2'); + expect(output).toContain('Articles stale: 1'); + expect(output).toContain('patterns, lessons'); + expect(output).toContain('Logs processed: 2'); + }); + + it('should display skip reason when skipped', async () => { + const report: KnowledgeFlushReport = { + articlesCreated: 0, + articlesUpdated: 0, + articlesStale: 0, + domainsCreated: [], + logsProcessed: [], + skipped: true, + skipReason: 'Knowledge is disabled', + }; + mockFlushKnowledge.mockResolvedValue(report); + + await knowledgeFlushCommand('/repo', 'acme/widget'); + + const output = stderrSpy.mock.calls.map(c => c[0]).join(''); + expect(output).toContain('Skipped: Knowledge is disabled'); + }); + + it('should suppress output in quiet mode', async () => { + const report: KnowledgeFlushReport = { + articlesCreated: 1, + articlesUpdated: 0, + articlesStale: 0, + domainsCreated: [], + logsProcessed: ['2026-04-11.md'], + skipped: false, + }; + mockFlushKnowledge.mockResolvedValue(report); + + await knowledgeFlushCommand('/repo', 'acme/widget', true); + + // No progress/report output in quiet mode (ignore pino logger output) + const output = stderrSpy.mock.calls.map(c => String(c[0])).filter(s => !s.startsWith('{')); + expect(output).toEqual([]); + }); + + it('should return 1 on invalid --project format', async () => { + const exitCode = await knowledgeFlushCommand('/repo', 'invalid'); + + expect(exitCode).toBe(1); + expect(consoleErrorSpy).toHaveBeenCalled(); + expect(mockFlushKnowledge).not.toHaveBeenCalled(); + }); + + it('should return 1 when flush throws', async () => { + mockFlushKnowledge.mockRejectedValue(new Error('AI client unavailable')); + + const exitCode = await knowledgeFlushCommand('/repo', 'acme/widget'); + + expect(exitCode).toBe(1); + expect(consoleErrorSpy).toHaveBeenCalled(); + }); + + it('should return 1 when no git remote found', async () => { + mockGetRemoteUrl.mockResolvedValue(null); + + const exitCode = await knowledgeFlushCommand('/repo'); + + expect(exitCode).toBe(1); + expect(consoleErrorSpy).toHaveBeenCalled(); + expect(mockFlushKnowledge).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/src/commands/knowledge.ts b/packages/cli/src/commands/knowledge.ts new file mode 100644 index 0000000000..d4d1b87d30 --- /dev/null +++ b/packages/cli/src/commands/knowledge.ts @@ -0,0 +1,451 @@ +/** + * Knowledge commands — manage the persistent knowledge base. + */ +import { readFile, readdir } from 'node:fs/promises'; +import { join } from 'node:path'; +import { + createLogger, + parseOwnerRepo, + getProjectKnowledgePath, + getGlobalKnowledgePath, +} from '@archon/paths'; +import * as git from '@archon/git'; +import { + flushKnowledge, + collectAllArticles, + checkBrokenWikilinks, + identifyStaleArticles, + getGitDiffNameOnly, + readLastFlush, +} from '@archon/core/services/knowledge-flush'; +import { loadConfig } from '@archon/core'; +import type { KnowledgeFlushReport } from '@archon/core/services/knowledge-flush'; + +/** Lazy-initialized logger */ +let cachedLog: ReturnType | undefined; +function getLog(): ReturnType { + if (!cachedLog) cachedLog = createLogger('cli.knowledge'); + return cachedLog; +} + +/** + * Resolve owner/repo from --project flag or current git repo remote URL. + * Returns null if resolution fails. + */ +async function resolveOwnerRepo( + cwd: string, + project?: string +): Promise<{ owner: string; repo: string } | null> { + // If --project flag provided, parse it directly + if (project) { + const parsed = parseOwnerRepo(project); + if (!parsed) { + console.error(`Error: Invalid --project format: "${project}". Expected "owner/repo".`); + return null; + } + return parsed; + } + + // Fall back to git remote URL + try { + const repoPath = git.toRepoPath(cwd); + const remoteUrl = await git.getRemoteUrl(repoPath); + if (!remoteUrl) { + console.error('Error: No git remote found. Use --project owner/repo to specify the project.'); + return null; + } + + // Parse owner/repo from remote URL (handles both HTTPS and SSH) + const urlParts = remoteUrl.replace(/\.git$/, '').split(/[/:]/); + const repo = urlParts.pop(); + const owner = urlParts.pop(); + if (!owner || !repo) { + console.error( + `Error: Could not parse owner/repo from remote URL: ${remoteUrl}. Use --project owner/repo.` + ); + return null; + } + return { owner, repo }; + } catch { + console.error( + 'Error: Could not resolve project from git remote. Use --project owner/repo to specify.' + ); + return null; + } +} + +/** + * `knowledge flush` — manually trigger a knowledge flush (compile daily logs into articles). + */ +export async function knowledgeFlushCommand( + cwd: string, + project?: string, + quiet?: boolean +): Promise { + const log = getLog(); + const ownerRepo = await resolveOwnerRepo(cwd, project); + if (!ownerRepo) return 1; + + const { owner, repo } = ownerRepo; + + if (!quiet) { + process.stderr.write(`Flushing knowledge base for ${owner}/${repo}...\n`); + } + + try { + const config = await loadConfig(); + const report = await flushKnowledge(owner, repo, config); + renderFlushReport(report, quiet); + return 0; + } catch (error) { + const err = error as Error; + log.error({ err, owner, repo }, 'knowledge.flush_command_failed'); + console.error(`Error: Flush failed — ${err.message}`); + return 1; + } +} + +/** Stats for a single knowledge base tier (project or global) */ +interface KBStats { + totalArticles: number; + articlesPerDomain: Record; + lastFlushTimestamp: string | null; + unprocessedLogCount: number; + staleArticleCount: number; +} + +/** + * Gather knowledge base stats from a knowledge directory. + * Pure filesystem inspection — no AI calls. + */ +async function gatherKBStats(knowledgePath: string): Promise { + const stats: KBStats = { + totalArticles: 0, + articlesPerDomain: {}, + lastFlushTimestamp: null, + unprocessedLogCount: 0, + staleArticleCount: 0, + }; + + // Read last-flush.json + let lastFlushDate: string | null = null; + try { + const metaContent = await readFile(join(knowledgePath, 'meta', 'last-flush.json'), 'utf-8'); + const meta = JSON.parse(metaContent) as { timestamp: string }; + stats.lastFlushTimestamp = meta.timestamp; + lastFlushDate = meta.timestamp.slice(0, 10); // YYYY-MM-DD + } catch { + // No last-flush.json — KB hasn't been flushed yet + } + + // Count unprocessed logs + try { + const logFiles = await readdir(join(knowledgePath, 'logs')); + const dailyLogs = logFiles.filter(f => /^\d{4}-\d{2}-\d{2}\.md$/.test(f)); + if (lastFlushDate) { + stats.unprocessedLogCount = dailyLogs.filter( + f => f.replace('.md', '') > lastFlushDate + ).length; + } else { + stats.unprocessedLogCount = dailyLogs.length; + } + } catch { + // No logs directory + } + + // Count articles per domain and staleness + try { + const domains = await readdir(join(knowledgePath, 'domains')); + for (const domain of domains) { + try { + const files = await readdir(join(knowledgePath, 'domains', domain)); + const articles = files.filter(f => f.endsWith('.md') && f !== '_index.md'); + stats.articlesPerDomain[domain] = articles.length; + stats.totalArticles += articles.length; + + // Check each article for staleness marker + for (const file of articles) { + try { + const content = await readFile(join(knowledgePath, 'domains', domain, file), 'utf-8'); + if (content.includes('> [!WARNING] This article may be stale')) { + stats.staleArticleCount++; + } + } catch { + // Skip unreadable articles + } + } + } catch { + // Skip unreadable domains + } + } + } catch { + // No domains directory + } + + return stats; +} + +/** + * `knowledge status` — display KB statistics. + */ +export async function knowledgeStatusCommand( + cwd: string, + project?: string, + jsonFlag?: boolean, + quiet?: boolean +): Promise { + const ownerRepo = await resolveOwnerRepo(cwd, project); + if (!ownerRepo) return 1; + + const { owner, repo } = ownerRepo; + + const projectPath = getProjectKnowledgePath(owner, repo); + const globalPath = getGlobalKnowledgePath(); + + const projectStats = await gatherKBStats(projectPath); + const globalStats = await gatherKBStats(globalPath); + + if (jsonFlag) { + console.log( + JSON.stringify( + { + project: { path: projectPath, owner, repo, ...projectStats }, + global: { path: globalPath, ...globalStats }, + }, + null, + 2 + ) + ); + return 0; + } + + if (quiet) return 0; + + renderKBStats(`Project KB (${owner}/${repo})`, projectStats); + process.stderr.write('\n'); + renderKBStats('Global KB', globalStats); + + return 0; +} + +/** + * Render KB stats to stderr. + */ +function renderKBStats(label: string, stats: KBStats): void { + process.stderr.write(`${label}:\n`); + process.stderr.write(` Total articles: ${stats.totalArticles}\n`); + + if (Object.keys(stats.articlesPerDomain).length > 0) { + process.stderr.write(' Articles per domain:\n'); + for (const [domain, count] of Object.entries(stats.articlesPerDomain).sort()) { + process.stderr.write(` ${domain}: ${count}\n`); + } + } + + process.stderr.write(` Last flush: ${stats.lastFlushTimestamp ?? 'never'}\n`); + process.stderr.write(` Unprocessed logs: ${stats.unprocessedLogCount}\n`); + process.stderr.write(` Stale articles: ${stats.staleArticleCount}\n`); +} + +/** Lint result for a single KB tier */ +interface KBLintResult { + articlesChecked: number; + staleArticles: string[]; + brokenWikilinks: { source: string; target: string }[]; + orphanedArticles: string[]; +} + +/** + * Find orphaned articles — articles that exist in domains/ but are not + * referenced from any _index.md file in the KB. + */ +async function findOrphanedArticles(knowledgePath: string): Promise { + const domainsDir = join(knowledgePath, 'domains'); + const orphans: string[] = []; + + let domains: string[]; + try { + domains = await readdir(domainsDir); + } catch { + return []; + } + + for (const domain of domains) { + const domainDir = join(domainsDir, domain); + let files: string[]; + try { + files = await readdir(domainDir); + } catch { + continue; + } + + // Read _index.md to find referenced articles + let indexContent = ''; + try { + indexContent = await readFile(join(domainDir, '_index.md'), 'utf-8'); + } catch { + // No index — all articles in this domain are orphaned + } + + const articles = files.filter(f => f.endsWith('.md') && f !== '_index.md'); + for (const file of articles) { + const concept = file.replace('.md', ''); + // Check if the article is referenced in the _index.md (by wikilink or plain name) + if (!indexContent.includes(concept)) { + orphans.push(`${domain}/${concept}`); + } + } + } + + return orphans; +} + +/** + * `knowledge lint` — validate KB integrity (staleness, broken links, orphans). + */ +export async function knowledgeLintCommand( + cwd: string, + project?: string, + jsonFlag?: boolean, + quiet?: boolean +): Promise { + const log = getLog(); + const ownerRepo = await resolveOwnerRepo(cwd, project); + if (!ownerRepo) return 1; + + const { owner, repo } = ownerRepo; + const knowledgePath = getProjectKnowledgePath(owner, repo); + + if (!quiet) { + process.stderr.write(`Linting knowledge base for ${owner}/${repo}...\n`); + } + + try { + const config = await loadConfig(); + const result = await lintKB(knowledgePath, owner, repo, config.knowledge.captureModel); + + if (jsonFlag) { + console.log(JSON.stringify(result, null, 2)); + return result.staleArticles.length > 0 || + result.brokenWikilinks.length > 0 || + result.orphanedArticles.length > 0 + ? 1 + : 0; + } + + if (!quiet) { + renderLintResult(result); + } + + // Return 1 if any issues found + return result.staleArticles.length > 0 || + result.brokenWikilinks.length > 0 || + result.orphanedArticles.length > 0 + ? 1 + : 0; + } catch (error) { + const err = error as Error; + log.error({ err, owner, repo }, 'knowledge.lint_command_failed'); + console.error(`Error: Lint failed — ${err.message}`); + return 1; + } +} + +/** + * Run all lint checks on a KB directory. + */ +async function lintKB( + knowledgePath: string, + owner: string, + repo: string, + captureModel: string +): Promise { + // Collect all articles + const articles = await collectAllArticles(knowledgePath); + + if (articles.length === 0) { + return { + articlesChecked: 0, + staleArticles: [], + brokenWikilinks: [], + orphanedArticles: [], + }; + } + + // Check staleness via git diff + AI + let staleArticles: string[] = []; + const lastFlush = await readLastFlush(knowledgePath); + if (lastFlush?.gitSha) { + const diffOutput = await getGitDiffNameOnly(owner, repo, lastFlush.gitSha); + if (diffOutput) { + staleArticles = await identifyStaleArticles(articles, diffOutput, captureModel); + } + } + + // Check broken wikilinks + const brokenWikilinks = checkBrokenWikilinks(articles); + + // Check orphaned articles + const orphanedArticles = await findOrphanedArticles(knowledgePath); + + return { + articlesChecked: articles.length, + staleArticles, + brokenWikilinks, + orphanedArticles, + }; +} + +/** + * Render lint results to stderr. + */ +function renderLintResult(result: KBLintResult): void { + process.stderr.write('\nLint results:\n'); + process.stderr.write(` Articles checked: ${result.articlesChecked}\n`); + + if (result.staleArticles.length > 0) { + process.stderr.write(` Stale articles (${result.staleArticles.length}):\n`); + for (const article of result.staleArticles) { + process.stderr.write(` - ${article}\n`); + } + } else { + process.stderr.write(' Stale articles: 0\n'); + } + + if (result.brokenWikilinks.length > 0) { + process.stderr.write(` Broken wikilinks (${result.brokenWikilinks.length}):\n`); + for (const link of result.brokenWikilinks) { + process.stderr.write(` - ${link.source} → [[${link.target}]]\n`); + } + } else { + process.stderr.write(' Broken wikilinks: 0\n'); + } + + if (result.orphanedArticles.length > 0) { + process.stderr.write(` Orphaned articles (${result.orphanedArticles.length}):\n`); + for (const article of result.orphanedArticles) { + process.stderr.write(` - ${article}\n`); + } + } else { + process.stderr.write(' Orphaned articles: 0\n'); + } +} + +/** + * Render flush results to stderr/stdout. + */ +function renderFlushReport(report: KnowledgeFlushReport, quiet?: boolean): void { + if (report.skipped) { + if (!quiet) { + process.stderr.write(`Skipped: ${report.skipReason ?? 'unknown reason'}\n`); + } + return; + } + + if (quiet) return; + + const domains = report.domainsCreated.length > 0 ? report.domainsCreated.join(', ') : 'none'; + process.stderr.write( + `\nFlush complete:\n Articles created: ${report.articlesCreated}\n Articles updated: ${report.articlesUpdated}\n Articles stale: ${report.articlesStale}\n Domains created: ${domains}\n Logs processed: ${report.logsProcessed.length}\n` + ); +} diff --git a/packages/core/package.json b/packages/core/package.json index 0c5f6b7810..b728994d48 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -23,7 +23,7 @@ "./state/*": "./src/state/*.ts" }, "scripts": { - "test": "bun test src/clients/ && bun test src/handlers/command-handler.test.ts && bun test src/handlers/clone.test.ts && bun test src/db/adapters/postgres.test.ts && bun test src/db/adapters/sqlite.test.ts src/db/codebases.test.ts src/db/connection.test.ts src/db/conversations.test.ts src/db/env-vars.test.ts src/db/isolation-environments.test.ts src/db/messages.test.ts src/db/sessions.test.ts src/db/workflow-events.test.ts src/db/workflows.test.ts src/utils/defaults-copy.test.ts src/utils/worktree-sync.test.ts src/utils/conversation-lock.test.ts src/utils/credential-sanitizer.test.ts src/utils/port-allocation.test.ts src/utils/error.test.ts src/utils/error-formatter.test.ts src/utils/github-graphql.test.ts src/utils/env-allowlist.test.ts src/config/ src/state/ && bun test src/utils/path-validation.test.ts && bun test src/services/cleanup-service.test.ts && bun test src/services/title-generator.test.ts && bun test src/workflows/ && bun test src/operations/workflow-operations.test.ts && bun test src/operations/isolation-operations.test.ts && bun test src/orchestrator/orchestrator.test.ts && bun test src/orchestrator/orchestrator-agent.test.ts && bun test src/orchestrator/orchestrator-isolation.test.ts", + "test": "bun test src/clients/ && bun test src/handlers/command-handler.test.ts && bun test src/handlers/clone.test.ts && bun test src/db/adapters/postgres.test.ts && bun test src/db/adapters/sqlite.test.ts src/db/codebases.test.ts src/db/connection.test.ts src/db/conversations.test.ts src/db/env-vars.test.ts src/db/isolation-environments.test.ts src/db/messages.test.ts src/db/sessions.test.ts src/db/workflow-events.test.ts src/db/workflows.test.ts src/utils/defaults-copy.test.ts src/utils/worktree-sync.test.ts src/utils/conversation-lock.test.ts src/utils/credential-sanitizer.test.ts src/utils/port-allocation.test.ts src/utils/error.test.ts src/utils/error-formatter.test.ts src/utils/github-graphql.test.ts src/utils/env-allowlist.test.ts src/config/ src/state/ && bun test src/utils/path-validation.test.ts && bun test src/services/cleanup-service.test.ts && bun test src/services/title-generator.test.ts && bun test src/services/knowledge-init.test.ts && bun test src/services/knowledge-capture.test.ts && bun test src/services/knowledge-trigger-capture.test.ts && bun test src/services/knowledge-extract.test.ts && bun test src/services/knowledge-flush.test.ts && bun test src/services/knowledge-scheduler.test.ts && bun test src/services/knowledge-workflow-capture.test.ts && bun test src/workflows/ && bun test src/operations/workflow-operations.test.ts && bun test src/operations/isolation-operations.test.ts && bun test src/orchestrator/orchestrator.test.ts && bun test src/orchestrator/orchestrator-agent.test.ts && bun test src/orchestrator/orchestrator-isolation.test.ts && bun test src/orchestrator/prompt-builder.test.ts && bun test src/orchestrator/prompt-builder-knowledge.test.ts", "type-check": "bun x tsc --noEmit", "build": "echo 'No build needed - Bun runs TypeScript directly'" }, diff --git a/packages/core/src/config/config-loader.ts b/packages/core/src/config/config-loader.ts index f0f51ba0a4..d1789e42ca 100644 --- a/packages/core/src/config/config-loader.ts +++ b/packages/core/src/config/config-loader.ts @@ -198,6 +198,13 @@ function getDefaults(): MergedConfig { loadDefaultCommands: true, loadDefaultWorkflows: true, }, + knowledge: { + enabled: true, + captureModel: 'haiku', + compileModel: 'sonnet', + flushDebounceMinutes: 10, + domains: ['architecture', 'decisions', 'patterns', 'lessons', 'connections'], + }, }; } @@ -302,6 +309,11 @@ function mergeGlobalConfig(defaults: MergedConfig, global: GlobalConfig): Merged result.concurrency.maxConversations = global.concurrency.maxConversations; } + // Knowledge preferences + if (global.knowledge) { + result.knowledge = { ...result.knowledge, ...global.knowledge }; + } + return result; } @@ -370,6 +382,11 @@ function mergeRepoConfig(merged: MergedConfig, repo: RepoConfig): MergedConfig { } } + // Knowledge configuration (repo overrides global) + if (repo.knowledge) { + result.knowledge = { ...result.knowledge, ...repo.knowledge }; + } + // Propagate per-project env vars from repo config if (repo.env) { result.envVars = { ...result.envVars, ...repo.env }; diff --git a/packages/core/src/config/config-types.ts b/packages/core/src/config/config-types.ts index 6b49bfbcff..eab65f6fa0 100644 --- a/packages/core/src/config/config-types.ts +++ b/packages/core/src/config/config-types.ts @@ -19,6 +19,42 @@ export interface AssistantDefaults { additionalDirectories?: string[]; } +/** + * Knowledge base configuration + * Controls automatic capture, compilation, and maintenance of cross-session knowledge + */ +export interface KnowledgeConfig { + /** + * Enable/disable knowledge base features + * @default true + */ + enabled?: boolean; + + /** + * Model to use for knowledge capture (fast extraction from transcripts) + * @default 'haiku' + */ + captureModel?: string; + + /** + * Model to use for knowledge compilation (synthesis into articles) + * @default 'sonnet' + */ + compileModel?: string; + + /** + * Minutes to wait after capture before triggering a flush + * @default 10 + */ + flushDebounceMinutes?: number; + + /** + * Knowledge domains to initialize + * @default ['architecture', 'decisions', 'patterns', 'lessons', 'connections'] + */ + domains?: string[]; +} + export interface ClaudeAssistantDefaults { model?: string; /** Claude Code settingSources — controls which CLAUDE.md files are loaded. @@ -84,6 +120,11 @@ export interface GlobalConfig { */ maxConversations?: number; }; + + /** + * Knowledge base configuration (global defaults) + */ + knowledge?: KnowledgeConfig; } /** @@ -158,6 +199,11 @@ export interface RepoConfig { */ env?: Record; + /** + * Knowledge base configuration (repo overrides global) + */ + knowledge?: KnowledgeConfig; + /** * Default commands/workflows configuration */ @@ -240,6 +286,11 @@ export interface MergedConfig { * Undefined when no env vars are configured. */ envVars?: Record; + /** + * Merged knowledge base configuration (global defaults + repo overrides). + * Repo-level fields override global-level fields. + */ + knowledge: Required; } /** diff --git a/packages/core/src/handlers/command-handler.test.ts b/packages/core/src/handlers/command-handler.test.ts index de6516cb98..2f52585ceb 100644 --- a/packages/core/src/handlers/command-handler.test.ts +++ b/packages/core/src/handlers/command-handler.test.ts @@ -161,6 +161,12 @@ mock.module('@archon/isolation', () => ({ }), })); +// Mock knowledge capture service +const mockTriggerCapture = mock(() => undefined); +mock.module('../services/knowledge-capture', () => ({ + triggerCapture: mockTriggerCapture, +})); + // Mock cleanup service const mockCleanupMergedWorktrees = mock(() => Promise.resolve({ @@ -240,6 +246,8 @@ function clearAllMocks(): void { mockCleanupMergedWorktrees.mockClear(); mockCleanupStaleWorktrees.mockClear(); mockCountActiveByCodebase.mockClear(); + // Knowledge capture mocks + mockTriggerCapture.mockClear(); } // Setup spies for internal modules @@ -644,6 +652,32 @@ describe('CommandHandler', () => { expect(mockDeactivateSession).toHaveBeenCalledWith('session-123', 'reset-requested'); }); + test('should trigger knowledge capture on reset', async () => { + mockGetActiveSession.mockResolvedValue({ + id: 'session-123', + conversation_id: 'conv-123', + codebase_id: 'cb-123', + ai_assistant_type: 'claude', + assistant_session_id: 'sdk-123', + active: true, + metadata: {}, + started_at: new Date(), + ended_at: null, + }); + mockDeactivateSession.mockResolvedValue(undefined); + const conversation = { ...baseConversation, codebase_id: 'cb-123' }; + + await handleCommand(conversation, '/reset'); + expect(mockTriggerCapture).toHaveBeenCalledWith('conv-123', 'cb-123'); + }); + + test('should not trigger knowledge capture when no active session', async () => { + mockGetActiveSession.mockResolvedValue(null); + + await handleCommand(baseConversation, '/reset'); + expect(mockTriggerCapture).not.toHaveBeenCalled(); + }); + test('should handle no active session gracefully', async () => { mockGetActiveSession.mockResolvedValue(null); diff --git a/packages/core/src/handlers/command-handler.ts b/packages/core/src/handlers/command-handler.ts index 94227e54b9..864dfff48c 100644 --- a/packages/core/src/handlers/command-handler.ts +++ b/packages/core/src/handlers/command-handler.ts @@ -36,6 +36,7 @@ import { import { getTriggerForCommand, type DeactivatingCommand } from '../state/session-transitions'; import { SessionNotFoundError } from '../db/sessions'; import { createLogger } from '@archon/paths'; +import { triggerCapture } from '../services/knowledge-capture'; /** Lazy-initialized logger (deferred so test mocks can intercept createLogger) */ let cachedLog: ReturnType | undefined; @@ -1046,6 +1047,8 @@ Talk naturally — the orchestrator routes your requests to the right workflow a case 'reset': { const session = await sessionDb.getActiveSession(conversation.id); if (session) { + // Trigger knowledge capture before deactivation (fire-and-forget) + triggerCapture(conversation.id, conversation.codebase_id); await safeDeactivateSession(session.id, 'reset'); return { success: true, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 90b1cefd80..bc0b3567e2 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -119,6 +119,7 @@ export { } from './services/cleanup-service'; export { generateAndSetTitle } from './services/title-generator'; +export { subscribeToWorkflowCapture } from './services/knowledge-workflow-capture'; // ============================================================================= // State diff --git a/packages/core/src/orchestrator/orchestrator-agent.ts b/packages/core/src/orchestrator/orchestrator-agent.ts index 97d989f47c..2a9fed3eb0 100644 --- a/packages/core/src/orchestrator/orchestrator-agent.ts +++ b/packages/core/src/orchestrator/orchestrator-agent.ts @@ -444,7 +444,7 @@ async function discoverAllWorkflows(conversation: Conversation): Promise { const scopedCodebase = conversation.codebase_id ? codebases.find(c => c.id === conversation.codebase_id) : undefined; const systemPrompt = scopedCodebase - ? buildProjectScopedPrompt(scopedCodebase, codebases, workflows) - : buildOrchestratorPrompt(codebases, workflows); + ? await buildProjectScopedPrompt(scopedCodebase, codebases, workflows) + : await buildOrchestratorPrompt(codebases, workflows); const contextSuffix = issueContext ? '\n\n---\n\n## Additional Context\n\n' + issueContext : ''; @@ -731,7 +731,7 @@ export async function handleMessage( }); } - const fullPrompt = buildFullPrompt( + const fullPrompt = await buildFullPrompt( conversation, codebases, workflows, diff --git a/packages/core/src/orchestrator/prompt-builder-knowledge.test.ts b/packages/core/src/orchestrator/prompt-builder-knowledge.test.ts new file mode 100644 index 0000000000..645a35b7ac --- /dev/null +++ b/packages/core/src/orchestrator/prompt-builder-knowledge.test.ts @@ -0,0 +1,355 @@ +import { describe, test, expect, mock, beforeEach } from 'bun:test'; + +// Mock node:fs/promises before importing the module under test +const mockReadFile = mock<(path: string, encoding: string) => Promise>(async () => ''); +const mockReaddir = mock<(path: string) => Promise>(async () => []); + +mock.module('node:fs/promises', () => ({ + readFile: mockReadFile, + readdir: mockReaddir, +})); + +mock.module('@archon/paths', () => ({ + getGlobalKnowledgePath: () => '/home/user/.archon/knowledge', + getProjectKnowledgePath: (owner: string, repo: string) => + `/home/user/.archon/workspaces/${owner}/${repo}/knowledge`, + parseOwnerRepo: (name: string) => { + const parts = name.split('/'); + if (parts.length !== 2) return null; + return { owner: parts[0], repo: parts[1] }; + }, +})); + +import { + buildOrchestratorPrompt, + buildProjectScopedPrompt, + formatKnowledgeSection, +} from './prompt-builder'; +import type { Codebase } from '../types'; +import type { WorkflowDefinition } from '@archon/workflows/schemas/workflow'; + +const makeCodebase = (overrides: Partial = {}): Codebase => ({ + id: 'cb-1', + name: 'acme/widget', + repository_url: 'https://github.com/acme/widget', + default_cwd: '/home/user/.archon/workspaces/acme/widget/source', + ai_assistant_type: 'claude', + commands: {}, + created_at: new Date(), + updated_at: new Date(), + ...overrides, +}); + +const emptyWorkflows: WorkflowDefinition[] = []; + +describe('formatKnowledgeSection', () => { + test('returns empty string when both indexes are empty', () => { + expect(formatKnowledgeSection('', '')).toBe(''); + }); + + test('formats global index only', () => { + const result = formatKnowledgeSection('Global KB content', ''); + expect(result).toContain('## Knowledge Base'); + expect(result).toContain('### Global Knowledge'); + expect(result).toContain('Global KB content'); + expect(result).not.toContain('### Project Knowledge'); + }); + + test('formats project index only', () => { + const result = formatKnowledgeSection('', 'Project KB content'); + expect(result).toContain('## Knowledge Base'); + expect(result).toContain('### Project Knowledge'); + expect(result).toContain('Project KB content'); + expect(result).not.toContain('### Global Knowledge'); + }); + + test('formats both indexes with project after global', () => { + const result = formatKnowledgeSection('Global content', 'Project content'); + expect(result).toContain('### Global Knowledge'); + expect(result).toContain('### Project Knowledge'); + // Project section appears after global + const globalPos = result.indexOf('### Global Knowledge'); + const projectPos = result.indexOf('### Project Knowledge'); + expect(projectPos).toBeGreaterThan(globalPos); + }); +}); + +describe('buildOrchestratorPrompt — knowledge loading', () => { + beforeEach(() => { + mockReadFile.mockReset(); + mockReadFile.mockImplementation(async () => ''); + mockReaddir.mockReset(); + mockReaddir.mockImplementation(async () => { + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); + }); + + test('loads global knowledge index', async () => { + mockReadFile.mockImplementation(async (path: string) => { + if (path === '/home/user/.archon/knowledge/index.md') { + return '# Global KB\n\n- Architecture overview\n- Decision log'; + } + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); + + const result = await buildOrchestratorPrompt([], emptyWorkflows); + expect(result).toContain('## Knowledge Base'); + expect(result).toContain('### Global Knowledge'); + expect(result).toContain('Global KB'); + }); + + test('skips knowledge section when no index.md exists', async () => { + mockReadFile.mockImplementation(async () => { + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); + + const result = await buildOrchestratorPrompt([], emptyWorkflows); + expect(result).not.toContain('## Knowledge Base'); + }); + + test('still includes routing rules and projects', async () => { + mockReadFile.mockImplementation(async () => { + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); + + const codebase = makeCodebase(); + const result = await buildOrchestratorPrompt([codebase], emptyWorkflows); + expect(result).toContain('acme/widget'); + expect(result).toContain('## Routing Rules'); + }); + + test('truncates index content that exceeds budget', async () => { + const longContent = 'x'.repeat(3000); + mockReadFile.mockImplementation(async (path: string) => { + if (path === '/home/user/.archon/knowledge/index.md') { + return longContent; + } + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); + + const result = await buildOrchestratorPrompt([], emptyWorkflows); + expect(result).toContain('*(index truncated)*'); + // Should not contain the full 3000-char string + expect(result).not.toContain(longContent); + }); +}); + +describe('buildProjectScopedPrompt — knowledge loading', () => { + beforeEach(() => { + mockReadFile.mockReset(); + mockReadFile.mockImplementation(async () => ''); + mockReaddir.mockReset(); + mockReaddir.mockImplementation(async () => { + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); + }); + + test('loads both global and project knowledge indexes', async () => { + mockReadFile.mockImplementation(async (path: string) => { + if (path === '/home/user/.archon/knowledge/index.md') { + return '# Global KB'; + } + if (path === '/home/user/.archon/workspaces/acme/widget/knowledge/index.md') { + return '# Project KB'; + } + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); + + const codebase = makeCodebase(); + const result = await buildProjectScopedPrompt(codebase, [codebase], emptyWorkflows); + expect(result).toContain('### Global Knowledge'); + expect(result).toContain('# Global KB'); + expect(result).toContain('### Project Knowledge'); + expect(result).toContain('# Project KB'); + }); + + test('loads project index only when global does not exist', async () => { + mockReadFile.mockImplementation(async (path: string) => { + if (path === '/home/user/.archon/workspaces/acme/widget/knowledge/index.md') { + return '# Project KB'; + } + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); + + const codebase = makeCodebase(); + const result = await buildProjectScopedPrompt(codebase, [codebase], emptyWorkflows); + expect(result).not.toContain('### Global Knowledge'); + expect(result).toContain('### Project Knowledge'); + expect(result).toContain('# Project KB'); + }); + + test('skips project index when codebase name is not owner/repo format', async () => { + mockReadFile.mockImplementation(async (path: string) => { + if (path === '/home/user/.archon/knowledge/index.md') { + return '# Global KB'; + } + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); + + const codebase = makeCodebase({ name: 'local-project' }); + const result = await buildProjectScopedPrompt(codebase, [codebase], emptyWorkflows); + expect(result).toContain('### Global Knowledge'); + expect(result).not.toContain('### Project Knowledge'); + }); + + test('gracefully skips when neither index exists', async () => { + mockReadFile.mockImplementation(async () => { + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); + + const codebase = makeCodebase(); + const result = await buildProjectScopedPrompt(codebase, [codebase], emptyWorkflows); + expect(result).not.toContain('## Knowledge Base'); + expect(result).toContain('## Routing Rules'); + }); + + test('project index appears after global index (project overrides)', async () => { + mockReadFile.mockImplementation(async (path: string) => { + if (path === '/home/user/.archon/knowledge/index.md') return 'Global content'; + if (path === '/home/user/.archon/workspaces/acme/widget/knowledge/index.md') + return 'Project content'; + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); + + const codebase = makeCodebase(); + const result = await buildProjectScopedPrompt(codebase, [codebase], emptyWorkflows); + const globalPos = result.indexOf('Global content'); + const projectPos = result.indexOf('Project content'); + expect(globalPos).toBeGreaterThan(-1); + expect(projectPos).toBeGreaterThan(globalPos); + }); +}); + +describe('buildProjectScopedPrompt — unprocessed logs fallback', () => { + beforeEach(() => { + mockReadFile.mockReset(); + mockReadFile.mockImplementation(async () => { + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); + mockReaddir.mockReset(); + mockReaddir.mockImplementation(async () => { + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); + }); + + test('includes all daily logs when no last-flush.json exists', async () => { + mockReadFile.mockImplementation(async (path: string) => { + if (path === '/home/user/.archon/workspaces/acme/widget/knowledge/logs/2026-04-10.md') { + return '## Decisions\n- Use Haiku for capture\n'; + } + if (path === '/home/user/.archon/workspaces/acme/widget/knowledge/logs/2026-04-11.md') { + return '## Patterns\n- Builder pattern for prompts\n'; + } + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); + mockReaddir.mockImplementation(async (path: string) => { + if (path === '/home/user/.archon/workspaces/acme/widget/knowledge/logs') { + return ['2026-04-10.md', '2026-04-11.md']; + } + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); + + const codebase = makeCodebase(); + const result = await buildProjectScopedPrompt(codebase, [codebase], emptyWorkflows); + expect(result).toContain('### Recent Knowledge (unprocessed)'); + expect(result).toContain('Use Haiku for capture'); + expect(result).toContain('Builder pattern for prompts'); + }); + + test('includes only logs newer than last-flush timestamp', async () => { + mockReadFile.mockImplementation(async (path: string) => { + if (path === '/home/user/.archon/workspaces/acme/widget/knowledge/meta/last-flush.json') { + return JSON.stringify({ timestamp: '2026-04-10T12:00:00Z', gitSha: 'abc123' }); + } + if (path === '/home/user/.archon/workspaces/acme/widget/knowledge/logs/2026-04-11.md') { + return '## New knowledge\n- Fresh insight\n'; + } + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); + mockReaddir.mockImplementation(async (path: string) => { + if (path === '/home/user/.archon/workspaces/acme/widget/knowledge/logs') { + return ['2026-04-09.md', '2026-04-10.md', '2026-04-11.md']; + } + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); + + const codebase = makeCodebase(); + const result = await buildProjectScopedPrompt(codebase, [codebase], emptyWorkflows); + expect(result).toContain('### Recent Knowledge (unprocessed)'); + expect(result).toContain('Fresh insight'); + // Should NOT include logs from 2026-04-09 or 2026-04-10 (at or before flush date) + expect(result).not.toContain('2026-04-09'); + }); + + test('truncates logs that exceed token budget', async () => { + const largeContent = 'x'.repeat(9000); + mockReadFile.mockImplementation(async (path: string) => { + if (path === '/home/user/.archon/workspaces/acme/widget/knowledge/logs/2026-04-11.md') { + return largeContent; + } + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); + mockReaddir.mockImplementation(async (path: string) => { + if (path === '/home/user/.archon/workspaces/acme/widget/knowledge/logs') { + return ['2026-04-11.md']; + } + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); + + const codebase = makeCodebase(); + const result = await buildProjectScopedPrompt(codebase, [codebase], emptyWorkflows); + expect(result).toContain('*(log truncated)*'); + expect(result).not.toContain(largeContent); + }); + + test('skips unprocessed logs section when no logs exist', async () => { + const codebase = makeCodebase(); + const result = await buildProjectScopedPrompt(codebase, [codebase], emptyWorkflows); + expect(result).not.toContain('### Recent Knowledge (unprocessed)'); + }); + + test('shows logs section after knowledge index', async () => { + mockReadFile.mockImplementation(async (path: string) => { + if (path === '/home/user/.archon/workspaces/acme/widget/knowledge/index.md') { + return '# Project Index'; + } + if (path === '/home/user/.archon/workspaces/acme/widget/knowledge/logs/2026-04-11.md') { + return '## Log entry'; + } + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); + mockReaddir.mockImplementation(async (path: string) => { + if (path === '/home/user/.archon/workspaces/acme/widget/knowledge/logs') { + return ['2026-04-11.md']; + } + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); + + const codebase = makeCodebase(); + const result = await buildProjectScopedPrompt(codebase, [codebase], emptyWorkflows); + const indexPos = result.indexOf('# Project Index'); + const logsPos = result.indexOf('### Recent Knowledge (unprocessed)'); + expect(indexPos).toBeGreaterThan(-1); + expect(logsPos).toBeGreaterThan(indexPos); + }); + + test('falls back to global logs when no project logs exist', async () => { + mockReadFile.mockImplementation(async (path: string) => { + if (path === '/home/user/.archon/knowledge/logs/2026-04-11.md') { + return '## Global log\n- Cross-project insight\n'; + } + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); + mockReaddir.mockImplementation(async (path: string) => { + if (path === '/home/user/.archon/knowledge/logs') { + return ['2026-04-11.md']; + } + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); + + const codebase = makeCodebase(); + const result = await buildProjectScopedPrompt(codebase, [codebase], emptyWorkflows); + expect(result).toContain('### Recent Knowledge (unprocessed)'); + expect(result).toContain('Cross-project insight'); + }); +}); diff --git a/packages/core/src/orchestrator/prompt-builder.ts b/packages/core/src/orchestrator/prompt-builder.ts index d5f307db5b..60054a662e 100644 --- a/packages/core/src/orchestrator/prompt-builder.ts +++ b/packages/core/src/orchestrator/prompt-builder.ts @@ -3,6 +3,9 @@ * Constructs the system prompt for the orchestrator agent with all * registered projects and available workflows. */ +import { readFile, readdir } from 'node:fs/promises'; +import { join } from 'node:path'; +import { getGlobalKnowledgePath, getProjectKnowledgePath, parseOwnerRepo } from '@archon/paths'; import type { Codebase } from '../types'; import type { WorkflowDefinition } from '@archon/workflows/schemas/workflow'; @@ -107,14 +110,126 @@ To remove a registered project: IMPORTANT: Always clone into ~/.archon/workspaces/{owner}/{repo}/source unless the user specifies a different location.`; } +/** Maximum approximate token budget for the knowledge index (~500 tokens ≈ ~2000 chars) */ +const KNOWLEDGE_INDEX_MAX_CHARS = 2000; + +/** Maximum approximate token budget for raw logs (~2000 tokens ≈ ~8000 chars) */ +const KNOWLEDGE_LOGS_MAX_CHARS = 8000; + +/** + * Load a knowledge index.md file, returning its content or empty string if not found. + * Gracefully handles ENOENT (empty KB state). + */ +async function loadKnowledgeIndex(knowledgePath: string): Promise { + try { + const indexPath = join(knowledgePath, 'index.md'); + const content = await readFile(indexPath, 'utf-8'); + // Truncate to stay within ~500 token budget + if (content.length > KNOWLEDGE_INDEX_MAX_CHARS) { + return content.slice(0, KNOWLEDGE_INDEX_MAX_CHARS) + '\n\n*(index truncated)*\n'; + } + return content; + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + return ''; + } + throw err; + } +} + +/** + * Load unprocessed daily logs newer than the last flush timestamp. + * If no last-flush.json exists, includes all daily logs (pre-first-flush state). + * Returns concatenated log content truncated to the token budget. + */ +async function loadUnprocessedLogs(knowledgePath: string): Promise { + const logsDir = join(knowledgePath, 'logs'); + const metaPath = join(knowledgePath, 'meta', 'last-flush.json'); + + // Read the last-flush timestamp (if any) + let lastFlushTimestamp: string | null = null; + try { + const metaContent = await readFile(metaPath, 'utf-8'); + const meta = JSON.parse(metaContent) as { timestamp?: string }; + if (meta.timestamp) { + lastFlushTimestamp = meta.timestamp; + } + } catch { + // No last-flush.json — include all logs + } + + // List log files + let logFiles: string[]; + try { + const entries = await readdir(logsDir); + logFiles = entries.filter(f => /^\d{4}-\d{2}-\d{2}\.md$/.test(f)).sort(); + } catch { + // No logs directory — nothing to include + return ''; + } + + // Filter to only logs newer than last flush + if (lastFlushTimestamp) { + const flushDate = lastFlushTimestamp.slice(0, 10); // Extract YYYY-MM-DD + logFiles = logFiles.filter(f => f.replace('.md', '') > flushDate); + } + + if (logFiles.length === 0) return ''; + + // Read and concatenate logs (newest first), respecting token budget + let combined = ''; + for (const file of logFiles.reverse()) { + try { + const content = await readFile(join(logsDir, file), 'utf-8'); + if (combined.length + content.length > KNOWLEDGE_LOGS_MAX_CHARS) { + // Include partial content if we have room + const remaining = KNOWLEDGE_LOGS_MAX_CHARS - combined.length; + if (remaining > 200) { + combined += content.slice(0, remaining) + '\n\n*(log truncated)*\n'; + } + break; + } + combined += content + '\n'; + } catch { + // Skip unreadable files + } + } + + return combined; +} + +/** + * Format knowledge base content as a prompt section. + * Combines global and project indexes with project taking precedence. + */ +export function formatKnowledgeSection( + globalIndex: string, + projectIndex: string, + unprocessedLogs?: string +): string { + if (!globalIndex && !projectIndex && !unprocessedLogs) return ''; + + let section = '\n## Knowledge Base\n\n'; + if (globalIndex) { + section += '### Global Knowledge\n\n' + globalIndex.trim() + '\n\n'; + } + if (projectIndex) { + section += '### Project Knowledge\n\n' + projectIndex.trim() + '\n\n'; + } + if (unprocessedLogs) { + section += '### Recent Knowledge (unprocessed)\n\n' + unprocessedLogs.trim() + '\n\n'; + } + return section; +} + /** * Build the full orchestrator system prompt. * Includes all registered projects, available workflows, and routing instructions. */ -export function buildOrchestratorPrompt( +export async function buildOrchestratorPrompt( codebases: readonly Codebase[], workflows: readonly WorkflowDefinition[] -): string { +): Promise { let prompt = `# Archon Orchestrator You are Archon, an intelligent coding assistant that manages multiple projects. @@ -138,6 +253,15 @@ You can answer questions directly or invoke workflows for structured development prompt += '## Available Workflows\n\n'; prompt += formatWorkflowSection(workflows); + // Load global knowledge index and unprocessed logs + const globalKnowledgePath = getGlobalKnowledgePath(); + const globalIndex = await loadKnowledgeIndex(globalKnowledgePath); + const globalLogs = await loadUnprocessedLogs(globalKnowledgePath); + const knowledgeSection = formatKnowledgeSection(globalIndex, '', globalLogs); + if (knowledgeSection) { + prompt += knowledgeSection; + } + prompt += buildRoutingRules(); return prompt; @@ -148,11 +272,11 @@ You can answer questions directly or invoke workflows for structured development * The scoped project is shown prominently; other projects are listed separately. * Routing rules default to the scoped project when ambiguous. */ -export function buildProjectScopedPrompt( +export async function buildProjectScopedPrompt( scopedCodebase: Codebase, allCodebases: readonly Codebase[], workflows: readonly WorkflowDefinition[] -): string { +): Promise { const otherCodebases = allCodebases.filter(c => c.id !== scopedCodebase.id); let prompt = `# Archon Orchestrator @@ -179,6 +303,25 @@ ${formatProjectSection(scopedCodebase)} prompt += '## Available Workflows\n\n'; prompt += formatWorkflowSection(workflows); + // Load global and project knowledge indexes + unprocessed logs + const globalKnowledgePath = getGlobalKnowledgePath(); + const globalIndex = await loadKnowledgeIndex(globalKnowledgePath); + let projectIndex = ''; + let projectLogs = ''; + const parsed = parseOwnerRepo(scopedCodebase.name); + if (parsed) { + const projectKnowledgePath = getProjectKnowledgePath(parsed.owner, parsed.repo); + projectIndex = await loadKnowledgeIndex(projectKnowledgePath); + projectLogs = await loadUnprocessedLogs(projectKnowledgePath); + } + // Prefer project logs over global logs for the supplementary context + const globalLogs = !projectLogs ? await loadUnprocessedLogs(globalKnowledgePath) : ''; + const unprocessedLogs = projectLogs || globalLogs; + const knowledgeSection = formatKnowledgeSection(globalIndex, projectIndex, unprocessedLogs); + if (knowledgeSection) { + prompt += knowledgeSection; + } + prompt += buildRoutingRulesWithProject(scopedCodebase.name); return prompt; diff --git a/packages/core/src/services/cleanup-service.test.ts b/packages/core/src/services/cleanup-service.test.ts index e4b2d7ec08..5a123aa732 100644 --- a/packages/core/src/services/cleanup-service.test.ts +++ b/packages/core/src/services/cleanup-service.test.ts @@ -88,6 +88,12 @@ mock.module('../db/codebases', () => ({ getCodebase: mockGetCodebase, })); +// Mock knowledge capture service +const mockTriggerCapture = mock(() => undefined); +mock.module('./knowledge-capture', () => ({ + triggerCapture: mockTriggerCapture, +})); + import { runScheduledCleanup, startCleanupScheduler, @@ -113,6 +119,7 @@ describe('cleanup-service', () => { mockUpdateStatus.mockClear(); mockGetById.mockClear(); mockGetCodebase.mockClear(); + mockTriggerCapture.mockClear(); // Reset defaults mockHasUncommittedChanges.mockResolvedValue(false); mockWorktreeExists.mockResolvedValue(false); @@ -930,11 +937,58 @@ describe('onConversationClosed', () => { mockUpdateConversation.mockClear(); mockWorktreeExists.mockClear(); mockHasUncommittedChanges.mockClear(); + mockTriggerCapture.mockClear(); // Reset defaults mockWorktreeExists.mockResolvedValue(false); mockHasUncommittedChanges.mockResolvedValue(false); }); + test('triggers knowledge capture on conversation closed', async () => { + mockGetConversationByPlatformId.mockResolvedValueOnce({ + id: 'conv-capture', + codebase_id: 'cb-capture', + isolation_env_id: 'env-capture', + }); + + mockGetActiveSession.mockResolvedValueOnce(null); + + mockGetById.mockResolvedValueOnce({ + id: 'env-capture', + codebase_id: 'cb-capture', + working_path: '/workspace/worktrees/pr-300', + branch_name: 'feature-z', + status: 'active', + }); + + mockGetConversationsUsingEnv.mockResolvedValueOnce([]); + + mockGetById.mockResolvedValueOnce({ + id: 'env-capture', + codebase_id: 'cb-capture', + working_path: '/workspace/worktrees/pr-300', + branch_name: 'feature-z', + status: 'active', + }); + + mockGetCodebase.mockResolvedValueOnce({ + id: 'cb-capture', + name: 'test-repo', + default_cwd: '/workspace/repo', + }); + + await onConversationClosed('github', 'owner/repo#300'); + + expect(mockTriggerCapture).toHaveBeenCalledWith('conv-capture', 'cb-capture'); + }); + + test('does not trigger knowledge capture when no conversation found', async () => { + mockGetConversationByPlatformId.mockResolvedValueOnce(null); + + await onConversationClosed('github', 'owner/repo#404'); + + expect(mockTriggerCapture).not.toHaveBeenCalled(); + }); + test('deactivates session with conversation-closed reason', async () => { mockGetConversationByPlatformId.mockResolvedValueOnce({ id: 'conv-active-session', diff --git a/packages/core/src/services/cleanup-service.ts b/packages/core/src/services/cleanup-service.ts index dc9ab69d67..195fb83c8a 100644 --- a/packages/core/src/services/cleanup-service.ts +++ b/packages/core/src/services/cleanup-service.ts @@ -23,6 +23,7 @@ import type { RepoPath } from '@archon/git'; import { createLogger } from '@archon/paths'; import type { IsolationEnvironmentRow } from '@archon/isolation'; import { ConversationNotFoundError } from '../types'; +import { triggerCapture } from './knowledge-capture'; /** Lazy-initialized logger (deferred so test mocks can intercept createLogger) */ let cachedLog: ReturnType | undefined; @@ -73,6 +74,9 @@ export async function onConversationClosed( const envId = conversation.isolation_env_id; + // Trigger knowledge capture (fire-and-forget, before cleanup) + triggerCapture(conversation.id, conversation.codebase_id); + // Deactivate any active sessions first const session = await sessionDb.getActiveSession(conversation.id); if (session) { diff --git a/packages/core/src/services/knowledge-capture.test.ts b/packages/core/src/services/knowledge-capture.test.ts new file mode 100644 index 0000000000..82cd85b723 --- /dev/null +++ b/packages/core/src/services/knowledge-capture.test.ts @@ -0,0 +1,371 @@ +import { mock, describe, test, expect, beforeEach } from 'bun:test'; + +// Track appendFile calls +const appendFileCalls: Array<{ path: string; content: string }> = []; +const mockAppendFile = mock(async (path: string, content: string) => { + appendFileCalls.push({ path, content }); + return undefined; +}); + +const mkdirCalls: string[] = []; +const mockMkdir = mock(async (path: string) => { + mkdirCalls.push(path); + return undefined; +}); + +mock.module('node:fs/promises', () => ({ + appendFile: mockAppendFile, + mkdir: mockMkdir, + writeFile: mock(async () => undefined), +})); + +// Mock @archon/paths +const mockLogger = { + fatal: mock(() => undefined), + error: mock(() => undefined), + warn: mock(() => undefined), + info: mock(() => undefined), + debug: mock(() => undefined), + trace: mock(() => undefined), +}; +mock.module('@archon/paths', () => ({ + getProjectKnowledgePath: (owner: string, repo: string) => + `/home/test/.archon/workspaces/${owner}/${repo}/knowledge`, + getGlobalKnowledgePath: () => '/home/test/.archon/knowledge', + createLogger: mock(() => mockLogger), +})); + +// Mock messages DB +const mockListMessages = mock( + async () => + [] as Array<{ + id: string; + conversation_id: string; + role: 'user' | 'assistant'; + content: string; + metadata: string; + created_at: string; + }> +); +mock.module('../db/messages', () => ({ + listMessages: mockListMessages, +})); + +// Mock config loader +const defaultKnowledgeConfig = { + enabled: true, + captureModel: 'haiku', + compileModel: 'sonnet', + flushDebounceMinutes: 10, + domains: ['architecture', 'decisions', 'patterns', 'lessons', 'connections'], +}; +const mockLoadConfig = mock(async () => ({ + knowledge: { ...defaultKnowledgeConfig }, + assistants: { claude: { model: 'sonnet', settingSources: ['project'] }, codex: {} }, + worktree: {}, + docs: { path: 'docs/' }, + defaults: { loadDefaultCommands: true, loadDefaultWorkflows: true }, +})); +mock.module('../config/config-loader', () => ({ + loadConfig: mockLoadConfig, +})); + +// Mock knowledge-init +const mockInitKnowledgeDir = mock(async () => undefined); +mock.module('./knowledge-init', () => ({ + initKnowledgeDir: mockInitKnowledgeDir, +})); + +// Mock AI client +let mockSendQueryChunks: Array<{ type: string; content?: string }> = []; +const mockSendQuery = mock(function* () { + for (const chunk of mockSendQueryChunks) { + yield chunk; + } +}); +const mockGetAssistantClient = mock(() => ({ + sendQuery: mockSendQuery, + getType: () => 'claude', +})); +mock.module('../clients/factory', () => ({ + getAssistantClient: mockGetAssistantClient, +})); + +import { captureKnowledge } from './knowledge-capture'; + +describe('knowledge-capture', () => { + beforeEach(() => { + appendFileCalls.length = 0; + mkdirCalls.length = 0; + mockAppendFile.mockClear(); + mockMkdir.mockClear(); + mockListMessages.mockClear(); + mockLoadConfig.mockClear(); + mockInitKnowledgeDir.mockClear(); + mockSendQuery.mockClear(); + mockGetAssistantClient.mockClear(); + Object.values(mockLogger).forEach(fn => fn.mockClear()); + + // Reset default mock implementations + mockListMessages.mockImplementation(async () => []); + mockSendQueryChunks = []; + mockSendQuery.mockImplementation(function* () { + for (const chunk of mockSendQueryChunks) { + yield chunk; + } + }); + }); + + test('skips capture when knowledge is disabled', async () => { + const config = { + knowledge: { ...defaultKnowledgeConfig, enabled: false }, + assistants: { claude: { model: 'sonnet', settingSources: ['project'] as const }, codex: {} }, + worktree: {}, + docs: { path: 'docs/' }, + defaults: { loadDefaultCommands: true, loadDefaultWorkflows: true }, + }; + + const result = await captureKnowledge('conv-123', 'acme', 'widget', config as never); + + expect(result.skipped).toBe(true); + expect(result.skipReason).toContain('disabled'); + expect(mockListMessages).not.toHaveBeenCalled(); + }); + + test('skips capture when conversation has no messages', async () => { + mockListMessages.mockResolvedValueOnce([]); + + const result = await captureKnowledge('conv-123', 'acme', 'widget'); + + expect(result.skipped).toBe(true); + expect(result.skipReason).toContain('No messages'); + expect(mockSendQuery).not.toHaveBeenCalled(); + }); + + test('extracts knowledge from conversation and appends to daily log', async () => { + mockListMessages.mockResolvedValueOnce([ + { + id: 'msg-1', + conversation_id: 'conv-123', + role: 'user' as const, + content: 'How should we handle auth?', + metadata: '{}', + created_at: '2026-04-11T10:00:00Z', + }, + { + id: 'msg-2', + conversation_id: 'conv-123', + role: 'assistant' as const, + content: 'We should use JWT tokens with short expiry.', + metadata: '{}', + created_at: '2026-04-11T10:00:01Z', + }, + ]); + + mockSendQueryChunks = [ + { type: 'assistant', content: '## Decisions\n' }, + { type: 'assistant', content: '- Use JWT tokens with short expiry for auth\n' }, + ]; + + const result = await captureKnowledge('conv-123', 'acme', 'widget'); + + expect(result.skipped).toBe(false); + expect(result.extractedContent).toBe( + '## Decisions\n- Use JWT tokens with short expiry for auth\n' + ); + expect(result.logFile).toContain('knowledge/logs/'); + expect(result.logFile).toMatch(/\d{4}-\d{2}-\d{2}\.md$/); + }); + + test('calls AI client with haiku model and no tools', async () => { + mockListMessages.mockResolvedValueOnce([ + { + id: 'msg-1', + conversation_id: 'conv-123', + role: 'user' as const, + content: 'test message', + metadata: '{}', + created_at: '2026-04-11T10:00:00Z', + }, + ]); + + mockSendQueryChunks = [{ type: 'assistant', content: '## Patterns\n- test\n' }]; + + await captureKnowledge('conv-123', 'acme', 'widget'); + + expect(mockGetAssistantClient).toHaveBeenCalledWith('claude'); + expect(mockSendQuery).toHaveBeenCalledTimes(1); + + // Check options passed to sendQuery + const callArgs = mockSendQuery.mock.calls[0]; + expect(callArgs).toBeDefined(); + // sendQuery(prompt, cwd, resumeSessionId, options) + const options = callArgs![3] as { model: string; tools: string[] }; + expect(options.model).toBe('haiku'); + expect(options.tools).toEqual([]); + }); + + test('formats transcript with role labels', async () => { + mockListMessages.mockResolvedValueOnce([ + { + id: 'msg-1', + conversation_id: 'conv-123', + role: 'user' as const, + content: 'Hello', + metadata: '{}', + created_at: '2026-04-11T10:00:00Z', + }, + { + id: 'msg-2', + conversation_id: 'conv-123', + role: 'assistant' as const, + content: 'Hi there', + metadata: '{}', + created_at: '2026-04-11T10:00:01Z', + }, + ]); + + mockSendQueryChunks = [{ type: 'assistant', content: '## Lessons\n- greeting\n' }]; + + await captureKnowledge('conv-123', 'acme', 'widget'); + + // The prompt should contain formatted transcript + const prompt = mockSendQuery.mock.calls[0]![0] as string; + expect(prompt).toContain('[USER]: Hello'); + expect(prompt).toContain('[ASSISTANT]: Hi there'); + }); + + test('skips when AI returns "No knowledge to extract"', async () => { + mockListMessages.mockResolvedValueOnce([ + { + id: 'msg-1', + conversation_id: 'conv-123', + role: 'user' as const, + content: 'Hi', + metadata: '{}', + created_at: '2026-04-11T10:00:00Z', + }, + ]); + + mockSendQueryChunks = [{ type: 'assistant', content: 'No knowledge to extract.' }]; + + const result = await captureKnowledge('conv-123', 'acme', 'widget'); + + expect(result.skipped).toBe(true); + expect(result.skipReason).toContain('No knowledge extracted'); + expect(mockAppendFile).not.toHaveBeenCalled(); + }); + + test('initializes KB directory before writing', async () => { + mockListMessages.mockResolvedValueOnce([ + { + id: 'msg-1', + conversation_id: 'conv-123', + role: 'user' as const, + content: 'test', + metadata: '{}', + created_at: '2026-04-11T10:00:00Z', + }, + ]); + + mockSendQueryChunks = [{ type: 'assistant', content: '## Decisions\n- test\n' }]; + + await captureKnowledge('conv-123', 'acme', 'widget'); + + expect(mockInitKnowledgeDir).toHaveBeenCalledWith('acme', 'widget'); + }); + + test('appends to daily log with conversation ID and timestamp', async () => { + mockListMessages.mockResolvedValueOnce([ + { + id: 'msg-1', + conversation_id: 'conv-123', + role: 'user' as const, + content: 'test', + metadata: '{}', + created_at: '2026-04-11T10:00:00Z', + }, + ]); + + mockSendQueryChunks = [{ type: 'assistant', content: '## Patterns\n- something\n' }]; + + await captureKnowledge('conv-123', 'acme', 'widget'); + + expect(appendFileCalls).toHaveLength(1); + const call = appendFileCalls[0]!; + expect(call.path).toContain('/knowledge/logs/'); + expect(call.content).toContain('conv-123'); + expect(call.content).toContain('## Patterns\n- something\n'); + expect(call.content).toContain('### Capture:'); + }); + + test('loads config when not provided', async () => { + mockListMessages.mockResolvedValueOnce([]); + + await captureKnowledge('conv-123', 'acme', 'widget'); + + expect(mockLoadConfig).toHaveBeenCalledTimes(1); + }); + + test('uses provided config without loading', async () => { + const config = { + knowledge: { ...defaultKnowledgeConfig }, + assistants: { claude: { model: 'sonnet', settingSources: ['project'] as const }, codex: {} }, + worktree: {}, + docs: { path: 'docs/' }, + defaults: { loadDefaultCommands: true, loadDefaultWorkflows: true }, + }; + + mockListMessages.mockResolvedValueOnce([]); + + await captureKnowledge('conv-123', 'acme', 'widget', config as never); + + expect(mockLoadConfig).not.toHaveBeenCalled(); + }); + + test('throws and logs on AI client error', async () => { + mockListMessages.mockResolvedValueOnce([ + { + id: 'msg-1', + conversation_id: 'conv-123', + role: 'user' as const, + content: 'test', + metadata: '{}', + created_at: '2026-04-11T10:00:00Z', + }, + ]); + + mockSendQuery.mockImplementation(function* () { + throw new Error('API rate limit exceeded'); + }); + + await expect(captureKnowledge('conv-123', 'acme', 'widget')).rejects.toThrow( + 'API rate limit exceeded' + ); + + expect(mockLogger.error).toHaveBeenCalled(); + }); + + test('ignores non-assistant chunks from AI response', async () => { + mockListMessages.mockResolvedValueOnce([ + { + id: 'msg-1', + conversation_id: 'conv-123', + role: 'user' as const, + content: 'test', + metadata: '{}', + created_at: '2026-04-11T10:00:00Z', + }, + ]); + + mockSendQueryChunks = [ + { type: 'thinking', content: 'analyzing...' }, + { type: 'assistant', content: '## Decisions\n- use X\n' }, + { type: 'result' }, + ]; + + const result = await captureKnowledge('conv-123', 'acme', 'widget'); + + expect(result.extractedContent).toBe('## Decisions\n- use X\n'); + }); +}); diff --git a/packages/core/src/services/knowledge-capture.ts b/packages/core/src/services/knowledge-capture.ts new file mode 100644 index 0000000000..632d4a89fa --- /dev/null +++ b/packages/core/src/services/knowledge-capture.ts @@ -0,0 +1,340 @@ +/** + * Knowledge capture service — extracts decisions/lessons/patterns from conversation + * transcripts and appends them to daily log files in the knowledge base. + */ +import { appendFile, mkdir } from 'node:fs/promises'; +import { join } from 'node:path'; +import { getProjectKnowledgePath, parseOwnerRepo } from '@archon/paths'; +import { createLogger } from '@archon/paths'; +import { getAssistantClient } from '../clients/factory'; +import * as messageDb from '../db/messages'; +import * as codebaseDb from '../db/codebases'; +import { loadConfig } from '../config/config-loader'; +import { initKnowledgeDir } from './knowledge-init'; +import { scheduleFlush } from './knowledge-scheduler'; +import type { MergedConfig } from '../config/config-types'; +import type { MessageRow } from '../db/messages'; + +/** Lazy-initialized logger (deferred so test mocks can intercept createLogger) */ +let cachedLog: ReturnType | undefined; +function getLog(): ReturnType { + if (!cachedLog) cachedLog = createLogger('knowledge.capture'); + return cachedLog; +} + +/** Extraction prompt sent to the capture model to extract structured knowledge from a transcript */ +const EXTRACTION_PROMPT = `You are a knowledge extraction agent. Analyze the following conversation transcript and extract any valuable knowledge into these categories: + +## Decisions +Architectural or design decisions made, with rationale. + +## Patterns +Recurring code patterns, conventions, or best practices discovered or applied. + +## Lessons +Mistakes encountered, debugging insights, gotchas, or constraints learned. + +## Connections +Cross-component dependencies, system relationships, or integration points discovered. + +Rules: +- Only include items that would be valuable for a future session on this project +- Skip trivial or obvious items +- Use bullet points with concise descriptions +- Include the "why" for decisions and lessons +- If no items exist for a category, omit that category entirely +- If the transcript contains no extractable knowledge, respond with "No knowledge to extract." + +--- + +TRANSCRIPT: +`; + +export interface KnowledgeCaptureReport { + logFile: string; + extractedContent: string; + skipped: boolean; + skipReason?: string; +} + +/** + * Extract knowledge from a conversation transcript and append to a daily log. + * + * @param conversationId - Database UUID of the conversation + * @param owner - Repository owner + * @param repo - Repository name + * @param config - Optional pre-loaded config (avoids redundant loading) + * @param additionalTranscript - Optional extra context (e.g. workflow JSONL logs) appended to transcript + */ +export async function captureKnowledge( + conversationId: string, + owner: string, + repo: string, + config?: MergedConfig, + additionalTranscript?: string +): Promise { + const log = getLog(); + + // Load config if not provided + const mergedConfig = config ?? (await loadConfig()); + + // Check if knowledge capture is enabled + if (!mergedConfig.knowledge.enabled) { + log.debug({ conversationId }, 'knowledge.capture_skipped_disabled'); + return { + logFile: '', + extractedContent: '', + skipped: true, + skipReason: 'Knowledge capture is disabled', + }; + } + + log.info({ conversationId, owner, repo }, 'knowledge.capture_started'); + + try { + // Read conversation messages + const messages = await messageDb.listMessages(conversationId); + + if (messages.length === 0) { + log.info({ conversationId }, 'knowledge.capture_skipped_empty'); + return { + logFile: '', + extractedContent: '', + skipped: true, + skipReason: 'No messages in conversation', + }; + } + + // Format transcript for extraction + const transcript = formatTranscript(messages) + (additionalTranscript ?? ''); + + // Call AI model to extract knowledge + const extractedContent = await extractKnowledge( + transcript, + mergedConfig.knowledge.captureModel + ); + + // Skip if nothing to extract + if (!extractedContent.trim() || extractedContent.includes('No knowledge to extract')) { + log.info({ conversationId }, 'knowledge.capture_completed_nothing'); + return { + logFile: '', + extractedContent: '', + skipped: true, + skipReason: 'No knowledge extracted from conversation', + }; + } + + // Ensure KB directory exists + await initKnowledgeDir(owner, repo); + + // Append to daily log + const logFile = await appendToDailyLog(owner, repo, conversationId, extractedContent); + + log.info( + { conversationId, logFile, contentLength: extractedContent.length }, + 'knowledge.capture_completed' + ); + + return { + logFile, + extractedContent, + skipped: false, + }; + } catch (e) { + const err = e as Error; + log.error( + { + conversationId, + error: err.message, + errorType: err.constructor.name, + err, + }, + 'knowledge.capture_failed' + ); + throw err; + } +} + +/** + * Format conversation messages into a readable transcript for extraction. + */ +function formatTranscript(messages: readonly MessageRow[]): string { + return messages.map(msg => `[${msg.role.toUpperCase()}]: ${msg.content}`).join('\n\n'); +} + +/** + * Call AI model to extract structured knowledge from a transcript. + * Falls back to default model if configured model is unavailable. + */ +async function extractKnowledge(transcript: string, captureModel: string): Promise { + const client = getAssistantClient('claude'); + const prompt = EXTRACTION_PROMPT + transcript; + + const chunks: string[] = []; + const generator = client.sendQuery(prompt, process.cwd(), undefined, { + model: captureModel, + tools: [], // No tools needed for extraction + }); + + for await (const chunk of generator) { + if (chunk.type === 'assistant') { + chunks.push(chunk.content); + } + // Ignore other chunk types (tool, thinking, result, etc.) + } + + return chunks.join(''); +} + +/** + * Append extracted knowledge to the daily log file. + * Creates the log file if it doesn't exist. + * Returns the log file path. + */ +async function appendToDailyLog( + owner: string, + repo: string, + conversationId: string, + content: string +): Promise { + const knowledgePath = getProjectKnowledgePath(owner, repo); + const logsDir = join(knowledgePath, 'logs'); + + // Ensure logs directory exists + await mkdir(logsDir, { recursive: true }); + + const today = new Date().toISOString().slice(0, 10); // YYYY-MM-DD + const logFile = join(logsDir, `${today}.md`); + + const timestamp = new Date().toISOString(); + const entry = `\n---\n\n### Capture: ${timestamp}\n**Conversation**: ${conversationId}\n\n${content}\n`; + + await appendFile(logFile, entry); + + return logFile; +} + +/** + * Extract knowledge using a custom prompt and context. + * Used by knowledge-extract workflow nodes for targeted extraction. + * + * @param prompt - Custom extraction prompt describing what to extract + * @param context - Upstream context (e.g. workflow node outputs) + * @param cwd - Working directory (used to resolve owner/repo via git remote) + * @param metadata - Workflow run and node identifiers for log entries + * @returns Extracted knowledge content + */ +export async function extractKnowledgeFromContext( + prompt: string, + context: string, + cwd: string, + metadata: { workflowRunId: string; nodeId: string } +): Promise { + const log = getLog(); + + // Resolve owner/repo from cwd via git remote + const { toRepoPath, getRemoteUrl } = await import('@archon/git'); + const repoPath = toRepoPath(cwd); + const remoteUrl = await getRemoteUrl(repoPath); + if (!remoteUrl) { + throw new Error('Cannot resolve owner/repo from git remote — no remote URL found'); + } + const urlParts = remoteUrl.replace(/\.git$/, '').split(/[/:]/); + const repo = urlParts.pop(); + const owner = urlParts.pop(); + if (!owner || !repo) { + throw new Error(`Cannot parse owner/repo from remote URL: ${remoteUrl}`); + } + + const mergedConfig = await loadConfig(); + if (!mergedConfig.knowledge.enabled) { + log.debug('knowledge.extract_skipped_disabled'); + return ''; + } + + log.info({ owner, repo, nodeId: metadata.nodeId }, 'knowledge.extract_started'); + + // Call AI with the custom prompt + context + const fullPrompt = `${prompt}\n\n---\n\nCONTEXT:\n${context}`; + const client = getAssistantClient('claude'); + const chunks: string[] = []; + const generator = client.sendQuery(fullPrompt, cwd, undefined, { + model: mergedConfig.knowledge.captureModel, + tools: [], + }); + + for await (const chunk of generator) { + if (chunk.type === 'assistant') { + chunks.push(chunk.content); + } + } + + const extracted = chunks.join(''); + if (!extracted.trim()) { + log.info({ nodeId: metadata.nodeId }, 'knowledge.extract_completed_nothing'); + return ''; + } + + // Append to daily log with workflow metadata + await initKnowledgeDir(owner, repo); + const knowledgePath = getProjectKnowledgePath(owner, repo); + const logsDir = join(knowledgePath, 'logs'); + await mkdir(logsDir, { recursive: true }); + + const today = new Date().toISOString().slice(0, 10); + const logFile = join(logsDir, `${today}.md`); + const timestamp = new Date().toISOString(); + const entry = `\n---\n\n### Knowledge Extract: ${timestamp}\n**Workflow Run**: ${metadata.workflowRunId}\n**Node**: ${metadata.nodeId}\n\n${extracted}\n`; + + await appendFile(logFile, entry); + + log.info( + { owner, repo, nodeId: metadata.nodeId, logFile, contentLength: extracted.length }, + 'knowledge.extract_completed' + ); + + // Schedule debounced flush after extraction + await scheduleFlush(owner, repo); + + return extracted; +} + +/** + * Fire-and-forget capture trigger for session transitions. + * Resolves owner/repo from codebaseId, then calls captureKnowledge(). + * Logs errors but never throws — safe to call without await. + */ +export function triggerCapture(conversationId: string, codebaseId: string | null): void { + if (!codebaseId) return; + + const log = getLog(); + + void (async (): Promise => { + const codebase = await codebaseDb.getCodebase(codebaseId); + if (!codebase) { + log.debug({ conversationId, codebaseId }, 'knowledge.trigger_skipped_no_codebase'); + return; + } + + const parsed = parseOwnerRepo(codebase.name); + if (!parsed) { + log.debug( + { conversationId, codebaseName: codebase.name }, + 'knowledge.trigger_skipped_no_owner_repo' + ); + return; + } + + const report = await captureKnowledge(conversationId, parsed.owner, parsed.repo); + // Schedule debounced flush after successful capture (non-skipped) + if (!report.skipped) { + await scheduleFlush(parsed.owner, parsed.repo); + } + })().catch(err => { + log.error( + { conversationId, codebaseId, error: (err as Error).message, err }, + 'knowledge.trigger_failed' + ); + }); +} diff --git a/packages/core/src/services/knowledge-extract.test.ts b/packages/core/src/services/knowledge-extract.test.ts new file mode 100644 index 0000000000..a97c968220 --- /dev/null +++ b/packages/core/src/services/knowledge-extract.test.ts @@ -0,0 +1,209 @@ +/** + * Tests for extractKnowledgeFromContext — used by knowledge-extract workflow nodes. + */ +import { mock, describe, test, expect, beforeEach } from 'bun:test'; + +// Track appendFile calls +const appendFileCalls: Array<{ path: string; content: string }> = []; +const mockAppendFile = mock(async (path: string, content: string) => { + appendFileCalls.push({ path, content }); + return undefined; +}); + +const mkdirCalls: string[] = []; +const mockMkdir = mock(async (path: string) => { + mkdirCalls.push(path); + return undefined; +}); + +mock.module('node:fs/promises', () => ({ + appendFile: mockAppendFile, + mkdir: mockMkdir, + writeFile: mock(async () => undefined), +})); + +// Mock @archon/paths +const mockLogger = { + fatal: mock(() => undefined), + error: mock(() => undefined), + warn: mock(() => undefined), + info: mock(() => undefined), + debug: mock(() => undefined), + trace: mock(() => undefined), +}; +mock.module('@archon/paths', () => ({ + getProjectKnowledgePath: (owner: string, repo: string) => + `/home/test/.archon/workspaces/${owner}/${repo}/knowledge`, + getGlobalKnowledgePath: () => '/home/test/.archon/knowledge', + createLogger: mock(() => mockLogger), + parseOwnerRepo: (name: string) => { + const parts = name.split('/'); + if (parts.length !== 2) return null; + return { owner: parts[0], repo: parts[1] }; + }, +})); + +// Mock @archon/git (used via dynamic import) +mock.module('@archon/git', () => ({ + toRepoPath: (cwd: string) => cwd, + getRemoteUrl: mock(async () => 'https://github.com/acme/widget.git'), +})); + +// Mock messages DB (not used by extractKnowledgeFromContext but imported by module) +mock.module('../db/messages', () => ({ + listMessages: mock(async () => []), +})); + +// Mock codebases DB +mock.module('../db/codebases', () => ({ + getCodebase: mock(async () => null), +})); + +// Mock config loader +const defaultKnowledgeConfig = { + enabled: true, + captureModel: 'haiku', + compileModel: 'sonnet', + flushDebounceMinutes: 10, + domains: ['architecture', 'decisions', 'patterns', 'lessons', 'connections'], +}; +const mockLoadConfig = mock(async () => ({ + knowledge: { ...defaultKnowledgeConfig }, + assistants: { claude: { model: 'sonnet', settingSources: ['project'] }, codex: {} }, + worktree: {}, + docs: { path: 'docs/' }, + defaults: { loadDefaultCommands: true, loadDefaultWorkflows: true }, +})); +mock.module('../config/config-loader', () => ({ + loadConfig: mockLoadConfig, +})); + +// Mock knowledge-init +const mockInitKnowledgeDir = mock(async () => undefined); +mock.module('./knowledge-init', () => ({ + initKnowledgeDir: mockInitKnowledgeDir, +})); + +// Mock knowledge-scheduler +const mockScheduleFlush = mock(async () => undefined); +mock.module('./knowledge-scheduler', () => ({ + scheduleFlush: mockScheduleFlush, +})); + +// Mock AI client +let mockSendQueryChunks: Array<{ type: string; content?: string }> = []; +const mockSendQuery = mock(function* () { + for (const chunk of mockSendQueryChunks) { + yield chunk; + } +}); +mock.module('../clients/factory', () => ({ + getAssistantClient: mock(() => ({ + sendQuery: mockSendQuery, + getType: () => 'claude', + })), +})); + +import { extractKnowledgeFromContext } from './knowledge-capture'; + +describe('extractKnowledgeFromContext', () => { + beforeEach(() => { + appendFileCalls.length = 0; + mkdirCalls.length = 0; + mockAppendFile.mockClear(); + mockMkdir.mockClear(); + mockLoadConfig.mockClear(); + mockInitKnowledgeDir.mockClear(); + mockScheduleFlush.mockClear(); + mockSendQuery.mockClear(); + Object.values(mockLogger).forEach(fn => fn.mockClear()); + mockSendQueryChunks = []; + mockSendQuery.mockImplementation(function* () { + for (const chunk of mockSendQueryChunks) { + yield chunk; + } + }); + }); + + test('extracts knowledge and appends to daily log', async () => { + mockSendQueryChunks = [ + { type: 'assistant', content: '## Decisions\n- Use JWT for auth' }, + { type: 'result' }, + ]; + + const result = await extractKnowledgeFromContext( + 'Extract architecture decisions', + 'Auth module uses session tokens', + '/tmp/repo', + { workflowRunId: 'run-123', nodeId: 'extract-node' } + ); + + expect(result).toContain('Use JWT for auth'); + expect(mockInitKnowledgeDir).toHaveBeenCalledWith('acme', 'widget'); + expect(appendFileCalls.length).toBe(1); + expect(appendFileCalls[0].content).toContain('Knowledge Extract:'); + expect(appendFileCalls[0].content).toContain('run-123'); + expect(appendFileCalls[0].content).toContain('extract-node'); + }); + + test('returns empty string when knowledge is disabled', async () => { + mockLoadConfig.mockResolvedValueOnce({ + knowledge: { ...defaultKnowledgeConfig, enabled: false }, + assistants: { claude: { model: 'sonnet' }, codex: {} }, + } as never); + + const result = await extractKnowledgeFromContext( + 'Extract patterns', + 'Some context', + '/tmp/repo', + { workflowRunId: 'run-123', nodeId: 'extract' } + ); + + expect(result).toBe(''); + expect(mockSendQuery).not.toHaveBeenCalled(); + }); + + test('returns empty string when AI produces no output', async () => { + mockSendQueryChunks = [{ type: 'result' }]; + + const result = await extractKnowledgeFromContext( + 'Extract decisions', + 'Some context', + '/tmp/repo', + { workflowRunId: 'run-123', nodeId: 'extract' } + ); + + expect(result).toBe(''); + expect(appendFileCalls.length).toBe(0); + }); + + test('schedules flush after successful extraction', async () => { + mockSendQueryChunks = [ + { type: 'assistant', content: '## Patterns\n- Use dependency injection' }, + { type: 'result' }, + ]; + + await extractKnowledgeFromContext('Extract patterns', 'Context', '/tmp/repo', { + workflowRunId: 'run-123', + nodeId: 'extract', + }); + + expect(mockScheduleFlush).toHaveBeenCalledWith('acme', 'widget'); + }); + + test('includes custom prompt and context in AI call', async () => { + mockSendQueryChunks = [{ type: 'assistant', content: 'Extracted content' }, { type: 'result' }]; + + await extractKnowledgeFromContext( + 'Focus on security patterns', + 'Auth uses bcrypt for hashing', + '/tmp/repo', + { workflowRunId: 'run-123', nodeId: 'extract' } + ); + + expect(mockSendQuery).toHaveBeenCalledTimes(1); + const callArgs = mockSendQuery.mock.calls[0]; + expect(callArgs[0]).toContain('Focus on security patterns'); + expect(callArgs[0]).toContain('Auth uses bcrypt for hashing'); + }); +}); diff --git a/packages/core/src/services/knowledge-flush.test.ts b/packages/core/src/services/knowledge-flush.test.ts new file mode 100644 index 0000000000..be6de38dd5 --- /dev/null +++ b/packages/core/src/services/knowledge-flush.test.ts @@ -0,0 +1,1250 @@ +import { mock, describe, test, expect, beforeEach } from 'bun:test'; + +// Track writeFile calls +const writeFileCalls: Array<{ path: string; content: string; options?: unknown }> = []; +const mockWriteFile = mock(async (path: string, content: string, options?: unknown) => { + // Simulate exclusive create (wx flag) — fail if file already exists in mock FS + if ( + options && + typeof options === 'object' && + 'flag' in options && + (options as { flag: string }).flag === 'wx' + ) { + if (fileSystem[path] !== undefined) { + const err = new Error(`EEXIST: file already exists, open '${path}'`) as NodeJS.ErrnoException; + err.code = 'EEXIST'; + throw err; + } + } + writeFileCalls.push({ path, content, options }); + fileSystem[path] = content; + return undefined; +}); + +const mkdirCalls: string[] = []; +const mockMkdir = mock(async (path: string) => { + mkdirCalls.push(path); + return undefined; +}); + +const renameCalls: Array<{ oldPath: string; newPath: string }> = []; +const mockRename = mock(async (oldPath: string, newPath: string) => { + renameCalls.push({ oldPath, newPath }); + return undefined; +}); + +const unlinkCalls: string[] = []; +const mockUnlink = mock(async (path: string) => { + unlinkCalls.push(path); + delete fileSystem[path]; + return undefined; +}); + +const rmCalls: string[] = []; +const mockRm = mock(async (path: string) => { + rmCalls.push(path); + return undefined; +}); + +// File system state for readFile/readdir +let fileSystem: Record = {}; +let directories: Record = {}; + +const mockReadFile = mock(async (path: string) => { + if (fileSystem[path] !== undefined) { + return fileSystem[path]; + } + const err = new Error( + `ENOENT: no such file or directory, open '${path}'` + ) as NodeJS.ErrnoException; + err.code = 'ENOENT'; + throw err; +}); + +const mockReaddir = mock(async (path: string) => { + if (directories[path] !== undefined) { + return directories[path]; + } + const err = new Error( + `ENOENT: no such file or directory, scandir '${path}'` + ) as NodeJS.ErrnoException; + err.code = 'ENOENT'; + throw err; +}); + +mock.module('node:fs/promises', () => ({ + writeFile: mockWriteFile, + mkdir: mockMkdir, + readFile: mockReadFile, + readdir: mockReaddir, + rename: mockRename, + unlink: mockUnlink, + rm: mockRm, +})); + +// Mock @archon/paths +const mockLogger = { + fatal: mock(() => undefined), + error: mock(() => undefined), + warn: mock(() => undefined), + info: mock(() => undefined), + debug: mock(() => undefined), + trace: mock(() => undefined), +}; +mock.module('@archon/paths', () => ({ + getProjectKnowledgePath: (owner: string, repo: string) => + `/home/test/.archon/workspaces/${owner}/${repo}/knowledge`, + getProjectSourcePath: (owner: string, repo: string) => + `/home/test/.archon/workspaces/${owner}/${repo}/source`, + getGlobalKnowledgePath: () => '/home/test/.archon/knowledge', + createLogger: mock(() => mockLogger), +})); + +// Mock @archon/git +const mockExecFileAsync = mock(async () => ({ stdout: '', stderr: '' })); +mock.module('@archon/git', () => ({ + execFileAsync: mockExecFileAsync, +})); + +// Mock config loader +const defaultKnowledgeConfig = { + enabled: true, + captureModel: 'haiku', + compileModel: 'sonnet', + flushDebounceMinutes: 10, + domains: ['architecture', 'decisions', 'patterns', 'lessons', 'connections'], +}; +const mockLoadConfig = mock(async () => ({ + knowledge: { ...defaultKnowledgeConfig }, + assistants: { claude: { model: 'sonnet', settingSources: ['project'] }, codex: {} }, + worktree: {}, + docs: { path: 'docs/' }, + defaults: { loadDefaultCommands: true, loadDefaultWorkflows: true }, +})); +mock.module('../config/config-loader', () => ({ + loadConfig: mockLoadConfig, +})); + +// Mock knowledge-init +const mockInitKnowledgeDir = mock(async () => undefined); +const mockInitGlobalKnowledgeDir = mock(async () => undefined); +mock.module('./knowledge-init', () => ({ + initKnowledgeDir: mockInitKnowledgeDir, + initGlobalKnowledgeDir: mockInitGlobalKnowledgeDir, +})); + +// Mock AI client +let mockSendQueryChunks: Array<{ type: string; content?: string }> = []; +const mockSendQuery = mock(function* () { + for (const chunk of mockSendQueryChunks) { + yield chunk; + } +}); +const mockGetAssistantClient = mock(() => ({ + sendQuery: mockSendQuery, + getType: () => 'claude', +})); +mock.module('../clients/factory', () => ({ + getAssistantClient: mockGetAssistantClient, +})); + +import { flushKnowledge, flushGlobalKnowledge } from './knowledge-flush'; + +const KB_PATH = '/home/test/.archon/workspaces/acme/widget/knowledge'; + +describe('knowledge-flush', () => { + beforeEach(() => { + writeFileCalls.length = 0; + mkdirCalls.length = 0; + renameCalls.length = 0; + unlinkCalls.length = 0; + rmCalls.length = 0; + fileSystem = {}; + directories = {}; + mockWriteFile.mockClear(); + mockMkdir.mockClear(); + mockRename.mockClear(); + mockUnlink.mockClear(); + mockRm.mockClear(); + mockReadFile.mockClear(); + mockReaddir.mockClear(); + mockLoadConfig.mockClear(); + mockInitKnowledgeDir.mockClear(); + mockInitGlobalKnowledgeDir.mockClear(); + mockSendQuery.mockClear(); + mockGetAssistantClient.mockClear(); + mockExecFileAsync.mockClear(); + Object.values(mockLogger).forEach(fn => fn.mockClear()); + + // Reset default mock implementations + mockExecFileAsync.mockImplementation(async () => ({ stdout: '', stderr: '' })); + mockSendQueryChunks = []; + mockSendQuery.mockImplementation(function* () { + for (const chunk of mockSendQueryChunks) { + yield chunk; + } + }); + mockReadFile.mockImplementation(async (path: string) => { + if (fileSystem[path] !== undefined) { + return fileSystem[path]; + } + const err = new Error(`ENOENT`) as NodeJS.ErrnoException; + err.code = 'ENOENT'; + throw err; + }); + mockReaddir.mockImplementation(async (path: string) => { + if (directories[path] !== undefined) { + return directories[path]; + } + const err = new Error(`ENOENT`) as NodeJS.ErrnoException; + err.code = 'ENOENT'; + throw err; + }); + }); + + test('skips flush when knowledge is disabled', async () => { + const config = { + knowledge: { ...defaultKnowledgeConfig, enabled: false }, + assistants: { claude: { model: 'sonnet', settingSources: ['project'] as const }, codex: {} }, + worktree: {}, + docs: { path: 'docs/' }, + defaults: { loadDefaultCommands: true, loadDefaultWorkflows: true }, + }; + + const result = await flushKnowledge('acme', 'widget', config as never); + + expect(result.skipped).toBe(true); + expect(result.skipReason).toContain('disabled'); + expect(mockInitKnowledgeDir).not.toHaveBeenCalled(); + }); + + test('skips flush when no unprocessed logs exist', async () => { + // Empty logs directory + directories[`${KB_PATH}/logs`] = []; + directories[`${KB_PATH}/domains`] = []; + + const result = await flushKnowledge('acme', 'widget'); + + expect(result.skipped).toBe(true); + expect(result.skipReason).toContain('No unprocessed logs'); + expect(mockSendQuery).not.toHaveBeenCalled(); + }); + + test('skips flush when logs directory does not exist', async () => { + // readdir will throw ENOENT by default + const result = await flushKnowledge('acme', 'widget'); + + expect(result.skipped).toBe(true); + expect(result.skipReason).toContain('No unprocessed logs'); + }); + + test('flushes logs into articles and updates indexes', async () => { + // Set up: one daily log, no existing articles, no last-flush + directories[`${KB_PATH}/logs`] = ['2026-04-11.md']; + directories[`${KB_PATH}/domains`] = ['architecture']; + directories[`${KB_PATH}/domains/architecture`] = ['_index.md']; + + fileSystem[`${KB_PATH}/logs/2026-04-11.md`] = '## Decisions\n- Use JWT for auth\n'; + fileSystem[`${KB_PATH}/domains/architecture/_index.md`] = + '# Architecture\n\nSystem design.\n\n## Articles\n\n_No articles yet. Articles will appear here as knowledge is compiled._\n'; + + const synthesisResponse = JSON.stringify({ + articles: [ + { + domain: 'decisions', + concept: 'auth-token-strategy', + content: + '# Auth Token Strategy\n\nUse JWT tokens with short expiry.\n\n## Related\n\n- [[architecture/api-design]]\n', + }, + ], + domainSummaries: { + decisions: 'Architectural decisions including auth strategy.', + }, + indexSummary: 'Project knowledge covering auth decisions.', + }); + + mockSendQueryChunks = [{ type: 'assistant', content: synthesisResponse }]; + + const result = await flushKnowledge('acme', 'widget'); + + expect(result.skipped).toBe(false); + expect(result.articlesCreated).toBe(1); + expect(result.logsProcessed).toEqual(['2026-04-11.md']); + + // Verify article was written + const articleWrite = writeFileCalls.find(c => c.path.includes('auth-token-strategy.md')); + expect(articleWrite).toBeDefined(); + expect(articleWrite!.content).toContain('Auth Token Strategy'); + expect(articleWrite!.content).toContain('[[architecture/api-design]]'); + }); + + test('calls AI client with sonnet model', async () => { + directories[`${KB_PATH}/logs`] = ['2026-04-11.md']; + directories[`${KB_PATH}/domains`] = []; + + fileSystem[`${KB_PATH}/logs/2026-04-11.md`] = '## Patterns\n- test\n'; + + mockSendQueryChunks = [ + { + type: 'assistant', + content: JSON.stringify({ + articles: [], + domainSummaries: {}, + indexSummary: '', + }), + }, + ]; + + await flushKnowledge('acme', 'widget'); + + expect(mockGetAssistantClient).toHaveBeenCalledWith('claude'); + const callArgs = mockSendQuery.mock.calls[0]; + const options = callArgs![3] as { model: string; tools: string[] }; + expect(options.model).toBe('sonnet'); + expect(options.tools).toEqual([]); + }); + + test('only processes logs newer than last flush', async () => { + // Last flush was on 2026-04-09 + fileSystem[`${KB_PATH}/meta/last-flush.json`] = JSON.stringify({ + timestamp: '2026-04-09T12:00:00Z', + gitSha: '', + logsCaptured: ['2026-04-09.md'], + }); + + directories[`${KB_PATH}/logs`] = [ + '2026-04-08.md', + '2026-04-09.md', + '2026-04-10.md', + '2026-04-11.md', + ]; + directories[`${KB_PATH}/domains`] = []; + + fileSystem[`${KB_PATH}/logs/2026-04-09.md`] = '## Day 09\n'; + fileSystem[`${KB_PATH}/logs/2026-04-10.md`] = '## Day 10\n'; + fileSystem[`${KB_PATH}/logs/2026-04-11.md`] = '## Day 11\n'; + + mockSendQueryChunks = [ + { + type: 'assistant', + content: JSON.stringify({ + articles: [], + domainSummaries: {}, + indexSummary: '', + }), + }, + ]; + + const result = await flushKnowledge('acme', 'widget'); + + // Should process logs from 2026-04-09 (same day — may have new entries), 2026-04-10, and 2026-04-11 + expect(result.logsProcessed).toEqual(['2026-04-09.md', '2026-04-10.md', '2026-04-11.md']); + + // Verify the prompt includes the relevant logs + const prompt = mockSendQuery.mock.calls[0]![0] as string; + expect(prompt).toContain('2026-04-09.md'); + expect(prompt).toContain('2026-04-10.md'); + expect(prompt).toContain('2026-04-11.md'); + expect(prompt).not.toContain('2026-04-08.md'); + }); + + test('creates new domain directories for organic domains', async () => { + directories[`${KB_PATH}/logs`] = ['2026-04-11.md']; + directories[`${KB_PATH}/domains`] = []; + + fileSystem[`${KB_PATH}/logs/2026-04-11.md`] = '## Security patterns\n'; + + const synthesisResponse = JSON.stringify({ + articles: [ + { + domain: 'security', + concept: 'auth-best-practices', + content: '# Auth Best Practices\n\nContent here.\n', + }, + ], + domainSummaries: { + security: 'Security-related knowledge.', + }, + indexSummary: 'Project knowledge including security.', + }); + + mockSendQueryChunks = [{ type: 'assistant', content: synthesisResponse }]; + + const result = await flushKnowledge('acme', 'widget'); + + expect(result.domainsCreated).toContain('security'); + expect(mkdirCalls).toContainEqual(expect.stringContaining('domains/security')); + }); + + test('tracks updated vs created articles', async () => { + directories[`${KB_PATH}/logs`] = ['2026-04-11.md']; + directories[`${KB_PATH}/domains`] = ['decisions']; + directories[`${KB_PATH}/domains/decisions`] = ['_index.md', 'auth-strategy.md']; + + fileSystem[`${KB_PATH}/logs/2026-04-11.md`] = '## Updates\n'; + fileSystem[`${KB_PATH}/domains/decisions/_index.md`] = '# Decisions\n\n## Articles\n'; + fileSystem[`${KB_PATH}/domains/decisions/auth-strategy.md`] = + '# Auth Strategy\n\nOld content.\n'; + + const synthesisResponse = JSON.stringify({ + articles: [ + { + domain: 'decisions', + concept: 'auth-strategy', + content: '# Auth Strategy\n\nUpdated content.\n', + }, + { + domain: 'decisions', + concept: 'caching-policy', + content: '# Caching Policy\n\nNew article.\n', + }, + ], + domainSummaries: { + decisions: 'Decisions about auth and caching.', + }, + indexSummary: 'Updated decisions.', + }); + + mockSendQueryChunks = [{ type: 'assistant', content: synthesisResponse }]; + + const result = await flushKnowledge('acme', 'widget'); + + expect(result.articlesUpdated).toBe(1); // auth-strategy existed + expect(result.articlesCreated).toBe(1); // caching-policy is new + }); + + test('updates meta/last-flush.json after flush', async () => { + directories[`${KB_PATH}/logs`] = ['2026-04-11.md']; + directories[`${KB_PATH}/domains`] = []; + + fileSystem[`${KB_PATH}/logs/2026-04-11.md`] = '## Content\n'; + + mockSendQueryChunks = [ + { + type: 'assistant', + content: JSON.stringify({ + articles: [], + domainSummaries: {}, + indexSummary: '', + }), + }, + ]; + + await flushKnowledge('acme', 'widget'); + + const flushWrite = writeFileCalls.find(c => c.path.includes('last-flush.json')); + expect(flushWrite).toBeDefined(); + const meta = JSON.parse(flushWrite!.content) as { timestamp: string; logsCaptured: string[] }; + expect(meta.timestamp).toBeTruthy(); + expect(meta.logsCaptured).toEqual(['2026-04-11.md']); + }); + + test('updates top-level index.md with domain summaries', async () => { + directories[`${KB_PATH}/logs`] = ['2026-04-11.md']; + directories[`${KB_PATH}/domains`] = []; + + fileSystem[`${KB_PATH}/logs/2026-04-11.md`] = '## Content\n'; + + const synthesisResponse = JSON.stringify({ + articles: [ + { + domain: 'patterns', + concept: 'error-handling', + content: '# Error Handling\n\nAlways use try-catch.\n', + }, + ], + domainSummaries: { + patterns: 'Code patterns including error handling.', + }, + indexSummary: 'Knowledge about patterns.', + }); + + mockSendQueryChunks = [{ type: 'assistant', content: synthesisResponse }]; + + await flushKnowledge('acme', 'widget'); + + // With atomic writes, index is written to .tmp/ first then renamed + const indexWrite = writeFileCalls.find(c => c.path.includes('.tmp/index.md')); + expect(indexWrite).toBeDefined(); + expect(indexWrite!.content).toContain('[[domains/patterns/_index|Patterns]]'); + expect(indexWrite!.content).toContain('Code patterns including error handling.'); + + // Verify it was renamed to the final path + const indexRename = renameCalls.find(r => r.newPath.endsWith('/knowledge/index.md')); + expect(indexRename).toBeDefined(); + }); + + test('handles AI response with markdown code fences', async () => { + directories[`${KB_PATH}/logs`] = ['2026-04-11.md']; + directories[`${KB_PATH}/domains`] = []; + + fileSystem[`${KB_PATH}/logs/2026-04-11.md`] = '## Content\n'; + + const jsonResponse = JSON.stringify({ + articles: [], + domainSummaries: {}, + indexSummary: '', + }); + + // AI wraps response in code fences + mockSendQueryChunks = [{ type: 'assistant', content: '```json\n' + jsonResponse + '\n```' }]; + + const result = await flushKnowledge('acme', 'widget'); + + // Should parse successfully despite code fences + expect(result.skipped).toBe(false); + expect(result.logsProcessed).toEqual(['2026-04-11.md']); + }); + + test('initializes KB directory before flush', async () => { + directories[`${KB_PATH}/logs`] = ['2026-04-11.md']; + directories[`${KB_PATH}/domains`] = []; + + fileSystem[`${KB_PATH}/logs/2026-04-11.md`] = '## Content\n'; + + mockSendQueryChunks = [ + { + type: 'assistant', + content: JSON.stringify({ articles: [], domainSummaries: {}, indexSummary: '' }), + }, + ]; + + await flushKnowledge('acme', 'widget'); + + expect(mockInitKnowledgeDir).toHaveBeenCalledWith('acme', 'widget'); + }); + + test('loads config when not provided', async () => { + directories[`${KB_PATH}/logs`] = []; + + await flushKnowledge('acme', 'widget'); + + expect(mockLoadConfig).toHaveBeenCalledTimes(1); + }); + + test('throws and logs on AI client error', async () => { + directories[`${KB_PATH}/logs`] = ['2026-04-11.md']; + directories[`${KB_PATH}/domains`] = []; + + fileSystem[`${KB_PATH}/logs/2026-04-11.md`] = '## Content\n'; + + mockSendQuery.mockImplementation(function* () { + throw new Error('API rate limit exceeded'); + }); + + await expect(flushKnowledge('acme', 'widget')).rejects.toThrow('API rate limit exceeded'); + + expect(mockLogger.error).toHaveBeenCalled(); + }); + + test('reads existing articles for merge context', async () => { + directories[`${KB_PATH}/logs`] = ['2026-04-11.md']; + directories[`${KB_PATH}/domains`] = ['architecture']; + directories[`${KB_PATH}/domains/architecture`] = ['_index.md', 'api-design.md']; + + fileSystem[`${KB_PATH}/logs/2026-04-11.md`] = '## New info\n'; + fileSystem[`${KB_PATH}/domains/architecture/_index.md`] = '# Architecture\n'; + fileSystem[`${KB_PATH}/domains/architecture/api-design.md`] = + '# API Design\n\nExisting content about API design.\n'; + + mockSendQueryChunks = [ + { + type: 'assistant', + content: JSON.stringify({ articles: [], domainSummaries: {}, indexSummary: '' }), + }, + ]; + + await flushKnowledge('acme', 'widget'); + + // Verify the prompt includes existing articles for merge context + const prompt = mockSendQuery.mock.calls[0]![0] as string; + expect(prompt).toContain('Existing Articles'); + expect(prompt).toContain('api-design.md'); + expect(prompt).toContain('Existing content about API design'); + }); + + test('articles use wikilink backlinks', async () => { + directories[`${KB_PATH}/logs`] = ['2026-04-11.md']; + directories[`${KB_PATH}/domains`] = []; + + fileSystem[`${KB_PATH}/logs/2026-04-11.md`] = '## Content\n'; + + const synthesisResponse = JSON.stringify({ + articles: [ + { + domain: 'lessons', + concept: 'mock-pollution', + content: + '# Mock Pollution\n\nBun mock.module() is irreversible.\n\n## Related\n\n- [[patterns/testing-patterns]]\n', + }, + ], + domainSummaries: { lessons: 'Testing lessons.' }, + indexSummary: 'Lessons learned.', + }); + + mockSendQueryChunks = [{ type: 'assistant', content: synthesisResponse }]; + + const result = await flushKnowledge('acme', 'widget'); + + expect(result.articlesCreated).toBe(1); + + const articleWrite = writeFileCalls.find(c => c.path.includes('mock-pollution.md')); + expect(articleWrite).toBeDefined(); + expect(articleWrite!.content).toContain('[[patterns/testing-patterns]]'); + }); + + test('logs flush events correctly', async () => { + directories[`${KB_PATH}/logs`] = ['2026-04-11.md']; + directories[`${KB_PATH}/domains`] = []; + + fileSystem[`${KB_PATH}/logs/2026-04-11.md`] = '## Content\n'; + + mockSendQueryChunks = [ + { + type: 'assistant', + content: JSON.stringify({ articles: [], domainSummaries: {}, indexSummary: '' }), + }, + ]; + + await flushKnowledge('acme', 'widget'); + + // Check that flush_started and flush_completed were logged + const infoMessages = mockLogger.info.mock.calls.map((call: unknown[]) => call[1] as string); + expect(infoMessages).toContain('knowledge.flush_started'); + expect(infoMessages).toContain('knowledge.flush_completed'); + }); + + test('acquires and releases flush lock', async () => { + directories[`${KB_PATH}/logs`] = ['2026-04-11.md']; + directories[`${KB_PATH}/domains`] = []; + + fileSystem[`${KB_PATH}/logs/2026-04-11.md`] = '## Content\n'; + + mockSendQueryChunks = [ + { + type: 'assistant', + content: JSON.stringify({ articles: [], domainSummaries: {}, indexSummary: '' }), + }, + ]; + + await flushKnowledge('acme', 'widget'); + + // Lock file was written with PID + const lockWrite = writeFileCalls.find(c => c.path.includes('flush.lock')); + expect(lockWrite).toBeDefined(); + expect(lockWrite!.content).toBe(String(process.pid)); + + // Lock was released (unlinked) + const lockUnlink = unlinkCalls.find(p => p.includes('flush.lock')); + expect(lockUnlink).toBeDefined(); + }); + + test('skips flush when lock held by another live process', async () => { + // Simulate lock held by our own process (which is alive) + fileSystem[`${KB_PATH}/meta/flush.lock`] = String(process.pid); + + const result = await flushKnowledge('acme', 'widget'); + + expect(result.skipped).toBe(true); + expect(result.skipReason).toContain('lock held'); + + // Warning logged + const warnMessages = mockLogger.warn.mock.calls.map((call: unknown[]) => call[1] as string); + expect(warnMessages).toContain('knowledge.flush_lock_held'); + }); + + test('reclaims stale lock from dead process', async () => { + // Use a PID that definitely doesn't exist (very large number) + fileSystem[`${KB_PATH}/meta/flush.lock`] = '99999999'; + + directories[`${KB_PATH}/logs`] = ['2026-04-11.md']; + directories[`${KB_PATH}/domains`] = []; + fileSystem[`${KB_PATH}/logs/2026-04-11.md`] = '## Content\n'; + + mockSendQueryChunks = [ + { + type: 'assistant', + content: JSON.stringify({ articles: [], domainSummaries: {}, indexSummary: '' }), + }, + ]; + + const result = await flushKnowledge('acme', 'widget'); + + // Should have reclaimed the stale lock and proceeded + expect(result.skipped).toBe(false); + + // Info log about reclaiming + const infoMessages = mockLogger.info.mock.calls.map((call: unknown[]) => call[1] as string); + expect(infoMessages).toContain('knowledge.flush_lock_reclaimed'); + }); + + test('releases lock even when flush throws', async () => { + directories[`${KB_PATH}/logs`] = ['2026-04-11.md']; + directories[`${KB_PATH}/domains`] = []; + fileSystem[`${KB_PATH}/logs/2026-04-11.md`] = '## Content\n'; + + mockSendQuery.mockImplementation(function* () { + throw new Error('Synthesis failed'); + }); + + await expect(flushKnowledge('acme', 'widget')).rejects.toThrow('Synthesis failed'); + + // Lock should still be released via finally + const lockUnlink = unlinkCalls.find(p => p.includes('flush.lock')); + expect(lockUnlink).toBeDefined(); + }); + + test('writes articles to temp dir then renames to final paths', async () => { + directories[`${KB_PATH}/logs`] = ['2026-04-11.md']; + directories[`${KB_PATH}/domains`] = []; + + fileSystem[`${KB_PATH}/logs/2026-04-11.md`] = '## Content\n'; + + const synthesisResponse = JSON.stringify({ + articles: [ + { + domain: 'decisions', + concept: 'test-concept', + content: '# Test Concept\n\nContent.\n', + }, + ], + domainSummaries: { decisions: 'Decisions.' }, + indexSummary: 'Summary.', + }); + + mockSendQueryChunks = [{ type: 'assistant', content: synthesisResponse }]; + + await flushKnowledge('acme', 'widget'); + + // Articles written to .tmp first + const tmpArticleWrite = writeFileCalls.find(c => + c.path.includes('.tmp/domains/decisions/test-concept.md') + ); + expect(tmpArticleWrite).toBeDefined(); + + // Renamed from tmp to final + const articleRename = renameCalls.find( + r => + r.oldPath.includes('.tmp/domains/decisions/test-concept.md') && + r.newPath.includes('domains/decisions/test-concept.md') && + !r.newPath.includes('.tmp') + ); + expect(articleRename).toBeDefined(); + }); + + test('updates last-flush.json via temp+rename', async () => { + directories[`${KB_PATH}/logs`] = ['2026-04-11.md']; + directories[`${KB_PATH}/domains`] = []; + fileSystem[`${KB_PATH}/logs/2026-04-11.md`] = '## Content\n'; + + mockSendQueryChunks = [ + { + type: 'assistant', + content: JSON.stringify({ articles: [], domainSummaries: {}, indexSummary: '' }), + }, + ]; + + await flushKnowledge('acme', 'widget'); + + // last-flush.json written to tmp path first + const tmpWrite = writeFileCalls.find(c => c.path.includes('last-flush.json.tmp')); + expect(tmpWrite).toBeDefined(); + + // Renamed from tmp to final + const flushRename = renameCalls.find( + r => r.oldPath.includes('last-flush.json.tmp') && r.newPath.includes('last-flush.json') + ); + expect(flushRename).toBeDefined(); + }); + + test('cleans up leftover temp dir from crashed flush', async () => { + directories[`${KB_PATH}/logs`] = ['2026-04-11.md']; + directories[`${KB_PATH}/domains`] = []; + fileSystem[`${KB_PATH}/logs/2026-04-11.md`] = '## Content\n'; + + mockSendQueryChunks = [ + { + type: 'assistant', + content: JSON.stringify({ articles: [], domainSummaries: {}, indexSummary: '' }), + }, + ]; + + await flushKnowledge('acme', 'widget'); + + // rm was called to clean up .tmp dir (at least twice — once before, once after) + const tmpRmCalls = rmCalls.filter(p => p.includes('.tmp')); + expect(tmpRmCalls.length).toBeGreaterThanOrEqual(1); + }); + + test('stores git SHA in last-flush.json', async () => { + directories[`${KB_PATH}/logs`] = ['2026-04-11.md']; + directories[`${KB_PATH}/domains`] = []; + fileSystem[`${KB_PATH}/logs/2026-04-11.md`] = '## Content\n'; + + // Mock git rev-parse HEAD to return a SHA + mockExecFileAsync.mockImplementation(async (_cmd: string, args: string[]) => { + if (args.includes('rev-parse')) { + return { stdout: 'abc123def456\n', stderr: '' }; + } + return { stdout: '', stderr: '' }; + }); + + mockSendQueryChunks = [ + { + type: 'assistant', + content: JSON.stringify({ articles: [], domainSummaries: {}, indexSummary: '' }), + }, + ]; + + await flushKnowledge('acme', 'widget'); + + const flushWrite = writeFileCalls.find(c => c.path.includes('last-flush.json')); + expect(flushWrite).toBeDefined(); + const meta = JSON.parse(flushWrite!.content) as { gitSha: string }; + expect(meta.gitSha).toBe('abc123def456'); + }); + + test('validates staleness when last flush has git SHA', async () => { + // Set up last-flush with a git SHA + fileSystem[`${KB_PATH}/meta/last-flush.json`] = JSON.stringify({ + timestamp: '2026-04-09T12:00:00Z', + gitSha: 'oldsha123', + logsCaptured: ['2026-04-09.md'], + }); + + directories[`${KB_PATH}/logs`] = ['2026-04-10.md']; + directories[`${KB_PATH}/domains`] = ['decisions']; + directories[`${KB_PATH}/domains/decisions`] = ['_index.md', 'auth-strategy.md']; + + fileSystem[`${KB_PATH}/logs/2026-04-10.md`] = '## New info\n'; + fileSystem[`${KB_PATH}/domains/decisions/_index.md`] = '# Decisions\n\n## Articles\n'; + fileSystem[`${KB_PATH}/domains/decisions/auth-strategy.md`] = + '# Auth Strategy\n\nUse JWT with src/auth/tokens.ts.\n\n## Related\n'; + + // Mock git commands: rev-parse returns new SHA, diff returns changed files + mockExecFileAsync.mockImplementation(async (_cmd: string, args: string[]) => { + if (args.includes('rev-parse')) { + return { stdout: 'newsha456\n', stderr: '' }; + } + if (args.includes('diff')) { + return { stdout: 'src/auth/tokens.ts\nsrc/api/routes.ts\n', stderr: '' }; + } + return { stdout: '', stderr: '' }; + }); + + // Synthesis call returns no new articles, staleness call returns stale articles + let callCount = 0; + mockSendQuery.mockImplementation(function* () { + callCount++; + if (callCount === 1) { + // Synthesis response + yield { + type: 'assistant', + content: JSON.stringify({ articles: [], domainSummaries: {}, indexSummary: '' }), + }; + } else { + // Staleness validation response + yield { + type: 'assistant', + content: JSON.stringify(['decisions/auth-strategy']), + }; + } + }); + + const result = await flushKnowledge('acme', 'widget'); + + expect(result.articlesStale).toBe(1); + + // Verify staleness marker was written to the article + const markerWrite = writeFileCalls.find( + c => + c.path.includes('auth-strategy.md') && + c.content.includes('> [!WARNING] This article may be stale') + ); + expect(markerWrite).toBeDefined(); + }); + + test('skips staleness check when no last flush SHA', async () => { + directories[`${KB_PATH}/logs`] = ['2026-04-11.md']; + directories[`${KB_PATH}/domains`] = ['patterns']; + directories[`${KB_PATH}/domains/patterns`] = ['_index.md', 'some-pattern.md']; + + fileSystem[`${KB_PATH}/logs/2026-04-11.md`] = '## Content\n'; + fileSystem[`${KB_PATH}/domains/patterns/_index.md`] = '# Patterns\n'; + fileSystem[`${KB_PATH}/domains/patterns/some-pattern.md`] = '# Some Pattern\n\nContent.\n'; + + mockSendQueryChunks = [ + { + type: 'assistant', + content: JSON.stringify({ articles: [], domainSummaries: {}, indexSummary: '' }), + }, + ]; + + const result = await flushKnowledge('acme', 'widget'); + + // No staleness check because no last flush SHA + expect(result.articlesStale).toBe(0); + + // Only one sendQuery call (synthesis), no staleness call + expect(mockSendQuery).toHaveBeenCalledTimes(1); + }); + + test('detects broken wikilinks between articles', async () => { + directories[`${KB_PATH}/logs`] = ['2026-04-11.md']; + directories[`${KB_PATH}/domains`] = ['decisions', 'patterns']; + directories[`${KB_PATH}/domains/decisions`] = ['_index.md', 'auth-strategy.md']; + directories[`${KB_PATH}/domains/patterns`] = ['_index.md']; + + fileSystem[`${KB_PATH}/logs/2026-04-11.md`] = '## Content\n'; + fileSystem[`${KB_PATH}/domains/decisions/_index.md`] = '# Decisions\n'; + fileSystem[`${KB_PATH}/domains/decisions/auth-strategy.md`] = + '# Auth Strategy\n\nSee [[patterns/nonexistent-pattern]] and [[decisions/auth-strategy]].\n'; + + mockSendQueryChunks = [ + { + type: 'assistant', + content: JSON.stringify({ articles: [], domainSummaries: {}, indexSummary: '' }), + }, + ]; + + const result = await flushKnowledge('acme', 'widget'); + + // Validation logs should mention broken links + const validationLog = mockLogger.info.mock.calls.find( + (call: unknown[]) => (call[1] as string) === 'knowledge.flush_validation_completed' + ); + expect(validationLog).toBeDefined(); + const logData = validationLog![0] as { brokenLinks: number }; + expect(logData.brokenLinks).toBe(1); + }); + + test('logs validation results', async () => { + directories[`${KB_PATH}/logs`] = ['2026-04-11.md']; + directories[`${KB_PATH}/domains`] = ['architecture']; + directories[`${KB_PATH}/domains/architecture`] = ['_index.md', 'api-design.md']; + + fileSystem[`${KB_PATH}/logs/2026-04-11.md`] = '## Content\n'; + fileSystem[`${KB_PATH}/domains/architecture/_index.md`] = '# Architecture\n'; + fileSystem[`${KB_PATH}/domains/architecture/api-design.md`] = + '# API Design\n\nREST endpoints.\n'; + + mockSendQueryChunks = [ + { + type: 'assistant', + content: JSON.stringify({ articles: [], domainSummaries: {}, indexSummary: '' }), + }, + ]; + + await flushKnowledge('acme', 'widget'); + + // Validation completed log should include articles checked, stale, broken links + const validationLog = mockLogger.info.mock.calls.find( + (call: unknown[]) => (call[1] as string) === 'knowledge.flush_validation_completed' + ); + expect(validationLog).toBeDefined(); + const data = validationLog![0] as { + articlesChecked: number; + articlesFlaggedStale: number; + brokenLinks: number; + }; + expect(data.articlesChecked).toBe(1); + expect(data.articlesFlaggedStale).toBe(0); + expect(data.brokenLinks).toBe(0); + }); + + test('staleness marker is idempotent', async () => { + // Set up with a last flush SHA so staleness check runs + fileSystem[`${KB_PATH}/meta/last-flush.json`] = JSON.stringify({ + timestamp: '2026-04-09T12:00:00Z', + gitSha: 'oldsha123', + logsCaptured: ['2026-04-09.md'], + }); + + directories[`${KB_PATH}/logs`] = ['2026-04-10.md']; + directories[`${KB_PATH}/domains`] = ['decisions']; + directories[`${KB_PATH}/domains/decisions`] = ['_index.md', 'auth-strategy.md']; + + fileSystem[`${KB_PATH}/logs/2026-04-10.md`] = '## New info\n'; + fileSystem[`${KB_PATH}/domains/decisions/_index.md`] = '# Decisions\n'; + // Article already has staleness marker + fileSystem[`${KB_PATH}/domains/decisions/auth-strategy.md`] = + '# Auth Strategy\n\n> [!WARNING] This article may be stale — referenced code has changed since last validation.\n\nContent.\n'; + + mockExecFileAsync.mockImplementation(async (_cmd: string, args: string[]) => { + if (args.includes('rev-parse')) return { stdout: 'newsha\n', stderr: '' }; + if (args.includes('diff')) return { stdout: 'src/auth.ts\n', stderr: '' }; + return { stdout: '', stderr: '' }; + }); + + let callCount = 0; + mockSendQuery.mockImplementation(function* () { + callCount++; + if (callCount === 1) { + yield { + type: 'assistant', + content: JSON.stringify({ articles: [], domainSummaries: {}, indexSummary: '' }), + }; + } else { + yield { type: 'assistant', content: JSON.stringify(['decisions/auth-strategy']) }; + } + }); + + await flushKnowledge('acme', 'widget'); + + // The marker write should NOT happen since article already has the marker + const markerWrites = writeFileCalls.filter( + c => + c.path.includes('auth-strategy.md') && + c.content.includes('> [!WARNING] This article may be stale') + ); + // Article already had marker, so writeFile should not be called for marker addition + expect(markerWrites.length).toBe(0); + }); + + // --- Global KB tier tests --- + + const GLOBAL_KB_PATH = '/home/test/.archon/knowledge'; + + test('flushGlobalKnowledge uses global knowledge path', async () => { + directories[`${GLOBAL_KB_PATH}/logs`] = ['2026-04-11.md']; + directories[`${GLOBAL_KB_PATH}/domains`] = []; + + fileSystem[`${GLOBAL_KB_PATH}/logs/2026-04-11.md`] = + '## Global lesson\n- Cross-project pattern\n'; + + mockSendQueryChunks = [ + { + type: 'assistant', + content: JSON.stringify({ + articles: [ + { + domain: 'patterns', + concept: 'cross-project-pattern', + content: '# Cross Project Pattern\n\nApplies everywhere.\n', + }, + ], + domainSummaries: { patterns: 'Cross-project patterns.' }, + indexSummary: 'Global knowledge.', + }), + }, + ]; + + const result = await flushGlobalKnowledge(); + + expect(result.skipped).toBe(false); + expect(result.articlesCreated).toBe(1); + expect(result.logsProcessed).toEqual(['2026-04-11.md']); + expect(mockInitGlobalKnowledgeDir).toHaveBeenCalled(); + expect(mockInitKnowledgeDir).not.toHaveBeenCalled(); + + // Articles written to global path + const articleWrite = writeFileCalls.find(c => + c.path.includes('/home/test/.archon/knowledge/.tmp/domains/patterns/cross-project-pattern.md') + ); + expect(articleWrite).toBeDefined(); + }); + + test('flushGlobalKnowledge skips staleness validation (no git repo)', async () => { + // Set up last-flush with a git SHA (would trigger staleness in project flush) + fileSystem[`${GLOBAL_KB_PATH}/meta/last-flush.json`] = JSON.stringify({ + timestamp: '2026-04-09T12:00:00Z', + gitSha: 'someshavalue', + logsCaptured: ['2026-04-09.md'], + }); + + directories[`${GLOBAL_KB_PATH}/logs`] = ['2026-04-10.md']; + directories[`${GLOBAL_KB_PATH}/domains`] = ['patterns']; + directories[`${GLOBAL_KB_PATH}/domains/patterns`] = ['_index.md', 'some-pattern.md']; + + fileSystem[`${GLOBAL_KB_PATH}/logs/2026-04-10.md`] = '## New global info\n'; + fileSystem[`${GLOBAL_KB_PATH}/domains/patterns/_index.md`] = '# Patterns\n'; + fileSystem[`${GLOBAL_KB_PATH}/domains/patterns/some-pattern.md`] = + '# Some Pattern\n\nContent.\n'; + + mockSendQueryChunks = [ + { + type: 'assistant', + content: JSON.stringify({ articles: [], domainSummaries: {}, indexSummary: '' }), + }, + ]; + + const result = await flushGlobalKnowledge(); + + // No staleness check — only one sendQuery call (synthesis, not staleness) + expect(result.articlesStale).toBe(0); + expect(mockSendQuery).toHaveBeenCalledTimes(1); + + // No git diff was requested + expect(mockExecFileAsync).not.toHaveBeenCalled(); + }); + + test('flushGlobalKnowledge stores empty git SHA in last-flush.json', async () => { + directories[`${GLOBAL_KB_PATH}/logs`] = ['2026-04-11.md']; + directories[`${GLOBAL_KB_PATH}/domains`] = []; + + fileSystem[`${GLOBAL_KB_PATH}/logs/2026-04-11.md`] = '## Content\n'; + + mockSendQueryChunks = [ + { + type: 'assistant', + content: JSON.stringify({ articles: [], domainSummaries: {}, indexSummary: '' }), + }, + ]; + + await flushGlobalKnowledge(); + + const flushWrite = writeFileCalls.find( + c => c.path.includes('last-flush.json') && c.path.includes('.archon/knowledge') + ); + expect(flushWrite).toBeDefined(); + const meta = JSON.parse(flushWrite!.content) as { gitSha: string; logsCaptured: string[] }; + expect(meta.gitSha).toBe(''); + expect(meta.logsCaptured).toEqual(['2026-04-11.md']); + }); + + test('flushGlobalKnowledge operates independently from project flush', async () => { + // Set up both project and global logs + directories[`${KB_PATH}/logs`] = ['2026-04-11.md']; + directories[`${KB_PATH}/domains`] = []; + fileSystem[`${KB_PATH}/logs/2026-04-11.md`] = '## Project content\n'; + + directories[`${GLOBAL_KB_PATH}/logs`] = ['2026-04-11.md']; + directories[`${GLOBAL_KB_PATH}/domains`] = []; + fileSystem[`${GLOBAL_KB_PATH}/logs/2026-04-11.md`] = '## Global content\n'; + + mockSendQueryChunks = [ + { + type: 'assistant', + content: JSON.stringify({ articles: [], domainSummaries: {}, indexSummary: '' }), + }, + ]; + + // Flush global — should NOT touch project KB + const globalResult = await flushGlobalKnowledge(); + expect(globalResult.skipped).toBe(false); + expect(mockInitGlobalKnowledgeDir).toHaveBeenCalledTimes(1); + expect(mockInitKnowledgeDir).not.toHaveBeenCalled(); + + // Verify the synthesis prompt includes global log content, not project + const prompt = mockSendQuery.mock.calls[0]![0] as string; + expect(prompt).toContain('Global content'); + expect(prompt).not.toContain('Project content'); + }); + + test('flushGlobalKnowledge skips when knowledge is disabled', async () => { + const config = { + knowledge: { ...defaultKnowledgeConfig, enabled: false }, + assistants: { claude: { model: 'sonnet', settingSources: ['project'] as const }, codex: {} }, + worktree: {}, + docs: { path: 'docs/' }, + defaults: { loadDefaultCommands: true, loadDefaultWorkflows: true }, + }; + + const result = await flushGlobalKnowledge(config as never); + + expect(result.skipped).toBe(true); + expect(result.skipReason).toContain('disabled'); + expect(mockInitGlobalKnowledgeDir).not.toHaveBeenCalled(); + expect(mockSendQuery).not.toHaveBeenCalled(); + }); + + // --- AI JSON parse failure tests --- + + test('throws on malformed JSON from AI synthesis', async () => { + directories[`${KB_PATH}/logs`] = ['2026-04-11.md']; + directories[`${KB_PATH}/domains`] = []; + + fileSystem[`${KB_PATH}/logs/2026-04-11.md`] = '## Content\n'; + + // AI returns garbage that is not valid JSON + mockSendQueryChunks = [{ type: 'assistant', content: 'This is not JSON at all!' }]; + + await expect(flushKnowledge('acme', 'widget')).rejects.toThrow('invalid JSON'); + + // Warning was logged about the parse failure + const warnMessages = mockLogger.warn.mock.calls.map((call: unknown[]) => call[1] as string); + expect(warnMessages).toContain('knowledge.flush_synthesis_json_parse_failed'); + + // Error was logged at the flush level + const errorMessages = mockLogger.error.mock.calls.map((call: unknown[]) => call[1] as string); + expect(errorMessages).toContain('knowledge.flush_failed'); + + // Lock was still released despite the error + const lockUnlink = unlinkCalls.find(p => p.includes('flush.lock')); + expect(lockUnlink).toBeDefined(); + }); + + test('throws on valid JSON with invalid schema from AI synthesis', async () => { + directories[`${KB_PATH}/logs`] = ['2026-04-11.md']; + directories[`${KB_PATH}/domains`] = []; + + fileSystem[`${KB_PATH}/logs/2026-04-11.md`] = '## Content\n'; + + // AI returns valid JSON but wrong structure (missing required fields) + mockSendQueryChunks = [ + { + type: 'assistant', + content: JSON.stringify({ wrongField: 'not what we expect' }), + }, + ]; + + await expect(flushKnowledge('acme', 'widget')).rejects.toThrow('invalid structure'); + + // Warning was logged about schema validation failure + const warnMessages = mockLogger.warn.mock.calls.map((call: unknown[]) => call[1] as string); + expect(warnMessages).toContain('knowledge.flush_synthesis_schema_validation_failed'); + + // Lock was still released + const lockUnlink = unlinkCalls.find(p => p.includes('flush.lock')); + expect(lockUnlink).toBeDefined(); + }); + + test('malformed JSON does not write any articles or update flush metadata', async () => { + directories[`${KB_PATH}/logs`] = ['2026-04-11.md']; + directories[`${KB_PATH}/domains`] = []; + + fileSystem[`${KB_PATH}/logs/2026-04-11.md`] = '## Content\n'; + + // AI returns invalid JSON + mockSendQueryChunks = [{ type: 'assistant', content: '{broken json' }]; + + await expect(flushKnowledge('acme', 'widget')).rejects.toThrow('invalid JSON'); + + // No articles were written (only lock file and maybe mkdir calls) + const articleWrites = writeFileCalls.filter( + c => c.path.includes('/domains/') || c.path.includes('last-flush.json') + ); + expect(articleWrites).toHaveLength(0); + + // No renames happened (no atomic writes) + const articleRenames = renameCalls.filter( + r => r.newPath.includes('/domains/') || r.newPath.includes('last-flush.json') + ); + expect(articleRenames).toHaveLength(0); + }); + + test('partial valid JSON array in articles field fails Zod validation', async () => { + directories[`${KB_PATH}/logs`] = ['2026-04-11.md']; + directories[`${KB_PATH}/domains`] = []; + + fileSystem[`${KB_PATH}/logs/2026-04-11.md`] = '## Content\n'; + + // AI returns JSON that parses but has wrong article shape (missing content field) + mockSendQueryChunks = [ + { + type: 'assistant', + content: JSON.stringify({ + articles: [{ domain: 'decisions', concept: 'test' }], // missing 'content' + domainSummaries: {}, + indexSummary: '', + }), + }, + ]; + + await expect(flushKnowledge('acme', 'widget')).rejects.toThrow('invalid structure'); + + // Schema validation failure was logged + const warnMessages = mockLogger.warn.mock.calls.map((call: unknown[]) => call[1] as string); + expect(warnMessages).toContain('knowledge.flush_synthesis_schema_validation_failed'); + }); +}); diff --git a/packages/core/src/services/knowledge-flush.ts b/packages/core/src/services/knowledge-flush.ts new file mode 100644 index 0000000000..9c7da751e3 --- /dev/null +++ b/packages/core/src/services/knowledge-flush.ts @@ -0,0 +1,1073 @@ +/** + * Knowledge flush service — synthesizes daily capture logs into structured + * domain articles in the knowledge base. + * + * Reads unprocessed logs since last flush, calls the compile model to produce/update + * concept articles, then updates indexes and the flush timestamp. + */ +import { readFile, readdir, writeFile, mkdir, rename, unlink, rm } from 'node:fs/promises'; +import { join } from 'node:path'; +import { z } from 'zod'; +import { + getProjectKnowledgePath, + getGlobalKnowledgePath, + getProjectSourcePath, +} from '@archon/paths'; +import { createLogger } from '@archon/paths'; +import { execFileAsync } from '@archon/git'; +import { getAssistantClient } from '../clients/factory'; +import { loadConfig } from '../config/config-loader'; +import { initKnowledgeDir, initGlobalKnowledgeDir } from './knowledge-init'; +import type { MergedConfig } from '../config/config-types'; + +/** Lazy-initialized logger */ +let cachedLog: ReturnType | undefined; +function getLog(): ReturnType { + if (!cachedLog) cachedLog = createLogger('knowledge.flush'); + return cachedLog; +} + +/** Shape of meta/last-flush.json */ +export interface LastFlushMeta { + timestamp: string; + gitSha: string; + logsCaptured: string[]; +} + +/** Zod schema for validating AI flush synthesis responses */ +const flushSynthesisSchema = z.object({ + articles: z.array( + z.object({ + domain: z.string(), + concept: z.string(), + content: z.string(), + }) + ), + domainSummaries: z.record(z.string()), + indexSummary: z.string(), +}); + +/** AI response structure for flush synthesis */ +type FlushSynthesis = z.infer; + +export interface KnowledgeFlushReport { + articlesCreated: number; + articlesUpdated: number; + articlesStale: number; + domainsCreated: string[]; + logsProcessed: string[]; + skipped: boolean; + skipReason?: string; +} + +/** + * Acquire a file-based flush lock. Returns true if lock acquired, false if + * another live process holds it (skip with warning). + * Stale locks (dead PID) are reclaimed automatically. + */ +async function acquireFlushLock(knowledgePath: string): Promise { + const log = getLog(); + const lockPath = join(knowledgePath, 'meta', 'flush.lock'); + await mkdir(join(knowledgePath, 'meta'), { recursive: true }); + + // Attempt atomic create — fails if file already exists + try { + await writeFile(lockPath, String(process.pid), { flag: 'wx' }); + return true; + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err; + } + + // Lock file exists — check if holder is alive + try { + const content = await readFile(lockPath, 'utf-8'); + const pid = parseInt(content.trim(), 10); + if (!isNaN(pid)) { + try { + process.kill(pid, 0); // Signal 0 = existence check + log.warn({ pid, lockPath }, 'knowledge.flush_lock_held'); + return false; // Live process holds lock + } catch { + // Process is dead — reclaim stale lock + log.info({ stalePid: pid, lockPath }, 'knowledge.flush_lock_reclaimed'); + } + } + await unlink(lockPath); + } catch { + // Lock file disappeared between our attempts — race with another reclaimer + } + + // Retry atomic create after stale lock removal + try { + await writeFile(lockPath, String(process.pid), { flag: 'wx' }); + return true; + } catch { + log.warn({ lockPath }, 'knowledge.flush_lock_held'); + return false; + } +} + +/** + * Release the flush lock file. + */ +async function releaseFlushLock(knowledgePath: string): Promise { + const lockPath = join(knowledgePath, 'meta', 'flush.lock'); + try { + await unlink(lockPath); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { + throw err; + } + } +} + +/** Prompt sent to the compile model to synthesize daily logs into structured articles */ +const SYNTHESIS_PROMPT = `You are a knowledge base compiler. Given daily capture logs and optionally existing articles, produce structured knowledge articles. + +## Output Format + +You MUST respond with a JSON object (no markdown fences, no explanation, just JSON) with this exact structure: + +{ + "articles": [ + { + "domain": "architecture|decisions|patterns|lessons|connections|", + "concept": "kebab-case-concept-name", + "content": "Full markdown article content with [[wikilink]] backlinks to related concepts" + } + ], + "domainSummaries": { + "architecture": "One-line summary of architecture knowledge", + "decisions": "One-line summary of decisions" + }, + "indexSummary": "Brief overview of all domains for the top-level index" +} + +## Rules +- Each article should be a focused concept (e.g., "auth-token-strategy", "database-migration-pattern") +- Use [[wikilinks]] to cross-reference related articles (e.g., [[decisions/auth-token-strategy]]) +- Domain names are lowercase kebab-case +- Concept filenames are lowercase kebab-case +- Merge new knowledge with existing articles when the concept overlaps (prefer updating over creating duplicates) +- You may create new domains beyond the starting set if the knowledge doesn't fit existing domains +- Every article should start with a level-1 heading matching the concept name in title case +- Include a "Related" section at the end of each article with [[wikilinks]] +- If no meaningful articles can be produced, return {"articles":[],"domainSummaries":{},"indexSummary":""} + +--- + +`; + +/** Options for the shared flush core logic */ +interface FlushCoreOptions { + /** Label for logging (e.g., "acme/widget" or "global") */ + label: string; + /** Path to the knowledge base directory */ + knowledgePath: string; + /** Merged config */ + config: MergedConfig; + /** Optional owner/repo for git-based staleness validation */ + git?: { owner: string; repo: string }; + /** Init function to call before flushing (ensures KB directory exists) */ + init: () => Promise; +} + +/** + * Shared flush core — used by both project and global flush functions. + */ +async function flushKnowledgeCore(options: FlushCoreOptions): Promise { + const log = getLog(); + const { label, knowledgePath, config: mergedConfig, git: gitInfo, init } = options; + + if (!mergedConfig.knowledge.enabled) { + log.debug({ label }, 'knowledge.flush_skipped_disabled'); + return { + articlesCreated: 0, + articlesUpdated: 0, + articlesStale: 0, + domainsCreated: [], + logsProcessed: [], + skipped: true, + skipReason: 'Knowledge is disabled', + }; + } + + log.info({ label }, 'knowledge.flush_started'); + + // Ensure KB directory exists + await init(); + + // Acquire flush lock (one concurrent flush per KB) + const lockAcquired = await acquireFlushLock(knowledgePath); + if (!lockAcquired) { + log.warn({ label }, 'knowledge.flush_skipped_locked'); + return { + articlesCreated: 0, + articlesUpdated: 0, + articlesStale: 0, + domainsCreated: [], + logsProcessed: [], + skipped: true, + skipReason: 'Flush lock held by another process', + }; + } + + try { + // Read last flush metadata + const lastFlush = await readLastFlush(knowledgePath); + + // Find unprocessed daily logs + const unprocessedLogs = await findUnprocessedLogs(knowledgePath, lastFlush?.timestamp); + + if (unprocessedLogs.length === 0) { + log.info({ label }, 'knowledge.flush_skipped_no_logs'); + return { + articlesCreated: 0, + articlesUpdated: 0, + articlesStale: 0, + domainsCreated: [], + logsProcessed: [], + skipped: true, + skipReason: 'No unprocessed logs to flush', + }; + } + + // Read log contents + const logContents = await readLogContents(knowledgePath, unprocessedLogs); + + // Read existing articles for merge context + const existingArticles = await readExistingArticles(knowledgePath); + + // Call AI to synthesize + const synthesis = await synthesizeLogs( + logContents, + existingArticles, + mergedConfig.knowledge.compileModel + ); + + // Write to temp dir first, then atomic rename to final paths + const report = await writeFlushResultsAtomic(knowledgePath, synthesis, unprocessedLogs); + + // Validate staleness against git history (project tier only — global has no git repo) + if (gitInfo) { + const validation = await validateStaleness( + knowledgePath, + gitInfo.owner, + gitInfo.repo, + lastFlush?.gitSha || undefined, + mergedConfig.knowledge.captureModel + ); + report.articlesStale = validation.articlesFlaggedStale; + } + + // Update last-flush metadata (git SHA only for project tier) + await updateLastFlush(knowledgePath, unprocessedLogs, gitInfo?.owner, gitInfo?.repo); + + log.info( + { + label, + articlesCreated: report.articlesCreated, + articlesUpdated: report.articlesUpdated, + articlesStale: report.articlesStale, + domainsCreated: report.domainsCreated, + logsProcessed: report.logsProcessed.length, + }, + 'knowledge.flush_completed' + ); + + return report; + } catch (e) { + const err = e as Error; + log.error( + { + label, + error: err.message, + errorType: err.constructor.name, + err, + }, + 'knowledge.flush_failed' + ); + throw err; + } finally { + // Always release the lock + await releaseFlushLock(knowledgePath); + } +} + +/** + * Flush daily logs into structured domain articles for a project KB. + * + * @param owner - Repository owner + * @param repo - Repository name + * @param config - Optional pre-loaded config (avoids redundant loading) + */ +export async function flushKnowledge( + owner: string, + repo: string, + config?: MergedConfig +): Promise { + const mergedConfig = config ?? (await loadConfig()); + return flushKnowledgeCore({ + label: `${owner}/${repo}`, + knowledgePath: getProjectKnowledgePath(owner, repo), + config: mergedConfig, + git: { owner, repo }, + init: () => initKnowledgeDir(owner, repo), + }); +} + +/** + * Flush daily logs into structured domain articles for the global KB. + * Same as project flush but operates on the global knowledge directory + * and skips git-based staleness validation (no associated repository). + * + * @param config - Optional pre-loaded config (avoids redundant loading) + */ +export async function flushGlobalKnowledge(config?: MergedConfig): Promise { + const mergedConfig = config ?? (await loadConfig()); + return flushKnowledgeCore({ + label: 'global', + knowledgePath: getGlobalKnowledgePath(), + config: mergedConfig, + init: () => initGlobalKnowledgeDir(), + }); +} + +/** + * Read meta/last-flush.json. Returns null if it doesn't exist. + */ +export async function readLastFlush(knowledgePath: string): Promise { + try { + const content = await readFile(join(knowledgePath, 'meta', 'last-flush.json'), 'utf-8'); + return JSON.parse(content) as LastFlushMeta; + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + return null; + } + throw err; + } +} + +/** + * Find daily log files that are newer than the last flush timestamp. + * If no last flush exists, return all logs. + * Returns sorted array of filenames (YYYY-MM-DD.md). + */ +async function findUnprocessedLogs( + knowledgePath: string, + lastFlushTimestamp?: string +): Promise { + const logsDir = join(knowledgePath, 'logs'); + + let files: string[]; + try { + files = await readdir(logsDir); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + return []; + } + throw err; + } + + // Filter to YYYY-MM-DD.md files + const logFiles = files.filter(f => /^\d{4}-\d{2}-\d{2}\.md$/.test(f)).sort(); + + if (!lastFlushTimestamp) { + return logFiles; + } + + // Filter to files newer than last flush date + const flushDate = lastFlushTimestamp.slice(0, 10); // YYYY-MM-DD + return logFiles.filter(f => f.replace('.md', '') >= flushDate); +} + +/** + * Read the contents of the specified log files. + */ +async function readLogContents(knowledgePath: string, logFiles: string[]): Promise { + const logsDir = join(knowledgePath, 'logs'); + const contents: string[] = []; + + for (const file of logFiles) { + const content = await readFile(join(logsDir, file), 'utf-8'); + contents.push(`## Log: ${file}\n\n${content}`); + } + + return contents.join('\n\n---\n\n'); +} + +/** + * Read existing articles from domains/ for merge context. + * Returns a formatted string of existing articles. + */ +async function readExistingArticles(knowledgePath: string): Promise { + const domainsDir = join(knowledgePath, 'domains'); + const articles: string[] = []; + + let domains: string[]; + try { + domains = await readdir(domainsDir); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + return ''; + } + throw err; + } + + for (const domain of domains) { + const domainDir = join(domainsDir, domain); + let files: string[]; + try { + files = await readdir(domainDir); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { + getLog().warn({ domain, error: (err as Error).message }, 'knowledge.read_domain_failed'); + } + continue; + } + + for (const file of files) { + if (file === '_index.md' || !file.endsWith('.md')) continue; + try { + const content = await readFile(join(domainDir, file), 'utf-8'); + articles.push(`### ${domain}/${file}\n\n${content}`); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { + getLog().warn( + { domain, file, error: (err as Error).message }, + 'knowledge.read_article_failed' + ); + } + continue; + } + } + } + + return articles.length > 0 + ? `## Existing Articles (merge with these if concepts overlap)\n\n${articles.join('\n\n---\n\n')}` + : ''; +} + +/** + * Call AI model to synthesize logs into structured articles. + */ +async function synthesizeLogs( + logContents: string, + existingArticles: string, + compileModel: string +): Promise { + const client = getAssistantClient('claude'); + + const contextParts = [SYNTHESIS_PROMPT]; + if (existingArticles) { + contextParts.push(existingArticles); + } + contextParts.push(`## New Daily Logs to Process\n\n${logContents}`); + + const prompt = contextParts.join('\n\n'); + + const chunks: string[] = []; + const generator = client.sendQuery(prompt, process.cwd(), undefined, { + model: compileModel, + tools: [], + }); + + for await (const chunk of generator) { + if (chunk.type === 'assistant') { + chunks.push(chunk.content); + } + } + + const rawResponse = chunks.join(''); + + // Parse JSON from response (handle potential markdown code fences) + const jsonStr = rawResponse.replace(/^```(?:json)?\s*\n?/m, '').replace(/\n?```\s*$/m, ''); + + let parsed: unknown; + try { + parsed = JSON.parse(jsonStr); + } catch (e) { + const err = e as Error; + getLog().warn( + { rawResponseLength: rawResponse.length, error: err.message }, + 'knowledge.flush_synthesis_json_parse_failed' + ); + throw new Error(`Flush synthesis returned invalid JSON: ${err.message}`); + } + + const result = flushSynthesisSchema.safeParse(parsed); + if (!result.success) { + getLog().warn( + { errors: result.error.issues }, + 'knowledge.flush_synthesis_schema_validation_failed' + ); + throw new Error(`Flush synthesis response has invalid structure: ${result.error.message}`); + } + return result.data; +} + +/** + * Write synthesized articles to a temp directory, then atomically rename + * into final paths. If flush crashes mid-write, the temp dir is cleaned up + * on next flush (idempotent). + */ +async function writeFlushResultsAtomic( + knowledgePath: string, + synthesis: FlushSynthesis, + processedLogs: string[] +): Promise { + const domainsDir = join(knowledgePath, 'domains'); + const tmpDir = join(knowledgePath, '.tmp'); + + // Clean up any leftover temp dir from a previous crashed flush + try { + await rm(tmpDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + await mkdir(tmpDir, { recursive: true }); + + let articlesCreated = 0; + let articlesUpdated = 0; + const domainsCreated: string[] = []; + const domainArticles: Record = {}; + + // Collect all files to write into temp dir first + const pendingRenames: { tmpPath: string; finalPath: string }[] = []; + + // Write individual articles to temp dir + for (const article of synthesis.articles) { + const domainDir = join(domainsDir, article.domain); + + // Create real domain directory (needed for _index.md check and final location) + await mkdir(domainDir, { recursive: true }); + + // Track domains created by checking if _index.md exists + const indexPath = join(domainDir, '_index.md'); + let isNewDomain = false; + try { + await readFile(indexPath, 'utf-8'); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + isNewDomain = true; + if (!domainsCreated.includes(article.domain)) { + domainsCreated.push(article.domain); + } + } + } + + const articlePath = join(domainDir, `${article.concept}.md`); + + // Check if article exists in real dir (for created vs updated tracking) + let articleExists = false; + try { + await readFile(articlePath, 'utf-8'); + articleExists = true; + } catch { + // File doesn't exist + } + + // Write to temp dir + const tmpArticleDir = join(tmpDir, 'domains', article.domain); + await mkdir(tmpArticleDir, { recursive: true }); + const tmpArticlePath = join(tmpArticleDir, `${article.concept}.md`); + await writeFile(tmpArticlePath, article.content); + pendingRenames.push({ tmpPath: tmpArticlePath, finalPath: articlePath }); + + if (articleExists) { + articlesUpdated++; + } else { + articlesCreated++; + } + + // Track articles per domain for index updates + if (!domainArticles[article.domain]) { + domainArticles[article.domain] = []; + } + domainArticles[article.domain].push(article.concept); + + // Create domain _index.md in temp if new domain + if (isNewDomain) { + const domainTitle = article.domain + .split('-') + .map(w => w.charAt(0).toUpperCase() + w.slice(1)) + .join(' '); + const domainIndexContent = `# ${domainTitle}\n\n${synthesis.domainSummaries[article.domain] ?? ''}\n\n## Articles\n\n${domainArticles[article.domain].map(c => `- [[${article.domain}/${c}|${formatConceptTitle(c)}]]`).join('\n')}\n`; + const tmpIndexPath = join(tmpArticleDir, '_index.md'); + await writeFile(tmpIndexPath, domainIndexContent); + pendingRenames.push({ tmpPath: tmpIndexPath, finalPath: indexPath }); + } + } + + // Update existing domain indexes — write to temp, then rename + for (const [domain, concepts] of Object.entries(domainArticles)) { + const indexPath = join(domainsDir, domain, '_index.md'); + try { + const existingIndex = await readFile(indexPath, 'utf-8'); + const updatedIndex = updateDomainIndex( + existingIndex, + domain, + concepts, + synthesis.domainSummaries[domain] + ); + const tmpIndexDir = join(tmpDir, 'domains', domain); + await mkdir(tmpIndexDir, { recursive: true }); + const tmpIndexPath = join(tmpIndexDir, '_index.md'); + await writeFile(tmpIndexPath, updatedIndex); + pendingRenames.push({ tmpPath: tmpIndexPath, finalPath: indexPath }); + } catch { + // Index was already created above for new domains + } + } + + // Write top-level index.md to temp + if (synthesis.indexSummary || synthesis.articles.length > 0) { + const indexContent = buildTopLevelIndex(synthesis, domainArticles); + const tmpIndexPath = join(tmpDir, 'index.md'); + await writeFile(tmpIndexPath, indexContent); + pendingRenames.push({ + tmpPath: tmpIndexPath, + finalPath: join(knowledgePath, 'index.md'), + }); + } + + // Atomic phase: rename all temp files into their final locations + for (const { tmpPath, finalPath } of pendingRenames) { + await rename(tmpPath, finalPath); + } + + // Clean up temp dir + try { + await rm(tmpDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + + return { + articlesCreated, + articlesUpdated, + articlesStale: 0, // Staleness is US-011 + domainsCreated, + logsProcessed: processedLogs, + skipped: false, + }; +} + +/** + * Update a domain _index.md with new article links. + */ +function updateDomainIndex( + existingContent: string, + domain: string, + newConcepts: string[], + summary?: string +): string { + let content = existingContent; + + // Update summary if provided + if (summary) { + // Replace the line after the heading with the summary + const lines = content.split('\n'); + if (lines.length >= 2) { + // Find the line after the first heading + const headingIdx = lines.findIndex(l => l.startsWith('# ')); + if (headingIdx !== -1 && headingIdx + 1 < lines.length) { + // Insert or replace summary after heading + if (lines[headingIdx + 1] === '' && headingIdx + 2 < lines.length) { + // Replace the description line + if (!lines[headingIdx + 2]?.startsWith('##')) { + lines[headingIdx + 2] = summary; + } + } + content = lines.join('\n'); + } + } + } + + // Add new article links if not already present + for (const concept of newConcepts) { + const wikilink = `[[${domain}/${concept}|${formatConceptTitle(concept)}]]`; + if (!content.includes(wikilink)) { + // Remove placeholder text if present + content = content.replace( + '_No articles yet. Articles will appear here as knowledge is compiled._', + '' + ); + // Append to Articles section + content = content.trimEnd() + `\n- ${wikilink}\n`; + } + } + + return content; +} + +/** + * Build the top-level index.md content (pure — no I/O). + */ +function buildTopLevelIndex( + synthesis: FlushSynthesis, + domainArticles: Record +): string { + const domainSections: string[] = []; + const allDomains = new Set([ + ...Object.keys(synthesis.domainSummaries), + ...Object.keys(domainArticles), + ]); + + for (const domain of [...allDomains].sort()) { + const title = formatConceptTitle(domain); + const summary = synthesis.domainSummaries[domain] ?? ''; + domainSections.push(`### [[domains/${domain}/_index|${title}]]\n${summary}`); + } + + return `# Knowledge Base Index + +> This index is auto-maintained. Navigate to domain indexes for detailed articles. + +## Domains + +${domainSections.join('\n\n')} +`; +} + +/** + * Update meta/last-flush.json with current timestamp and git SHA via atomic temp+rename. + * Git SHA is only fetched when owner/repo are provided (project tier). + */ +async function updateLastFlush( + knowledgePath: string, + processedLogs: string[], + owner?: string, + repo?: string +): Promise { + const metaDir = join(knowledgePath, 'meta'); + await mkdir(metaDir, { recursive: true }); + + const gitSha = owner && repo ? await getCurrentGitSha(owner, repo) : ''; + + const meta: LastFlushMeta = { + timestamp: new Date().toISOString(), + gitSha, + logsCaptured: processedLogs, + }; + + const tmpPath = join(metaDir, 'last-flush.json.tmp'); + const finalPath = join(metaDir, 'last-flush.json'); + await writeFile(tmpPath, JSON.stringify(meta, null, 2)); + await rename(tmpPath, finalPath); +} + +/** + * Get the current HEAD SHA from the project source repository. + * Returns empty string if git is unavailable. + */ +export async function getCurrentGitSha(owner: string, repo: string): Promise { + try { + const sourcePath = getProjectSourcePath(owner, repo); + const { stdout } = await execFileAsync('git', ['-C', sourcePath, 'rev-parse', 'HEAD'], { + timeout: 10000, + }); + return stdout.trim(); + } catch (err) { + getLog().debug({ owner, repo, error: (err as Error).message }, 'knowledge.git_sha_unavailable'); + return ''; + } +} + +/** + * Get git diff output (changed file names) between two SHAs. + * Returns empty string if git is unavailable or SHAs are invalid. + */ +export async function getGitDiffNameOnly( + owner: string, + repo: string, + fromSha: string +): Promise { + try { + const sourcePath = getProjectSourcePath(owner, repo); + const { stdout } = await execFileAsync( + 'git', + ['-C', sourcePath, 'diff', '--name-only', `${fromSha}..HEAD`], + { timeout: 30000 } + ); + return stdout.trim(); + } catch (err) { + getLog().debug( + { owner, repo, fromSha, error: (err as Error).message }, + 'knowledge.git_diff_unavailable' + ); + return ''; + } +} + +/** Prompt for Haiku to perform staleness comparison */ +const STALENESS_PROMPT = `You are a knowledge base validator. Given a list of changed files from git and a set of knowledge articles, identify which articles reference files, functions, or patterns that have changed significantly. + +## Output Format + +Respond with a JSON array of article paths that are stale (no markdown fences, no explanation, just JSON): + +["domain/concept", "domain/concept2"] + +If no articles are stale, return an empty array: [] + +## Rules +- An article is stale if it specifically references files, functions, classes, or patterns that appear in the changed files list +- General conceptual articles that don't reference specific code are NOT stale +- Only flag articles where the changes are significant enough to potentially invalidate the article's content +- Be conservative — only flag clearly affected articles + +--- + +`; + +/** Staleness validation result */ +interface StalenessResult { + articlesChecked: number; + articlesFlaggedStale: number; + brokenLinks: number; + staleArticles: string[]; + brokenWikilinks: { source: string; target: string }[]; +} + +/** + * Validate articles for staleness against git history and check for broken wikilinks. + */ +async function validateStaleness( + knowledgePath: string, + owner: string, + repo: string, + lastFlushSha: string | undefined, + captureModel: string +): Promise { + const log = getLog(); + + // Collect all articles + const articles = await collectAllArticles(knowledgePath); + + if (articles.length === 0) { + return { + articlesChecked: 0, + articlesFlaggedStale: 0, + brokenLinks: 0, + staleArticles: [], + brokenWikilinks: [], + }; + } + + // Check staleness via git diff + AI + let staleArticles: string[] = []; + if (lastFlushSha) { + const diffOutput = await getGitDiffNameOnly(owner, repo, lastFlushSha); + if (diffOutput) { + staleArticles = await identifyStaleArticles(articles, diffOutput, captureModel); + } + } + + // Add staleness markers to flagged articles + for (const articleKey of staleArticles) { + const [domain, concept] = articleKey.split('/'); + if (!domain || !concept) continue; + const articlePath = join(knowledgePath, 'domains', domain, `${concept}.md`); + await addStalenessMarker(articlePath); + } + + // Check for broken wikilinks + const brokenWikilinks = checkBrokenWikilinks(articles); + + log.info( + { + articlesChecked: articles.length, + articlesFlaggedStale: staleArticles.length, + brokenLinks: brokenWikilinks.length, + }, + 'knowledge.flush_validation_completed' + ); + + return { + articlesChecked: articles.length, + articlesFlaggedStale: staleArticles.length, + brokenLinks: brokenWikilinks.length, + staleArticles, + brokenWikilinks, + }; +} + +/** Collected article with domain/concept key and content */ +export interface CollectedArticle { + key: string; // "domain/concept" + content: string; +} + +/** + * Collect all articles from domains/ directory. + */ +export async function collectAllArticles(knowledgePath: string): Promise { + const domainsDir = join(knowledgePath, 'domains'); + const articles: CollectedArticle[] = []; + + let domains: string[]; + try { + domains = await readdir(domainsDir); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return []; + throw err; + } + + for (const domain of domains) { + const domainDir = join(domainsDir, domain); + let files: string[]; + try { + files = await readdir(domainDir); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { + getLog().warn({ domain, error: (err as Error).message }, 'knowledge.collect_domain_failed'); + } + continue; + } + + for (const file of files) { + if (file === '_index.md' || !file.endsWith('.md')) continue; + try { + const content = await readFile(join(domainDir, file), 'utf-8'); + const concept = file.replace('.md', ''); + articles.push({ key: `${domain}/${concept}`, content }); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { + getLog().warn( + { domain, file, error: (err as Error).message }, + 'knowledge.collect_article_failed' + ); + } + continue; + } + } + } + + return articles; +} + +/** + * Call Haiku to identify which articles are stale based on git diff. + */ +export async function identifyStaleArticles( + articles: CollectedArticle[], + diffOutput: string, + captureModel: string +): Promise { + const client = getAssistantClient('claude'); + + const articlesSummary = articles.map(a => `### ${a.key}\n\n${a.content}`).join('\n\n---\n\n'); + + const prompt = `${STALENESS_PROMPT}## Changed Files\n\n${diffOutput}\n\n## Articles to Check\n\n${articlesSummary}`; + + const chunks: string[] = []; + const generator = client.sendQuery(prompt, process.cwd(), undefined, { + model: captureModel, + tools: [], + }); + + for await (const chunk of generator) { + if (chunk.type === 'assistant') { + chunks.push(chunk.content); + } + } + + const rawResponse = chunks.join(''); + const jsonStr = rawResponse.replace(/^```(?:json)?\s*\n?/m, '').replace(/\n?```\s*$/m, ''); + + try { + const result = JSON.parse(jsonStr) as unknown; + if (!Array.isArray(result)) return []; + return result.filter((item): item is string => typeof item === 'string'); + } catch (e) { + getLog().warn( + { rawResponseLength: rawResponse.length, error: (e as Error).message }, + 'knowledge.staleness_json_parse_failed' + ); + return []; + } +} + +/** + * Add a staleness warning marker to an article file. + * Idempotent — won't add if already present. + */ +async function addStalenessMarker(articlePath: string): Promise { + const STALENESS_MARKER = + '> [!WARNING] This article may be stale — referenced code has changed since last validation.'; + + try { + let content = await readFile(articlePath, 'utf-8'); + if (content.includes('> [!WARNING] This article may be stale')) { + return; // Already marked + } + + // Remove any existing staleness markers before adding fresh one + // Insert after the first heading line + const lines = content.split('\n'); + const headingIdx = lines.findIndex(l => l.startsWith('# ')); + if (headingIdx !== -1) { + lines.splice(headingIdx + 1, 0, '', STALENESS_MARKER, ''); + content = lines.join('\n'); + } else { + content = STALENESS_MARKER + '\n\n' + content; + } + + await writeFile(articlePath, content); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { + getLog().warn( + { articlePath, error: (err as Error).message }, + 'knowledge.staleness_marker_write_failed' + ); + } + } +} + +/** + * Check for broken [[wikilink]] cross-references between articles. + * Returns list of broken links with source article and target reference. + */ +export function checkBrokenWikilinks( + articles: CollectedArticle[] +): { source: string; target: string }[] { + const articleKeys = new Set(articles.map(a => a.key)); + const brokenLinks: { source: string; target: string }[] = []; + + const wikilinkPattern = /\[\[([^\]|]+)(?:\|[^\]]+)?\]\]/g; + + for (const article of articles) { + let match: RegExpExecArray | null; + // Reset lastIndex for each article + wikilinkPattern.lastIndex = 0; + + while ((match = wikilinkPattern.exec(article.content)) !== null) { + const target = match[1]; + if (!target) continue; + + // Skip links to _index files (domain-level links) + if (target.includes('_index')) continue; + + // Normalize: strip domains/ prefix if present + const normalizedTarget = target.replace(/^domains\//, ''); + + // Check if target article exists + if (normalizedTarget.includes('/') && !articleKeys.has(normalizedTarget)) { + brokenLinks.push({ source: article.key, target: normalizedTarget }); + } + } + } + + return brokenLinks; +} + +/** + * Convert kebab-case concept name to Title Case. + */ +function formatConceptTitle(concept: string): string { + return concept + .split('-') + .map(w => w.charAt(0).toUpperCase() + w.slice(1)) + .join(' '); +} diff --git a/packages/core/src/services/knowledge-init.test.ts b/packages/core/src/services/knowledge-init.test.ts new file mode 100644 index 0000000000..34f6652c62 --- /dev/null +++ b/packages/core/src/services/knowledge-init.test.ts @@ -0,0 +1,225 @@ +import { mock, describe, test, expect, beforeEach } from 'bun:test'; + +// Track mkdir calls +const mkdirCalls: Array<{ path: string; options: { recursive: boolean } }> = []; +const mockMkdir = mock(async (path: string, options: { recursive: boolean }) => { + mkdirCalls.push({ path, options }); + return undefined; +}); + +// Track writeFile calls +const writeFileCalls: Array<{ path: string; content: string; options: { flag: string } }> = []; +const mockWriteFile = mock(async (path: string, content: string, options: { flag: string }) => { + writeFileCalls.push({ path, content, options }); + return undefined; +}); + +mock.module('node:fs/promises', () => ({ + mkdir: mockMkdir, + writeFile: mockWriteFile, +})); + +// Mock @archon/paths +mock.module('@archon/paths', () => ({ + getProjectKnowledgePath: (owner: string, repo: string) => + `/home/test/.archon/workspaces/${owner}/${repo}/knowledge`, + getGlobalKnowledgePath: () => '/home/test/.archon/knowledge', +})); + +import { + initKnowledgeDir, + initGlobalKnowledgeDir, + DEFAULT_DOMAINS, + SCHEMA_TEMPLATE, + INDEX_TEMPLATE, + DOMAIN_INDEX_TEMPLATES, +} from './knowledge-init'; + +describe('knowledge-init', () => { + beforeEach(() => { + mkdirCalls.length = 0; + mockMkdir.mockClear(); + writeFileCalls.length = 0; + mockWriteFile.mockReset(); + mockWriteFile.mockImplementation( + async (path: string, content: string, options: { flag: string }) => { + writeFileCalls.push({ path, content, options }); + return undefined; + } + ); + }); + + describe('initKnowledgeDir', () => { + test('creates full directory tree for a project', async () => { + await initKnowledgeDir('acme', 'widget'); + + const base = '/home/test/.archon/workspaces/acme/widget/knowledge'; + const createdPaths = mkdirCalls.map(c => c.path); + + // Top-level dirs + expect(createdPaths).toContain(base); + expect(createdPaths).toContain(`${base}/meta`); + expect(createdPaths).toContain(`${base}/logs`); + expect(createdPaths).toContain(`${base}/domains`); + + // Domain subdirs + for (const domain of DEFAULT_DOMAINS) { + expect(createdPaths).toContain(`${base}/domains/${domain}`); + } + }); + + test('all mkdir calls use recursive: true', async () => { + await initKnowledgeDir('acme', 'widget'); + + for (const call of mkdirCalls) { + expect(call.options.recursive).toBe(true); + } + }); + + test('is idempotent (can be called multiple times)', async () => { + await initKnowledgeDir('acme', 'widget'); + const firstCount = mkdirCalls.length; + + await initKnowledgeDir('acme', 'widget'); + // Should make the same calls again without error (mkdir recursive is idempotent) + expect(mkdirCalls.length).toBe(firstCount * 2); + }); + + test('writes template files with wx flag', async () => { + await initKnowledgeDir('acme', 'widget'); + + const base = '/home/test/.archon/workspaces/acme/widget/knowledge'; + const writtenPaths = writeFileCalls.map(c => c.path); + + // schema.md and index.md + expect(writtenPaths).toContain(`${base}/meta/schema.md`); + expect(writtenPaths).toContain(`${base}/index.md`); + + // Domain _index.md files + for (const domain of DEFAULT_DOMAINS) { + expect(writtenPaths).toContain(`${base}/domains/${domain}/_index.md`); + } + + // All writes use 'wx' flag (write-exclusive: fail if exists) + for (const call of writeFileCalls) { + expect(call.options.flag).toBe('wx'); + } + }); + + test('writes correct template content', async () => { + await initKnowledgeDir('acme', 'widget'); + + const base = '/home/test/.archon/workspaces/acme/widget/knowledge'; + + const schemaCall = writeFileCalls.find(c => c.path === `${base}/meta/schema.md`); + expect(schemaCall?.content).toBe(SCHEMA_TEMPLATE); + + const indexCall = writeFileCalls.find(c => c.path === `${base}/index.md`); + expect(indexCall?.content).toBe(INDEX_TEMPLATE); + + for (const domain of DEFAULT_DOMAINS) { + const domainCall = writeFileCalls.find( + c => c.path === `${base}/domains/${domain}/_index.md` + ); + expect(domainCall?.content).toBe(DOMAIN_INDEX_TEMPLATES[domain]); + } + }); + + test('skips writing if files already exist (EEXIST)', async () => { + const eexistError = Object.assign(new Error('EEXIST'), { code: 'EEXIST' }); + mockWriteFile.mockRejectedValue(eexistError); + + // Should not throw — EEXIST is silently ignored + await expect(initKnowledgeDir('acme', 'widget')).resolves.toBeUndefined(); + }); + + test('propagates non-EEXIST write errors', async () => { + const permError = Object.assign(new Error('EACCES'), { code: 'EACCES' }); + mockWriteFile.mockRejectedValue(permError); + + await expect(initKnowledgeDir('acme', 'widget')).rejects.toThrow('EACCES'); + }); + }); + + describe('initGlobalKnowledgeDir', () => { + test('creates full directory tree at global path', async () => { + await initGlobalKnowledgeDir(); + + const base = '/home/test/.archon/knowledge'; + const createdPaths = mkdirCalls.map(c => c.path); + + // Top-level dirs + expect(createdPaths).toContain(base); + expect(createdPaths).toContain(`${base}/meta`); + expect(createdPaths).toContain(`${base}/logs`); + expect(createdPaths).toContain(`${base}/domains`); + + // Domain subdirs + for (const domain of DEFAULT_DOMAINS) { + expect(createdPaths).toContain(`${base}/domains/${domain}`); + } + }); + + test('all mkdir calls use recursive: true', async () => { + await initGlobalKnowledgeDir(); + + for (const call of mkdirCalls) { + expect(call.options.recursive).toBe(true); + } + }); + + test('writes template files at global path', async () => { + await initGlobalKnowledgeDir(); + + const base = '/home/test/.archon/knowledge'; + const writtenPaths = writeFileCalls.map(c => c.path); + + expect(writtenPaths).toContain(`${base}/meta/schema.md`); + expect(writtenPaths).toContain(`${base}/index.md`); + + for (const domain of DEFAULT_DOMAINS) { + expect(writtenPaths).toContain(`${base}/domains/${domain}/_index.md`); + } + }); + }); + + describe('DEFAULT_DOMAINS', () => { + test('contains all starting domains', () => { + expect(DEFAULT_DOMAINS).toContain('architecture'); + expect(DEFAULT_DOMAINS).toContain('decisions'); + expect(DEFAULT_DOMAINS).toContain('patterns'); + expect(DEFAULT_DOMAINS).toContain('lessons'); + expect(DEFAULT_DOMAINS).toContain('connections'); + expect(DEFAULT_DOMAINS).toHaveLength(5); + }); + }); + + describe('templates', () => { + test('schema template describes KB structure and navigation', () => { + expect(SCHEMA_TEMPLATE).toContain('# Knowledge Base Schema'); + expect(SCHEMA_TEMPLATE).toContain('[[index]]'); + expect(SCHEMA_TEMPLATE).toContain('[[wikilink]]'); + expect(SCHEMA_TEMPLATE).toContain('architecture'); + expect(SCHEMA_TEMPLATE).toContain('decisions'); + expect(SCHEMA_TEMPLATE).toContain('patterns'); + expect(SCHEMA_TEMPLATE).toContain('lessons'); + expect(SCHEMA_TEMPLATE).toContain('connections'); + }); + + test('index template has sections for each domain with wikilinks', () => { + expect(INDEX_TEMPLATE).toContain('# Knowledge Base Index'); + for (const domain of DEFAULT_DOMAINS) { + expect(INDEX_TEMPLATE).toContain(`domains/${domain}/_index`); + } + }); + + test('domain index templates exist for all default domains', () => { + for (const domain of DEFAULT_DOMAINS) { + expect(DOMAIN_INDEX_TEMPLATES[domain]).toBeDefined(); + expect(DOMAIN_INDEX_TEMPLATES[domain]).toContain( + `# ${domain.charAt(0).toUpperCase() + domain.slice(1)}` + ); + } + }); + }); +}); diff --git a/packages/core/src/services/knowledge-init.ts b/packages/core/src/services/knowledge-init.ts new file mode 100644 index 0000000000..2004c9df86 --- /dev/null +++ b/packages/core/src/services/knowledge-init.ts @@ -0,0 +1,194 @@ +import { mkdir, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { getProjectKnowledgePath, getGlobalKnowledgePath } from '@archon/paths'; + +/** Default domain subdirectories created during KB initialization */ +const DEFAULT_DOMAINS = [ + 'architecture', + 'decisions', + 'patterns', + 'lessons', + 'connections', +] as const; + +/** KB structure description for AI agents — loaded at session start */ +const SCHEMA_TEMPLATE = `# Knowledge Base Schema + +## Structure + +\`\`\` +knowledge/ +├── index.md # Entry point — start here +├── meta/ +│ ├── schema.md # This file +│ └── last-flush.json # Last compile timestamp +├── logs/ +│ └── YYYY-MM-DD.md # Daily capture logs (raw) +└── domains/ + ├── architecture/ # System design, components, data flow + ├── decisions/ # ADRs, trade-offs, rationale + ├── patterns/ # Recurring code/workflow patterns + ├── lessons/ # Mistakes, gotchas, debugging insights + └── connections/ # Cross-domain links, dependency maps +\`\`\` + +## Navigation + +1. Start at [[index]] for a summary of all domains +2. Each domain has a \`_index.md\` listing its articles +3. Articles use [[wikilinks]] to cross-reference related concepts +4. New domains can be created organically during compilation + +## Conventions + +- Articles use standard markdown with [[wikilink]] backlinks +- Staleness warnings appear as \`> [!WARNING]\` admonitions +- Daily logs in \`logs/\` are raw capture output — not curated +- Compiled articles in \`domains/\` are synthesized and maintained +`; + +/** Top-level index template — the agent's entry point (~500 tokens) */ +const INDEX_TEMPLATE = `# Knowledge Base Index + +> This index is auto-maintained. Navigate to domain indexes for detailed articles. + +## Domains + +### [[domains/architecture/_index|Architecture]] +System design, components, data flow, and technical infrastructure. + +### [[domains/decisions/_index|Decisions]] +Architectural decision records, trade-offs, and rationale. + +### [[domains/patterns/_index|Patterns]] +Recurring code patterns, workflow conventions, and best practices. + +### [[domains/lessons/_index|Lessons]] +Mistakes encountered, debugging insights, and gotchas to avoid. + +### [[domains/connections/_index|Connections]] +Cross-domain links, dependency maps, and system relationships. +`; + +/** Domain-specific _index.md templates */ +const DOMAIN_INDEX_TEMPLATES: Record = { + architecture: `# Architecture + +System design, components, data flow, and technical infrastructure. + +## Articles + +_No articles yet. Articles will appear here as knowledge is compiled._ +`, + decisions: `# Decisions + +Architectural decision records, trade-offs, and rationale. + +## Articles + +_No articles yet. Articles will appear here as knowledge is compiled._ +`, + patterns: `# Patterns + +Recurring code patterns, workflow conventions, and best practices. + +## Articles + +_No articles yet. Articles will appear here as knowledge is compiled._ +`, + lessons: `# Lessons + +Mistakes encountered, debugging insights, and gotchas to avoid. + +## Articles + +_No articles yet. Articles will appear here as knowledge is compiled._ +`, + connections: `# Connections + +Cross-domain links, dependency maps, and system relationships. + +## Articles + +_No articles yet. Articles will appear here as knowledge is compiled._ +`, +}; + +/** + * Initialize the knowledge base directory tree for a project. + * Creates: knowledge/, meta/, logs/, domains/, starting domain subdirs, and template files. + * Idempotent — safe to call multiple times. Templates only written if files don't exist. + */ +export async function initKnowledgeDir(owner: string, repo: string): Promise { + const knowledgePath = getProjectKnowledgePath(owner, repo); + await createKnowledgeTree(knowledgePath, owner, repo); +} + +/** + * Initialize the global knowledge base directory tree. + * Creates: knowledge/, meta/, logs/, domains/, starting domain subdirs, and template files. + * Idempotent — safe to call multiple times. Templates only written if files don't exist. + */ +export async function initGlobalKnowledgeDir(): Promise { + const knowledgePath = getGlobalKnowledgePath(); + await createKnowledgeTreeGlobal(knowledgePath); +} + +/** + * Create the full KB directory tree at the given base path (project variant). + */ +async function createKnowledgeTree(basePath: string, _owner: string, _repo: string): Promise { + await createDirectoryStructure(basePath); +} + +/** + * Create the full KB directory tree at the given base path (global variant). + */ +async function createKnowledgeTreeGlobal(basePath: string): Promise { + await createDirectoryStructure(basePath); +} + +/** + * Write a file only if it doesn't already exist. Uses 'wx' flag which fails on existing files. + */ +async function writeIfNotExists(filePath: string, content: string): Promise { + try { + await writeFile(filePath, content, { flag: 'wx' }); + } catch (err) { + // EEXIST means file already exists — skip silently (idempotent) + if ((err as NodeJS.ErrnoException).code !== 'EEXIST') { + throw err; + } + } +} + +/** + * Shared implementation: creates the directory structure and template files under a knowledge base path. + */ +async function createDirectoryStructure(basePath: string): Promise { + // Create top-level directories + await mkdir(basePath, { recursive: true }); + await mkdir(join(basePath, 'meta'), { recursive: true }); + await mkdir(join(basePath, 'logs'), { recursive: true }); + + // Create domains/ and each default domain subdirectory + const domainsPath = join(basePath, 'domains'); + await mkdir(domainsPath, { recursive: true }); + + for (const domain of DEFAULT_DOMAINS) { + await mkdir(join(domainsPath, domain), { recursive: true }); + } + + // Write template files (only if they don't already exist) + await writeIfNotExists(join(basePath, 'meta', 'schema.md'), SCHEMA_TEMPLATE); + await writeIfNotExists(join(basePath, 'index.md'), INDEX_TEMPLATE); + + for (const domain of DEFAULT_DOMAINS) { + const template = DOMAIN_INDEX_TEMPLATES[domain]; + if (template) { + await writeIfNotExists(join(domainsPath, domain, '_index.md'), template); + } + } +} + +export { DEFAULT_DOMAINS, SCHEMA_TEMPLATE, INDEX_TEMPLATE, DOMAIN_INDEX_TEMPLATES }; diff --git a/packages/core/src/services/knowledge-scheduler.test.ts b/packages/core/src/services/knowledge-scheduler.test.ts new file mode 100644 index 0000000000..14fa6ac347 --- /dev/null +++ b/packages/core/src/services/knowledge-scheduler.test.ts @@ -0,0 +1,202 @@ +import { describe, it, expect, mock, beforeEach, afterEach } from 'bun:test'; + +// Track flush calls +let flushCalls: { owner: string; repo: string }[] = []; +let globalFlushCalls = 0; +let loadConfigCalls = 0; +const DEFAULT_CONFIG = { + knowledge: { + enabled: true, + captureModel: 'haiku', + compileModel: 'sonnet', + flushDebounceMinutes: 10, + domains: ['architecture', 'decisions', 'patterns', 'lessons', 'connections'], + }, +}; + +mock.module('@archon/paths', () => ({ + createLogger: () => ({ + info: () => {}, + debug: () => {}, + warn: () => {}, + error: () => {}, + }), +})); + +mock.module('../config/config-loader', () => ({ + loadConfig: async () => { + loadConfigCalls++; + return DEFAULT_CONFIG; + }, +})); + +mock.module('./knowledge-flush', () => ({ + flushKnowledge: async (owner: string, repo: string) => { + flushCalls.push({ owner, repo }); + return { + articlesCreated: 0, + articlesUpdated: 0, + articlesStale: 0, + domainsCreated: [], + logsProcessed: [], + skipped: false, + }; + }, + flushGlobalKnowledge: async () => { + globalFlushCalls++; + return { + articlesCreated: 0, + articlesUpdated: 0, + articlesStale: 0, + domainsCreated: [], + logsProcessed: [], + skipped: false, + }; + }, +})); + +const { + scheduleFlush, + scheduleGlobalFlush, + cancelScheduledFlush, + isFlushScheduled, + cancelAllScheduledFlushes, +} = await import('./knowledge-scheduler'); + +describe('knowledge-scheduler', () => { + beforeEach(() => { + cancelAllScheduledFlushes(); + flushCalls = []; + globalFlushCalls = 0; + loadConfigCalls = 0; + }); + + afterEach(() => { + cancelAllScheduledFlushes(); + }); + + it('should schedule a flush that fires after the debounce period', async () => { + // Use a very short debounce for testing (0.001 minutes = 60ms) + await scheduleFlush('acme', 'widget', 0.001); + + expect(isFlushScheduled('acme', 'widget')).toBe(true); + expect(flushCalls).toHaveLength(0); + + // Wait for the timer to fire + await new Promise(resolve => setTimeout(resolve, 120)); + + expect(flushCalls).toHaveLength(1); + expect(flushCalls[0]).toEqual({ owner: 'acme', repo: 'widget' }); + expect(isFlushScheduled('acme', 'widget')).toBe(false); + }); + + it('should reset the timer if scheduleFlush is called again (debounce)', async () => { + await scheduleFlush('acme', 'widget', 0.002); // ~120ms + + // Wait 60ms then reschedule + await new Promise(resolve => setTimeout(resolve, 60)); + expect(flushCalls).toHaveLength(0); + + await scheduleFlush('acme', 'widget', 0.002); // reset timer + + // Wait 60ms more — original timer would have fired by now + await new Promise(resolve => setTimeout(resolve, 60)); + expect(flushCalls).toHaveLength(0); // Still no flush — timer was reset + + // Wait for the new timer to fire + await new Promise(resolve => setTimeout(resolve, 100)); + expect(flushCalls).toHaveLength(1); + }); + + it('should use per-project timers (different projects are independent)', async () => { + await scheduleFlush('acme', 'widget', 0.001); + await scheduleFlush('acme', 'other', 0.001); + + expect(isFlushScheduled('acme', 'widget')).toBe(true); + expect(isFlushScheduled('acme', 'other')).toBe(true); + + await new Promise(resolve => setTimeout(resolve, 120)); + + expect(flushCalls).toHaveLength(2); + const projects = flushCalls.map(c => `${c.owner}/${c.repo}`).sort(); + expect(projects).toEqual(['acme/other', 'acme/widget']); + }); + + it('should load config for debounce minutes when not provided', async () => { + // Don't pass debounceMinutes — should load from config + await scheduleFlush('acme', 'widget'); + + expect(loadConfigCalls).toBe(1); + expect(isFlushScheduled('acme', 'widget')).toBe(true); + + // Cancel to avoid waiting for the 10-minute timer + cancelScheduledFlush('acme', 'widget'); + }); + + it('should cancel a scheduled flush', async () => { + await scheduleFlush('acme', 'widget', 0.001); + expect(isFlushScheduled('acme', 'widget')).toBe(true); + + const cancelled = cancelScheduledFlush('acme', 'widget'); + expect(cancelled).toBe(true); + expect(isFlushScheduled('acme', 'widget')).toBe(false); + + await new Promise(resolve => setTimeout(resolve, 120)); + expect(flushCalls).toHaveLength(0); + }); + + it('should return false when cancelling a non-existent schedule', () => { + const cancelled = cancelScheduledFlush('acme', 'nonexistent'); + expect(cancelled).toBe(false); + }); + + it('should cancel all scheduled flushes', async () => { + await scheduleFlush('acme', 'widget', 0.001); + await scheduleFlush('acme', 'other', 0.001); + + cancelAllScheduledFlushes(); + + expect(isFlushScheduled('acme', 'widget')).toBe(false); + expect(isFlushScheduled('acme', 'other')).toBe(false); + + await new Promise(resolve => setTimeout(resolve, 120)); + expect(flushCalls).toHaveLength(0); + }); + + it('should schedule a global flush that fires after debounce', async () => { + await scheduleGlobalFlush(0.001); + + expect(globalFlushCalls).toBe(0); + + await new Promise(resolve => setTimeout(resolve, 120)); + + expect(globalFlushCalls).toBe(1); + expect(flushCalls).toHaveLength(0); // No project flush triggered + }); + + it('should reset global flush timer on re-schedule', async () => { + await scheduleGlobalFlush(0.002); // ~120ms + + await new Promise(resolve => setTimeout(resolve, 60)); + expect(globalFlushCalls).toBe(0); + + await scheduleGlobalFlush(0.002); // reset + + await new Promise(resolve => setTimeout(resolve, 60)); + expect(globalFlushCalls).toBe(0); // Original timer would have fired + + await new Promise(resolve => setTimeout(resolve, 100)); + expect(globalFlushCalls).toBe(1); + }); + + it('should cancel all clears global flush timer too', async () => { + await scheduleGlobalFlush(0.001); + await scheduleFlush('acme', 'widget', 0.001); + + cancelAllScheduledFlushes(); + + await new Promise(resolve => setTimeout(resolve, 120)); + expect(globalFlushCalls).toBe(0); + expect(flushCalls).toHaveLength(0); + }); +}); diff --git a/packages/core/src/services/knowledge-scheduler.ts b/packages/core/src/services/knowledge-scheduler.ts new file mode 100644 index 0000000000..c7cb379f35 --- /dev/null +++ b/packages/core/src/services/knowledge-scheduler.ts @@ -0,0 +1,156 @@ +/** + * Knowledge flush scheduler — debounced per-project flush triggers. + * + * After capture completes, schedules a flush with a configurable debounce. + * If another capture fires within the debounce window, the timer resets. + * Timers are in-memory only (not persisted across restarts). + */ +import { createLogger } from '@archon/paths'; +import { loadConfig } from '../config/config-loader'; +import { flushKnowledge, flushGlobalKnowledge } from './knowledge-flush'; + +/** Lazy-initialized logger */ +let cachedLog: ReturnType | undefined; +function getLog(): ReturnType { + if (!cachedLog) cachedLog = createLogger('knowledge.scheduler'); + return cachedLog; +} + +/** Per-project debounce timers. Key is "owner/repo". */ +const flushTimers = new Map>(); + +/** + * Schedule a debounced flush for a project. + * If a flush is already scheduled, the timer resets. + * + * @param owner - Repository owner + * @param repo - Repository name + * @param debounceMinutes - Override debounce interval (uses config default if omitted) + */ +export async function scheduleFlush( + owner: string, + repo: string, + debounceMinutes?: number +): Promise { + const log = getLog(); + const projectKey = `${owner}/${repo}`; + + // Resolve debounce interval + let minutes = debounceMinutes; + if (minutes === undefined) { + const config = await loadConfig(); + minutes = config.knowledge.flushDebounceMinutes; + } + + // Cancel existing timer for this project (debounce reset) + const existing = flushTimers.get(projectKey); + if (existing) { + clearTimeout(existing); + log.debug({ projectKey }, 'knowledge.flush_debounce_reset'); + } + + const delayMs = minutes * 60 * 1000; + + log.info({ projectKey, debounceMinutes: minutes }, 'knowledge.flush_scheduled'); + + const timer = setTimeout(() => { + flushTimers.delete(projectKey); + log.info({ projectKey }, 'knowledge.flush_debounce_fired'); + + void flushKnowledge(owner, repo).catch(err => { + log.error( + { projectKey, error: (err as Error).message, err }, + 'knowledge.flush_scheduled_failed' + ); + }); + }, delayMs); + + // Prevent the timer from keeping the process alive + if (typeof timer === 'object' && 'unref' in timer) { + timer.unref(); + } + + flushTimers.set(projectKey, timer); +} + +/** + * Schedule a debounced flush for the global knowledge base. + * If a flush is already scheduled, the timer resets. + * + * @param debounceMinutes - Override debounce interval (uses config default if omitted) + */ +export async function scheduleGlobalFlush(debounceMinutes?: number): Promise { + const log = getLog(); + const projectKey = '__global__'; + + // Resolve debounce interval + let minutes = debounceMinutes; + if (minutes === undefined) { + const config = await loadConfig(); + minutes = config.knowledge.flushDebounceMinutes; + } + + // Cancel existing timer (debounce reset) + const existing = flushTimers.get(projectKey); + if (existing) { + clearTimeout(existing); + log.debug({ projectKey }, 'knowledge.flush_debounce_reset'); + } + + const delayMs = minutes * 60 * 1000; + + log.info({ projectKey, debounceMinutes: minutes }, 'knowledge.flush_scheduled'); + + const timer = setTimeout(() => { + flushTimers.delete(projectKey); + log.info({ projectKey }, 'knowledge.flush_debounce_fired'); + + void flushGlobalKnowledge().catch(err => { + log.error( + { projectKey, error: (err as Error).message, err }, + 'knowledge.flush_scheduled_failed' + ); + }); + }, delayMs); + + // Prevent the timer from keeping the process alive + if (typeof timer === 'object' && 'unref' in timer) { + timer.unref(); + } + + flushTimers.set(projectKey, timer); +} + +/** + * Cancel a scheduled flush for a project. + * Returns true if a timer was cancelled, false if none was pending. + */ +export function cancelScheduledFlush(owner: string, repo: string): boolean { + const projectKey = `${owner}/${repo}`; + const timer = flushTimers.get(projectKey); + if (timer) { + clearTimeout(timer); + flushTimers.delete(projectKey); + getLog().debug({ projectKey }, 'knowledge.flush_schedule_cancelled'); + return true; + } + return false; +} + +/** + * Check if a flush is scheduled for a project. + */ +export function isFlushScheduled(owner: string, repo: string): boolean { + return flushTimers.has(`${owner}/${repo}`); +} + +/** + * Cancel all scheduled flushes. Used for graceful shutdown. + */ +export function cancelAllScheduledFlushes(): void { + for (const [key, timer] of flushTimers) { + clearTimeout(timer); + getLog().debug({ projectKey: key }, 'knowledge.flush_schedule_cancelled'); + } + flushTimers.clear(); +} diff --git a/packages/core/src/services/knowledge-trigger-capture.test.ts b/packages/core/src/services/knowledge-trigger-capture.test.ts new file mode 100644 index 0000000000..6161c8fce7 --- /dev/null +++ b/packages/core/src/services/knowledge-trigger-capture.test.ts @@ -0,0 +1,327 @@ +import { mock, describe, test, expect, beforeEach } from 'bun:test'; + +// Track appendFile calls +const appendFileCalls: Array<{ path: string; content: string }> = []; +const mockAppendFile = mock(async (path: string, content: string) => { + appendFileCalls.push({ path, content }); + return undefined; +}); + +const mockMkdir = mock(async () => undefined); + +mock.module('node:fs/promises', () => ({ + appendFile: mockAppendFile, + mkdir: mockMkdir, + writeFile: mock(async () => undefined), +})); + +// Mock @archon/paths +const mockLogger = { + fatal: mock(() => undefined), + error: mock(() => undefined), + warn: mock(() => undefined), + info: mock(() => undefined), + debug: mock(() => undefined), + trace: mock(() => undefined), +}; +mock.module('@archon/paths', () => ({ + getProjectKnowledgePath: (owner: string, repo: string) => + `/home/test/.archon/workspaces/${owner}/${repo}/knowledge`, + getGlobalKnowledgePath: () => '/home/test/.archon/knowledge', + parseOwnerRepo: (name: string) => { + const parts = name.split('/'); + if (parts.length !== 2) return null; + const [owner, repo] = parts; + if (!owner || !repo) return null; + return { owner, repo }; + }, + createLogger: mock(() => mockLogger), +})); + +// Mock messages DB +const mockListMessages = mock( + async () => + [] as Array<{ + id: string; + conversation_id: string; + role: 'user' | 'assistant'; + content: string; + metadata: string; + created_at: string; + }> +); +mock.module('../db/messages', () => ({ + listMessages: mockListMessages, +})); + +// Mock codebases DB +const mockGetCodebase = mock( + async (_id: string) => null as { id: string; name: string; default_cwd: string } | null +); +mock.module('../db/codebases', () => ({ + getCodebase: mockGetCodebase, +})); + +// Mock config loader +const defaultKnowledgeConfig = { + enabled: true, + captureModel: 'haiku', + compileModel: 'sonnet', + flushDebounceMinutes: 10, + domains: ['architecture', 'decisions', 'patterns', 'lessons', 'connections'], +}; +const mockLoadConfig = mock(async () => ({ + knowledge: { ...defaultKnowledgeConfig }, + assistants: { claude: { model: 'sonnet', settingSources: ['project'] }, codex: {} }, + worktree: {}, + docs: { path: 'docs/' }, + defaults: { loadDefaultCommands: true, loadDefaultWorkflows: true }, +})); +mock.module('../config/config-loader', () => ({ + loadConfig: mockLoadConfig, +})); + +// Mock knowledge-init +const mockInitKnowledgeDir = mock(async () => undefined); +mock.module('./knowledge-init', () => ({ + initKnowledgeDir: mockInitKnowledgeDir, +})); + +// Mock knowledge-scheduler +const mockScheduleFlush = mock(async () => undefined); +mock.module('./knowledge-scheduler', () => ({ + scheduleFlush: mockScheduleFlush, +})); + +// Mock AI client +let mockSendQueryChunks: Array<{ type: string; content?: string }> = []; +const mockSendQuery = mock(function* () { + for (const chunk of mockSendQueryChunks) { + yield chunk; + } +}); +const mockGetAssistantClient = mock(() => ({ + sendQuery: mockSendQuery, + getType: () => 'claude', +})); +mock.module('../clients/factory', () => ({ + getAssistantClient: mockGetAssistantClient, +})); + +import { triggerCapture } from './knowledge-capture'; + +/** + * Helper to flush microtasks + fire-and-forget promises. + * triggerCapture uses `void (async () => { ... })().catch(...)` which + * schedules work on the microtask queue. We need to await that work. + */ +async function flushFireAndForget(): Promise { + // Multiple rounds to handle chained awaits inside the async IIFE + for (let i = 0; i < 10; i++) { + await new Promise(resolve => setTimeout(resolve, 0)); + } +} + +describe('triggerCapture fire-and-forget wiring', () => { + beforeEach(() => { + appendFileCalls.length = 0; + mockAppendFile.mockClear(); + mockMkdir.mockClear(); + mockListMessages.mockClear(); + mockGetCodebase.mockClear(); + mockLoadConfig.mockClear(); + mockInitKnowledgeDir.mockClear(); + mockScheduleFlush.mockClear(); + mockSendQuery.mockClear(); + mockGetAssistantClient.mockClear(); + Object.values(mockLogger).forEach(fn => fn.mockClear()); + + // Reset default mock implementations + mockGetCodebase.mockImplementation(async () => null); + mockListMessages.mockImplementation(async () => []); + mockSendQueryChunks = []; + mockSendQuery.mockImplementation(function* () { + for (const chunk of mockSendQueryChunks) { + yield chunk; + } + }); + }); + + test('returns immediately without awaiting (fire-and-forget)', () => { + // triggerCapture returns void (not a promise) — this is the fire-and-forget contract + const result = triggerCapture('conv-123', 'cb-456'); + expect(result).toBeUndefined(); + }); + + test('skips when codebaseId is null', async () => { + triggerCapture('conv-123', null); + await flushFireAndForget(); + + expect(mockGetCodebase).not.toHaveBeenCalled(); + }); + + test('skips when codebase is not found', async () => { + mockGetCodebase.mockResolvedValueOnce(null); + + triggerCapture('conv-123', 'cb-missing'); + await flushFireAndForget(); + + expect(mockGetCodebase).toHaveBeenCalledWith('cb-missing'); + expect(mockListMessages).not.toHaveBeenCalled(); + expect(mockLogger.debug).toHaveBeenCalled(); + }); + + test('skips when codebase name cannot be parsed as owner/repo', async () => { + mockGetCodebase.mockResolvedValueOnce({ + id: 'cb-1', + name: 'bare-name', // no slash, so parseOwnerRepo returns null + default_cwd: '/tmp', + }); + + triggerCapture('conv-123', 'cb-1'); + await flushFireAndForget(); + + expect(mockGetCodebase).toHaveBeenCalledWith('cb-1'); + expect(mockListMessages).not.toHaveBeenCalled(); + expect(mockLogger.debug).toHaveBeenCalled(); + }); + + test('calls captureKnowledge with resolved owner/repo', async () => { + mockGetCodebase.mockResolvedValueOnce({ + id: 'cb-1', + name: 'acme/widget', + default_cwd: '/tmp/acme/widget', + }); + + // Set up messages and AI response for captureKnowledge to succeed + mockListMessages.mockResolvedValueOnce([ + { + id: 'msg-1', + conversation_id: 'conv-123', + role: 'user' as const, + content: 'How to do auth?', + metadata: '{}', + created_at: '2026-04-11T10:00:00Z', + }, + ]); + + mockSendQueryChunks = [{ type: 'assistant', content: '## Decisions\n- Use JWT\n' }]; + + triggerCapture('conv-123', 'cb-1'); + await flushFireAndForget(); + + // captureKnowledge should have been called (it reads messages) + expect(mockListMessages).toHaveBeenCalledWith('conv-123'); + // AI client was called for extraction + expect(mockGetAssistantClient).toHaveBeenCalledWith('claude'); + // Knowledge dir was initialized + expect(mockInitKnowledgeDir).toHaveBeenCalledWith('acme', 'widget'); + // Daily log was appended + expect(appendFileCalls).toHaveLength(1); + expect(appendFileCalls[0]!.content).toContain('conv-123'); + }); + + test('schedules flush after successful non-skipped capture', async () => { + mockGetCodebase.mockResolvedValueOnce({ + id: 'cb-1', + name: 'acme/widget', + default_cwd: '/tmp/acme/widget', + }); + + mockListMessages.mockResolvedValueOnce([ + { + id: 'msg-1', + conversation_id: 'conv-123', + role: 'user' as const, + content: 'design decision', + metadata: '{}', + created_at: '2026-04-11T10:00:00Z', + }, + ]); + + mockSendQueryChunks = [{ type: 'assistant', content: '## Patterns\n- test\n' }]; + + triggerCapture('conv-123', 'cb-1'); + await flushFireAndForget(); + + expect(mockScheduleFlush).toHaveBeenCalledWith('acme', 'widget'); + }); + + test('does not schedule flush when capture is skipped', async () => { + mockGetCodebase.mockResolvedValueOnce({ + id: 'cb-1', + name: 'acme/widget', + default_cwd: '/tmp/acme/widget', + }); + + // No messages => capture skipped + mockListMessages.mockResolvedValueOnce([]); + + triggerCapture('conv-123', 'cb-1'); + await flushFireAndForget(); + + expect(mockScheduleFlush).not.toHaveBeenCalled(); + }); + + test('errors in captureKnowledge are caught and logged, not propagated', async () => { + mockGetCodebase.mockResolvedValueOnce({ + id: 'cb-1', + name: 'acme/widget', + default_cwd: '/tmp/acme/widget', + }); + + mockListMessages.mockResolvedValueOnce([ + { + id: 'msg-1', + conversation_id: 'conv-123', + role: 'user' as const, + content: 'test', + metadata: '{}', + created_at: '2026-04-11T10:00:00Z', + }, + ]); + + // AI client throws an error + mockSendQuery.mockImplementation(function* () { + throw new Error('API rate limit exceeded'); + }); + + // This should NOT throw — fire-and-forget catches errors + triggerCapture('conv-123', 'cb-1'); + await flushFireAndForget(); + + // Error was logged (capture_failed from captureKnowledge, then trigger_failed from .catch) + const errorCalls = mockLogger.error.mock.calls; + expect(errorCalls.length).toBeGreaterThanOrEqual(1); + const triggerFailedLog = errorCalls.find( + (call: unknown[]) => (call[1] as string) === 'knowledge.trigger_failed' + ); + expect(triggerFailedLog).toBeDefined(); + const logData = triggerFailedLog![0] as { + conversationId: string; + codebaseId: string; + error: string; + }; + expect(logData.conversationId).toBe('conv-123'); + expect(logData.codebaseId).toBe('cb-1'); + expect(logData.error).toContain('API rate limit'); + }); + + test('errors in getCodebase are caught and logged, not propagated', async () => { + mockGetCodebase.mockRejectedValueOnce(new Error('Database connection lost')); + + // Should NOT throw + triggerCapture('conv-123', 'cb-1'); + await flushFireAndForget(); + + // Error was logged via .catch handler + const errorCalls = mockLogger.error.mock.calls; + expect(errorCalls.length).toBeGreaterThanOrEqual(1); + const triggerFailedLog = errorCalls.find( + (call: unknown[]) => (call[1] as string) === 'knowledge.trigger_failed' + ); + expect(triggerFailedLog).toBeDefined(); + const logData = triggerFailedLog![0] as { error: string }; + expect(logData.error).toContain('Database connection lost'); + }); +}); diff --git a/packages/core/src/services/knowledge-workflow-capture.test.ts b/packages/core/src/services/knowledge-workflow-capture.test.ts new file mode 100644 index 0000000000..4d35f5156f --- /dev/null +++ b/packages/core/src/services/knowledge-workflow-capture.test.ts @@ -0,0 +1,269 @@ +import { describe, test, expect, mock, beforeEach } from 'bun:test'; + +// Mocks must be set up before importing the module under test +const mockSubscribe = mock((_listener: (event: unknown) => void) => mock(() => {})); +const mockGetConversationId = mock((_runId: string) => 'conv-123' as string | undefined); + +mock.module('@archon/workflows/event-emitter', () => ({ + getWorkflowEventEmitter: () => ({ + subscribe: mockSubscribe, + getConversationId: mockGetConversationId, + }), +})); + +const mockGetWorkflowRun = mock((_id: string) => + Promise.resolve({ + id: 'run-1', + codebase_id: 'codebase-1', + conversation_id: 'conv-123', + workflow_name: 'test-workflow', + status: 'completed', + }) +); + +mock.module('../db/workflows', () => ({ + getWorkflowRun: mockGetWorkflowRun, +})); + +const mockGetCodebase = mock((_id: string) => + Promise.resolve({ id: 'codebase-1', name: 'acme/widget', path: '/path' }) +); + +mock.module('../db/codebases', () => ({ + getCodebase: mockGetCodebase, +})); + +const mockCaptureKnowledge = mock( + ( + _conversationId: string, + _owner: string, + _repo: string, + _config?: unknown, + _additionalTranscript?: string + ) => + Promise.resolve({ + logFile: '/path/to/log.md', + extractedContent: 'some content', + skipped: false, + }) +); + +mock.module('./knowledge-capture', () => ({ + captureKnowledge: mockCaptureKnowledge, +})); + +const mockReadFile = mock((_path: string, _encoding: string) => + Promise.resolve( + '{"type":"workflow_start","workflow_name":"test","workflow_id":"run-1","ts":"2026-04-11T00:00:00Z"}\n' + + '{"type":"assistant","content":"Hello world","workflow_id":"run-1","ts":"2026-04-11T00:00:01Z"}\n' + + '{"type":"tool","tool_name":"Read","workflow_id":"run-1","ts":"2026-04-11T00:00:02Z"}\n' + ) +); + +mock.module('node:fs/promises', () => ({ + readFile: mockReadFile, +})); + +mock.module('@archon/paths', () => ({ + createLogger: () => ({ + info: () => {}, + debug: () => {}, + error: () => {}, + warn: () => {}, + }), + getRunLogPath: (_owner: string, _repo: string, runId: string) => + `/home/.archon/workspaces/acme/widget/logs/${runId}.jsonl`, + parseOwnerRepo: (name: string) => { + const parts = name.split('/'); + if (parts.length !== 2) return null; + return { owner: parts[0], repo: parts[1] }; + }, +})); + +// Import after mocks +import { + subscribeToWorkflowCapture, + resetWorkflowCaptureSubscription, +} from './knowledge-workflow-capture'; + +describe('knowledge-workflow-capture', () => { + let capturedListener: ((event: unknown) => void) | null = null; + + beforeEach(() => { + resetWorkflowCaptureSubscription(); + capturedListener = null; + mockSubscribe.mockReset(); + mockSubscribe.mockImplementation((listener: (event: unknown) => void) => { + capturedListener = listener; + return mock(() => {}); + }); + mockGetConversationId.mockReset(); + mockGetConversationId.mockImplementation(() => 'conv-123'); + mockGetWorkflowRun.mockReset(); + mockGetWorkflowRun.mockImplementation(() => + Promise.resolve({ + id: 'run-1', + codebase_id: 'codebase-1', + conversation_id: 'conv-123', + workflow_name: 'test-workflow', + status: 'completed', + }) + ); + mockGetCodebase.mockReset(); + mockGetCodebase.mockImplementation(() => + Promise.resolve({ id: 'codebase-1', name: 'acme/widget', path: '/path' }) + ); + mockCaptureKnowledge.mockReset(); + mockCaptureKnowledge.mockImplementation(() => + Promise.resolve({ + logFile: '/path/to/log.md', + extractedContent: 'some content', + skipped: false, + }) + ); + mockReadFile.mockReset(); + mockReadFile.mockImplementation(() => + Promise.resolve( + '{"type":"workflow_start","workflow_name":"test","workflow_id":"run-1","ts":"2026-04-11T00:00:00Z"}\n' + + '{"type":"assistant","content":"Hello world","workflow_id":"run-1","ts":"2026-04-11T00:00:01Z"}\n' + + '{"type":"tool","tool_name":"Read","workflow_id":"run-1","ts":"2026-04-11T00:00:02Z"}\n' + ) + ); + }); + + test('subscribes to workflow event emitter', () => { + subscribeToWorkflowCapture(); + expect(mockSubscribe).toHaveBeenCalledTimes(1); + expect(capturedListener).toBeFunction(); + }); + + test('triggers capture on workflow_completed event', async () => { + subscribeToWorkflowCapture(); + + // Emit a workflow_completed event + capturedListener!({ + type: 'workflow_completed', + runId: 'run-1', + workflowName: 'test-workflow', + duration: 5000, + }); + + // Wait for async fire-and-forget + await new Promise(resolve => setTimeout(resolve, 50)); + + expect(mockGetConversationId).toHaveBeenCalledWith('run-1'); + expect(mockGetWorkflowRun).toHaveBeenCalledWith('run-1'); + expect(mockGetCodebase).toHaveBeenCalledWith('codebase-1'); + expect(mockCaptureKnowledge).toHaveBeenCalledTimes(1); + expect(mockCaptureKnowledge.mock.calls[0][0]).toBe('conv-123'); + expect(mockCaptureKnowledge.mock.calls[0][1]).toBe('acme'); + expect(mockCaptureKnowledge.mock.calls[0][2]).toBe('widget'); + // Should include workflow log content + expect(mockCaptureKnowledge.mock.calls[0][4]).toContain('WORKFLOW EXECUTION LOG'); + expect(mockCaptureKnowledge.mock.calls[0][4]).toContain('Hello world'); + expect(mockCaptureKnowledge.mock.calls[0][4]).toContain('TOOL: Read'); + }); + + test('ignores non-workflow_completed events', () => { + subscribeToWorkflowCapture(); + + capturedListener!({ + type: 'workflow_started', + runId: 'run-1', + workflowName: 'test-workflow', + conversationId: 'conv-123', + }); + + expect(mockGetWorkflowRun).not.toHaveBeenCalled(); + }); + + test('skips when no conversationId found', async () => { + mockGetConversationId.mockImplementation(() => undefined); + subscribeToWorkflowCapture(); + + capturedListener!({ + type: 'workflow_completed', + runId: 'run-unknown', + workflowName: 'test-workflow', + duration: 5000, + }); + + await new Promise(resolve => setTimeout(resolve, 50)); + expect(mockGetWorkflowRun).not.toHaveBeenCalled(); + expect(mockCaptureKnowledge).not.toHaveBeenCalled(); + }); + + test('skips when workflow run has no codebase_id', async () => { + mockGetWorkflowRun.mockImplementation(() => + Promise.resolve({ + id: 'run-1', + codebase_id: null, + conversation_id: 'conv-123', + workflow_name: 'test-workflow', + status: 'completed', + }) + ); + subscribeToWorkflowCapture(); + + capturedListener!({ + type: 'workflow_completed', + runId: 'run-1', + workflowName: 'test-workflow', + duration: 5000, + }); + + await new Promise(resolve => setTimeout(resolve, 50)); + expect(mockCaptureKnowledge).not.toHaveBeenCalled(); + }); + + test('skips when codebase name is not owner/repo format', async () => { + mockGetCodebase.mockImplementation(() => + Promise.resolve({ id: 'codebase-1', name: 'local-project', path: '/path' }) + ); + subscribeToWorkflowCapture(); + + capturedListener!({ + type: 'workflow_completed', + runId: 'run-1', + workflowName: 'test-workflow', + duration: 5000, + }); + + await new Promise(resolve => setTimeout(resolve, 50)); + expect(mockCaptureKnowledge).not.toHaveBeenCalled(); + }); + + test('handles missing JSONL log gracefully', async () => { + mockReadFile.mockImplementation(() => Promise.reject(new Error('ENOENT'))); + subscribeToWorkflowCapture(); + + capturedListener!({ + type: 'workflow_completed', + runId: 'run-1', + workflowName: 'test-workflow', + duration: 5000, + }); + + await new Promise(resolve => setTimeout(resolve, 50)); + // Capture still called, but with empty additional transcript + expect(mockCaptureKnowledge).toHaveBeenCalledTimes(1); + expect(mockCaptureKnowledge.mock.calls[0][4]).toBe(''); + }); + + test('does not block on capture errors', async () => { + mockCaptureKnowledge.mockImplementation(() => Promise.reject(new Error('capture failed'))); + subscribeToWorkflowCapture(); + + // Should not throw + capturedListener!({ + type: 'workflow_completed', + runId: 'run-1', + workflowName: 'test-workflow', + duration: 5000, + }); + + await new Promise(resolve => setTimeout(resolve, 50)); + // No assertion needed — just verify it doesn't throw + expect(mockCaptureKnowledge).toHaveBeenCalled(); + }); +}); diff --git a/packages/core/src/services/knowledge-workflow-capture.ts b/packages/core/src/services/knowledge-workflow-capture.ts new file mode 100644 index 0000000000..e5b3812086 --- /dev/null +++ b/packages/core/src/services/knowledge-workflow-capture.ts @@ -0,0 +1,172 @@ +/** + * Knowledge workflow capture — subscribes to workflow_completed events + * and triggers knowledge capture with JSONL log context. + * + * Must be initialized once (call subscribeToWorkflowCapture()) at server startup. + * Fire-and-forget: errors are logged but never surface to the caller. + */ +import { readFile } from 'node:fs/promises'; +import { + getWorkflowEventEmitter, + type WorkflowEmitterEvent, +} from '@archon/workflows/event-emitter'; +import { createLogger, getRunLogPath, parseOwnerRepo } from '@archon/paths'; +import * as codebaseDb from '../db/codebases'; +import * as workflowDb from '../db/workflows'; +import { captureKnowledge } from './knowledge-capture'; + +/** Lazy-initialized logger */ +let cachedLog: ReturnType | undefined; +function getLog(): ReturnType { + if (!cachedLog) cachedLog = createLogger('knowledge.workflow-capture'); + return cachedLog; +} + +/** Track whether subscription is active (prevent double-subscribe) */ +let subscribed = false; +/** Active unsubscribe function */ +let activeUnsubscribe: (() => void) | null = null; + +/** + * Subscribe to workflow_completed events and trigger knowledge capture. + * Safe to call multiple times — only subscribes once. + * Returns an unsubscribe function. + */ +export function subscribeToWorkflowCapture(): () => void { + if (subscribed) { + // Already subscribed — return no-op unsubscribe + return () => { + /* noop */ + }; + } + + const emitter = getWorkflowEventEmitter(); + const unsubscribe = emitter.subscribe((event: WorkflowEmitterEvent) => { + if (event.type !== 'workflow_completed') return; + + const conversationId = emitter.getConversationId(event.runId); + if (!conversationId) { + getLog().debug({ runId: event.runId }, 'knowledge.workflow_capture_skipped_no_conversation'); + return; + } + + // Fire-and-forget + void handleWorkflowCompleted(event.runId, conversationId).catch(err => { + getLog().error( + { + runId: event.runId, + conversationId, + error: (err as Error).message, + err, + }, + 'knowledge.workflow_capture_failed' + ); + }); + }); + + subscribed = true; + activeUnsubscribe = unsubscribe; + + return () => { + unsubscribe(); + subscribed = false; + activeUnsubscribe = null; + }; +} + +/** + * Reset internal state for testing. + */ +export function resetWorkflowCaptureSubscription(): void { + if (activeUnsubscribe) { + activeUnsubscribe(); + } + subscribed = false; + activeUnsubscribe = null; +} + +/** + * Handle a completed workflow — look up codebase, read JSONL logs, trigger capture. + */ +async function handleWorkflowCompleted(runId: string, conversationId: string): Promise { + const log = getLog(); + + // Look up workflow run to get codebase_id + const run = await workflowDb.getWorkflowRun(runId); + if (!run?.codebase_id) { + log.debug({ runId, conversationId }, 'knowledge.workflow_capture_skipped_no_codebase'); + return; + } + + // Look up codebase to get owner/repo + const codebase = await codebaseDb.getCodebase(run.codebase_id); + if (!codebase) { + log.debug( + { runId, codebaseId: run.codebase_id }, + 'knowledge.workflow_capture_skipped_codebase_not_found' + ); + return; + } + + const parsed = parseOwnerRepo(codebase.name); + if (!parsed) { + log.debug( + { runId, codebaseName: codebase.name }, + 'knowledge.workflow_capture_skipped_no_owner_repo' + ); + return; + } + + // Read JSONL workflow logs as additional context + const workflowLogContent = await readWorkflowLogs(parsed.owner, parsed.repo, runId); + + log.info( + { runId, conversationId, owner: parsed.owner, repo: parsed.repo }, + 'knowledge.workflow_capture_started' + ); + + await captureKnowledge(conversationId, parsed.owner, parsed.repo, undefined, workflowLogContent); +} + +/** + * Read and format JSONL workflow logs for use as additional capture context. + * Returns empty string if logs don't exist or can't be read. + */ +async function readWorkflowLogs(owner: string, repo: string, runId: string): Promise { + try { + const logPath = getRunLogPath(owner, repo, runId); + const content = await readFile(logPath, 'utf-8'); + + // Parse JSONL and extract relevant events (assistant messages, tool calls) + const lines = content.trim().split('\n'); + const relevant: string[] = []; + + for (const line of lines) { + try { + const event = JSON.parse(line) as { + type: string; + content?: string; + tool_name?: string; + workflow_name?: string; + step?: string; + }; + if (event.type === 'assistant' && event.content) { + relevant.push(`[ASSISTANT${event.step ? ` (${event.step})` : ''}]: ${event.content}`); + } else if (event.type === 'tool' && event.tool_name) { + relevant.push(`[TOOL: ${event.tool_name}]`); + } else if (event.type === 'workflow_start' && event.workflow_name) { + relevant.push(`[WORKFLOW: ${event.workflow_name}]`); + } + } catch { + // Skip malformed JSONL lines + } + } + + if (relevant.length === 0) return ''; + + return '\n\n---\n\nWORKFLOW EXECUTION LOG:\n' + relevant.join('\n'); + } catch { + // Log file doesn't exist or can't be read — not an error + return ''; + } +} diff --git a/packages/core/src/workflows/store-adapter.ts b/packages/core/src/workflows/store-adapter.ts index 0bf8683fb8..37e27f4c74 100644 --- a/packages/core/src/workflows/store-adapter.ts +++ b/packages/core/src/workflows/store-adapter.ts @@ -12,6 +12,7 @@ import * as codebaseDb from '../db/codebases'; import * as envVarDb from '../db/env-vars'; import { getAssistantClient } from '../clients/factory'; import { loadConfig as loadMergedConfig } from '../config/config-loader'; +import { extractKnowledgeFromContext } from '../services/knowledge-capture'; import { createLogger } from '@archon/paths'; // Compile-time assertion: MergedConfig must remain a structural subtype of WorkflowConfig. @@ -71,5 +72,6 @@ export function createWorkflowDeps(): WorkflowDeps { store: createWorkflowStore(), getAssistantClient, loadConfig: loadMergedConfig, + extractKnowledge: extractKnowledgeFromContext, }; } diff --git a/packages/paths/src/archon-paths.test.ts b/packages/paths/src/archon-paths.test.ts index 734516375f..e2f5c6e9a1 100644 --- a/packages/paths/src/archon-paths.test.ts +++ b/packages/paths/src/archon-paths.test.ts @@ -28,6 +28,10 @@ import { getProjectLogsPath, getRunArtifactsPath, getRunLogPath, + getProjectKnowledgePath, + getGlobalKnowledgePath, + getKnowledgeLogsPath, + getKnowledgeDomainsPath, resolveProjectRootFromCwd, ensureProjectStructure, createProjectSourceSymlink, @@ -422,6 +426,69 @@ describe('archon-paths', () => { }); }); + describe('getProjectKnowledgePath', () => { + test('appends knowledge/ to project root', () => { + delete process.env.WORKSPACE_PATH; + delete process.env.ARCHON_HOME; + delete process.env.ARCHON_DOCKER; + expect(getProjectKnowledgePath('acme', 'widget')).toBe( + join(homedir(), '.archon', 'workspaces', 'acme', 'widget', 'knowledge') + ); + }); + + test('respects ARCHON_HOME', () => { + delete process.env.WORKSPACE_PATH; + delete process.env.ARCHON_DOCKER; + process.env.ARCHON_HOME = '/custom/archon'; + expect(getProjectKnowledgePath('acme', 'widget')).toBe( + join('/custom/archon', 'workspaces', 'acme', 'widget', 'knowledge') + ); + }); + }); + + describe('getGlobalKnowledgePath', () => { + test('returns knowledge/ under archon home', () => { + delete process.env.WORKSPACE_PATH; + delete process.env.ARCHON_HOME; + delete process.env.ARCHON_DOCKER; + expect(getGlobalKnowledgePath()).toBe(join(homedir(), '.archon', 'knowledge')); + }); + + test('returns /.archon/knowledge in Docker', () => { + process.env.ARCHON_DOCKER = 'true'; + expect(getGlobalKnowledgePath()).toBe(join('/', '.archon', 'knowledge')); + }); + + test('respects ARCHON_HOME', () => { + delete process.env.WORKSPACE_PATH; + delete process.env.ARCHON_DOCKER; + process.env.ARCHON_HOME = '/custom/archon'; + expect(getGlobalKnowledgePath()).toBe(join('/custom/archon', 'knowledge')); + }); + }); + + describe('getKnowledgeLogsPath', () => { + test('appends knowledge/logs/ to project root', () => { + delete process.env.WORKSPACE_PATH; + delete process.env.ARCHON_HOME; + delete process.env.ARCHON_DOCKER; + expect(getKnowledgeLogsPath('acme', 'widget')).toBe( + join(homedir(), '.archon', 'workspaces', 'acme', 'widget', 'knowledge', 'logs') + ); + }); + }); + + describe('getKnowledgeDomainsPath', () => { + test('appends knowledge/domains/ to project root', () => { + delete process.env.WORKSPACE_PATH; + delete process.env.ARCHON_HOME; + delete process.env.ARCHON_DOCKER; + expect(getKnowledgeDomainsPath('acme', 'widget')).toBe( + join(homedir(), '.archon', 'workspaces', 'acme', 'widget', 'knowledge', 'domains') + ); + }); + }); + describe('resolveProjectRootFromCwd', () => { test('resolves project root from a path under workspaces', () => { delete process.env.WORKSPACE_PATH; diff --git a/packages/paths/src/archon-paths.ts b/packages/paths/src/archon-paths.ts index 45fddc3292..716a2a19a7 100644 --- a/packages/paths/src/archon-paths.ts +++ b/packages/paths/src/archon-paths.ts @@ -276,6 +276,38 @@ export function getRunLogPath(owner: string, repo: string, workflowRunId: string return join(getProjectLogsPath(owner, repo), `${workflowRunId}.jsonl`); } +/** + * Get the knowledge base directory for a project. + * Returns: ~/.archon/workspaces/owner/repo/knowledge/ + */ +export function getProjectKnowledgePath(owner: string, repo: string): string { + return join(getProjectRoot(owner, repo), 'knowledge'); +} + +/** + * Get the global knowledge base directory. + * Returns: ~/.archon/knowledge/ + */ +export function getGlobalKnowledgePath(): string { + return join(getArchonHome(), 'knowledge'); +} + +/** + * Get the knowledge logs directory for a project. + * Returns: ~/.archon/workspaces/owner/repo/knowledge/logs/ + */ +export function getKnowledgeLogsPath(owner: string, repo: string): string { + return join(getProjectKnowledgePath(owner, repo), 'logs'); +} + +/** + * Get the knowledge domains directory for a project. + * Returns: ~/.archon/workspaces/owner/repo/knowledge/domains/ + */ +export function getKnowledgeDomainsPath(owner: string, repo: string): string { + return join(getProjectKnowledgePath(owner, repo), 'domains'); +} + /** * Resolve the project root path from a working directory path. * If the path is under ~/.archon/workspaces/owner/repo/..., returns the project root. diff --git a/packages/paths/src/index.ts b/packages/paths/src/index.ts index da33e99049..b406c6bb15 100644 --- a/packages/paths/src/index.ts +++ b/packages/paths/src/index.ts @@ -21,6 +21,10 @@ export { getProjectLogsPath, getRunArtifactsPath, getRunLogPath, + getProjectKnowledgePath, + getGlobalKnowledgePath, + getKnowledgeLogsPath, + getKnowledgeDomainsPath, resolveProjectRootFromCwd, ensureProjectStructure, createProjectSourceSymlink, diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 4b15b05ef3..32a09f83ec 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -70,6 +70,7 @@ import { logConfig, getPort, createWorkflowStore, + subscribeToWorkflowCapture, } from '@archon/core'; import type { IPlatformAdapter } from '@archon/core'; import { createLogger, logArchonPaths, validateAppDefaultsPaths } from '@archon/paths'; @@ -192,6 +193,9 @@ async function main(): Promise { getLog().error({ err }, 'workflow.fail_orphans_failed'); }); + // Subscribe to workflow completion events for knowledge capture + subscribeToWorkflowCapture(); + // Log Archon paths configuration logArchonPaths(); diff --git a/packages/workflows/package.json b/packages/workflows/package.json index 22e093476f..d4b538c384 100644 --- a/packages/workflows/package.json +++ b/packages/workflows/package.json @@ -18,7 +18,7 @@ "./test-utils": "./src/test-utils.ts" }, "scripts": { - "test": "bun test src/dag-executor.test.ts && bun test src/loader.test.ts && bun test src/logger.test.ts && bun test src/condition-evaluator.test.ts && bun test src/event-emitter.test.ts && bun test src/executor-shared.test.ts && bun test src/executor.test.ts && bun test src/executor-preamble.test.ts && bun test src/defaults/ src/model-validation.test.ts src/router.test.ts src/utils/ src/hooks.test.ts && bun test src/validation-parser.test.ts src/schemas.test.ts src/command-validation.test.ts && bun test src/validator.test.ts", + "test": "bun test src/dag-executor.test.ts && bun test src/loader.test.ts && bun test src/logger.test.ts && bun test src/condition-evaluator.test.ts && bun test src/event-emitter.test.ts && bun test src/executor-shared.test.ts && bun test src/executor.test.ts && bun test src/executor-preamble.test.ts && bun test src/defaults/ src/model-validation.test.ts src/router.test.ts src/utils/ src/hooks.test.ts && bun test src/validation-parser.test.ts src/schemas.test.ts src/command-validation.test.ts && bun test src/validator.test.ts && bun test src/knowledge-extract-node.test.ts", "type-check": "bun x tsc --noEmit" }, "dependencies": { diff --git a/packages/workflows/src/dag-executor.ts b/packages/workflows/src/dag-executor.ts index f52ea5fe74..0c82335f59 100644 --- a/packages/workflows/src/dag-executor.ts +++ b/packages/workflows/src/dag-executor.ts @@ -23,6 +23,7 @@ import type { CommandNode, PromptNode, LoopNode, + KnowledgeExtractNode, NodeOutput, TriggerRule, WorkflowRun, @@ -31,7 +32,14 @@ import type { ThinkingConfig, SandboxSettings, } from './schemas'; -import { isBashNode, isLoopNode, isApprovalNode, isCancelNode, isApprovalContext } from './schemas'; +import { + isBashNode, + isLoopNode, + isApprovalNode, + isCancelNode, + isKnowledgeExtractNode, + isApprovalContext, +} from './schemas'; import { formatToolCall } from './utils/tool-formatter'; import { createLogger } from '@archon/paths'; import { getWorkflowEventEmitter } from './event-emitter'; @@ -1437,6 +1445,121 @@ async function executeBashNode( } } +/** + * Execute a knowledge-extract node — runs targeted knowledge extraction using + * the capture service infrastructure (Haiku model, daily log format). + * + * Collects upstream node outputs as context, substitutes workflow variables in the + * extraction prompt, and calls the injected `deps.extractKnowledge` callback. + */ +async function executeKnowledgeExtractNode( + deps: WorkflowDeps, + node: KnowledgeExtractNode, + cwd: string, + workflowRun: WorkflowRun, + nodeOutputs: Map, + artifactsDir: string, + logDir: string, + baseBranch: string, + docsDir: string, + issueContext?: string +): Promise { + const log = getLog(); + + if (!deps.extractKnowledge) { + const errorMsg = `Knowledge-extract node '${node.id}' requires extractKnowledge in WorkflowDeps (not available in this context)`; + log.error({ nodeId: node.id }, 'dag_node_failed'); + await logNodeError(logDir, workflowRun.id, node.id, errorMsg); + return { state: 'failed', output: '', error: errorMsg }; + } + + await logNodeStart(logDir, workflowRun.id, node.id, 'knowledge-extract'); + const startTime = Date.now(); + getWorkflowEventEmitter().emit({ + type: 'node_started', + runId: workflowRun.id, + nodeId: node.id, + nodeName: node.id, + }); + + try { + // Substitute workflow variables in the extraction prompt + const { prompt: substitutedPrompt } = substituteWorkflowVariables( + node.knowledge_extract, + workflowRun.id, + '', + artifactsDir, + baseBranch, + docsDir, + issueContext + ); + const finalPrompt = substituteNodeOutputRefs(substitutedPrompt, nodeOutputs); + + // Build context from upstream node outputs + const contextParts: string[] = []; + for (const [upstreamId, nodeOutput] of nodeOutputs.entries()) { + if (nodeOutput.state === 'completed' && nodeOutput.output) { + contextParts.push(`--- Output from node '${upstreamId}' ---\n${nodeOutput.output}`); + } + } + const context = contextParts.join('\n\n'); + + const extracted = await deps.extractKnowledge(finalPrompt, context, cwd, { + workflowRunId: workflowRun.id, + nodeId: node.id, + }); + + const duration = Date.now() - startTime; + await logNodeComplete(logDir, workflowRun.id, node.id, 'knowledge-extract', { + durationMs: duration, + }); + getWorkflowEventEmitter().emit({ + type: 'node_completed', + runId: workflowRun.id, + nodeId: node.id, + nodeName: node.id, + duration, + }); + + deps.store + .createWorkflowEvent({ + workflow_run_id: workflowRun.id, + event_type: 'node_completed', + step_name: node.id, + data: { type: 'knowledge-extract', outputLength: extracted.length }, + }) + .catch((dbErr: Error) => { + log.error( + { err: dbErr, workflowRunId: workflowRun.id, eventType: 'node_completed' }, + 'workflow_event_persist_failed' + ); + }); + + return { state: 'completed', output: extracted }; + } catch (error) { + const err = error as Error; + const errorMsg = `Knowledge-extract node '${node.id}' failed: ${err.message}`; + log.error({ err, nodeId: node.id }, 'dag_node_failed'); + await logNodeError(logDir, workflowRun.id, node.id, errorMsg); + + deps.store + .createWorkflowEvent({ + workflow_run_id: workflowRun.id, + event_type: 'node_failed', + step_name: node.id, + data: { error: errorMsg, type: 'knowledge-extract' }, + }) + .catch((dbErr: Error) => { + log.error( + { err: dbErr, workflowRunId: workflowRun.id, eventType: 'node_failed' }, + 'workflow_event_persist_failed' + ); + }); + + return { state: 'failed', output: '', error: errorMsg }; + } +} + /** * Build WorkflowAssistantOptions from resolved provider, model, and config. * Caller is responsible for resolving per-node overrides before passing model. @@ -2477,6 +2600,23 @@ export async function executeDagWorkflow( return { nodeId: node.id, output: { state: 'completed' as const, output: reason } }; } + // 3e. Knowledge-extract node dispatch — targeted knowledge extraction + if (isKnowledgeExtractNode(node)) { + const output = await executeKnowledgeExtractNode( + deps, + node, + cwd, + workflowRun, + nodeOutputs, + artifactsDir, + logDir, + baseBranch, + docsDir, + issueContext + ); + return { nodeId: node.id, output }; + } + // 4. Resolve per-node provider/model/options const { provider, options: nodeOptions } = await resolveNodeProviderAndModel( node, diff --git a/packages/workflows/src/deps.ts b/packages/workflows/src/deps.ts index ce586a177b..925228f9e5 100644 --- a/packages/workflows/src/deps.ts +++ b/packages/workflows/src/deps.ts @@ -270,8 +270,28 @@ export interface WorkflowConfig { // WorkflowDeps — the single injection point // --------------------------------------------------------------------------- +/** + * Callback for knowledge-extract DAG nodes. + * Calls AI (capture model) with a custom prompt + context, appends extracted knowledge + * to the daily log, and returns the extracted content as node output. + * + * @param prompt - Extraction prompt describing what knowledge to extract + * @param context - Upstream node outputs and workflow context + * @param cwd - Working directory (used to resolve owner/repo) + * @param metadata - Workflow run and node identifiers for log entries + * @returns Extracted knowledge content + */ +export type KnowledgeExtractFn = ( + prompt: string, + context: string, + cwd: string, + metadata: { workflowRunId: string; nodeId: string } +) => Promise; + export interface WorkflowDeps { store: IWorkflowStore; getAssistantClient: AssistantClientFactory; loadConfig: (cwd: string) => Promise; + /** Optional callback for knowledge-extract DAG nodes. If not provided, knowledge-extract nodes fail with an error. */ + extractKnowledge?: KnowledgeExtractFn; } diff --git a/packages/workflows/src/knowledge-extract-node.test.ts b/packages/workflows/src/knowledge-extract-node.test.ts new file mode 100644 index 0000000000..b11d48f102 --- /dev/null +++ b/packages/workflows/src/knowledge-extract-node.test.ts @@ -0,0 +1,360 @@ +/** + * Tests for knowledge-extract DAG node type. + * + * Uses a separate test batch to avoid mock.module pollution with dag-executor.test.ts. + */ +import { describe, test, expect, beforeEach, mock, type Mock } from 'bun:test'; + +// --- Mock logger (MUST come before imports) --- +const mockLogFn = mock(() => {}); +const mockLogger = { + info: mockLogFn, + warn: mockLogFn, + error: mockLogFn, + debug: mockLogFn, + trace: mockLogFn, + fatal: mockLogFn, + child: mock(() => mockLogger), +}; +mock.module('@archon/paths', () => ({ + createLogger: mock(() => mockLogger), + getCommandFolderSearchPaths: (folder?: string) => { + const paths = ['.archon/commands']; + if (folder) paths.unshift(folder); + return paths; + }, + getDefaultCommandsPath: () => '/nonexistent/defaults', +})); + +// --- Imports (after mocks) --- +import { executeDagWorkflow } from './dag-executor'; +import type { DagNode, KnowledgeExtractNode, NodeOutput, WorkflowRun } from './schemas'; +import { isKnowledgeExtractNode } from './schemas'; +import type { WorkflowDeps, IWorkflowPlatform, WorkflowConfig, KnowledgeExtractFn } from './deps'; +import type { IWorkflowStore } from './store'; + +// --- Test helpers --- + +function createMockStore(): IWorkflowStore { + return { + createWorkflowRun: mock(() => Promise.resolve({} as WorkflowRun)), + getWorkflowRun: mock(() => Promise.resolve(null)), + getActiveWorkflowRunByPath: mock(() => Promise.resolve(null)), + findResumableRun: mock(() => Promise.resolve(null)), + failOrphanedRuns: mock(() => Promise.resolve()), + resumeWorkflowRun: mock(() => Promise.resolve()), + updateWorkflowRun: mock(() => Promise.resolve()), + updateWorkflowActivity: mock(() => Promise.resolve()), + getWorkflowRunStatus: mock(() => Promise.resolve('running' as const)), + completeWorkflowRun: mock(() => Promise.resolve()), + failWorkflowRun: mock(() => Promise.resolve()), + pauseWorkflowRun: mock(() => Promise.resolve()), + cancelWorkflowRun: mock(() => Promise.resolve()), + createWorkflowEvent: mock(() => Promise.resolve()), + getCompletedDagNodeOutputs: mock(() => Promise.resolve([])), + getCodebase: mock(() => Promise.resolve(null)), + getCodebaseEnvVars: mock(() => Promise.resolve([])), + }; +} + +function createMockPlatform(): IWorkflowPlatform { + return { + sendMessage: mock(() => Promise.resolve()), + getStreamingMode: () => 'batch' as const, + getPlatformType: () => 'test', + }; +} + +function createMockConfig(): WorkflowConfig { + return { + assistant: 'claude', + commands: {}, + assistants: { + claude: { model: 'sonnet' }, + codex: { model: 'gpt-4' }, + }, + }; +} + +function createWorkflowRun(overrides?: Partial): WorkflowRun { + return { + id: 'run-123', + workflow_name: 'test-workflow', + conversation_id: 'conv-123', + parent_conversation_id: null, + codebase_id: null, + status: 'running', + user_message: 'test', + metadata: {}, + started_at: new Date(), + completed_at: null, + last_activity_at: null, + working_path: '/tmp/test', + ...overrides, + }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('knowledge-extract node', () => { + let store: IWorkflowStore; + let platform: IWorkflowPlatform; + let config: WorkflowConfig; + let mockExtractKnowledge: Mock; + + beforeEach(() => { + store = createMockStore(); + platform = createMockPlatform(); + config = createMockConfig(); + mockExtractKnowledge = mock(async () => 'Extracted: architecture decision about auth'); + mockLogFn.mockClear(); + }); + + test('isKnowledgeExtractNode type guard works', () => { + const node: KnowledgeExtractNode = { + id: 'extract', + knowledge_extract: 'Extract patterns from the analysis', + }; + expect(isKnowledgeExtractNode(node)).toBe(true); + }); + + test('knowledge-extract node calls extractKnowledge callback', async () => { + const nodes: DagNode[] = [ + { + id: 'extract', + knowledge_extract: 'Extract architecture decisions', + } as KnowledgeExtractNode, + ]; + + const deps: WorkflowDeps = { + store, + getAssistantClient: () => ({ + sendQuery: async function* () { + /* noop */ + }, + getType: () => 'claude', + }), + loadConfig: async () => config, + extractKnowledge: mockExtractKnowledge, + }; + + const workflowRun = createWorkflowRun(); + (store.getWorkflowRunStatus as Mock<() => Promise>).mockResolvedValue('running'); + + await executeDagWorkflow( + deps, + platform, + 'conv-123', + '/tmp/test', + { name: 'test', nodes }, + workflowRun, + 'claude', + 'sonnet', + '/tmp/artifacts', + '/tmp/logs', + 'main', + 'docs/', + config + ); + + expect(mockExtractKnowledge).toHaveBeenCalledTimes(1); + const call = mockExtractKnowledge.mock.calls[0]; + expect(call[0]).toBe('Extract architecture decisions'); // prompt + expect(call[2]).toBe('/tmp/test'); // cwd + expect(call[3]).toEqual({ workflowRunId: 'run-123', nodeId: 'extract' }); // metadata + }); + + test('knowledge-extract node collects upstream outputs as context', async () => { + // Use priorCompletedNodes to simulate a completed upstream node + const nodes: DagNode[] = [ + { id: 'analyze', bash: 'echo "Found auth pattern"' } as DagNode, + { + id: 'extract', + knowledge_extract: 'Extract patterns from $analyze.output', + depends_on: ['analyze'], + } as KnowledgeExtractNode, + ]; + + const deps: WorkflowDeps = { + store, + getAssistantClient: () => ({ + sendQuery: async function* () { + /* noop */ + }, + getType: () => 'claude', + }), + loadConfig: async () => config, + extractKnowledge: mockExtractKnowledge, + }; + + const workflowRun = createWorkflowRun(); + (store.getWorkflowRunStatus as Mock<() => Promise>).mockResolvedValue('running'); + + // Simulate 'analyze' node already completed with output + const priorCompleted = new Map(); + priorCompleted.set('analyze', 'Found auth pattern'); + + await executeDagWorkflow( + deps, + platform, + 'conv-123', + '/tmp/test', + { name: 'test', nodes }, + workflowRun, + 'claude', + 'sonnet', + '/tmp/artifacts', + '/tmp/logs', + 'main', + 'docs/', + config, + undefined, + undefined, + priorCompleted + ); + + expect(mockExtractKnowledge).toHaveBeenCalledTimes(1); + const call = mockExtractKnowledge.mock.calls[0]; + // Prompt should have $analyze.output substituted + expect(call[0]).toContain('Found auth pattern'); + // Context should include upstream output + expect(call[1]).toContain("Output from node 'analyze'"); + expect(call[1]).toContain('Found auth pattern'); + }); + + test('knowledge-extract node fails if extractKnowledge not provided', async () => { + const nodes: DagNode[] = [ + { + id: 'extract', + knowledge_extract: 'Extract patterns', + } as KnowledgeExtractNode, + ]; + + const deps: WorkflowDeps = { + store, + getAssistantClient: () => ({ + sendQuery: async function* () { + /* noop */ + }, + getType: () => 'claude', + }), + loadConfig: async () => config, + // extractKnowledge NOT provided + }; + + const workflowRun = createWorkflowRun(); + (store.getWorkflowRunStatus as Mock<() => Promise>).mockResolvedValue('running'); + + // Should not throw — but the node should fail + const result = await executeDagWorkflow( + deps, + platform, + 'conv-123', + '/tmp/test', + { name: 'test', nodes }, + workflowRun, + 'claude', + 'sonnet', + '/tmp/artifacts', + '/tmp/logs', + 'main', + 'docs/', + config + ); + + // Workflow should fail because the node failed + expect(store.failWorkflowRun).toHaveBeenCalled(); + }); + + test('knowledge-extract node returns extracted content as output', async () => { + mockExtractKnowledge.mockResolvedValue('## Decisions\n- Use JWT for auth'); + + const nodes: DagNode[] = [ + { + id: 'extract', + knowledge_extract: 'Extract decisions', + } as KnowledgeExtractNode, + ]; + + const deps: WorkflowDeps = { + store, + getAssistantClient: () => ({ + sendQuery: async function* () { + /* noop */ + }, + getType: () => 'claude', + }), + loadConfig: async () => config, + extractKnowledge: mockExtractKnowledge, + }; + + const workflowRun = createWorkflowRun(); + (store.getWorkflowRunStatus as Mock<() => Promise>).mockResolvedValue('running'); + + await executeDagWorkflow( + deps, + platform, + 'conv-123', + '/tmp/test', + { name: 'test', nodes }, + workflowRun, + 'claude', + 'sonnet', + '/tmp/artifacts', + '/tmp/logs', + 'main', + 'docs/', + config + ); + + // The workflow should complete successfully + expect(store.completeWorkflowRun).toHaveBeenCalled(); + }); + + test('existing workflows without knowledge-extract nodes work unchanged', async () => { + const nodes: DagNode[] = [{ id: 'build', bash: 'echo "built"' } as DagNode]; + + const deps: WorkflowDeps = { + store, + getAssistantClient: () => ({ + sendQuery: async function* () { + /* noop */ + }, + getType: () => 'claude', + }), + loadConfig: async () => config, + // extractKnowledge not provided — should be fine for non-knowledge-extract workflows + }; + + const workflowRun = createWorkflowRun(); + (store.getWorkflowRunStatus as Mock<() => Promise>).mockResolvedValue('running'); + + // Simulate 'build' node already completed + const priorCompleted = new Map(); + priorCompleted.set('build', 'built'); + + await executeDagWorkflow( + deps, + platform, + 'conv-123', + '/tmp/test', + { name: 'test', nodes }, + workflowRun, + 'claude', + 'sonnet', + '/tmp/artifacts', + '/tmp/logs', + 'main', + 'docs/', + config, + undefined, + undefined, + priorCompleted + ); + + // Should complete without issues + expect(store.completeWorkflowRun).toHaveBeenCalled(); + }); +}); diff --git a/packages/workflows/src/loader.ts b/packages/workflows/src/loader.ts index 616c3f1477..3b140316a1 100644 --- a/packages/workflows/src/loader.ts +++ b/packages/workflows/src/loader.ts @@ -2,7 +2,7 @@ * Workflow loader - discovers and parses workflow YAML files */ import type { WorkflowDefinition, WorkflowLoadError, DagNode, WorkflowNodeHooks } from './schemas'; -import { isLoopNode, isApprovalNode, isCancelNode } from './schemas'; +import { isLoopNode, isApprovalNode, isCancelNode, isKnowledgeExtractNode } from './schemas'; import { createLogger } from '@archon/paths'; import { isModelCompatible } from './model-validation'; import { dagNodeSchema, BASH_NODE_AI_FIELDS } from './schemas/dag-node'; @@ -55,15 +55,18 @@ function parseDagNode(raw: unknown, index: number, errors: string[]): DagNode | const node = result.data; - // Warn about AI-specific fields on bash/loop nodes (runtime behavior, not schema errors) + // Warn about AI-specific fields on non-AI nodes (runtime behavior, not schema errors) const isNonAiNode = ('bash' in node && typeof node.bash === 'string') || isLoopNode(node) || isApprovalNode(node) || - isCancelNode(node); + isCancelNode(node) || + isKnowledgeExtractNode(node); if (isNonAiNode) { let nodeType: string; - if (isCancelNode(node)) { + if (isKnowledgeExtractNode(node)) { + nodeType = 'knowledge-extract'; + } else if (isCancelNode(node)) { nodeType = 'cancel'; } else if (isApprovalNode(node)) { nodeType = 'approval'; @@ -147,6 +150,9 @@ function validateDagStructure(nodes: DagNode[]): string | null { if (isLoopNode(node)) { sources.push(node.loop.prompt); } + if (isKnowledgeExtractNode(node)) { + sources.push(node.knowledge_extract); + } for (const source of sources) { let m: RegExpExecArray | null; outputRefPattern.lastIndex = 0; // reset stateful g-flag regex before each new source string diff --git a/packages/workflows/src/schemas.test.ts b/packages/workflows/src/schemas.test.ts index 2a3564e94b..4954ba2d34 100644 --- a/packages/workflows/src/schemas.test.ts +++ b/packages/workflows/src/schemas.test.ts @@ -2,6 +2,7 @@ import { describe, test, expect } from 'bun:test'; import { isBashNode, isCancelNode, + isKnowledgeExtractNode, isTriggerRule, TRIGGER_RULES, approvalOnRejectSchema, @@ -14,6 +15,7 @@ import type { PromptNode, BashNode, CancelNode, + KnowledgeExtractNode, TriggerRule, } from './schemas'; @@ -25,6 +27,10 @@ const commandNode: CommandNode = { id: 'n1', command: 'build' }; const promptNode: PromptNode = { id: 'n2', prompt: 'Do this inline.' }; const bashNode: BashNode = { id: 'n3', bash: 'echo hello' }; const cancelNode: CancelNode = { id: 'n5', cancel: 'Precondition failed' }; +const knowledgeExtractNode: KnowledgeExtractNode = { + id: 'n6', + knowledge_extract: 'Extract architecture decisions from the output', +}; const dagWorkflow: WorkflowDefinition = { name: 'dag-workflow', @@ -98,6 +104,32 @@ describe('isCancelNode', () => { }); }); +// --------------------------------------------------------------------------- +// isKnowledgeExtractNode +// --------------------------------------------------------------------------- + +describe('isKnowledgeExtractNode', () => { + test('returns true for a knowledge-extract node', () => { + expect(isKnowledgeExtractNode(knowledgeExtractNode)).toBe(true); + }); + + test('returns false for a command node', () => { + expect(isKnowledgeExtractNode(commandNode)).toBe(false); + }); + + test('returns false for a prompt node', () => { + expect(isKnowledgeExtractNode(promptNode)).toBe(false); + }); + + test('returns false for a bash node', () => { + expect(isKnowledgeExtractNode(bashNode)).toBe(false); + }); + + test('returns false for a cancel node', () => { + expect(isKnowledgeExtractNode(cancelNode)).toBe(false); + }); +}); + // --------------------------------------------------------------------------- // isTriggerRule // --------------------------------------------------------------------------- @@ -274,6 +306,58 @@ describe('dagNodeSchema — empty bash/prompt', () => { }); }); +// --------------------------------------------------------------------------- +// dagNodeSchema — knowledge_extract node type +// --------------------------------------------------------------------------- + +describe('dagNodeSchema — knowledge_extract', () => { + test('parses valid knowledge_extract node', () => { + const result = dagNodeSchema.safeParse({ + id: 'extract', + knowledge_extract: 'Extract architecture decisions from the output', + }); + expect(result.success).toBe(true); + if (result.success) { + expect(isKnowledgeExtractNode(result.data)).toBe(true); + expect((result.data as KnowledgeExtractNode).knowledge_extract).toBe( + 'Extract architecture decisions from the output' + ); + } + }); + + test('rejects empty knowledge_extract prompt', () => { + const result = dagNodeSchema.safeParse({ id: 'extract', knowledge_extract: '' }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0].message).toContain('knowledge_extract prompt cannot be empty'); + } + }); + + test('rejects knowledge_extract with other mode fields (mutual exclusivity)', () => { + const result = dagNodeSchema.safeParse({ + id: 'extract', + knowledge_extract: 'Extract decisions', + prompt: 'Also do this', + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0].message).toContain('mutually exclusive'); + } + }); + + test('parses knowledge_extract with depends_on', () => { + const result = dagNodeSchema.safeParse({ + id: 'extract', + knowledge_extract: 'Extract patterns from analysis', + depends_on: ['analyze'], + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.depends_on).toEqual(['analyze']); + } + }); +}); + // --------------------------------------------------------------------------- // dagNodeSchema — Claude SDK options // --------------------------------------------------------------------------- diff --git a/packages/workflows/src/schemas/dag-node.ts b/packages/workflows/src/schemas/dag-node.ts index 28643c8212..7fd2b9f89e 100644 --- a/packages/workflows/src/schemas/dag-node.ts +++ b/packages/workflows/src/schemas/dag-node.ts @@ -2,8 +2,9 @@ * Zod schemas for DAG node types. * * Design: a flat "raw" schema validates all fields (with mutual exclusivity enforced via - * superRefine), then a transform produces one of the six concrete variant types - * (CommandNode, PromptNode, BashNode, LoopNode, ApprovalNode, CancelNode) as the DagNode union. + * superRefine), then a transform produces one of the seven concrete variant types + * (CommandNode, PromptNode, BashNode, LoopNode, ApprovalNode, CancelNode, KnowledgeExtractNode) + * as the DagNode union. * Per-variant schemas (commandNodeSchema etc.) are exported for type derivation only — * use dagNodeSchema for validation. * @@ -155,6 +156,7 @@ export type CommandNode = z.infer & { loop?: never; approval?: never; cancel?: never; + knowledge_extract?: never; }; export const promptNodeSchema = dagNodeBaseSchema.extend({ @@ -168,6 +170,7 @@ export type PromptNode = z.infer & { loop?: never; approval?: never; cancel?: never; + knowledge_extract?: never; }; /** @@ -186,6 +189,7 @@ export type BashNode = z.infer & { loop?: never; approval?: never; cancel?: never; + knowledge_extract?: never; }; /** @@ -204,6 +208,7 @@ export type LoopNode = z.infer & { bash?: never; approval?: never; cancel?: never; + knowledge_extract?: never; }; /** Schema for the `on_reject` sub-object on approval nodes. */ @@ -233,6 +238,7 @@ export type ApprovalNode = z.infer & { bash?: never; loop?: never; cancel?: never; + knowledge_extract?: never; }; /** @@ -250,10 +256,37 @@ export type CancelNode = z.infer & { bash?: never; loop?: never; approval?: never; + knowledge_extract?: never; }; -/** A single node in a DAG workflow. command, prompt, bash, loop, approval, and cancel are mutually exclusive. */ -export type DagNode = CommandNode | PromptNode | BashNode | LoopNode | ApprovalNode | CancelNode; +/** + * Knowledge-extract node schema — extends base with `knowledge_extract` (extraction prompt). + * Runs targeted knowledge extraction using the capture service infrastructure. + * AI-specific fields from the base are present in the type but ignored at runtime with a warning. + */ +export const knowledgeExtractNodeSchema = dagNodeBaseSchema.extend({ + knowledge_extract: z.string().min(1, "'knowledge_extract' prompt must not be empty"), +}); + +/** DAG node that runs targeted knowledge extraction and appends to the daily log */ +export type KnowledgeExtractNode = z.infer & { + command?: never; + prompt?: never; + bash?: never; + loop?: never; + approval?: never; + cancel?: never; +}; + +/** A single node in a DAG workflow. command, prompt, bash, loop, approval, cancel, and knowledge_extract are mutually exclusive. */ +export type DagNode = + | CommandNode + | PromptNode + | BashNode + | LoopNode + | ApprovalNode + | CancelNode + | KnowledgeExtractNode; // --------------------------------------------------------------------------- // AI-specific fields that are meaningless on bash/loop nodes @@ -310,6 +343,7 @@ export const dagNodeSchema = dagNodeBaseSchema }) .optional(), cancel: z.string().optional(), + knowledge_extract: z.string().optional(), // Bash-only timeout: z.number().optional(), }) @@ -332,16 +366,24 @@ export const dagNodeSchema = dagNodeBaseSchema const hasLoop = data.loop !== undefined; const hasApproval = data.approval !== undefined; const hasCancel = typeof data.cancel === 'string' && data.cancel.trim().length > 0; - - const modeCount = [hasCommand, hasPrompt, hasBash, hasLoop, hasApproval, hasCancel].filter( - Boolean - ).length; + const hasKnowledgeExtract = + typeof data.knowledge_extract === 'string' && data.knowledge_extract.trim().length > 0; + + const modeCount = [ + hasCommand, + hasPrompt, + hasBash, + hasLoop, + hasApproval, + hasCancel, + hasKnowledgeExtract, + ].filter(Boolean).length; if (modeCount > 1) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: - "'command', 'prompt', 'bash', 'loop', 'approval', and 'cancel' are mutually exclusive", + "'command', 'prompt', 'bash', 'loop', 'approval', 'cancel', and 'knowledge_extract' are mutually exclusive", }); return z.NEVER; } @@ -362,9 +404,18 @@ export const dagNodeSchema = dagNodeBaseSchema }); return z.NEVER; } + if (typeof data.knowledge_extract === 'string') { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'knowledge_extract prompt cannot be empty', + path: ['knowledge_extract'], + }); + return z.NEVER; + } ctx.addIssue({ code: z.ZodIssueCode.custom, - message: "must have either 'command', 'prompt', 'bash', 'loop', 'approval', or 'cancel'", + message: + "must have either 'command', 'prompt', 'bash', 'loop', 'approval', 'cancel', or 'knowledge_extract'", }); return z.NEVER; } @@ -411,7 +462,7 @@ export const dagNodeSchema = dagNodeBaseSchema } // Provider/model compatibility (AI nodes only) - if (!hasBash && !hasLoop && data.provider && data.model) { + if (!hasBash && !hasLoop && !hasKnowledgeExtract && data.provider && data.model) { if (!isModelCompatible(data.provider, data.model)) { ctx.addIssue({ code: z.ZodIssueCode.custom, @@ -479,6 +530,13 @@ export const dagNodeSchema = dagNodeBaseSchema if (data.cancel !== undefined && data.cancel.trim().length > 0) { return { ...base, ...shared, cancel: data.cancel.trim() } as CancelNode; } + if (data.knowledge_extract !== undefined && data.knowledge_extract.trim().length > 0) { + return { + ...base, + ...shared, + knowledge_extract: data.knowledge_extract.trim(), + } as KnowledgeExtractNode; + } // loop — guaranteed by superRefine to be defined at this point if (!data.loop) throw new Error('unreachable: loop must be defined after superRefine'); return { ...base, loop: data.loop } as LoopNode; @@ -509,6 +567,11 @@ export function isCancelNode(node: DagNode): node is CancelNode { return 'cancel' in node && typeof node.cancel === 'string'; } +/** Type guard: check if a DAG node is a knowledge-extract node */ +export function isKnowledgeExtractNode(node: DagNode): node is KnowledgeExtractNode { + return 'knowledge_extract' in node && typeof node.knowledge_extract === 'string'; +} + /** Type guard: validates a value is a known TriggerRule */ export function isTriggerRule(value: unknown): value is TriggerRule { return typeof value === 'string' && (TRIGGER_RULES as readonly string[]).includes(value); diff --git a/packages/workflows/src/schemas/index.ts b/packages/workflows/src/schemas/index.ts index b2e0c93658..7464c57bdb 100644 --- a/packages/workflows/src/schemas/index.ts +++ b/packages/workflows/src/schemas/index.ts @@ -37,11 +37,13 @@ export { approvalNodeSchema, approvalOnRejectSchema, cancelNodeSchema, + knowledgeExtractNodeSchema, dagNodeSchema, isBashNode, isLoopNode, isApprovalNode, isCancelNode, + isKnowledgeExtractNode, isTriggerRule, BASH_NODE_AI_FIELDS, effortLevelSchema, @@ -58,6 +60,7 @@ export type { ApprovalNode, ApprovalOnReject, CancelNode, + KnowledgeExtractNode, DagNode, EffortLevel, ThinkingConfig,