Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .archon/ralph/llm-knowledge-base-system/prd.md
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,10 @@ knowledge/
| 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 |
| US-021 | Add scope field to knowledge-extract nodes (project/global/both) | 13 | US-020, US-013 |
| US-022 | Scoped extraction routing (project log, global log, or both) | 13 | US-021 |
| US-023 | Global synthesis prompt (codebase-agnostic, Sources, contradictions) | 13 | US-013 |
| US-024 | Knowledge correction workflow (archon-knowledge-correct) | 13 | US-013 |

### Dependency Graph
```
Expand Down Expand Up @@ -248,6 +252,9 @@ Every story must pass:
| 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. |
| Scope classification in extraction | AI classifies as PROJECT/GLOBAL with project fallback | Conservative default prevents low-quality global entries. |
| Global synthesis prompt | Codebase-agnostic with Sources + Contradictions sections | Global articles must generalize; contradiction detection surfaces conflicting claims across projects. |
| Knowledge correction workflow | AI-mediated with approval gate | User review before destructive operations (delete/merge) — matches interactive-prd pattern. |
| 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. |

Expand Down
132 changes: 132 additions & 0 deletions .archon/workflows/defaults/archon-knowledge-correct.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
name: archon-knowledge-correct
description: |
Use when: User wants to correct, update, or delete a knowledge base article.
Triggers: "correct knowledge", "fix kb article", "update knowledge", "knowledge correct",
"delete knowledge article", "kb correction", "fix article".
NOT for: Browsing or reading knowledge (use the $KNOWLEDGE variable in prompts).

AI-mediated knowledge base correction workflow with approval gate:
1. Analyze the correction request and find affected articles
2. Propose edits (update, merge, or delete) with diff preview
3. Wait for user approval
4. Apply approved changes to the knowledge base

provider: claude
interactive: true

nodes:
# ═══════════════════════════════════════════════════════════════
# PHASE 1: ANALYZE — Find and assess affected articles
# ══════���═════════════════════════════════���══════════════════════

- id: analyze
prompt: |
You are a knowledge base editor. The user wants to make a correction to the knowledge base.

**Correction request**: $ARGUMENTS

$KNOWLEDGE

Your task:
1. Search the knowledge base directories for articles that match the correction request.
- Project KB: Look under the project's `knowledge/domains/` directory
- Global KB: Look under `~/.archon/knowledge/domains/`
2. Read the affected article(s) fully.
3. Determine the type of correction needed:
- **UPDATE**: Modify content within an existing article
- **MERGE**: Combine two overlapping articles into one
- **DELETE**: Remove an article that is incorrect or obsolete
- **RECLASSIFY**: Move an article to a different domain

Present your findings:

**Affected Articles:**
- `{domain}/{concept}.md` — {brief description of current content}

**Proposed Changes:**
For each article, show a clear before/after or describe the change:
- What will change and why
- If deleting: why the article should be removed
- If merging: which articles combine and what the merged result looks like

**Impact Assessment:**
- Are there [[wikilinks]] from other articles pointing to affected articles?
- Will any cross-references break?

Keep the proposal concise and actionable.

# ═══════════════════════════════════════════════════════════���═══
# GATE: User approves the proposed changes
# ���══════════��═══════════════════════════════════════════════════

- id: approval-gate
approval:
message: "Review the proposed knowledge base changes above. Approve to apply them, or provide feedback to adjust the proposal."
depends_on: [analyze]

# ══��═════��══════════════════════════════════════════════════════
# PHASE 2: APPLY — Execute approved changes
# ��══════���═══════════════════════════════════════════════════════

- id: apply
prompt: |
You are a knowledge base editor applying approved corrections.

**Original request**: $ARGUMENTS
**Approved changes**: $approval-gate.output

Apply the approved changes now:

1. **Read** each affected article file before modifying it
2. **Edit** articles as approved — use precise edits, not full rewrites
3. **Update wikilinks** in other articles if any cross-references changed
4. **Update domain _index.md** files if articles were added, removed, or moved

For deletions:
- Delete the article file
- Remove its entry from the domain's _index.md
- Fix broken [[wikilinks]] in other articles that referenced it

For merges:
- Write the merged article to the target location
- Delete the source article
- Update all [[wikilinks]] that pointed to the source

After applying all changes, output a summary:

**Changes Applied:**
- {action}: `{domain}/{concept}.md` — {what changed}

**Wikilinks Updated:** {count} cross-references fixed (or "None needed")
depends_on: [approval-gate]

# ════════════════════════════════════════��══════════════════════
# PHASE 3: VERIFY — Confirm changes are consistent
# ════════��════════���═══════════════════════════════════���═════════

- id: verify
bash: |
echo "=== Knowledge Base Verification ==="
# Check for broken wikilinks in all domain articles
broken=0
for f in $(find knowledge/domains/ ~/.archon/knowledge/domains/ -name "*.md" 2>/dev/null); do
# Extract wikilinks and check if targets exist
links=$(grep -oP '\[\[([^\]|]+)' "$f" 2>/dev/null | sed 's/\[\[//' || true)
for link in $links; do
# Skip _index links
echo "$link" | grep -q "_index" && continue
# Normalize: strip domains/ prefix
normalized=$(echo "$link" | sed 's|^domains/||')
# Check if article exists in any KB
if ! find knowledge/domains/ ~/.archon/knowledge/domains/ -path "*/${normalized}.md" -print -quit 2>/dev/null | grep -q .; then
echo "BROKEN: $f -> [[${link}]]"
broken=$((broken + 1))
fi
done
done
if [ "$broken" -eq 0 ]; then
echo "All wikilinks valid."
else
echo "WARNING: ${broken} broken wikilink(s) found."
fi
depends_on: [apply]
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -718,7 +718,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), `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)
- **`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; `scope` field routes to project, global, or both logs — default `'both'`) . 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)
Expand Down
118 changes: 118 additions & 0 deletions packages/core/src/services/knowledge-capture.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,18 @@ mock.module('../config/config-loader', () => ({

// Mock knowledge-init
const mockInitKnowledgeDir = mock(async () => undefined);
const mockInitGlobalKnowledgeDir = mock(async () => undefined);
mock.module('./knowledge-init', () => ({
initKnowledgeDir: mockInitKnowledgeDir,
initGlobalKnowledgeDir: mockInitGlobalKnowledgeDir,
}));

// Mock knowledge-scheduler (imported by capture module for global flush)
const mockScheduleFlush = mock(async () => undefined);
const mockScheduleGlobalFlush = mock(async () => undefined);
mock.module('./knowledge-scheduler', () => ({
scheduleFlush: mockScheduleFlush,
scheduleGlobalFlush: mockScheduleGlobalFlush,
}));

// Mock AI client
Expand Down Expand Up @@ -102,6 +112,9 @@ describe('knowledge-capture', () => {
mockListMessages.mockClear();
mockLoadConfig.mockClear();
mockInitKnowledgeDir.mockClear();
mockInitGlobalKnowledgeDir.mockClear();
mockScheduleFlush.mockClear();
mockScheduleGlobalFlush.mockClear();
mockSendQuery.mockClear();
mockGetAssistantClient.mockClear();
Object.values(mockLogger).forEach(fn => fn.mockClear());
Expand Down Expand Up @@ -427,4 +440,109 @@ describe('knowledge-capture', () => {

expect(result.extractedContent).toBe('## Decisions\n- use X\n');
});

describe('scope routing', () => {
/** Seed a minimal conversation so capture proceeds to extraction. */
function seedConversation(): void {
mockListMessages.mockResolvedValueOnce([
{
id: 'msg-1',
conversation_id: 'conv-123',
role: 'user' as const,
content: 'test',
metadata: '{}',
created_at: '2026-04-11T10:00:00Z',
},
]);
}

test('writes BOTH logs when extraction contains project and global blocks', async () => {
seedConversation();
mockSendQueryChunks = [
{
type: 'assistant',
content:
'## PROJECT\n### Decisions\n- Use Drizzle ORM\n\n## GLOBAL\n### Lessons\n- Bun mock.module is process-global\n',
},
];

const result = await captureKnowledge('conv-123', 'acme', 'widget');

expect(result.skipped).toBe(false);

// Two writes: one to project log, one to global log
expect(appendFileCalls).toHaveLength(2);
const projectWrite = appendFileCalls.find(c => c.path.includes('/workspaces/acme/widget/'));
const globalWrite = appendFileCalls.find(c => c.path.includes('/.archon/knowledge/'));
expect(projectWrite).toBeDefined();
expect(globalWrite).toBeDefined();

// Project log contains only the PROJECT block content
expect(projectWrite!.content).toContain('Use Drizzle ORM');
expect(projectWrite!.content).not.toContain('Bun mock.module');

// Global log contains only the GLOBAL block content + source attribution
expect(globalWrite!.content).toContain('Bun mock.module');
expect(globalWrite!.content).not.toContain('Use Drizzle ORM');
expect(globalWrite!.content).toContain('**Source**: acme/widget');
expect(globalWrite!.content).toContain('**Conversation**: conv-123');

// Global flush must be scheduled
expect(mockScheduleGlobalFlush).toHaveBeenCalledTimes(1);
expect(mockInitGlobalKnowledgeDir).toHaveBeenCalledTimes(1);
});

test('writes ONLY project log when extraction is project-only', async () => {
seedConversation();
mockSendQueryChunks = [
{
type: 'assistant',
content: '## PROJECT\n### Decisions\n- Store conversations in DB\n',
},
];

const result = await captureKnowledge('conv-123', 'acme', 'widget');

expect(result.skipped).toBe(false);
expect(appendFileCalls).toHaveLength(1);
expect(appendFileCalls[0]!.path).toContain('/workspaces/acme/widget/');
expect(mockScheduleGlobalFlush).not.toHaveBeenCalled();
expect(mockInitGlobalKnowledgeDir).not.toHaveBeenCalled();
});

test('writes ONLY global log when extraction is global-only', async () => {
seedConversation();
mockSendQueryChunks = [
{
type: 'assistant',
content: '## GLOBAL\n### Lessons\n- Prefer structured logging\n',
},
];

const result = await captureKnowledge('conv-123', 'acme', 'widget');

expect(result.skipped).toBe(false);
expect(appendFileCalls).toHaveLength(1);
expect(appendFileCalls[0]!.path).toContain('/.archon/knowledge/');
expect(mockInitKnowledgeDir).not.toHaveBeenCalled();
expect(mockScheduleGlobalFlush).toHaveBeenCalledTimes(1);
expect(mockInitGlobalKnowledgeDir).toHaveBeenCalledTimes(1);
});

test('falls back to project log when extraction lacks scope tags (malformed)', async () => {
seedConversation();
mockSendQueryChunks = [
{ type: 'assistant', content: '## Decisions\n- Legacy unscoped output\n' },
];

const result = await captureKnowledge('conv-123', 'acme', 'widget');

expect(result.skipped).toBe(false);
// Fallback routes the whole content to project
expect(appendFileCalls).toHaveLength(1);
expect(appendFileCalls[0]!.path).toContain('/workspaces/acme/widget/');
expect(appendFileCalls[0]!.content).toContain('Legacy unscoped output');
expect(mockScheduleGlobalFlush).not.toHaveBeenCalled();
});
});
});
Loading