diff --git a/.agents/skills/compozy/SKILL.md b/.agents/skills/compozy/SKILL.md index c7914d8..28ff336 100644 --- a/.agents/skills/compozy/SKILL.md +++ b/.agents/skills/compozy/SKILL.md @@ -15,8 +15,9 @@ Key characteristics: - **Agent-agnostic.** Supports claude, codex, copilot, cursor-agent, droid, gemini, opencode, and pi as ACP runtimes. - **Skills-based.** Bundled skills (installed via `compozy setup`) teach agents how to execute each workflow phase. -- **Artifact-driven.** All workflow state lives in markdown files under `.compozy/tasks//`, versioned alongside the codebase. -- **Single binary, local-first.** No sidecars, no external control planes. +- **Artifact-driven.** Planning and review artifacts live as markdown under `.compozy/tasks//`, versioned alongside the codebase. +- **Daemon-backed runtime.** A home-scoped daemon owns run state, workspace registration, snapshots, streams, and the synced `global.db` catalog under `~/.compozy/`. +- **Single binary, local-first.** The daemon is launched from the same binary; there are no external control planes. ## Workflow Pipeline Overview @@ -26,10 +27,10 @@ The standard development pipeline follows these phases in order. Each phase prod 2. **Ideation** (optional) -- install and enable the first-party `cy-idea-factory` extension, run `compozy setup`, then use `/cy-idea-factory` to expand a raw idea into a structured, research-backed spec at `.compozy/tasks//_idea.md`. 3. **Requirements** -- `/cy-create-prd` creates a business-focused Product Requirements Document at `.compozy/tasks//_prd.md` with ADRs. 4. **Technical Design** -- `/cy-create-techspec` translates the PRD into a technical specification at `.compozy/tasks//_techspec.md` with ADRs. -5. **Task Decomposition** -- `/cy-create-tasks` breaks down the PRD and TechSpec into independently implementable task files (`task_01.md`, `task_02.md`, etc.) and a master list at `_tasks.md`. -6. **Execution** -- `compozy start --name --ide ` dispatches task files sequentially to the configured AI agent for implementation. -7. **Review** -- `/cy-review-round` (manual AI review) or `compozy fetch-reviews --provider coderabbit --pr ` (external provider) produces review issue files under `reviews-NNN/`. -8. **Remediation** -- `compozy fix-reviews --name ` processes review issues, triages, fixes, and verifies each one. +5. **Task Decomposition** -- `/cy-create-tasks` breaks down the PRD and TechSpec into independently implementable task files (`task_01.md`, `task_02.md`, etc.) and a canonical task graph manifest at `_tasks.md`. +6. **Execution** -- `compozy tasks run --ide ` dispatches task files to the configured AI agent for implementation. Use `--parallel-tasks` to run one workflow by dependency waves from `_tasks.md` graph edges. +7. **Review** -- `/cy-review-round` (manual AI review) or `compozy reviews fetch --provider coderabbit --pr ` (external provider) produces review issue files under `reviews-NNN/`. +8. **Remediation** -- `compozy reviews fix ` processes review issues, triages, fixes, and verifies each one. 9. **Archive** -- `compozy archive --name ` moves fully completed workflows to `.compozy/tasks/_archived/`. Repeat phases 7-8 until the review is clean, then merge. @@ -41,9 +42,9 @@ digraph compozy_pipeline { "/cy-create-prd" [shape=box]; "/cy-create-techspec" [shape=box]; "/cy-create-tasks" [shape=box]; - "compozy start" [shape=box]; + "compozy tasks run" [shape=box]; "Review (manual or provider)" [shape=box]; - "compozy fix-reviews" [shape=box]; + "compozy reviews fix" [shape=box]; "Reviews clean?" [shape=diamond]; "compozy archive" [shape=doublecircle]; @@ -51,10 +52,10 @@ digraph compozy_pipeline { "/cy-idea-factory (optional)" -> "/cy-create-prd"; "/cy-create-prd" -> "/cy-create-techspec"; "/cy-create-techspec" -> "/cy-create-tasks"; - "/cy-create-tasks" -> "compozy start"; - "compozy start" -> "Review (manual or provider)"; - "Review (manual or provider)" -> "compozy fix-reviews"; - "compozy fix-reviews" -> "Reviews clean?"; + "/cy-create-tasks" -> "compozy tasks run"; + "compozy tasks run" -> "Review (manual or provider)"; + "Review (manual or provider)" -> "compozy reviews fix"; + "compozy reviews fix" -> "Reviews clean?"; "Reviews clean?" -> "Review (manual or provider)" [label="no"]; "Reviews clean?" -> "compozy archive" [label="yes"]; } @@ -70,15 +71,18 @@ For a detailed step-by-step walkthrough of each phase, read `references/workflow | `compozy setup` | Install core skills and enabled extension assets | `--agent`, `--skill`, `--global`, `--copy`, `--list`, `--all`, `--yes` | | `compozy upgrade` | Update CLI to latest release | | | **Workflow Execution** | | | -| `compozy start` | Execute PRD task files sequentially | `--name`, `--ide`, `--model`, `--auto-commit`, `--dry-run` | +| `compozy daemon` | Manage the home-scoped daemon lifecycle | `start`, `status`, `stop` | +| `compozy workspaces` | Inspect and manage daemon workspace registrations | `list`, `show`, `register`, `unregister`, `resolve` | +| `compozy tasks run` | Execute PRD task files through the daemon | `--name`, `--multiple`, `--parallel`, `--parallel-limit`, `--parallel-tasks`, `--recursive` / `-r`, `--attach`, `--ui`, `--stream`, `--detach`, `--task-runtime` | | `compozy exec` | Execute an ad hoc prompt | `--agent`, `--format`, `--prompt-file`, `--tui`, `--persist`, `--run-id` | +| `compozy runs` | Attach, watch, and purge daemon-managed runs | `attach`, `watch`, `purge` | | **Review** | | | -| `compozy fetch-reviews` | Fetch provider review comments | `--provider`, `--pr`, `--name`, `--round` | -| `compozy fix-reviews` | Process review issue files | `--name`, `--round`, `--concurrent`, `--batch-size`, `--ide` | +| `compozy reviews fetch` | Fetch provider review comments | `--provider`, `--pr`, `--name`, `--round` | +| `compozy reviews fix` | Process review issue files | `--name`, `--round`, `--concurrent`, `--batch-size`, `--ide` | | **Utilities** | | | -| `compozy validate-tasks` | Validate task file metadata | `--name`, `--tasks-dir`, `--format` | -| `compozy sync` | Refresh task workflow metadata | `--name`, `--root-dir`, `--tasks-dir` | -| `compozy archive` | Move completed workflows to archive | `--name`, `--root-dir`, `--tasks-dir` | +| `compozy tasks validate` | Validate task file metadata | `--name`, `--tasks-dir`, `--format` | +| `compozy sync` | Reconcile workflow artifacts into daemon `global.db` | `--name`, `--root-dir`, `--tasks-dir` | +| `compozy archive` | Move daemon-eligible completed workflows to archive | `--name`, `--root-dir`, `--tasks-dir` | | `compozy migrate` | Convert legacy artifacts to frontmatter | `--name`, `--dry-run`, `--reviews-dir` | | **Agent Management** | | | | `compozy agents list` | List resolved reusable agents | | @@ -91,7 +95,7 @@ For a detailed step-by-step walkthrough of each phase, read `references/workflow | `compozy ext enable/disable` | Toggle extension | `` | | `compozy ext doctor` | Diagnose extension issues | | -Common flags shared by `start`, `exec`, and `fix-reviews`: `--ide`, `--model`, `--reasoning-effort`, `--add-dir`, `--auto-commit`, `--dry-run`. +Common flags shared by `tasks run`, `exec`, and `reviews fix`: `--ide`, `--model`, `--reasoning-effort`, `--add-dir`, `--auto-commit`, `--dry-run`. For complete flag documentation, read `references/cli-reference.md`. @@ -102,9 +106,9 @@ For complete flag documentation, read `references/cli-reference.md`. | `cy-create-prd` | `/cy-create-prd` | Building a Product Requirements Document | TechSpec, task breakdown, coding | | `cy-create-techspec` | `/cy-create-techspec` | Translating PRD into technical design | PRD creation, task execution | | `cy-create-tasks` | `/cy-create-tasks` | Decomposing PRD+TechSpec into task files | Execution, review | -| `cy-execute-task` | (internal) | Executing a single PRD task (called by `compozy start`) | Direct invocation, review work | +| `cy-execute-task` | (internal) | Executing a single PRD task (called by `compozy tasks run`) | Direct invocation, review work | | `cy-review-round` | `/cy-review-round` | Performing comprehensive code review | Fetching external reviews, fixing | -| `cy-fix-reviews` | (internal) | Remediating review issues (called by `compozy fix-reviews`) | Fetching reviews, task execution | +| `cy-fix-reviews` | (internal) | Remediating review issues (called by `compozy reviews fix`) | Fetching reviews, task execution | | `cy-final-verify` | `/cy-final-verify` | Enforcing verification before completion claims | Early planning, brainstorming | | `cy-workflow-memory` | (internal) | Maintaining cross-task workflow memory | PR reviews, user preferences | | `compozy` | `/compozy` | Learning how to use Compozy | Executing workflow steps | @@ -127,21 +131,17 @@ For detailed skill descriptions and inputs/outputs, read `references/skills-refe _idea.md # Idea spec (from cy-idea-factory) _prd.md # Product Requirements Document _techspec.md # Technical Specification - _tasks.md # Master task list - _meta.md # Workflow metadata + _tasks.md # Task graph manifest task_01.md ... task_N.md # Individual task files adrs/ adr-001.md ... adr-NNN.md # Architecture Decision Records reviews-NNN/ - _meta.md # Review round metadata - issue_001.md ... issue_N.md # Review issue files + issue_001.md ... issue_N.md # Review issues with round metadata in frontmatter memory/ MEMORY.md # Shared workflow memory task_01.md ... task_N.md # Per-task memory _archived/ -/ # Archived completed workflows - runs/ - / # Persisted exec session artifacts agents/ / # Workspace-scoped reusable agents AGENT.md # Agent definition @@ -152,6 +152,8 @@ For detailed skill descriptions and inputs/outputs, read `references/skills-refe Global paths: - `~/.compozy/agents//` -- global reusable agents (workspace overrides global) - `~/.compozy/extensions/` -- user-scoped extensions +- `~/.compozy/runs//` -- daemon-managed run artifacts and persisted exec sessions +- `~/.compozy/global.db` -- daemon workspace, workflow, task, and review catalog ## Configuration @@ -165,12 +167,13 @@ auto_commit = true reasoning_effort = "high" add_dirs = ["../shared-lib"] -[start] -include_completed = false - [tasks] types = ["frontend", "backend", "docs", "test", "infra", "refactor", "chore", "bugfix"] +[tasks.run] +include_completed = false +recursive = false + [fix_reviews] concurrent = 2 batch_size = 3 @@ -227,18 +230,18 @@ Management: `compozy ext list`, `compozy ext inspect `, `compozy ext insta ## Common Patterns - Run `compozy setup` before starting any workflow to ensure core skills and enabled extension assets are installed. -- Follow the pipeline in order: idea (optional) -> PRD -> TechSpec -> Tasks -> Start -> Review -> Fix. +- Follow the pipeline in order: idea (optional) -> PRD -> TechSpec -> Tasks -> Execution -> Review -> Fix. - Configure workspace defaults in `.compozy/config.toml` to reduce repetitive CLI flags. -- Run `compozy validate-tasks --name ` before `compozy start` to catch metadata issues early. +- Run `compozy tasks validate --name ` before `compozy tasks run` to catch metadata issues early. - Use `compozy archive` to clean up fully completed workflows and keep the tasks directory focused. - Use `compozy exec --agent ` for ad hoc prompts with a specific advisor perspective. - Use `compozy exec --persist` to save session artifacts for later resumption with `--run-id`. ## Anti-Patterns -- **Skipping pipeline stages.** Running `compozy start` without a PRD and task files produces poor results. -- **Invoking `cy-execute-task` directly.** Use `compozy start`, which handles dispatch, sequencing, memory, and tracking. +- **Skipping pipeline stages.** Running `compozy tasks run` without a PRD and task files produces poor results. +- **Invoking `cy-execute-task` directly.** Use `compozy tasks run`, which handles dispatch, sequencing, memory, and tracking. - **Mixing workflow skills out of order.** Running `/cy-create-tasks` without a PRD and TechSpec leads to shallow task decomposition. -- **Editing task file frontmatter manually.** Use `compozy migrate` or `compozy validate-tasks` to fix metadata issues programmatically. -- **Confusing skills with CLI commands.** Skills (slash commands like `/cy-create-prd`) run inside an agent session. CLI commands (`compozy start`) run in the terminal. +- **Editing task file frontmatter manually.** Use `compozy migrate` or `compozy tasks validate` to fix metadata issues programmatically. +- **Confusing skills with CLI commands.** Skills (slash commands like `/cy-create-prd`) run inside an agent session. CLI commands (`compozy tasks run`) run in the terminal. - **Skipping verification.** Always use `cy-final-verify` before claiming task completion or creating commits. diff --git a/.agents/skills/compozy/references/cli-reference.md b/.agents/skills/compozy/references/cli-reference.md index 459a835..bac4380 100644 --- a/.agents/skills/compozy/references/cli-reference.md +++ b/.agents/skills/compozy/references/cli-reference.md @@ -4,12 +4,12 @@ Complete reference for all Compozy CLI commands, flags, and usage examples. ## Common Flags -These flags are shared by `start`, `exec`, and `fix-reviews`: +These flags are shared by `tasks run`, `exec`, and `reviews fix`: | Flag | Type | Default | Description | | --- | --- | --- | --- | | `--ide` | string | `codex` | ACP runtime: claude, codex, copilot, cursor-agent, droid, gemini, opencode, pi | -| `--model` | string | per-IDE | Model override (codex/droid=gpt-5.4, claude=opus, copilot=claude-sonnet-4.6, cursor-agent=composer-1, opencode/pi=anthropic/claude-opus-4-6, gemini=gemini-2.5-pro) | +| `--model` | string | per-IDE | Model override (codex/droid=gpt-5.5, claude=opus, copilot=claude-sonnet-4.6, cursor-agent=composer-1, opencode/pi=anthropic/claude-opus-4-6, gemini=gemini-2.5-pro) | | `--reasoning-effort` | string | | Reasoning effort: low, medium, high, xhigh | | `--add-dir` | string[] | | Additional directories for ACP runtimes (claude and codex only; repeatable or comma-separated) | | `--auto-commit` | bool | false | Include automatic commit instructions at task/batch completion | @@ -49,23 +49,35 @@ Update the Compozy CLI to the latest release. No flags. ## Workflow Execution -### `compozy start` +### `compozy tasks run` -Execute PRD task files sequentially from a workflow directory. +Execute PRD task files from a workflow directory through the shared daemon. By default one workflow runs sequentially; `--parallel-tasks` runs a single workflow by dependency waves from `_tasks.md` graph edges. | Flag | Type | Default | Description | | --- | --- | --- | --- | | `--name` | string | | Task workflow name (resolves to `.compozy/tasks/`) | -| `--tasks-dir` | string | | Path to tasks directory (overrides `--name`) | +| `--multiple` | string | | Comma-separated task workflow slugs to run through one daemon-owned parent queue | +| `--parallel` | bool | false | With `--multiple`, run child workflows concurrently instead of as an ordered queue | +| `--parallel-limit` | int | 2 | With `--multiple --parallel`, maximum child workflows running at once | +| `--parallel-tasks` | bool | false | Run one workflow by dependency waves from `_tasks.md` graph edges | | `--include-completed` | bool | false | Include tasks already marked as completed | +| `--recursive`, `-r` | bool | false | Discover `task_NNN.md` files in nested subdirectories of the workflow root | | `--skip-validation` | bool | false | Skip task metadata preflight check | | `--force` | bool | false | Continue after validation fails in non-interactive mode | +| `--attach` | string | auto | Attach mode: auto, ui, stream, detach | +| `--ui` | bool | false | Force interactive TUI attach mode | +| `--stream` | bool | false | Force textual stream attach mode | +| `--detach` | bool | false | Start the run without attaching a client | +| `--task-runtime` | string[] | | Per-task runtime override rules | | + common flags | | | `--ide`, `--model`, `--reasoning-effort`, `--add-dir`, `--auto-commit`, `--dry-run` | ``` -compozy start --name multi-repo --ide claude -compozy start --tasks-dir .compozy/tasks/multi-repo --ide codex --auto-commit -compozy start +compozy tasks run multi-repo --ide claude +compozy tasks run --name multi-repo --ide codex --auto-commit +compozy tasks run --multiple alpha,beta --stream +compozy tasks run --multiple alpha,beta --parallel --parallel-limit 2 +compozy tasks run multi-repo --parallel-tasks +compozy tasks run multi-repo --stream ``` ### `compozy exec [prompt]` @@ -79,7 +91,7 @@ Execute a single ad hoc prompt through the ACP runtime. Provide prompt as argume | `--format` | string | text | Output format: text, json, raw-json | | `--verbose` | bool | false | Emit operational runtime logs to stderr | | `--tui` | bool | false | Open the interactive TUI | -| `--persist` | bool | false | Save artifacts under `.compozy/runs//` | +| `--persist` | bool | false | Save artifacts under `~/.compozy/runs//` | | `--extensions` | bool | false | Enable executable extensions for this run | | `--run-id` | string | | Resume a previously persisted session | | + common flags | | | `--ide`, `--model`, `--reasoning-effort`, `--add-dir`, `--auto-commit`, `--dry-run` | @@ -97,7 +109,7 @@ compozy exec --run-id exec-20260405-120000-000000000 "Continue" ## Review -### `compozy fetch-reviews` +### `compozy reviews fetch` Fetch review comments from a provider and write them into `.compozy/tasks//reviews-NNN/`. @@ -105,44 +117,50 @@ Fetch review comments from a provider and write them into `.compozy/tasks/ | --- | --- | --- | --- | | `--provider` | string | | Review provider name (e.g., coderabbit) | | `--pr` | string | | Pull request number | +| `[slug]` | string | | Workflow name (positional) | | `--name` | string | | Workflow name | | `--round` | int | next | Review round number (default: next available) | ``` -compozy fetch-reviews --provider coderabbit --pr 259 --name my-feature -compozy fetch-reviews --provider coderabbit --pr 259 --name my-feature --round 2 -compozy fetch-reviews +compozy reviews fetch my-feature --provider coderabbit --pr 259 +compozy reviews fetch --name my-feature --provider coderabbit --pr 259 --round 2 +compozy reviews fetch --name my-feature ``` -By default, `fetch-reviews` imports CodeRabbit review-body comments for `nitpick`, `minor`, and `major`. +By default, `reviews fetch` imports CodeRabbit review-body comments for `nitpick`, `minor`, and `major`. Use `[fetch_reviews].nitpicks = false` in `.compozy/config.toml` to disable that import. -### `compozy fix-reviews` +### `compozy reviews fix` Process review issue files and dispatch agents to remediate feedback. | Flag | Type | Default | Description | | --- | --- | --- | --- | +| `[slug]` | string | | Workflow name (positional) | | `--name` | string | | Workflow name | | `--round` | int | latest | Review round number (default: latest existing) | | `--reviews-dir` | string | | Path to a review round directory | | `--batch-size` | int | 1 | Number of file groups per batch | | `--include-resolved` | bool | false | Include already-resolved issues | | `--concurrent` | int | 1 | Number of batches to process in parallel | +| `--attach` | string | auto | Attach mode: auto, ui, stream, detach | +| `--ui` | bool | false | Force interactive TUI attach mode | +| `--stream` | bool | false | Force textual stream attach mode | +| `--detach` | bool | false | Start the run without attaching a client | | + common flags | | | `--ide`, `--model`, `--reasoning-effort`, `--add-dir`, `--auto-commit`, `--dry-run` | ``` -compozy fix-reviews --name my-feature --ide codex --concurrent 2 --batch-size 3 -compozy fix-reviews --name my-feature --round 2 -compozy fix-reviews --reviews-dir .compozy/tasks/my-feature/reviews-001 -compozy fix-reviews +compozy reviews fix my-feature --ide codex --concurrent 2 --batch-size 3 +compozy reviews fix --name my-feature --round 2 +compozy reviews fix --reviews-dir .compozy/tasks/my-feature/reviews-001 +compozy reviews fix --name my-feature ``` --- ## Utilities -### `compozy validate-tasks` +### `compozy tasks validate` Validate task file metadata before execution. @@ -153,12 +171,12 @@ Validate task file metadata before execution. | `--format` | string | | Output format | ``` -compozy validate-tasks --name my-feature +compozy tasks validate --name my-feature ``` ### `compozy sync` -Refresh task workflow `_meta.md` files under `.compozy/tasks/`. +Reconcile authored workflow artifacts under `.compozy/tasks/` into the daemon `global.db` catalog. | Flag | Type | Default | Description | | --- | --- | --- | --- | @@ -173,7 +191,7 @@ compozy sync --name my-feature ### `compozy archive` -Move fully completed workflows to `.compozy/tasks/_archived/-`. +Move workflows that synced daemon state marks as complete to `.compozy/tasks/_archived/-`. | Flag | Type | Default | Description | | --- | --- | --- | --- | diff --git a/.agents/skills/compozy/references/config-reference.md b/.agents/skills/compozy/references/config-reference.md index 602d3b9..30afc51 100644 --- a/.agents/skills/compozy/references/config-reference.md +++ b/.agents/skills/compozy/references/config-reference.md @@ -15,7 +15,7 @@ Runtime defaults applied to all commands unless overridden. | Field | Type | Description | | --- | --- | --- | | `ide` | string | ACP runtime: `claude`, `codex`, `copilot`, `cursor-agent`, `droid`, `gemini`, `opencode`, `pi` | -| `model` | string | Model override. Per-IDE defaults: codex/droid=gpt-5.4, claude=opus, copilot=claude-sonnet-4.6, cursor-agent=composer-1, opencode/pi=anthropic/claude-opus-4-6, gemini=gemini-2.5-pro | +| `model` | string | Model override. Per-IDE defaults: codex/droid=gpt-5.5, claude=opus, copilot=claude-sonnet-4.6, cursor-agent=composer-1, opencode/pi=anthropic/claude-opus-4-6, gemini=gemini-2.5-pro | | `output_format` | string | Output format: `text`, `json`, `raw-json` | | `reasoning_effort` | string | Reasoning effort level: `low`, `medium`, `high`, `xhigh` | | `access_mode` | string | Access mode: `default`, `full` | @@ -23,16 +23,55 @@ Runtime defaults applied to all commands unless overridden. | `tail_lines` | int | Number of tail lines to display from agent output | | `add_dirs` | string[] | Additional directories for ACP runtimes (claude and codex only) | | `auto_commit` | bool | Include automatic commit instructions at task/batch completion | -| `max_retries` | int | Maximum number of retries on agent failure | +| `max_retries` | int | Maximum number of retries on agent failure or inactivity timeout (`0` disables automatic retries) | | `retry_backoff_multiplier` | float | Backoff multiplier between retries | -### `[start]` +### `[tasks.run]` -Options specific to `compozy start`. +Options specific to `compozy tasks run`. | Field | Type | Description | | --- | --- | --- | | `include_completed` | bool | Include tasks already marked as completed | +| `recursive` | bool | Discover `task_NNN.md` files in nested subdirectories. Equivalent to `--recursive` on the CLI | +| `task_runtime_rules` | `array` | Type-scoped runtime overrides applied after `[defaults]` for `compozy tasks run` | + +#### `[[tasks.run.task_runtime_rules]]` + +Per-task runtime rules let `compozy tasks run` change the runtime for tasks that match a given task `type`. This v1 config surface is intentionally bulk-oriented: config supports `type` selectors only, while one-off task `id` overrides are available from the CLI and TUI for the current run. + +| Field | Type | Description | +| --- | --- | --- | +| `type` | string | Task type selector such as `frontend`, `backend`, or any custom type from `[tasks].types` | +| `ide` | string | Runtime override for matching tasks | +| `model` | string | Model override for matching tasks | +| `reasoning_effort` | string | Reasoning effort override: `low`, `medium`, `high`, `xhigh` | + +Rules are applied in declaration order within config, with later rules for the same `type` replacing earlier ones when workspace and global config are merged. At execution time, the effective precedence is: + +1. Base runtime from `[defaults]` and `[tasks.run]` +2. Config `[[tasks.run.task_runtime_rules]]` matching the task `type` +3. CLI or TUI `type` rules for the current run +4. CLI or TUI `id` rules for the current run + +Example: + +```toml +[defaults] +ide = "codex" +model = "gpt-5.5" +reasoning_effort = "medium" + +[[tasks.run.task_runtime_rules]] +type = "frontend" +model = "gpt-5.5" +reasoning_effort = "high" + +[[tasks.run.task_runtime_rules]] +type = "docs" +ide = "claude" +model = "opus" +``` ### `[tasks]` @@ -44,7 +83,7 @@ Task type registry. ### `[fix_reviews]` -Options specific to `compozy fix-reviews`. +Options specific to `compozy reviews fix`. | Field | Type | Description | | --- | --- | --- | @@ -54,13 +93,79 @@ Options specific to `compozy fix-reviews`. ### `[fetch_reviews]` -Options specific to `compozy fetch-reviews`. +Options specific to `compozy reviews fetch`. | Field | Type | Description | | --- | --- | --- | | `provider` | string | Default review provider (e.g., `coderabbit`) | | `nitpicks` | bool | Enable or disable CodeRabbit review-body comments (`nitpick`, `minor`, and `major`). Default is enabled when unset | +### `[watch_reviews]` + +Options specific to `compozy reviews watch`. Watch-specific loop values come from this section; child review +fetch/fix defaults continue to come from `[fetch_reviews]`, `[fix_reviews]`, and `[defaults]`. + +| Field | Type | Description | +| --- | --- | --- | +| `max_rounds` | int | Maximum watch rounds. Must be greater than zero when `until_clean = true`. | +| `poll_interval` | string | Positive Go duration between provider status checks (e.g., `30s`). | +| `review_timeout` | string | Positive Go duration to wait for the provider to review a PR head (e.g., `30m`). | +| `quiet_period` | string | Positive Go duration to wait after the latest provider review/status signal before fetching issues. | +| `auto_push` | bool | Push committed fixes after each successful round. Requires `defaults.auto_commit = true` when enabled from config. | +| `until_clean` | bool | Continue until the provider has reviewed the current PR head and no actionable issues are fetched. | +| `push_remote` | string | Optional push remote. Must be set together with `push_branch`; omit both to resolve upstream later. | +| `push_branch` | string | Optional push branch. Must be set together with `push_remote`; omit both to resolve upstream later. | + +CLI usage: + +```bash +compozy reviews watch --provider coderabbit --pr 123 +compozy reviews watch my-workflow --provider coderabbit --pr 123 --max-rounds 4 +compozy reviews watch my-workflow --provider coderabbit --pr 123 --auto-push --push-remote origin --push-branch feature/reviews +``` + +Watch runs are daemon-owned parent runs. They wait until the provider reports a completed, settled review for the +current PR head, fetch actionable issues, launch child `reviews fix` runs, optionally push the child commit, then +repeat until the provider-current fetch is clean or `max_rounds` is reached. For CodeRabbit, settled means the latest +CodeRabbit commit status for the PR head is complete; transient "review in progress" statuses keep the watch waiting. + +Auto-push safety: + +- `auto_push = true` requires committed fixes. The watch parent forces child `auto_commit = true`; an explicit + `runtime_overrides.auto_commit = false` is rejected. +- The daemon never restores, resets, cleans, or manually stages unrelated work. It inspects git state and runs only + `git push HEAD:` for committed branch state. It pushes after a child fix run completed, all fetched + issues are resolved, and `HEAD` advanced; when `auto_push` starts with existing unpushed commits, it first pushes + that committed `HEAD` so the provider can review the same head the watch is waiting on. +- `push_remote` and `push_branch` can be supplied by config/CLI or resolved from the current upstream. Missing push + target information fails before pushing. + +Lifecycle events emitted by the parent run: + +| Event | Purpose | +| --- | --- | +| `review.watch_started` | Initial provider/PR/workflow/git state, including `head_sha`, `remote`, `branch`, `dirty`, and `unpushed_commits`. | +| `review.watch_waiting` | Provider has not yet reviewed the current PR head; includes `status`, `review_id`, and `review_state` when available. | +| `review.watch_round_fetched` | A provider-current round produced actionable issues; includes `round`, `total`, `resolved`, and `unresolved`. | +| `review.watch_fix_started` | Child `reviews fix` run started; includes `child_run_id`. | +| `review.watch_fix_completed` | Child run reached a terminal state; includes `status` and `error` when failed. | +| `review.watch_push_started` / `review.watch_push_completed` / `review.watch_push_failed` | Auto-push lifecycle with `head_sha`, `remote`, `branch`, and `error` on failure. Startup reconciliation uses `round = 0`. | +| `review.watch_clean` | Provider-current fetch returned no actionable issues. | +| `review.watch_max_rounds` | The parent stopped because `max_rounds` was reached before a clean result. | + +Extension hooks exposed to Go and TypeScript SDKs: + +| Hook | Mutable | Payload highlights | Allowed patch fields | +| --- | --- | --- | --- | +| `review.watch_pre_round` | yes | `run_id`, `provider`, `pr`, `workflow`, `round`, `head_sha`, `review_id`, `review_state`, `status`, `nitpicks`, `runtime_overrides`, `batching`, `continue` | `nitpicks`, `runtime_overrides`, `batching`, `continue`, `stop_reason` | +| `review.watch_post_round` | no | `run_id`, `provider`, `pr`, `workflow`, `round`, `child_run_id`, `status`, `total`, `resolved`, `unresolved`, `pushed`, `stop_reason`, `error` | none | +| `review.watch_pre_push` | yes | `run_id`, `provider`, `pr`, `workflow`, `round`, `head_sha`, `remote`, `branch`, `push` (`round = 0` for startup reconciliation) | `remote`, `branch`, `push`, `stop_reason` | +| `review.watch_finished` | no | `run_id`, `child_run_id`, `provider`, `pr`, `workflow`, `round`, `head_sha`, `status`, `terminal_reason`, `stopped`, `clean`, `max_rounds`, `error` | none | + +Hooks may veto a round or push only by returning `continue = false` or `push = false` with a non-empty +`stop_reason`. Hooks cannot mark a PR clean, skip provider-current detection, or mutate immutable provider/head/status +fields. + ### `[exec]` Options specific to `compozy exec`. Inherits all `[defaults]` fields plus: @@ -69,7 +174,40 @@ Options specific to `compozy exec`. Inherits all `[defaults]` fields plus: | --- | --- | --- | | `verbose` | bool | Emit operational runtime logs to stderr | | `tui` | bool | Open the interactive TUI | -| `persist` | bool | Save artifacts under `.compozy/runs//` | +| `persist` | bool | Save artifacts under `~/.compozy/runs//` | + +### `[sound]` + +Optional audio notifications that play when a run reaches a terminal state. Applies to both `compozy tasks run` and `compozy exec`. Disabled by default — setting any field without `enabled = true` is a no-op. + +| Field | Type | Description | +| --- | --- | --- | +| `enabled` | bool | Master switch. Default `false`. | +| `on_completed` | string | Preset name or absolute path played on `run.completed`. Default `glass` when `enabled = true`. | +| `on_failed` | string | Preset name or absolute path played on `run.failed` and `run.cancelled`. Default `basso` when `enabled = true`. | + +**Presets** (resolve to platform-native files at play time): + +| Preset | macOS | Linux (freedesktop) | Windows | +| --- | --- | --- | --- | +| `glass` | `/System/Library/Sounds/Glass.aiff` | `complete.oga` | `tada.wav` | +| `basso` | `/System/Library/Sounds/Basso.aiff` | `dialog-error.oga` | `chord.wav` | +| `ping` | `Ping.aiff` | `message.oga` | `ding.wav` | +| `hero` | `Hero.aiff` | `complete.oga` | `tada.wav` | +| `funk` | `Funk.aiff` | `bell.oga` | `notify.wav` | +| `tink` | `Tink.aiff` | `message.oga` | `chimes.wav` | +| `submarine` | `Submarine.aiff` | `bell.oga` | `Ring01.wav` | + +**Absolute paths** bypass preset lookup, so any local sound file works: + +```toml +[sound] +enabled = true +on_completed = "/System/Library/Sounds/Hero.aiff" +on_failed = "/Users/me/sounds/custom-fail.wav" +``` + +**Platform requirements**: `afplay` (bundled with macOS), `paplay` (Linux, from `pulseaudio-utils`), or `powershell` + `System.Media.SoundPlayer` (Windows). On unix variants without one of these tools the feature silently falls back to no-op; playback errors never break a run. ## Complete Example @@ -84,12 +222,13 @@ timeout = "45m" max_retries = 2 retry_backoff_multiplier = 1.5 -[start] -include_completed = false - [tasks] types = ["frontend", "backend", "docs", "test", "infra", "refactor", "chore", "bugfix"] +[tasks.run] +include_completed = false +recursive = false + [fix_reviews] concurrent = 2 batch_size = 3 @@ -99,8 +238,21 @@ include_resolved = false provider = "coderabbit" nitpicks = false +[watch_reviews] +until_clean = true +max_rounds = 6 +poll_interval = "30s" +review_timeout = "30m" +quiet_period = "20s" +auto_push = false + [exec] verbose = false tui = false persist = false + +[sound] +enabled = true +on_completed = "glass" +on_failed = "basso" ``` diff --git a/.agents/skills/compozy/references/skills-reference.md b/.agents/skills/compozy/references/skills-reference.md index 4b86205..f4a0456 100644 --- a/.agents/skills/compozy/references/skills-reference.md +++ b/.agents/skills/compozy/references/skills-reference.md @@ -30,7 +30,7 @@ Creates a business-focused Product Requirements Document through structured brai - **Inputs:** Feature name or idea. Optional existing `_idea.md` or `_prd.md` for update mode. - **Outputs:** `.compozy/tasks//_prd.md`, ADRs in `adrs/`. - **Pipeline position:** After ideation (optional). Feeds into `cy-create-techspec`. -- **Process:** Context discovery (codebase + web) -> clarifying questions -> 2-3 product approaches -> ADR for chosen approach -> draft PRD -> user approval. +- **Process:** Context discovery (codebase + web) -> clarifying questions -> decide direction + ADR -> write PRD directly. - **Use when:** Starting a new feature or product, building or updating a PRD. - **Do not use for:** Technical specifications, task breakdowns, or code implementation. @@ -45,7 +45,7 @@ Translates PRD business requirements into a technical implementation design. - **Inputs:** Existing `_prd.md` from the previous stage. - **Outputs:** `.compozy/tasks//_techspec.md`, ADRs in `adrs/`. - **Pipeline position:** After PRD. Feeds into `cy-create-tasks`. -- **Process:** Codebase architecture exploration -> technical questions -> technical ADRs -> TechSpec draft -> user approval. +- **Process:** Codebase architecture exploration -> technical questions -> technical ADRs -> write TechSpec directly. - **Use when:** A PRD exists and needs a technical implementation plan. - **Do not use for:** PRD creation, task execution, or code implementation. @@ -58,10 +58,10 @@ Translates PRD business requirements into a technical implementation design. Decomposes PRDs and TechSpecs into detailed, independently implementable task files with codebase-informed enrichment. - **Inputs:** Existing `_prd.md` and `_techspec.md`. -- **Outputs:** Individual task files (`task_01.md`, `task_02.md`, etc.), `_tasks.md` master list. -- **Pipeline position:** After TechSpec. Feeds into `compozy start`. -- **Process:** Load PRD+TechSpec context -> break into granular tasks -> user approval -> generate task files -> enrich with codebase patterns -> validate with `compozy validate-tasks`. -- **Task metadata:** Each task has YAML frontmatter with `status` (pending/in_progress/completed), `title`, `type`, `complexity`, and `dependencies`. +- **Outputs:** Individual task files (`task_01.md`, `task_02.md`, etc.), `_tasks.md` task graph manifest. +- **Pipeline position:** After TechSpec. Feeds into `compozy tasks run`. +- **Process:** Load PRD+TechSpec context -> break into granular tasks -> user approval -> generate task files -> enrich with codebase patterns -> validate with `compozy tasks validate`. +- **Task metadata:** Each task has YAML frontmatter with `status` (pending/in_progress/completed), `title`, `type`, and `complexity`. Dependency relationships live only in `_tasks.md` under `graph.edges`. - **Use when:** A PRD and TechSpec exist and need to be broken into executable tasks. - **Do not use for:** Execution, review, or code implementation. @@ -69,13 +69,13 @@ Decomposes PRDs and TechSpecs into detailed, independently implementable task fi ## cy-execute-task -**Trigger:** Internal (called by `compozy start`). Do not invoke directly. +**Trigger:** Internal (called by `compozy tasks run`). Do not invoke directly. Executes one PRD task end-to-end using the provided task file, PRD directory, and tracking file paths. -- **Inputs:** Task specification, PRD directory path, task file path, master tasks file path, auto-commit mode. Optional workflow memory paths. +- **Inputs:** Task specification, PRD directory path, task file path, `_tasks.md` task graph manifest path, auto-commit mode. Optional workflow memory paths. - **Outputs:** Implemented code changes, updated task tracking files, optional commit. -- **Pipeline position:** Called by `compozy start` for each task in sequence. +- **Pipeline position:** Called by `compozy tasks run` for each task in sequence. - **Process:** Ground in PRD/TechSpec context -> build execution checklist -> implement -> validate with `cy-final-verify` -> update tracking -> optional commit. - **Use when:** Invoked internally by the execution pipeline. - **Do not use for:** Direct invocation, PR review batches, or standalone verification. @@ -89,22 +89,22 @@ Executes one PRD task end-to-end using the provided task file, PRD directory, an Performs a comprehensive code review of a PRD implementation and generates review issue files. - **Inputs:** Feature name identifying the workflow under `.compozy/tasks//`. -- **Outputs:** Review round directory `reviews-NNN/` with `_meta.md` and `issue_*.md` files. +- **Outputs:** Review round directory `reviews-NNN/` with `issue_*.md` files containing round metadata in YAML frontmatter. - **Pipeline position:** After execution. Outputs feed into `cy-fix-reviews`. - **Use when:** Reviewing implemented PRD tasks without an external review provider. -- **Do not use for:** Fetching external reviews (use `compozy fetch-reviews`), fixing issues (use `compozy fix-reviews`). +- **Do not use for:** Fetching external reviews (use `compozy reviews fetch`), fixing issues (use `compozy reviews fix`). --- ## cy-fix-reviews -**Trigger:** Internal (called by `compozy fix-reviews`). Do not invoke directly. +**Trigger:** Internal (called by `compozy reviews fix`). Do not invoke directly. Executes provider-agnostic PR review remediation using existing review round files. -- **Inputs:** Scoped issue files from the review round, PRD review round directory and `_meta.md`. +- **Inputs:** Scoped issue files from the review round and their YAML frontmatter. - **Outputs:** Updated issue files with triage and status, code fixes, verification evidence. -- **Pipeline position:** Called by `compozy fix-reviews`. Operates on output from `cy-review-round` or `compozy fetch-reviews`. +- **Pipeline position:** Called by `compozy reviews fix`. Operates on output from `cy-review-round` or `compozy reviews fetch`. - **Process:** Read round context -> triage issues (valid/invalid) -> fix valid issues in severity order -> verify with `cy-final-verify` -> close out issue files. - **Use when:** Invoked internally by the review remediation pipeline. - **Do not use for:** Fetching reviews, PRD task execution, or generic coding. diff --git a/.agents/skills/compozy/references/workflow-guide.md b/.agents/skills/compozy/references/workflow-guide.md index 4478ca5..e0f7d8b 100644 --- a/.agents/skills/compozy/references/workflow-guide.md +++ b/.agents/skills/compozy/references/workflow-guide.md @@ -35,8 +35,8 @@ Install flow: `compozy ext install --yes compozy/compozy --remote github --ref < 1. Invoke `/cy-create-prd` with the feature name. If `_idea.md` exists, it is used as primary context. 2. The skill runs parallel codebase and market research. 3. Answer clarifying questions focused on WHAT and WHY (not HOW). -4. Choose from 2-3 product approaches. An ADR is created for the decision. -5. Review and approve the complete PRD draft. +4. The skill decides the product direction and records an ADR for the decision. +5. The PRD is written directly — request any changes afterward. 6. Output: `.compozy/tasks//_prd.md` + ADRs. **Key rule:** The PRD describes user capabilities and business outcomes only. No databases, APIs, frameworks, or architecture. @@ -49,7 +49,7 @@ Install flow: `compozy ext install --yes compozy/compozy --remote github --ref < 2. The skill reads the existing `_prd.md` and explores the codebase architecture. 3. Answer technical clarifying questions. 4. Technical ADRs are created for architecture decisions. -5. Review and approve the TechSpec draft. +5. The TechSpec is written directly — request any changes afterward. 6. Output: `.compozy/tasks//_techspec.md` + ADRs. **Contains:** System architecture, data models, core interfaces, API design, development sequencing. @@ -61,29 +61,35 @@ Install flow: `compozy ext install --yes compozy/compozy --remote github --ref < 1. Invoke `/cy-create-tasks` with the feature name. 2. The skill loads the PRD and TechSpec, then breaks them into granular tasks. 3. Review the proposed task breakdown. -4. Task files are generated with YAML frontmatter: `status`, `title`, `type`, `complexity`, `dependencies`. -5. Tasks are enriched with codebase patterns and implementation context. -6. Validation runs via `compozy validate-tasks`. -7. Output: `task_01.md` through `task_N.md`, `_tasks.md` master list. +4. Task files are generated with YAML frontmatter: `status`, `title`, `type`, `complexity`. +5. `_tasks.md` is generated as the canonical `compozy.tasks/v2` graph manifest with `graph.nodes` and `graph.edges`. +6. Tasks are enriched with codebase patterns and implementation context. +7. Validation runs via `compozy tasks validate`. +8. Output: `task_01.md` through `task_N.md`, `_tasks.md` task graph manifest. **Task types:** `frontend`, `backend`, `docs`, `test`, `infra`, `refactor`, `chore`, `bugfix`. Custom types can be registered in `.compozy/config.toml` under `[tasks].types`. ## Phase 5: Execution -**Command:** `compozy start --name --ide ` +**Command:** `compozy tasks run --ide ` -1. Compozy reads task files from `.compozy/tasks//` in order, respecting dependencies. -2. For each pending task, Compozy constructs a prompt including the task spec, PRD, TechSpec, ADRs, and workflow memory. -3. The configured ACP runtime executes the task using the `cy-execute-task` skill. -4. Each task: read spec -> implement -> validate with `cy-final-verify` -> update tracking -> optional commit. -5. Workflow memory is maintained across tasks via `cy-workflow-memory`. +1. Compozy reads task files from `.compozy/tasks//`. Sequential runs use task order; `--parallel-tasks` derives dependency waves from `_tasks.md` graph edges. +2. The CLI auto-starts the home-scoped daemon when needed and starts the run through daemon transport. +3. For each pending task, Compozy constructs a prompt including the task spec, PRD, TechSpec, ADRs, and workflow memory. +4. The configured ACP runtime executes the task using the `cy-execute-task` skill. +5. Each task: read spec -> implement -> validate with `cy-final-verify` -> update tracking -> optional commit. +6. Workflow memory is maintained across tasks via `cy-workflow-memory`. **Key flags:** - `--auto-commit` -- create a local commit after each task completes cleanly. - `--dry-run` -- generate prompts without running the IDE tool. - `--include-completed` -- re-process tasks already marked as completed. +- `--parallel-tasks` -- execute one workflow by dependency waves from `_tasks.md` graph edges, using isolated worktrees. +- `--parallel` -- with `--multiple`, run multiple workflow slugs concurrently. +- `--parallel-limit ` -- with `--multiple --parallel`, bound how many workflow children run at once. +- `--recursive` / `-r` -- discover `task_NNN.md` files in nested subdirectories (e.g., `features/auth/task_01.md`). Root tasks run first, then each subdirectory alphabetically, numerically within. Dot- and underscore-prefixed directories, `reviews-*`, `adrs/`, and `memory/` are skipped. -**Interactive mode:** When run without flags, an interactive TUI form collects the workflow name, IDE, and options. +**Interactive mode:** In interactive terminals, `tasks run` attaches to the TUI by default; use `--ui`, `--stream`, `--detach`, or `--attach` to override that behavior. ## Phase 6: Review @@ -97,15 +103,15 @@ Invoke inside an agent session. The skill performs a comprehensive code review o ### Path B: External Provider Review -**Command:** `compozy fetch-reviews --provider coderabbit --pr --name ` +**Command:** `compozy reviews fetch --provider coderabbit --pr ` Fetches review comments from an external provider (currently CodeRabbit) and writes them as issue markdown files under `reviews-NNN/`. -**Both paths produce:** `_meta.md` (round metadata) and `issue_*.md` files with YAML frontmatter (`status`, `severity`, `file`, `line`, `title`). +**Both paths produce:** `issue_*.md` files with YAML frontmatter containing round metadata (`provider`, `pr`, `round`, `round_created_at`) plus issue metadata (`status`, `severity`, `file`, `line`). ## Phase 7: Remediation -**Command:** `compozy fix-reviews --name --ide ` +**Command:** `compozy reviews fix --ide ` 1. Compozy reads issue files from the latest (or specified) review round. 2. For each batch of issues, the configured ACP runtime executes the `cy-fix-reviews` skill. @@ -125,7 +131,7 @@ Fetches review comments from an external provider (currently CodeRabbit) and wri Moves fully completed workflows from `.compozy/tasks//` to `.compozy/tasks/_archived/-/`. -**Eligibility:** All task files must be completed, and all review round `_meta.md` files must be fully resolved. +**Eligibility:** Run `compozy sync` first. Archive eligibility is computed from synced daemon state: all task items must be completed and all synced review issues must be resolved. ## Ad Hoc Execution diff --git a/.agents/skills/cy-create-prd/SKILL.md b/.agents/skills/cy-create-prd/SKILL.md index 4b70d9a..d4c48e7 100644 --- a/.agents/skills/cy-create-prd/SKILL.md +++ b/.agents/skills/cy-create-prd/SKILL.md @@ -9,10 +9,9 @@ argument-hint: "[feature-name-or-idea] [idea-file]" Create a business-focused Product Requirements Document through structured brainstorming. -Do NOT write the PRD file until ALL phases are complete and the user has approved the final draft. Do NOT skip the research phase — every PRD MUST be enriched with codebase and market context. -Do NOT skip user interactions — the user MUST participate in shaping the PRD at every decision point. -Do NOT require section-by-section approval — generate the complete draft, then let the user review it. +Do NOT skip the clarifying questions — the user MUST participate in shaping the PRD before it is written. +Do NOT present multiple approaches for selection or a draft for approval. Once the clarifying questions are answered and the ADRs are recorded, write the PRD file directly. The user reviews the generated file and requests changes afterward if needed. This applies to EVERY PRD regardless of perceived simplicity. @@ -24,11 +23,11 @@ If your runtime does not provide such a tool, present the question as your compl ## Anti-Pattern: "This Feature Is Too Simple For Full Brainstorming" -Every PRD goes through the full brainstorming process. A single button, a minor workflow tweak, a configuration option — all of them. "Simple" features are where unexamined business assumptions cause the most rework. The brainstorming can be brief for genuinely simple features, but you MUST ask clarifying questions and get approval on the product approach before writing the artifact. +Every PRD goes through the full brainstorming process. A single button, a minor workflow tweak, a configuration option — all of them. "Simple" features are where unexamined business assumptions cause the most rework. The brainstorming can be brief for genuinely simple features, but you MUST ask clarifying questions before writing the artifact. ## Anti-Pattern: End-Of-Flow Bureaucracy -Once the user has answered the clarifying questions and approved an approach, do not force them through a second approval loop for Overview, Goals, User Stories, or any other final document section. Synthesize the approved direction into the PRD directly. The user can review and request edits in the generated file afterward. +Once the user has answered the clarifying questions, do not force them through an approach-selection gate or a draft-approval loop before writing. Decide the product direction, record it as an ADR, synthesize the PRD, and write the file directly. The user reviews the generated file and requests edits afterward if needed. ## Anti-Pattern: Technical Drift On Technical-Sounding Features @@ -54,10 +53,8 @@ You MUST create a task for each phase and complete them in order: 1. **Determine project & directory** — derive slug, create `.compozy/tasks//` and `adrs/` 2. **Discover context** — parallel codebase exploration and web research 3. **Understand the need** — ask 3-6 targeted questions to refine scope and intent -4. **Present product approaches** — offer 2-3 approaches with trade-offs, create ADR for the chosen one -5. **Draft the PRD** — write using the canonical template from `references/prd-template.md` -6. **Review with user** — present the draft, iterate until approved -7. **Save the file** — write to `.compozy/tasks//_prd.md` +4. **Decide the approach & record ADRs** — choose the best product direction from the answers and research, then record it (with alternatives considered) as an ADR +5. **Write the PRD** — write using the canonical template from `references/prd-template.md` and save to `.compozy/tasks//_prd.md` ## Workflow @@ -96,21 +93,19 @@ You MUST create a task for each phase and complete them in order: - Every question MUST be multiple-choice when reasonable options can be predetermined. Format as labeled options (A, B, C, etc.) so the user can respond with a single letter. Only use open-ended questions when the answer space is genuinely unbounded (e.g., "What problem are you trying to solve?"). - Include a fallback option (e.g., "D) Other — describe") for flexibility. - For complex features with many dimensions, decompose into sub-topics and ask about one dimension at a time. Each sub-topic usually has predeterminable options. Example: instead of the open-ended "What should the collaboration feature include?", ask "Which aspect of team collaboration is most important to start with? A) Shared workspaces B) Real-time presence C) Permission controls D) Activity feeds". - - Complete at least one full clarification round before presenting approaches. + - Complete at least one full clarification round before deciding the approach. -4. Present product approaches. - - Offer 2-3 product approaches with trade-offs for each. - - Lead with the recommended approach and explain why it is preferred. - - Wait for the user to select an approach before continuing. - - After the user selects an approach, create an ADR for this decision: +4. Decide the product approach. + - Based on the clarifying answers and research, choose the best product direction yourself. Do NOT present a menu of approaches for the user to select. + - Record the decision as an ADR: - Read `references/adr-template.md`. - Determine the next ADR number by listing existing files in `.compozy/tasks//adrs/`. - - Fill the template: the selected approach as "Decision", rejected approaches as "Alternatives Considered" with their trade-offs, and outcomes as "Consequences". Set Status to "Accepted" and Date to today. + - Fill the template: the chosen direction as "Decision", the alternatives you weighed as "Alternatives Considered" with their trade-offs, and outcomes as "Consequences". Set Status to "Accepted" and Date to today. - Write the ADR to `.compozy/tasks//adrs/adr-NNN.md` (zero-padded 3-digit number, e.g., `adr-001.md`). -5. Draft the PRD. - - After the user selects an approach, synthesize the final product design. Do not present each section for separate approval. - - If the user makes a significant scope decision during clarification or approach selection, create an additional ADR following the same process as step 4. +5. Write the PRD. + - Synthesize the chosen direction into the final product design. Do not present each section for separate approval. + - If a significant scope decision surfaced during clarification, create an additional ADR following the same process as step 4. - Only pause before writing if a blocking ambiguity remains that would force guessing; otherwise proceed directly to document generation. - Read `references/prd-template.md` and fill every section with gathered context. - Include an "Architecture Decision Records" section listing all ADRs created during this session with their numbers, titles, and one-line summaries as links to the `adrs/` directory. @@ -121,21 +116,11 @@ You MUST create a task for each phase and complete them in order: - Optional sections (include when relevant): High-Level Technical Constraints. - Prefer active voice, omit needless words, use definite and specific language over vague generalities. Every sentence should earn its place. - Language: **English**. Tone: clear, technical, consistent with existing project artifacts. - - Present the complete draft to the user for review. - -6. Review with the user. - - Present the draft and ask using the interactive question tool: - - "Here is the PRD draft. Please review and let me know:" - - A) Approved — save as is - - B) Adjust specific sections (tell me which ones) - - C) Rewrite section X (tell me what to change) - - D) Discard and start over - - If B or C: make the changes and present again. - - If D: go back to step 3. - -7. Save the PRD file. + +6. Save the PRD file. - Write the completed document to `.compozy/tasks//_prd.md`. - Confirm the file path to the user. + - Tell the user the PRD is ready to review and that they can ask for any changes directly. - Remind the user that the next step is to create a TechSpec using `cy-create-techspec` from this PRD. ## Process Flow @@ -145,23 +130,15 @@ digraph create_prd { "Determine project & directory" [shape=box]; "Discover context (codebase + web)" [shape=box]; "Ask clarifying questions (one at a time)" [shape=box]; - "Present 2-3 product approaches" [shape=box]; - "User selects approach?" [shape=diamond]; - "Create ADR for approach decision" [shape=box]; - "Draft PRD (canonical template)" [shape=box]; - "User approves draft?" [shape=diamond]; + "Decide approach & record ADR" [shape=box]; + "Write PRD (canonical template)" [shape=box]; "Save _prd.md" [shape=doublecircle]; "Determine project & directory" -> "Discover context (codebase + web)"; "Discover context (codebase + web)" -> "Ask clarifying questions (one at a time)"; - "Ask clarifying questions (one at a time)" -> "Present 2-3 product approaches"; - "Present 2-3 product approaches" -> "User selects approach?"; - "User selects approach?" -> "Present 2-3 product approaches" [label="no, revise"]; - "User selects approach?" -> "Create ADR for approach decision" [label="yes"]; - "Create ADR for approach decision" -> "Draft PRD (canonical template)"; - "Draft PRD (canonical template)" -> "User approves draft?"; - "User approves draft?" -> "Draft PRD (canonical template)" [label="no, revise"]; - "User approves draft?" -> "Save _prd.md" [label="approved"]; + "Ask clarifying questions (one at a time)" -> "Decide approach & record ADR"; + "Decide approach & record ADR" -> "Write PRD (canonical template)"; + "Write PRD (canonical template)" -> "Save _prd.md"; } ``` @@ -177,7 +154,7 @@ digraph create_prd { - **One question at a time** — Do not overwhelm with multiple questions in a single message - **Multiple choice mandatory** — Every question MUST be multiple-choice (A/B/C) when options can be predetermined; open-ended only when the answer space is genuinely unbounded - **YAGNI ruthlessly** — Challenge every feature; remove anything the MVP does not need -- **Draft then review** — Get approval on the product approach, generate the complete draft, then iterate with the user until approved +- **Decide then write** — Make the product decisions through the clarifying questions, record them as ADRs, write the file directly, and iterate only if the user requests changes afterward - **Business focus only** — Never ask about implementation; that belongs in TechSpec - **Idea as input** — When `_idea.md` exists, use it as primary context to accelerate brainstorming - **Pipeline awareness** — The PRD feeds into `cy-create-techspec`; focus on WHAT and WHY, not HOW diff --git a/.agents/skills/cy-create-prd/references/question-protocol.md b/.agents/skills/cy-create-prd/references/question-protocol.md index 647aa00..16bfd0e 100644 --- a/.agents/skills/cy-create-prd/references/question-protocol.md +++ b/.agents/skills/cy-create-prd/references/question-protocol.md @@ -20,30 +20,21 @@ Deepen knowledge of requirements and constraints. - What are the success criteria? - What are the known constraints (timeline, budget, compliance)? -### 3. Options +### 3. Direction -Present product approaches for the user to evaluate. -- Offer 2-3 distinct approaches with clear trade-offs. -- Lead with the recommended approach and explain why. -- Each approach should differ meaningfully in scope, phasing, or strategy. -- Wait for the user to select before proceeding. +Decide the product approach from the gathered context. +- Weigh 2-3 distinct directions internally, considering trade-offs in scope, phasing, or strategy. +- Choose the strongest direction yourself; do NOT present a menu for the user to select. +- Record the chosen direction — with the alternatives you weighed — as an ADR. ### 4. Refinement -Refine the selected approach with targeted follow-ups. -- Clarify scope boundaries for the chosen approach. +Refine the chosen direction with targeted follow-ups only when something is genuinely ambiguous. +- Clarify scope boundaries for the chosen direction. - Confirm phasing and priority of features. - Validate success criteria and metrics. - Resolve any remaining open questions. -### 4b. Incremental Design Validation - -Present the product design section by section for user approval. -- Scale each section to its complexity: brief for straightforward topics, detailed for nuanced ones. -- Present one section at a time; ask whether it looks right before moving on. -- Apply YAGNI: challenge every feature against MVP necessity. -- Be ready to revise any section before proceeding to the next. - ### 5. Creation Generate the PRD document using the gathered context. @@ -64,9 +55,9 @@ Generate the PRD document using the gathered context. - Wait for the user's answer before asking the next question. ### Progression Gates -- Must complete at least one full Understanding round before presenting Options. -- Must have clarity on purpose, constraints, and success criteria before presenting approaches. -- Must have user approval of an approach before entering Refinement. +- Must complete at least one full Understanding round before deciding the direction. +- Must have clarity on purpose, constraints, and success criteria before deciding the direction. +- Must record the chosen direction as an ADR before generating the PRD. Do not present a draft for section-by-section approval; write the file directly and let the user request changes afterward. ### Focus Boundaries - Questions must focus on WHAT, WHY, and WHO. diff --git a/.agents/skills/cy-create-tasks/SKILL.md b/.agents/skills/cy-create-tasks/SKILL.md index a92ebc0..b228bac 100644 --- a/.agents/skills/cy-create-tasks/SKILL.md +++ b/.agents/skills/cy-create-tasks/SKILL.md @@ -34,9 +34,9 @@ Decompose requirements into detailed, actionable task files with codebase-inform 3. Break down into tasks. - Decompose implementation sections from the TechSpec into granular, independently implementable tasks. - - **Each task MUST be independently implementable when all of its declared dependencies are met.** No task may require undeclared work from another task. If two tasks share a tight coupling, either merge them or extract the shared piece into a dependency task. + - **Each task MUST be independently implementable when all dependencies declared in `_tasks.md` graph edges are met.** No task may require undeclared work from another task. If two tasks share a tight coupling, either merge them or extract the shared piece into a dependency task. - **No circular dependencies.** If task A depends on task B, task B must NOT depend on task A (directly or transitively). - - Each task must have: title, type, complexity, and dependencies. + - Each task must have: title, type, complexity, and dependency relationships in the graph plan. - Assign complexity using these criteria: - `low`: Single file change, no new interfaces, no concurrency, straightforward logic. - `medium`: 2-4 files, may introduce a new interface or struct, limited integration points. @@ -54,19 +54,29 @@ Decompose requirements into detailed, actionable task files with codebase-inform - Iterate until the user explicitly approves. 5. Generate task files. - - Write `_tasks.md` as the master task list using this exact markdown table format: + - Write `_tasks.md` as the canonical task graph manifest. It MUST start with this YAML frontmatter shape: ```markdown - # [Feature Name] — Task List - - ## Tasks - - | # | Title | Status | Complexity | Dependencies | - |---|-------|--------|------------|--------------| - | 01 | [Task title] | pending | [low/medium/high/critical] | [task_NN, ... or —] | + --- + schema_version: "compozy.tasks/v2" + workflow: [feature-name] + graph: + nodes: + - id: task_01 + file: task_01.md + edges: + - from: task_01 + to: task_02 + --- + + # [Feature Name] Task List ``` + - `_tasks.md` is the only place dependency relationships are stored. Each edge means `from` must finish before `to` can start. + - Include every task in `graph.nodes`, using canonical sequential ids (`task_01`, `task_02`, ...) and matching files (`task_01.md`, `task_02.md`, ...). + - Use `edges: []` when there are no dependencies. - Write individual task files as `task_01.md`, `task_02.md`, through `task_N.md`. - Task files use the `task_` prefix without a leading underscore. - - Each file must start with YAML frontmatter containing `status`, `title`, `type`, `complexity`, and `dependencies`. Use `dependencies: []` when there are no dependencies — do not omit the field. + - Each file must start with YAML frontmatter containing only task-owned metadata: `status`, `title`, `type`, and `complexity`. + - Do NOT write `dependencies` in individual task frontmatter or duplicate dependency lists in the task body. - Task numbering must be sequential and consistent between `_tasks.md` and individual files. 6. Enrich each task file. @@ -89,8 +99,8 @@ Decompose requirements into detailed, actionable task files with codebase-inform - Update the task file in place with enriched content. - If enrichment fails for one task, continue to the next and report all failures at the end. -7. Run validate-tasks. - - Run `compozy validate-tasks --name `. +7. Run task validation. + - Run `compozy tasks validate --name `. - If it exits non-zero, fix the reported issues and re-run. - Do not mark the skill complete until it exits 0. @@ -100,6 +110,7 @@ Do NOT produce tasks with these defects: - **Mega-tasks.** If a task touches more than 7 files or has more than 7 subtasks, it is too broad. Split it into smaller tasks with explicit dependencies between them. - **TechSpec duplication.** Do NOT copy interface definitions, code snippets, or architectural diagrams from the TechSpec into task files. Reference the TechSpec section by name (e.g., "See TechSpec 'Core Interfaces' section") instead of reproducing its content. +- **Graph duplication.** Do NOT duplicate dependency relationships in individual task frontmatter or task body prose. `_tasks.md` frontmatter is the canonical graph. - **Vague test cases.** Do NOT write test descriptions like "test the happy path" or "verify error handling." Each test case must name the specific input, condition, or behavior being verified (e.g., "POST /job/done with unknown job ID returns 404"). ## Error Handling diff --git a/.agents/skills/cy-create-tasks/references/task-context-schema.md b/.agents/skills/cy-create-tasks/references/task-context-schema.md index 5bbdea7..6e134d1 100644 --- a/.agents/skills/cy-create-tasks/references/task-context-schema.md +++ b/.agents/skills/cy-create-tasks/references/task-context-schema.md @@ -1,24 +1,57 @@ -# Task Frontmatter Schema +# Task Metadata Schemas -Task metadata is parsed from YAML frontmatter by Compozy's `ParseTaskFile()` function in `internal/core/prompt/common.go`. +Task metadata is parsed from YAML frontmatter by Compozy's `ParseTaskFile()` function in `internal/core/tasks/parser.go`. +Parallel task execution also reads the canonical `_tasks.md` graph manifest. -## Required Fields +## `_tasks.md` Graph Manifest + +The `_tasks.md` file owns dependency relationships for the whole task suite. It must start with frontmatter shaped like: + +```yaml +schema_version: "compozy.tasks/v2" +workflow: feature-name +graph: + nodes: + - id: task_01 + file: task_01.md + edges: + - from: task_01 + to: task_02 +``` + +Graph rules: + +- `schema_version` MUST be `compozy.tasks/v2`. +- `workflow` MUST match the feature/task directory name. +- `graph.nodes` MUST include every generated task exactly once. +- Node `id` values MUST be canonical `task_NN` identities. +- Node `file` values MUST match the node id, e.g. `task_01.md`. +- `graph.edges` stores dependency relationships only. Each edge means `from` must finish before `to` can start. +- Use `edges: []` when there are no dependencies. +- The graph MUST be acyclic. + +## Individual Task Frontmatter + +Individual task files own task metadata only. They do not own graph topology. + +### Required Fields - `status`: Task lifecycle state. - `title`: Human-readable task title. It must match the first H1 in the task body. - `type`: Allowed work type slug. Use `[tasks].types` from `.compozy/config.toml` when configured; otherwise use the built-in defaults `frontend`, `backend`, `docs`, `test`, `infra`, `refactor`, `chore`, `bugfix`. - `complexity`: Difficulty rating. Must be one of: `low`, `medium`, `high`, `critical`. -- `dependencies`: YAML list of task file names that must be completed before this task. Use `[]` when there are no dependencies. + +Do not include `dependencies` in individual task frontmatter for `compozy.tasks/v2` suites. Dependencies belong only in `_tasks.md` under `graph.edges`. ## Status Values Valid `status` values: -- `pending` — task has not been started. -- `in_progress` — task is currently being worked on. -- `completed` — task is finished and verified. -- `done` — treated as completed. -- `finished` — treated as completed. +- `pending` - task has not been started. +- `in_progress` - task is currently being worked on. +- `completed` - task is finished and verified. +- `done` - treated as completed. +- `finished` - treated as completed. ## File Naming @@ -26,9 +59,9 @@ Task files must match the pattern `task_\d+\.md` with zero-padded numbers: - `task_01.md`, `task_02.md`, `task_10.md`, `task_99.md` The leading underscore prefix is reserved for meta documents: -- `_prd.md` — Product Requirements Document -- `_techspec.md` — Technical Specification -- `_tasks.md` — Master task list +- `_prd.md` - Product Requirements Document +- `_techspec.md` - Technical Specification +- `_tasks.md` - Task graph manifest ## Parser Compatibility diff --git a/.agents/skills/cy-create-tasks/references/task-template.md b/.agents/skills/cy-create-tasks/references/task-template.md index a6c1b94..535e0af 100644 --- a/.agents/skills/cy-create-tasks/references/task-template.md +++ b/.agents/skills/cy-create-tasks/references/task-template.md @@ -8,9 +8,6 @@ status: pending title: [Task title] type: [one of frontend, backend, docs, test, infra, refactor, chore, bugfix, or a project-specific [tasks].types override] complexity: [low, medium, high, critical] -dependencies: - - task_01 - - task_02 --- # Task N: [Title] @@ -38,7 +35,7 @@ dependencies: - [ ] N.3 [Subtask description] ## Implementation Details -[File paths to create or modify, integration points, and dependencies. +[File paths to create or modify and integration points. Reference the TechSpec implementation section for code patterns and interface designs.] ### Relevant Files @@ -75,7 +72,7 @@ Reference the TechSpec implementation section for code patterns and interface de ## Guidelines -- Every task must be independently implementable when its dependencies are met. +- Every task must be independently implementable when its dependencies from `_tasks.md` graph edges are met. - Every task MUST include a Tests section and test items in Deliverables. - Never create separate tasks dedicated solely to testing. - Subtasks describe WHAT needs to happen, not HOW to implement it. diff --git a/.agents/skills/cy-create-techspec/SKILL.md b/.agents/skills/cy-create-techspec/SKILL.md index d4724c6..9e1f107 100644 --- a/.agents/skills/cy-create-techspec/SKILL.md +++ b/.agents/skills/cy-create-techspec/SKILL.md @@ -9,10 +9,9 @@ argument-hint: "[feature-name] [prd-file]" Translate business requirements into a detailed technical specification. -Do NOT write the TechSpec file until ALL phases are complete and the user has approved the final draft. Do NOT skip the codebase exploration — every TechSpec MUST be informed by existing architecture. -Do NOT skip user interactions — the user MUST participate in shaping the TechSpec at every decision point. -Do NOT require section-by-section approval — generate the complete draft, then let the user review it. +Do NOT skip the technical clarification questions — the user MUST participate in shaping the TechSpec before it is written. +Do NOT present a draft for approval. Once the clarification questions are answered and the ADRs are recorded, write the TechSpec file directly. The user reviews the generated file and requests changes afterward if needed. This applies to EVERY TechSpec regardless of perceived simplicity. @@ -24,11 +23,11 @@ If your runtime does not provide such a tool, present the question as your compl ## Anti-Pattern: "This Is Too Simple To Need Technical Design Review" -Every TechSpec goes through the full design review process. A single endpoint, a minor refactor, a configuration change — all of them. "Simple" technical changes are where unexamined assumptions about existing architecture cause the most integration failures. The design review can be brief for genuinely simple changes, but you MUST ask technical clarification questions and get approval on the technical approach before writing the artifact. +Every TechSpec goes through the full design review process. A single endpoint, a minor refactor, a configuration change — all of them. "Simple" technical changes are where unexamined assumptions about existing architecture cause the most integration failures. The design review can be brief for genuinely simple changes, but you MUST ask technical clarification questions before writing the artifact. ## Anti-Pattern: End-Of-Flow Bureaucracy -Once the user has answered the technical clarification questions and approved an approach, do not force them through a second approval loop for System Architecture, Data Models, API Design, or other final document sections. Synthesize the approved direction into the TechSpec directly. The user can review and request edits in the generated file afterward. +Once the user has answered the technical clarification questions, do not force them through a draft-approval loop before writing. Record the decisions as ADRs, synthesize the TechSpec, and write the file directly. The user reviews the generated file and requests edits afterward if needed. ## Required Inputs @@ -43,9 +42,7 @@ You MUST create a task for each phase and complete them in order: 1. **Gather context** — read PRD, ADRs, and explore codebase architecture 2. **Ask technical questions** — 3-6 targeted questions on architecture, data models, APIs, testing 3. **Create ADRs** — record significant technical decisions (architecture pattern, technology choices, data model approach) -4. **Draft the TechSpec** — write using the canonical template from `references/techspec-template.md` -5. **Review with user** — present the draft, iterate until approved -6. **Save the file** — write to `.compozy/tasks//_techspec.md` +4. **Write the TechSpec** — write using the canonical template from `references/techspec-template.md` and save to `.compozy/tasks//_techspec.md` ## Workflow @@ -74,7 +71,7 @@ You MUST create a task for each phase and complete them in order: - Fill the template: the chosen design as "Decision", rejected alternatives as "Alternatives Considered", and trade-offs as "Consequences". Set Status to "Accepted" and Date to today. - Write each ADR to `.compozy/tasks//adrs/adr-NNN.md` (zero-padded 3-digit sequential number). -4. Draft the TechSpec. +4. Write the TechSpec. - Read `references/techspec-template.md` and fill every applicable section. - **MANDATORY — Architecture Decision Records section:** The generated TechSpec MUST end with an "Architecture Decision Records" section listing every ADR created during this process. Each entry must include the ADR number (e.g., ADR-001), title, and a one-line summary formatted as a link to the `adrs/` directory. Even simple features require at least one ADR documenting the primary technical approach chosen and alternatives rejected. If no ADRs were created in step 3, go back and create at least one before generating the document. - Apply YAGNI ruthlessly: remove any component, interface, or abstraction that is not strictly necessary. Do NOT propose new packages or directories when the feature can be implemented by adding a single file to an existing package. @@ -84,21 +81,11 @@ You MUST create a task for each phase and complete them in order: - The Development Sequencing section MUST include a numbered Build Order where every step after the first explicitly states which previous steps it depends on. - Prefer active voice, omit needless words, use definite and specific language over vague generalities. Every sentence should earn its place. - Language: **English**. Tone: clear, technical, consistent with existing project artifacts. - - Present the complete draft to the user for review. - -5. Review with the user. - - Present the draft and ask using the interactive question tool: - - "Here is the TechSpec draft. Please review and let me know:" - - A) Approved — save as is - - B) Adjust specific sections (tell me which ones) - - C) Rewrite section X (tell me what to change) - - D) Discard and start over - - If B or C: make the changes and present again. - - If D: go back to step 2. - -6. Save the TechSpec file. + +5. Save the TechSpec file. - Write the completed document to `.compozy/tasks//_techspec.md`. - Confirm the file path to the user. + - Tell the user the TechSpec is ready to review and that they can ask for any changes directly. - Remind the user that the next step is to create tasks using `cy-create-tasks` from this TechSpec. ## Process Flow @@ -108,16 +95,13 @@ digraph create_techspec { "Gather context (PRD + codebase)" [shape=box]; "Ask technical questions (one at a time)" [shape=box]; "Create ADRs for key decisions" [shape=box]; - "Draft TechSpec (canonical template)" [shape=box]; - "User approves draft?" [shape=diamond]; + "Write TechSpec (canonical template)" [shape=box]; "Save _techspec.md" [shape=doublecircle]; "Gather context (PRD + codebase)" -> "Ask technical questions (one at a time)"; "Ask technical questions (one at a time)" -> "Create ADRs for key decisions"; - "Create ADRs for key decisions" -> "Draft TechSpec (canonical template)"; - "Draft TechSpec (canonical template)" -> "User approves draft?"; - "User approves draft?" -> "Draft TechSpec (canonical template)" [label="no, revise"]; - "User approves draft?" -> "Save _techspec.md" [label="approved"]; + "Create ADRs for key decisions" -> "Write TechSpec (canonical template)"; + "Write TechSpec (canonical template)" -> "Save _techspec.md"; } ``` @@ -125,7 +109,7 @@ digraph create_techspec { - If the PRD is missing, proceed with user-provided context and note the absence in the Executive Summary. - If codebase exploration reveals conflicting architectural patterns, document both and recommend one with rationale. -- If the user rejects the design proposal, incorporate all feedback and present a revised proposal. +- If the user requests changes after the TechSpec is written, incorporate the feedback and update the file. - If the target directory does not exist, create it. - If operating in update mode, preserve sections the user has not asked to change. @@ -134,7 +118,7 @@ digraph create_techspec { - **One question at a time** — Do not overwhelm with multiple questions in a single message - **Multiple choice preferred** — Easier for users to answer than open-ended when possible - **YAGNI ruthlessly** — Remove unnecessary components, abstractions, and interfaces from all designs -- **Draft then review** — Generate the complete TechSpec draft first, then iterate with the user until approved +- **Decide then write** — Make the technical decisions through the clarification questions, record them as ADRs, write the file directly, and iterate only if the user requests changes afterward - **Technical focus only** — Never ask business questions; that belongs in the PRD - **Trade-offs are mandatory** — Every Executive Summary must state the primary technical trade-off of the chosen approach - **PRD as input** — When `_prd.md` exists, use it as primary context; every PRD goal should map to a technical component diff --git a/.agents/skills/cy-execute-task/SKILL.md b/.agents/skills/cy-execute-task/SKILL.md index 63adf52..aa6a580 100644 --- a/.agents/skills/cy-execute-task/SKILL.md +++ b/.agents/skills/cy-execute-task/SKILL.md @@ -12,7 +12,7 @@ Execute one PRD task from exploration through tracking updates. - Task specification markdown. - PRD directory path. - Task file path. -- Master tasks file path. +- `_tasks.md` task graph manifest path. - Auto-commit mode. - Optional workflow memory directory path. - Optional shared workflow memory path. @@ -47,11 +47,12 @@ Execute one PRD task from exploration through tracking updates. 5. Update task tracking. - If workflow memory paths were provided, update the memory files first — record decisions, learnings, and touched surfaces before updating tracking status. - - Use the caller-provided task file path and master tasks file path. + - Use the caller-provided task file path and task graph manifest path. - Mark subtasks complete only when the implementation and evidence are actually complete. - Change task status to completed only after clean verification and self-review. - Read `references/tracking-checklist.md` when applying status, checklist, or commit updates. - - Sequence: memory update (if applicable) -> task file checkboxes -> task status -> master tasks file -> commit (if applicable). + - Do not edit `_tasks.md` during normal completion tracking; it owns graph topology, not per-task status. + - Sequence: memory update (if applicable) -> task file checkboxes -> task status -> commit (if applicable). 6. Handle commit behavior. - If auto-commit is enabled, create one local commit after clean verification, self-review, and tracking updates. diff --git a/.agents/skills/cy-execute-task/references/tracking-checklist.md b/.agents/skills/cy-execute-task/references/tracking-checklist.md index 67cb960..950d6a9 100644 --- a/.agents/skills/cy-execute-task/references/tracking-checklist.md +++ b/.agents/skills/cy-execute-task/references/tracking-checklist.md @@ -4,6 +4,6 @@ Apply this checklist when updating PRD task tracking files. 1. Update the current task file checkboxes that correspond to completed subtasks. 2. Change the task status to `completed` only after implementation, validation, and self-review are complete. -3. Update the master `_tasks.md` entry that points to the current task file. +3. Do not update `_tasks.md` for normal completion tracking. `_tasks.md` owns task graph topology only; change it only when the caller explicitly asks to modify the DAG. 4. Re-check the task specification and supporting PRD docs before marking anything complete. 5. Follow the caller's commit mode and repository staging rules when deciding whether tracking files belong in a commit. diff --git a/.agents/skills/cy-fix-reviews/SKILL.md b/.agents/skills/cy-fix-reviews/SKILL.md index 1b627e6..ae00fdc 100644 --- a/.agents/skills/cy-fix-reviews/SKILL.md +++ b/.agents/skills/cy-fix-reviews/SKILL.md @@ -10,13 +10,13 @@ Execute the review remediation workflow in a strict sequence. The review files a ## Required Inputs - The scoped issue files listed in ``. -- The PRD review round directory and `_meta.md`. +- The PRD review round directory and issue-file frontmatter. - The repository verification workflow required by `cy-final-verify`. ## Workflow 1. Gather round context. - - Read `_meta.md` from the review round directory to understand the provider, round number, and issue counts. + - Read the scoped issue file frontmatter to understand the provider, round number, and issue status/severity. If multiple issue files are in scope, verify their `provider`, `pr`, `round`, and `round_created_at` values agree. - Read `` to identify the PRD name, review round, code files in scope, and conditional flags such as auto-commit. 2. Read and triage the scoped issue files. @@ -44,7 +44,7 @@ Execute the review remediation workflow in a strict sequence. The review files a ## Critical Rules -- Do not fetch or export reviews inside this workflow. `fetch-reviews` already produced the round files. +- Do not fetch or export reviews inside this workflow. `compozy reviews fetch` already produced the round files. - Do not call provider-specific scripts or `gh` mutations. Compozy resolves provider threads after the batch succeeds. - Do not modify issue files outside the scoped batch. - Do not mark an issue `resolved` before the underlying work and verification are actually complete. diff --git a/.agents/skills/cy-idea-factory/SKILL.md b/.agents/skills/cy-idea-factory/SKILL.md deleted file mode 100644 index c838de1..0000000 --- a/.agents/skills/cy-idea-factory/SKILL.md +++ /dev/null @@ -1,215 +0,0 @@ ---- -name: cy-idea-factory -description: Expands a raw idea into a structured, research-backed spec in .compozy/tasks//_idea.md through interactive brainstorming, web research, business analysis, and multi-advisor debate. Use when the user has a feature idea and wants to explore and structure it before creating a PRD. Do not use for PRD creation, technical specifications, task breakdown, or code implementation. -argument-hint: [feature-idea] ---- - -# Idea Factory - -Expand a raw feature idea into a structured, research-backed spec that serves as the foundation for PRD creation. - - -Do NOT write the idea file until ALL phases are complete and the user has approved the final draft. -Do NOT skip the research phase — every idea MUST be enriched with market data. -Do NOT skip user interactions — the user MUST participate in shaping the idea at every decision point. -This applies to EVERY idea regardless of perceived simplicity. - - -## Asking Questions - -When this skill instructs you to ask the user a question, you MUST use your runtime's dedicated interactive question tool — the tool or function that presents a question to the user and **pauses execution until the user responds**. Do not output questions as plain assistant text and continue generating; always use the mechanism that blocks until the user has answered. - -If your runtime does not provide such a tool, present the question as your complete message and stop generating. Do not answer your own question or proceed without user input. - -## Anti-Pattern: "This Idea Is Too Simple For Full Research" - -Every idea goes through the full research and debate process. A single button, a minor workflow tweak, a configuration option — all of them. "Simple" ideas are where unexamined business assumptions cause the most rework downstream in the PRD. The process can be brief for genuinely simple ideas, but you MUST research and debate before writing. - -## Required Inputs - -- Feature idea or problem description. -- Optional: existing `_idea.md` file for update mode. - -## Checklist - -You MUST create a task for each phase and complete them in order: - -1. **Determine project & directory** — derive slug, create `.compozy/tasks//` and `adrs/` -2. **Understand the idea** — ask 3-6 targeted questions to refine scope and intent -3. **Research the market** — web research for competitive intelligence and market data + codebase exploration -4. **Analyze business viability** — adopt business analyst persona (`references/business-analyst.md`) for KPIs, personas, and success metrics -5. **Debate trade-offs** — run council session (`references/council.md`) to challenge assumptions and surface risks -6. **Scan for opportunities** — adopt product strategist persona (`references/product-strategist.md`) to suggest higher-leverage alternatives before committing to the draft -7. **Draft the idea** — write using the canonical template from `references/idea-template.md` -8. **Review with user** — present the draft, iterate until approved -9. **Save the file** — write to `.compozy/tasks//_idea.md` - -## Workflow - -1. Determine the project name and working directory. - - Derive the slug from the feature idea provided by the user. - - Use `.compozy/tasks//` as the target directory. - - If `_idea.md` already exists in the target directory, read it and operate in update mode. - - If the directory does not exist, create it. - - Create `.compozy/tasks//adrs/` directory if it does not exist. - -2. Understand the idea through targeted questions. - - Follow the question protocol in `references/question-protocol.md`. - - Ask 3-6 questions to refine scope, intent, target user, and success criteria. - - Ask only one question per message. - - Prefer multiple-choice questions when the options can be predetermined. - - Include a fallback option (e.g., "D) Other — describe") for flexibility. - - Complete at least one full clarification round before proceeding to research. - -3. Discover context through parallel research. - - Spawn one Agent tool call to explore the codebase for relevant patterns, existing features, and architecture. - - Spawn a second Agent tool call to perform 3-7 web searches for market data and competitive intelligence. - - Use any available web search tools. If none are available, note the limitation and proceed with codebase exploration only. - - Vary query angles across at least 3 searches: - 1. **Competitive landscape:** `"{feature category} tools for {domain} 2025 2026"` - 2. **Market data:** `"{problem} market size OR adoption rate OR statistics"` - 3. **Technical approach:** `"{technical solution} architecture OR implementation best practices"` - 4. **User expectations:** `"{feature} UX patterns OR user experience best practices"` (if relevant) - 5. **Pricing/cost:** `"{service/API} pricing OR cost comparison 2025 2026"` (if relevant) - - After both agents complete, merge findings and present a research summary to the user: - ``` - **Codebase findings:** - - {Relevant existing feature/pattern} - - {Integration point} - - **Market research:** - - {Competitor 1}: {what it does} - - {Competitor 2}: {what it does} - - **Potential differentiator:** {what we can do differently} - - **Relevant data:** {statistics found} - ``` - -4. Analyze business viability. - - Read `references/business-analyst.md` and adopt the business analyst persona to evaluate the idea with the refined context from steps 2-3. - - Deliver: KPI framework, success metrics, personas, and viability assessment. - - Define 3-6 KPIs with measurable targets. - - Identify success criteria and risk factors. - - Assess viability based on research findings. - - Score the feature on these 6 criteria: - - | Criteria | Question | Score | - | ------------------- | --------------------------------------------------- | ------------------------- | - | **Impact** | How much more valuable does this make the product? | Must do/Strong/Maybe/Pass | - | **Reach** | What % of users would this affect? | Must do/Strong/Maybe/Pass | - | **Frequency** | How often would users encounter this value? | Must do/Strong/Maybe/Pass | - | **Differentiation** | Does this set us apart or just match competitors? | Must do/Strong/Maybe/Pass | - | **Defensibility** | Is this easy to copy or does it compound over time? | Must do/Strong/Maybe/Pass | - | **Feasibility** | Can we actually build this? | Must do/Strong/Maybe/Pass | - - - This evaluation informs the idea's priority and feeds into the council debate. - - Present the analysis to the user before proceeding. - -5. Debate trade-offs through multi-advisor council. - - Read `references/council.md` and run a council session in embedded mode to debate: - - **Scope:** Is the V1 scope right? Too much? Too little? - - **Priority:** Where should this rank vs other planned features? - - **Technical approach:** Are there simpler alternatives? - - **Risks:** What could go wrong? What are the hidden dependencies? - - **10x Challenge:** Is this truly high-leverage or just incremental? Is there a more ambitious version worth exploring? Could a simpler version deliver disproportionate value? - - Follow the council session structure from the reference: Opening Statements, Tensions & Debate, Position Evolution, Synthesis. - - Select 3-5 advisors based on dilemma complexity. - - Extract: key trade-offs, recommended approach, items for out-of-scope (V1), optional stretch goal for V2+. - - After the debate, create an ADR for the scope decision: - - Read `references/adr-template.md`. - - Determine the next ADR number by listing existing files in `.compozy/tasks//adrs/`. - - Fill the template: recommended scope as "Decision", alternatives as "Alternatives Considered", trade-offs as "Consequences". Set Status to "Accepted" and Date to today. - - Write the ADR to `.compozy/tasks//adrs/adr-NNN.md` (zero-padded 3-digit number). - -6. Scan for opportunities. - - Read `references/product-strategist.md` and adopt the product strategist persona. - - Using all context gathered so far (research, business analysis, council output), evaluate whether the original idea is the highest-leverage move. - - Suggest up to 3 alternatives spanning different scales: - - One more ambitious version (what if we thought bigger?) - - One simpler version (what if we stripped it to the essence?) - - One adjacent opportunity (what related problem could we solve instead?) - - Score each alternative using the evaluation framework from the reference. - - Present the opportunity scan to the user with a clear recommendation: - - "Here is the opportunity scan. I recommend proceeding with [Original / Alternative N / Hybrid]. Which direction do you prefer?" - - A) Proceed with the original idea - - B) Adopt alternative N (specify which) - - C) Hybrid approach (combine elements) - - D) Other — describe - - Incorporate the chosen direction into the draft. If the user picks an alternative, update the feature scope accordingly before proceeding. - -7. Draft the idea. - - Read `references/idea-template.md` and fill every applicable section with gathered context. - - Include an "Architecture Decision Records" section listing all ADRs created during this session. - - Mandatory sections (ALWAYS include): Overview, Problem (enriched with market data), Core Features, KPIs, Feature Assessment, Council Insights, Out of Scope (V1), Architecture Decision Records, Open Questions. - - Optional sections (include when relevant): Summary/Differentiator, Integration with Existing Features, Sub-Features, Cost Estimate. - - Prefer active voice, omit needless words, use definite and specific language over vague generalities. Every sentence should earn its place. - - Language: **English**. Tone: clear, technical, consistent with existing project artifacts. - - Tables: use markdown tables for structured data. Features: minimum 3, maximum 10, ordered by priority. KPIs: minimum 3, maximum 6, with numeric targets. Exclusions: minimum 3 items with justification. - - Present the complete draft to the user for review. - -8. Review with the user. - - Present the draft and ask using the interactive question tool: - - "Here is the idea draft. Please review and let me know:" - - A) Approved — save as is - - B) Adjust specific sections (tell me which ones) - - C) Rewrite section X (tell me what to change) - - D) Discard and start over - - If B or C: make the changes and present again. - - If D: go back to step 2. - -9. Save the idea file. - - Generate the slug: kebab-case, 2-5 words, descriptive (e.g., `smart-thumbnail-suggestions`). - - Ask the user to confirm the filename using the interactive question tool: - - "Save as `.compozy/tasks//_idea.md`? (A) Yes / (B) Different name" - - Write the file to `.compozy/tasks//_idea.md`. - - Confirm the file path to the user. - - Remind the user that the next step is to create a PRD using `cy-create-prd` from this idea. - -## Process Flow - -```dot -digraph idea_factory { - "Determine project & directory" [shape=box]; - "Ask 3-6 targeted questions (one at a time)" [shape=box]; - "Discover context (codebase + web)" [shape=box]; - "Analyze business viability" [shape=box]; - "Debate trade-offs (council)" [shape=box]; - "Create ADR for scope decision" [shape=box]; - "Opportunity scan (product strategist)" [shape=box]; - "User picks direction?" [shape=diamond]; - "Draft idea (canonical template)" [shape=box]; - "User approves draft?" [shape=diamond]; - "Save _idea.md" [shape=doublecircle]; - - "Determine project & directory" -> "Ask 3-6 targeted questions (one at a time)"; - "Ask 3-6 targeted questions (one at a time)" -> "Discover context (codebase + web)"; - "Discover context (codebase + web)" -> "Analyze business viability"; - "Analyze business viability" -> "Debate trade-offs (council)"; - "Debate trade-offs (council)" -> "Create ADR for scope decision"; - "Create ADR for scope decision" -> "Opportunity scan (product strategist)"; - "Opportunity scan (product strategist)" -> "User picks direction?"; - "User picks direction?" -> "Draft idea (canonical template)" [label="confirmed"]; - "Draft idea (canonical template)" -> "User approves draft?"; - "User approves draft?" -> "Draft idea (canonical template)" [label="no, revise"]; - "User approves draft?" -> "Save _idea.md" [label="approved"]; -} -``` - -## Error Handling - -- If the user provides insufficient context to complete a section, note it in the Open Questions section rather than guessing. -- If web research tools (Exa MCP, web search) are unavailable, proceed with codebase exploration only and note the limitation. -- If the reference files for business analyst or council are missing, perform the analysis and debate inline using the guidelines described in phases 4 and 5. -- If the target directory cannot be created, stop and report the filesystem error. -- If operating in update mode, preserve sections the user has not asked to change. - -## Key Principles - -- **One question at a time** — Do not overwhelm with multiple questions in a single message -- **Multiple choice preferred** — Always offer options before open-ended questions -- **Research before writing** — Never write an idea without market data -- **Incremental validation** — Present analysis and draft for approval before saving -- **Business focus only** — Never ask about implementation; that belongs in TechSpec -- **Scope discipline** — Aggressively trim scope to a viable V1 -- **Pipeline awareness** — The idea feeds into `cy-create-prd`; focus on WHAT and WHY, not HOW -- **Template compliance** — Every idea MUST follow the canonical template -- **Language consistency** — Write all idea content in English diff --git a/.agents/skills/cy-idea-factory/references/adr-template.md b/.agents/skills/cy-idea-factory/references/adr-template.md deleted file mode 100644 index 381ad3f..0000000 --- a/.agents/skills/cy-idea-factory/references/adr-template.md +++ /dev/null @@ -1,55 +0,0 @@ -# ADR-XXX: [Title] - -## Status - -[Proposed | Accepted | Deprecated | Superseded by ADR-XXX] - -## Date - -YYYY-MM-DD - -## Context - -[Describe the issue or problem that motivates this decision. Include relevant technical context, constraints, and any forces at play.] - -## Decision - -[State the decision that was made. Be specific and clear about what will be done.] - -## Alternatives Considered - -### Alternative 1: [Name] - -- **Description**: [Brief description] -- **Pros**: [List advantages] -- **Cons**: [List disadvantages] -- **Why rejected**: [Reason] - -### Alternative 2: [Name] - -- **Description**: [Brief description] -- **Pros**: [List advantages] -- **Cons**: [List disadvantages] -- **Why rejected**: [Reason] - -## Consequences - -### Positive - -- [List positive outcomes] - -### Negative - -- [List negative outcomes or trade-offs] - -### Risks - -- [List risks and mitigation strategies] - -## Implementation Notes - -[Any specific implementation details, migration steps, or technical notes relevant to this decision.] - -## References - -- [Link to relevant documents, PRDs, tech specs, or external resources] diff --git a/.agents/skills/cy-idea-factory/references/business-analyst.md b/.agents/skills/cy-idea-factory/references/business-analyst.md deleted file mode 100644 index 6260740..0000000 --- a/.agents/skills/cy-idea-factory/references/business-analyst.md +++ /dev/null @@ -1,99 +0,0 @@ -# Business Analyst Reference - -You are an expert business analyst specializing in data-driven decision making through advanced analytics, modern BI -tools, and strategic business intelligence. - -## When to Use - -- Turning raw product, sales, or usage data into concrete business insights -- Designing or refining KPI frameworks, dashboards, or executive reports -- Evaluating experiment results, A/B tests, or go-to-market performance -- Supporting PRDs or strategy docs with quantitative analysis and forecasts -- Prioritizing initiatives using LTV, CAC, cohort, or funnel analysis - -## Purpose - -Expert business analyst focused on transforming complex business data into actionable insights and strategic -recommendations. Masters modern analytics platforms, predictive modeling, and data storytelling to drive business growth -and optimize operational efficiency. Combines technical proficiency with business acumen to deliver comprehensive -analysis that influences executive decision-making. - -## Capabilities - -### Strategic KPI Framework Development - -- Comprehensive KPI strategy design and implementation -- North Star metrics identification and tracking -- OKR (Objectives and Key Results) framework development -- Balanced scorecard implementation and management -- Performance measurement system design -- Metric hierarchy and dependency mapping -- KPI benchmarking against industry standards - -### Financial Analysis and Modeling - -- Advanced revenue modeling and forecasting techniques -- Customer lifetime value (CLV) and acquisition cost (CAC) optimization -- Cohort analysis and retention modeling -- Unit economics analysis and profitability modeling -- Scenario planning and sensitivity analysis -- Investment analysis and ROI calculations - -### Customer and Market Analytics - -- Customer segmentation and persona development -- Churn prediction and prevention strategies -- Market sizing and total addressable market (TAM) analysis -- Competitive intelligence and market positioning -- Product-market fit analysis and validation -- Customer journey mapping and funnel optimization -- Voice of customer (VoC) analysis and insights - -### Data Visualization and Storytelling - -- Advanced data visualization techniques and best practices -- Executive presentation design and narrative development -- Data storytelling frameworks and methodologies -- Visual analytics for pattern recognition and insight discovery - -### Statistical Analysis and Research - -- Advanced statistical analysis and hypothesis testing -- A/B testing design, execution, and analysis -- Survey design and market research methodologies -- Experimental design and causal inference -- Time series analysis and forecasting - -### Data Management and Quality - -- Data governance frameworks and implementation -- Data quality assessment and improvement strategies -- Privacy and compliance considerations (GDPR, CCPA) -- Sensitive data: PII handling, tokenization, secure storage patterns - -### Business Process Optimization - -- Process mining and workflow analysis -- Operational efficiency measurement and improvement -- Resource allocation and capacity planning -- Automation opportunity identification and assessment - -## Behavioral Traits - -- Focuses on business impact and actionable recommendations -- Translates complex technical concepts for non-technical stakeholders -- Maintains objectivity while providing strategic guidance -- Validates assumptions through data-driven testing -- Communicates insights through compelling visual narratives -- Considers ethical implications of data use and analysis - -## Response Approach - -1. **Define business objectives** and success criteria clearly -2. **Assess data availability** and quality for analysis -3. **Design analytical framework** with appropriate methodologies -4. **Execute comprehensive analysis** with statistical rigor -5. **Create compelling visualizations** that tell the data story -6. **Develop actionable recommendations** with implementation guidance -7. **Present insights effectively** to target audiences -8. **Plan for ongoing monitoring** and continuous improvement diff --git a/.agents/skills/cy-idea-factory/references/council.md b/.agents/skills/cy-idea-factory/references/council.md deleted file mode 100644 index d53f6a6..0000000 --- a/.agents/skills/cy-idea-factory/references/council.md +++ /dev/null @@ -1,171 +0,0 @@ -# Council of Advisors Reference - -You are the Council Facilitator, orchestrating a high-level roundtable simulation with diverse expert advisors. Your -role is to simulate multiple perspectives, highlight contradictions, synthesize insights, and guide toward well-reasoned -decisions. - -## When to Use - -- Making high-impact architecture, technology, or product strategy choices with real trade-offs -- Comparing multiple viable options where different stakeholders would disagree -- Stress-testing an existing decision, PRD, or Tech Spec against alternative viewpoints -- Documenting rationale and dissent for complex decisions in planning artifacts - -## Research-Backed Approach - -This council implements findings from multi-agent debate research: - -- **Diversity of Thought**: Different perspectives elicit stronger reasoning than homogeneous viewpoints -- **Constructive Disagreement**: Agents must change positions based on reasoning, not arbitrary contradiction -- **Agreement Modulation**: Balance between maintaining positions and being open to persuasion -- **Teacher-Student Dynamics**: Allow expertise to emerge naturally through debate - -## Council Composition - -Select **3-5 advisors** based on dilemma complexity: - -- **3 advisors** — binary choices (A vs B), clear trade-off axis -- **4 advisors** — multi-factor decisions with 2-3 competing concerns -- **5 advisors** — complex, multi-faceted dilemmas with broad impact - -### Standard Tech Council (Default for technical decisions) - -1. **The Pragmatic Engineer** - Focuses on "what works today", maintenance burden, team velocity -2. **The Architect** - Long-term scalability, patterns, system boundaries, technical debt -3. **The Security Advocate** - Attack vectors, compliance, data protection, worst-case scenarios -4. **The Product Mind** - User impact, time-to-market, business value, opportunity cost -5. **The Devil's Advocate** - Challenges assumptions, finds edge cases, stress-tests reasoning - -For 3-advisor sessions, pick the 3 most relevant archetypes for the dilemma. - -### Alternative Councils - -- **Strategy Council**: CEO, CFO, CTO, Customer Advocate, Risk Manager -- **Innovation Council**: Innovator, Skeptic, Researcher, Practitioner, Ethicist -- **Custom Council**: User specifies advisors (historical, fictional, or role-based) - -## Session Structure (Embedded Mode) - -When invoked as a sub-step by another skill, council runs in embedded mode: - -- **Skip Phase 1 confirmation** — the parent skill already established context; select advisors automatically -- **Skip Phase 6 decision capture** — the parent skill owns the decision; council just delivers the analysis -- Run Phases 2-5 (Opening Statements, Tensions, Position Evolution, Synthesis) -- Return the synthesis output for the parent skill to extract what it needs - -### Phase 2: Opening Statements - -Each advisor presents their initial position (2-3 paragraphs each): - -```markdown -## Opening Statements - -### [Advisor 1 Name] — [Archetype] - -[Their initial position, reasoning, and key concerns] - -**Key Point:** [One-line summary] -``` - -### Phase 3: Tensions & Debate - -Identify the core disagreements and present them as a structured tension analysis. -Focus on the **substance of disagreement**, not simulated dialogue. - -```markdown -## Core Tensions - -| Tension | Side A ([Advisor]) | Side B ([Advisor]) | Facilitator Note | -| --------------------- | ---------------------- | ---------------------- | --------------------------- | -| [Core disagreement] | [Position + reasoning] | [Position + reasoning] | [What this tension reveals] | - -### Key Concessions - -- **[Advisor A]** concedes to **[Advisor B]** on [point] because [reasoning] -- **[Advisor C]** maintains position on [point] despite challenge because [reasoning] -``` - -### Phase 4: Position Evolution - -Track how positions shift through debate: - -```markdown -## Position Evolution - -| Advisor | Initial Position | Final Position | Changed? | -| ------- | ---------------- | -------------- | -------- | -| [Name] | [Brief] | [Brief] | Yes/No | - -**Key Shifts:** - -- [Who changed and why] -``` - -### Phase 5: Synthesis & Recommendations - -```markdown -## Council Synthesis - -### Points of Consensus - -- [What most/all advisors agree on] - -### Unresolved Tensions - -| Tension | Position A | Position B | Trade-off | -| ------- | ---------- | ---------- | -------------------- | -| [Issue] | [View] | [View] | [What you sacrifice] | - -### Recommended Path Forward - -**Primary Recommendation:** [Clear recommendation] - -**Rationale:** [Why this balances tensions] - -**Dissenting View:** [Who disagrees and why - important to capture] - -### Risk Mitigation - -- [How to address concerns from dissenting advisors] -``` - -## Downstream Extraction Guide - -When council is invoked by the idea creation workflow, extract: - -- Out of Scope items for V1 -- Risk factors that inform KPIs -- Priority recommendations -- Stretch goal (optional): a more ambitious version to consider for V2+ - -## Debate Protocols - -### Ensuring Productive Disagreement - -1. **Steel-Man Arguments**: Each advisor must present the strongest version of opposing views before critiquing -2. **Evidence Required**: Claims must be supported with reasoning, not just assertions -3. **Concession Protocol**: Advisors should acknowledge when a counter-argument has merit -4. **No False Consensus**: If genuine disagreement exists, preserve it in synthesis - -### Advisor Authenticity Rules - -- Each advisor must stay true to their archetype's priorities -- The Pragmatic Engineer won't suddenly prioritize theoretical purity -- The Security Advocate won't dismiss a risk for convenience -- Contradictions between archetypes are expected and valuable - -### Facilitator Responsibilities - -- Ensure all advisors get adequate voice -- Highlight when advisors talk past each other -- Identify hidden assumptions -- Call out false dichotomies -- Synthesize without forcing agreement - -## Key Principles - -1. **Diversity Over Agreement**: The value is in exploring tensions, not reaching false consensus -2. **Authentic Perspectives**: Each archetype must argue from their genuine priorities -3. **Productive Conflict**: Disagreement should illuminate, not obstruct -4. **Actionable Synthesis**: End with clear options and their trade-offs -5. **Preserved Dissent**: Minority views have value and should be captured diff --git a/.agents/skills/cy-idea-factory/references/idea-template.md b/.agents/skills/cy-idea-factory/references/idea-template.md deleted file mode 100644 index e769bcb..0000000 --- a/.agents/skills/cy-idea-factory/references/idea-template.md +++ /dev/null @@ -1,122 +0,0 @@ -# Idea Template - -Use this template to structure every idea in `.compozy/tasks//_idea.md`. Fill each section based on research and brainstorming outcomes. Leave placeholder guidance in sections where information is insufficient and note them in Open Questions. - -## Overview - -High-level overview of the feature idea. Describe: -- What problem it solves -- Who it is for -- Why it is valuable -- How ambitious the V1 should be - -## Problem - -The real problem the user or business faces: -- 2-4 paragraphs with concrete scenarios and data -- Why the current solution is insufficient -- Include market data as a "### Market Data" subsection when available from research - -## Core Features - -Main features grouped by priority: - -| # | Feature | Priority | Description | -| --- | ------- | ---------------------- | ----------------------------------------------------------- | -| F1 | {Name} | {Critical/High/Medium} | {Concise description of the feature with expected behavior} | -| F2 | {Name} | {Critical/High/Medium} | {Description} | - -Rules: -- Number features with a 2-letter prefix (e.g., F1, F2) -- Order by priority (Critical > High > Medium) -- Each feature described in 1-2 lines with concrete behavior -- Minimum 3 features, maximum 10 - -## KPIs - -Quantifiable measures of success from business analysis: - -| KPI | Target | How to Measure | -| ------------------- | ------------------------- | ----------------------------- | -| {Observable metric} | {Numeric value with unit} | {Concrete measurement method} | - -Rules: -- Minimum 3 KPIs, maximum 6 -- Targets must be numeric and measurable (e.g., "> 30%", "< 200ms", "-80%") -- "How to Measure" must be concrete and implementable - -## Feature Assessment - -Score from the business analysis phase: - -| Criteria | Question | Score | -| ------------------- | --------------------------------------------------- | ----------- | -| **Impact** | How much more valuable does this make the product? | {score} | -| **Reach** | What % of users would this affect? | {score} | -| **Frequency** | How often would users encounter this value? | {score} | -| **Differentiation** | Does this set us apart or just match competitors? | {score} | -| **Defensibility** | Is this easy to copy or does it compound over time? | {score} | -| **Feasibility** | Can we actually build this? | {score} | - -Leverage type: {Quick Win / Strategic Bet / Compounding Feature} - -## Council Insights - -Key findings from the multi-advisor debate: -- **Recommended approach:** {summary of the council recommendation} -- **Key trade-offs:** {list of trade-offs surfaced} -- **Risks identified:** {list of risks and mitigations} -- **Stretch goal (V2+):** {optional more ambitious version for later} - -## Out of Scope (V1) - -Explicitly excluded features and boundaries: - -- **{Excluded feature}** — {short justification for why it is out of V1 scope} - -Rules: -- Minimum 3 exclusions -- Each item must have a justification -- Helps prevent scope creep and align expectations - -## Architecture Decision Records - -ADRs documenting key decisions made during idea creation: -- [ADR-NNN: Title](adrs/adr-NNN.md) — One-line summary of the decision - -## Open Questions - -Remaining items that need clarification: -- Unclear requirements -- Edge cases requiring stakeholder input -- Dependencies on decisions not yet made - -## Optional Sections - -Include these between mandatory sections when the content justifies it: - -### Summary / Differentiator - -Short paragraph describing the competitive angle of the proposed solution. Use when the feature has a clear differentiator vs the market. - -### Integration with Existing Features - -| Integration Point | How | -| ------------------------- | ------------------------------ | -| {Existing feature/module} | {How the new feature connects} | - -Use when the feature modifies or extends already implemented features. - -### Sub-Features - -- **{Name}** — {Brief description of the sub-scope} - -Use when the feature is large enough to be split into multiple ideas or PRDs. - -### Cost Estimate - -| Type | Volume | Estimated Cost | -| ---------- | ----------------- | --------------------- | -| {Resource} | {Expected volume} | {Monthly/annual cost} | - -Use for features with relevant operational costs (paid APIs, cloud storage, etc.). diff --git a/.agents/skills/cy-idea-factory/references/product-strategist.md b/.agents/skills/cy-idea-factory/references/product-strategist.md deleted file mode 100644 index 599ccb4..0000000 --- a/.agents/skills/cy-idea-factory/references/product-strategist.md +++ /dev/null @@ -1,123 +0,0 @@ -# Product Strategist Reference - -You are a product strategist with founder mentality. Your role is not to add features — it is to find the moves that -dramatically increase the product's value. Think like you own this. What would make users unable to live without it? - -## When to Use - -- Evaluating whether a feature idea is truly high-leverage or just incremental -- Suggesting more ambitious or simpler versions that deliver disproportionate value -- Challenging scope by asking "what would make this 10x more valuable?" -- Surfacing adjacent opportunities the original idea might miss -- Identifying compounding features that get better over time - -## Purpose - -Force a strategic lens onto any feature idea before it becomes a spec. Most product work is incremental: fix bugs, add -requested features, polish edges. That is necessary but not sufficient. This persona asks the harder question: **is there -a version of this idea that changes the game?** - -## Evaluation Framework - -Score every opportunity on six criteria: - -| Criteria | Question | Score | -| ------------------- | --------------------------------------------------- | ---------------------------------------- | -| **Impact** | How much more valuable does this make the product? | Must do / Strong / Maybe / Pass | -| **Reach** | What % of users would this affect? | Must do / Strong / Maybe / Pass | -| **Frequency** | How often would users encounter this value? | Must do / Strong / Maybe / Pass | -| **Differentiation** | Does this set us apart or just match competitors? | Must do / Strong / Maybe / Pass | -| **Defensibility** | Is this easy to copy or does it compound over time? | Must do / Strong / Maybe / Pass | -| **Feasibility** | Can we actually build this? | Must do / Strong / Maybe / Pass | - -## Opportunity Categories - -Force yourself through each category when scanning for alternatives: - -| Category | Question | Example | -| ------------------- | ---------------------------------- | ------------------------------------- | -| **Speed** | What takes too long? | Instant search, predictive loading | -| **Automation** | What is repetitive? | Auto-scheduling, smart defaults | -| **Intelligence** | What could be smarter? | Recommendations, anomaly detection | -| **Integration** | What else do users use? | Calendar sync, export options | -| **Collaboration** | How do users work together? | Sharing, comments, real-time | -| **Personalization** | How is everyone different? | Custom views, preferences | -| **Visibility** | What is hidden that should not be? | Dashboards, progress tracking | -| **Confidence** | What creates anxiety? | Confirmations, undo, previews | -| **Delight** | What could spark joy? | Animations, celebrations, polish | -| **Access** | Who cannot use this yet? | Mobile, offline, accessibility | - -## Opportunity Scan Process - -Given a feature idea and its context (research, business analysis, council output): - -### 1. Assess the Original Idea - -- Is it high-leverage or incremental? -- Does it compound over time or is it a one-time improvement? -- What is the ceiling on its impact? - -### 2. Suggest Alternatives (up to 3) - -For each alternative, provide: - -- **What**: One-line description -- **Why powerful**: Why it punches above its weight -- **Scale**: Massive / Medium / Small effort -- **Score**: Apply the evaluation framework - -Alternatives should span different scales: -- One more ambitious version (what if we thought bigger?) -- One simpler version (what if we stripped it to the essence?) -- One adjacent opportunity (what related problem could we solve instead?) - -### 3. Recommend - -Present a clear recommendation: - -```markdown -## Opportunity Scan - -### Original Idea Assessment -[Is this high-leverage or incremental? What is the ceiling?] - -### Alternative 1: [Name] (More ambitious) -**What**: ... -**Why powerful**: ... -**Scale**: Massive / Medium -**Score**: [evaluation] - -### Alternative 2: [Name] (Simpler version) -**What**: ... -**Why powerful**: ... -**Scale**: Small / Medium -**Score**: [evaluation] - -### Alternative 3: [Name] (Adjacent opportunity) -**What**: ... -**Why powerful**: ... -**Scale**: ... -**Score**: [evaluation] - -### Recommendation -**Proceed with**: [Original / Alternative N / Hybrid] -**Rationale**: [Why this is the highest-leverage move] -``` - -## Behavioral Traits - -- Thinks like an owner, not an employee -- Challenges "obvious" ideas — simple does not mean low-value, but it also does not mean high-value -- Prefers compounding features over one-time improvements -- Values specificity — "better UX" is not an idea; "one-click rescheduling from notification" is -- Questions assumptions — "users want X" may be wrong; what do they actually need? -- Cites evidence from research and codebase findings - -## Prompts to Unstick Thinking - -- "What would make a user tell their friend about this?" -- "What is the thing users do every day that is slightly annoying?" -- "What would a competitor need to build to beat us?" -- "What do power users do manually that we could make native?" -- "What is the feature that sounds crazy but might work?" -- "What is the insight we have from data that users do not see?" diff --git a/.agents/skills/cy-idea-factory/references/question-protocol.md b/.agents/skills/cy-idea-factory/references/question-protocol.md deleted file mode 100644 index 736569c..0000000 --- a/.agents/skills/cy-idea-factory/references/question-protocol.md +++ /dev/null @@ -1,82 +0,0 @@ -# Question Protocol - -Structured brainstorming protocol for idea creation. Follow these phases and rules to guide the conversation from raw idea to structured spec. - -## Phases - -### 1. Problem and Pain Point - -Understand the core problem or opportunity. -- What concrete problem does this feature solve for the user? -- What prompted this initiative? -- Offer 2-3 interpretations if the idea is ambiguous. - -### 2. Target User and Context - -Identify who benefits and when. -- Who is the primary user? -- At what point in their workflow would they use this? -- What are their current workarounds? - -### 3. Scope and Priority - -Define V1 boundaries. -- What is the ideal V1 size? Offer options: - - A) Minimal MVP — solve only the main use case - - B) Complete feature — cover the most common scenarios - - C) Platform — extensible and configurable -- Always recommend option A or B with justification. - -### 4. Ambition and Leverage - -Assess the strategic value. -- How ambitious should this be? Offer options: - - A) Quick Win — small effort, disproportionate value (ship fast, validate fast) - - B) Strategic Bet — larger effort, potentially transformative (opens new possibilities) - - C) Compounding Feature — gets more valuable over time (data effects, habit formation, network effects) -- Follow up: What would make this 10x more valuable instead of just incremental? - -### 5. Dependencies and Integration - -Map connections to existing work. -- Does this feature depend on or integrate with anything existing? -- List relevant existing features or modules as options. - -### 6. Success Criteria - -Define how to measure success (if not already clear from earlier answers). -- How would you know this feature is working well? -- Offer quantitative vs qualitative options. - -## Rules - -### Interactive Question Enforcement -- Every question MUST be asked using the runtime's dedicated interactive question tool — the one that presents the question and pauses execution until the user responds. -- Do not output questions as plain text and continue generating. -- If no such tool is available, present the question as your complete message and stop generating. - -### Question Limits -- Ask only one question per message. If a topic needs deeper exploration, break it into a sequence of individual questions. -- Prefer multiple-choice questions when the options can be predetermined. -- Include a fallback option (e.g., "D) Other — describe") for flexibility. -- Wait for the user's answer before asking the next question. -- Minimum 3 questions, maximum 6. - -### Progression Gates -- Must complete at least one full clarification round before proceeding to research. -- Must have clarity on problem, target user, and scope before moving forward. -- If the user's answers reveal the idea is already well-defined, skip remaining questions. - -### Focus Boundaries -- Questions must focus on WHAT, WHY, and WHO. -- Never ask HOW, WHERE, or WHICH regarding technical implementation. -- Forbidden topics: databases, APIs, code structure, frameworks, testing strategies, architecture patterns, deployment infrastructure. - -### YAGNI Principle -- Ruthlessly remove non-essential features during scope discussion. -- Challenge every feature: does the MVP need this? -- Defer nice-to-have features to later phases. -- Prefer smaller, well-defined scope over ambitious breadth. - -### Anti-Pattern: Skipping Questions For "Obvious" Ideas -Every idea goes through the question protocol regardless of perceived simplicity. Simple ideas are where unexamined business assumptions cause the most rework. The questioning can be brief, but it must happen. diff --git a/.agents/skills/cy-review-round/SKILL.md b/.agents/skills/cy-review-round/SKILL.md index 24f1380..1d5870b 100644 --- a/.agents/skills/cy-review-round/SKILL.md +++ b/.agents/skills/cy-review-round/SKILL.md @@ -53,6 +53,10 @@ Perform a structured code review of a PRD implementation and produce a review ro ``` --- + provider: manual + pr: + round: + round_created_at: status: pending file: path/to/file.go line: 42 @@ -75,44 +79,25 @@ Perform a structured code review of a PRD implementation and produce a review ro - The `` field must be `claude-code`. - The `provider_ref` field must be empty. - - The `severity` field must be exactly one of: `critical`, `high`, `medium`, `low`. - -5. Generate the round metadata file. - - Write `_meta.md` in the review round directory with this format: - - ``` - --- - provider: manual - pr: - round: - created_at: - --- - - ## Summary - - Total: - - Resolved: 0 - - Unresolved: - ``` - - The `provider` field must be `manual`. - The `pr` field is empty for manual reviews. If the user provides a PR number, include it. - The `round` field must match the directory number as an integer (not zero-padded). - - The `created_at` field must use the current UTC time in RFC3339 format. - - The counts must accurately reflect the number of issue files written. + - The `round_created_at` field must use the same current UTC RFC3339 timestamp in every issue in this round. + - The `severity` field must be exactly one of: `critical`, `high`, `medium`, `low`. -6. Summarize and present the review. +5. Summarize and present the review. - Print a summary listing: - **Merge recommendation**: If any critical or high issues exist, state "Needs fixes before merge" with the blocking issues. If only medium/low issues exist, state "Safe to merge with follow-ups." If no issues, state "Clean — ready to merge." - Total issues found, broken down by severity (critical, high, medium, low). - The review round directory path. - The full list of generated issue file names. - Well-implemented aspects observed during the review. - - Suggest running `compozy fix-reviews --name ` to process the review round. + - Suggest running `compozy reviews fix ` to process the review round. -7. Verify before completion. +6. Verify before completion. - Use installed `cy-final-verify` before claiming the review round is complete. - Read back each generated issue file and verify the frontmatter parses correctly. - - Verify `_meta.md` has correct counts matching the actual number of issue files. + - Verify every issue file in the round has matching `provider`, `pr`, `round`, and `round_created_at` values. - Confirm the review round directory follows the `reviews-NNN` naming convention. ## Critical Rules @@ -120,7 +105,7 @@ Perform a structured code review of a PRD implementation and produce a review ro - Do not fix the issues found. This skill only identifies and documents issues. The `cy-fix-reviews` workflow handles remediation. - Do not create issue files for problems that linters or formatters already catch. - Every issue file must have valid YAML frontmatter parseable by `prompt.ParseReviewContext()`. -- The `_meta.md` must be parseable by `reviews.ReadRoundMeta()`. +- Do not create or maintain review `_meta.md`; round metadata lives in each issue file frontmatter. - Do not create empty review rounds. If no issues are found, report a clean review and do not create the round directory. - Do not modify any source code files. This is a review-only skill. - Do not call provider-specific scripts or `gh` mutations. diff --git a/.agents/skills/git-rebase/SKILL.md b/.agents/skills/git-rebase/SKILL.md index 9b8051a..7571f0d 100644 --- a/.agents/skills/git-rebase/SKILL.md +++ b/.agents/skills/git-rebase/SKILL.md @@ -1,422 +1,58 @@ --- name: git-rebase -description: Intelligently handle git rebase operations and resolve merge conflicts while preserving features and maintaining code quality. Use when rebasing feature branches, resolving conflicts across commits, and ensuring clean linear history without losing changes. +description: Resolve Git merge and rebase conflicts conservatively, preserving both sides' intent, staging only understood resolutions, and leaving a git-clean conflict state. --- -# Git Rebase with Intelligent Conflict Resolution - -## Quick Start - -For most rebases with multiple commits, use the squash-first strategy to resolve conflicts only once: - -```bash -# Step 1: Backup current state -bash scripts/pre-rebase-backup.sh - -# Step 2: Squash commits (interactive rebase on current branch) -git rebase -i $(git merge-base HEAD origin/main) - -# Step 3: Rebase onto target -git rebase origin/main - -# Step 4: If conflicts, resolve them once (see workflow below) -# Then continue: git rebase --continue - -# Step 5: Force push safely -git push origin $(git rev-parse --abbrev-ref HEAD) --force-with-lease -``` - -This approach resolves conflicts once instead of per-commit, saving time and mental overhead. - -## Core Workflow: Conflict Analysis & Resolution - -Copy this checklist and mark progress: - -``` -Rebase Workflow: -- [ ] Step 1: Create safety backup -- [ ] Step 2: Fetch latest from target branch -- [ ] Step 3: Analyze conflict scope -- [ ] Step 4: Choose resolution strategy -- [ ] Step 5: Apply conflict resolutions -- [ ] Step 6: Validate merged code -- [ ] Step 7: Run tests -- [ ] Step 8: Force push safely -``` - -### Step 1: Create Safety Backup - -ALWAYS do this first. If rebase goes wrong, you can recover: - -```bash -# Use the bundled script -bash scripts/pre-rebase-backup.sh - -# Or manually create timestamped backup branch -TIMESTAMP=$(date +%Y%m%d_%H%M%S) -git branch backup-rebase-$TIMESTAMP - -# Alternative: Create temporary ref to your current commit -git reflog # Note your current HEAD SHA for manual recovery -``` - -This costs nothing and saves hours of work if something goes wrong. - -### Step 2: Fetch Latest Changes - -Ensure you have the most recent remote state: - -```bash -# Fetch without modifying local branches -git fetch origin - -# View the divergence -git log --oneline origin/main..HEAD # Your commits -git log --oneline HEAD..origin/main # New commits on main -``` - -Understand how many commits you're rebasing and how much main has changed. - -### Step 3: Analyze Conflict Scope - -Before starting the rebase, predict conflicts: - -```bash -# See which files you changed -git diff --name-only origin/main...HEAD - -# See which files main changed -git diff --name-only origin/main HEAD - -# Likely conflict areas: files changed in both -``` - -**Key insight**: If you changed `auth.ts` and so did main, you WILL get conflicts in `auth.ts`. - -Anticipating conflicts helps you understand how to resolve them. - -### Step 4: Choose Resolution Strategy - -**For detailed strategy comparison and decision matrix**, see [references/strategies.md](references/strategies.md). - -#### Strategy A: Squash First (Recommended for 3+ commits) - -**When to use**: Multiple feature commits with many conflicts expected - -**Why**: Reduces conflicts to one resolution phase instead of per-commit - -```bash -# Interactive rebase on current branch first -git rebase -i $(git merge-base HEAD origin/main) - -# In editor, change all "pick" to "squash" (or 's') except first commit -# Save and exit - commits are squashed into one -# Edit commit message to describe the entire feature - -# Now rebase the squashed commit -git rebase origin/main - -# Resolve conflicts once, then git rebase --continue -``` - -**Tradeoffs**: Lose individual commit history, but simpler conflict resolution - -#### Strategy B: Interactive Rebase with Conflict Awareness - -**When to use**: 1-2 commits, clean history, or complex per-commit logic - -```bash -git rebase -i origin/main - -# In editor, you can: -# - Reorder commits to isolate conflict-prone ones -# - Drop commits that are already in main (git detects this) -# - Combine related commits before rebasing - -# Save and exit - rebase proceeds, stopping at conflicts -``` - -**Tradeoffs**: More control, but more conflict-resolution iterations - -#### Strategy C: Simple Linear Rebase (Fastest, Auto-Resolution) - -**When to use**: Simple cases, no critical decisions, or in automated pipelines - -```bash -# Rebase all commits at once -git rebase origin/main - -# If no conflicts, done -# If conflicts, you resolve each one -``` - -**Warning**: Not recommended for complex scenarios. Use Strategies A or B instead. - -### Step 5: Apply Conflict Resolutions - -When `git rebase` pauses with conflicts, use the analysis script: - -```bash -# Analyze conflicts -bash scripts/analyze-conflicts.sh - -# See which files conflict -git status - -# For each conflicted file: -# - RECOMMENDED: Use merge tool for visual clarity -git mergetool --no-prompt - -# - ALTERNATIVE: Manual edit in your editor -# Search for conflict markers: <<<<<<, ======, >>>>>> -``` - -#### Conflict Marker Anatomy - -```javascript -<<<<<<< HEAD -// Your current feature code -function authenticate(token) { - validateToken(token); - return true; -} -======= -// Main branch code (incoming) -function authenticate(token) { - if (!token) throw new Error("No token"); - validateToken(token); - setSession(token); - return true; -} ->>>>>>> origin/main -``` - -**Decision framework** (before deleting markers): - -1. **Can you keep both?** YES → Merge them intelligently - - ```javascript - function authenticate(token) { - if (!token) throw new Error("No token"); // Keep main's validation - validateToken(token); - setSession(token); // Keep main's session setup - return true; // Keep feature's return - } - ``` - -2. **Conflicting logic?** Understand WHY they differ, then decide - - Did main add critical security checks? → Keep main's version - - Did your feature add essential functionality? → Keep feature's version - - Are they trying to do different things? → Combine intentionally - -3. **Lost features?** NEVER let a feature silently disappear - - If you added authentication logic, ensure it's in final version - - If main improved database access, ensure that's preserved - -**For detailed resolution patterns**, see [references/resolution-patterns.md](references/resolution-patterns.md). - -#### Key Resolution Principles - -**✅ DO**: - -- Keep both versions' important functionality when possible -- Use the merge tool for visual representation -- Add comments explaining merged conflicts: `// Merged from both versions: main's validation + feature's session setup` -- Test each file after resolution - -**❌ DON'T**: - -- Mindlessly pick one version without understanding both -- Delete conflict markers without understanding the conflict -- Keep duplicate code - merge intelligently -- Skip testing before continuing - -### Step 6: Validate Merged Code - -After resolving conflicts, validate the merged code: - -```bash -# Use the validation script -bash scripts/validate-merge.sh - -# Manual checks: -# 1. Check syntax -npm run lint # or eslint, pylint, etc. - -# 2. Check types (if TypeScript) -npm run type-check # or tsc --noEmit - -# 3. Spot-check key files -git diff HEAD origin/main -- - -# If validation fails: -# 1. Fix the issue in the file -# 2. git add -# 3. git rebase --continue -``` - -**Important**: Validation catches mistakes BEFORE you commit them. - -### Step 7: Run Tests - -This is your safety net: - -```bash -# Run full test suite -npm test - -# Or specific tests for changed areas -npm test -- --testPathPattern=auth # If auth.ts was changed - -# If tests fail: -# 1. Understand what broke -# 2. Fix in the files -# 3. git add -# 4. git rebase --continue -``` - -**Rule**: Never force-push code that fails tests. - -### Step 8: Force Push Safely - -Use `--force-with-lease` instead of `--force`. It protects against accidentally overwriting others' work: - -```bash -# SAFE: Protects others' commits -git push origin $(git rev-parse --abbrev-ref HEAD) --force-with-lease - -# UNSAFE: Can overwrite others' work -git push origin your-branch -f # Don't do this - -# If force-with-lease fails: -# Someone else pushed to your branch -# Coordinate with them before forcing -``` - -## Common Scenarios & Strategies - -### Scenario 1: Many Small Conflicts Across 5+ Commits - -**Use**: Squash-first strategy - -```bash -git rebase -i $(git merge-base HEAD origin/main) -# Mark all but first commit as 's' (squash) -# Save - commits squash into one - -git rebase origin/main -# Resolve conflicts once -git rebase --continue - -# Only one conflict-resolution phase! -``` - -**Why**: Each commit might have conflicts. Squashing before rebasing means one pass. - -### Scenario 2: One Specific Commit Has Conflicts - -**Use**: Target that commit with interactive rebase - -```bash -git rebase -i origin/main - -# In editor, move the problematic commit to the end -# Save - rebase proceeds, stopping at that commit - -# When it stops, you know exactly which commit conflicts -git status # See what changed in this commit - -# Resolve, then continue -git rebase --continue -``` - -**Why**: Isolating the commit helps you understand what it's trying to do. - -### Scenario 3: Conflicts Keep Repeating (Same File, Different Commits) - -**Use**: Git rerere (reuse recorded resolution) - -```bash -# Enable rerere globally (one-time setup) -git config --global rerere.enabled true - -# Now when you hit the same conflict in a second commit, -# Git automatically applies the first commit's resolution - -git rebase origin/main - -# Git remembers your first conflict resolution -# and replays it automatically for similar conflicts -``` - -**Why**: When rebasing and hitting the same file repeatedly, rerere saves manual work. - -### Scenario 4: Rebase Conflicts Are Too Complex - -**Emergency escape plan**: - -```bash -# Abort the rebase - return to original state -git rebase --abort - -# Fall back to merge (safer for complex scenarios) -git merge origin/main - -# Or try a different approach: -# - Squash your entire feature branch first -# - Cherry-pick main's critical changes selectively -``` - -**Important**: It's okay to abort and rethink. Better than a broken rebase. - -## Bundled Scripts - -This skill includes helper scripts in `scripts/`: - -- **`pre-rebase-backup.sh`**: Creates a safety backup before rebasing -- **`analyze-conflicts.sh`**: Analyzes current conflicts and provides detailed information -- **`validate-merge.sh`**: Validates that merge/rebase is clean and ready for testing - -Run scripts with `bash scripts/.sh`. - -## Reference Files - -For detailed information on specific topics: - -- **Strategies**: [references/strategies.md](references/strategies.md) - Complete strategy comparison and decision matrix -- **Resolution Patterns**: [references/resolution-patterns.md](references/resolution-patterns.md) - Common conflict resolution patterns and heuristics -- **Troubleshooting**: [references/troubleshooting.md](references/troubleshooting.md) - Solutions to common rebase problems -- **Automation**: [references/automation.md](references/automation.md) - Automated conflict resolution for CI/CD -- **Scripts & Tools**: [references/scripts-tools.md](references/scripts-tools.md) - Additional git commands and tool usage - -## Checklist: Before You Commit to Rebasing - -- [ ] Understand why you're rebasing (cleaner history, syncing with main, etc.) -- [ ] Backup your current branch: `bash scripts/pre-rebase-backup.sh` -- [ ] Run tests on current branch - they should pass -- [ ] Fetch latest: `git fetch origin` -- [ ] Understand what you'll be rebasing (5 commits? 50?) -- [ ] Understand main's recent changes (1 commit? Major refactor?) -- [ ] Choose your strategy (see [references/strategies.md](references/strategies.md)) -- [ ] Have your merge tool ready (if using GUI) -- [ ] Block 30 mins - don't rush conflict resolution -- [ ] Have tests ready to run after rebase - -## When NOT to Rebase - -- Shared branches (use merge instead: `git merge origin/main`) -- Critical production code without comprehensive tests -- When multiple people are pushing to the same branch -- If you don't understand the conflicts you're seeing - -**Default to merge if uncertain.** Merge is safer for collaborative work. - -**Use rebase when**: Solo feature branch, clean history matters, no shared dependencies. - -## Summary: The Rebase Philosophy - -Rebasing is a tool for creating clean, linear commit history. **Used well**, it makes debugging and code review easier. **Used poorly**, it loses work. - -**The key principle**: Understand every conflict before resolving it. Don't automate away the thinking. - -With this Skill, you can rebase with confidence, understanding each decision and protecting your code throughout the process. +# Git Rebase Conflict Resolution + +Use this skill when Compozy asks you to resolve conflicts in an integration +worktree. The goal is a clean merge result, not a clever shortcut. + +## Core Rules + +1. Understand every conflicted hunk before editing it. +2. Preserve important behavior from both sides whenever possible. +3. Prefer the smallest readable merge that keeps the code idiomatic for the + affected language and project. +4. Do not delete tests, weaken assertions, suppress lint, swallow errors, or + otherwise hide a failing invariant. +5. Do not commit. Compozy owns the final squash commit. +6. Do not leave conflict markers in any file. +7. If a conflict is unsafe or unclear, leave it unresolved so Compozy can abort + and roll back honestly. + +## Required Workflow + +1. Inspect the conflicted files listed in the prompt. +2. For each hunk, identify what the integration branch changed and what the + incoming task changed. +3. Edit the file so both sides' required behavior is represented. +4. Run only language-specific formatting commands that are clearly required for + the files you edited and are safe for this repository. +5. Stage resolved files with `git add`. +6. Check `git status --porcelain`; no unmerged entries may remain. +7. Report what was resolved and any files that remain unsafe. + +## Resolution Guidance + +- For Go files, keep error wrapping with `fmt.Errorf("context: %w", err)`. +- For Go files, preserve `context.Context` propagation and cancellation behavior. +- Preserve synchronization ownership; do not introduce unmanaged background + work. +- Keep tests focused on behavior and invariants, not implementation details. +- When both sides add cases to a table test, combine the cases unless they prove + the same invariant twice. +- When both sides alter an interface, update every implementation instead of + guessing from the conflicted file alone. + +## Fail-Honestly Criteria + +Stop and leave the conflict unresolved when: + +- you cannot tell which side owns the invariant, +- resolving would require deleting behavior from either side without evidence, +- conflict markers remain, or +- a binary/generated file conflict cannot be validated safely. + +Compozy will roll back the integration branch when resolution is exhausted, so +an honest unresolved conflict is safer than a speculative broken merge. diff --git a/.agents/skills/git-rebase/references/automation.md b/.agents/skills/git-rebase/references/automation.md deleted file mode 100644 index 5cc3066..0000000 --- a/.agents/skills/git-rebase/references/automation.md +++ /dev/null @@ -1,25 +0,0 @@ -# Advanced: Automated Conflict Resolution - -## When You KNOW the Resolution Strategy Upfront - -For CI/CD pipelines or automated rebases: - -```bash -# Accept ALL incoming changes (risky - verify first!) -git rebase -X theirs origin/main - -# Accept ALL your changes (also risky!) -git rebase -X ours origin/main - -# Three-way recursive merge (safest auto-strategy) -git rebase -Xrecursive origin/main -``` - -**⚠️ WARNING**: Only use automated strategies when: - -1. You understand the consequences -2. You have comprehensive tests -3. Changes are non-critical -4. You've verified the strategy is correct for this scenario - -**For production code**: Use manual resolution from the core workflow in SKILL.md. diff --git a/.agents/skills/git-rebase/references/resolution-patterns.md b/.agents/skills/git-rebase/references/resolution-patterns.md deleted file mode 100644 index 10ba0a0..0000000 --- a/.agents/skills/git-rebase/references/resolution-patterns.md +++ /dev/null @@ -1,76 +0,0 @@ -# Resolution Patterns & Heuristics - -## Pattern 1: Main Added New Validation/Security - -**Indicator**: Main's version has `if` checks or validations you don't have - -**Action**: Keep main's validation, integrate with your feature - -```javascript -// WRONG: Removing main's validation -function authenticate(token) { - validateToken(token); // Lost: if (!token) check - return true; -} - -// RIGHT: Keeping all validations -function authenticate(token) { - if (!token) throw new Error("No token"); // main's addition - validateToken(token); // your code - return true; -} -``` - -## Pattern 2: Your Feature Added Critical Functionality - -**Indicator**: Main's version doesn't have your key feature additions - -**Action**: Ensure your additions are preserved - -```typescript -// Before conflict -export class AuthService { - validate() {} - // Your addition - setUserContext(user) {} -} - -// After resolving (keep your addition) -export class AuthService { - validate() {} // main's version - setUserContext(user) {} // your addition (don't lose this!) -} -``` - -## Pattern 3: Both Changed Same Line (Logic Conflict) - -**Indicator**: Same line different in both versions - -**Action**: Understand why, choose based on correctness - -```javascript -// YOU: Changed return value -return new User(profile); - -// MAIN: Changed return statement entirely -return User.fromProfile(profile); - -// DECISION: What's the actual implementation? -// Check User class for which method exists -// Pick the one that matches current API -``` - -## Pattern 4: Main Deleted Code You Still Need - -**Indicator**: Code block exists in your branch, missing in main - -**Action**: Keep your code - main might have deleted prematurely - -```javascript -// Main deleted some logging -// You kept it in your feature -// DECISION: If logging is useful, keep it -// If it was debug code, you can delete it too - -// Usually: Keep your feature code + main's latest -``` diff --git a/.agents/skills/git-rebase/references/scripts-tools.md b/.agents/skills/git-rebase/references/scripts-tools.md deleted file mode 100644 index 4832286..0000000 --- a/.agents/skills/git-rebase/references/scripts-tools.md +++ /dev/null @@ -1,49 +0,0 @@ -# Scripts & Tools Reference - -## bash: Analyze Current Conflicts - -```bash -# See all conflicts at a glance -git diff --name-only --diff-filter=U - -# Count conflicts -git diff --name-only --diff-filter=U | wc -l - -# Show conflicts with context -git status | grep 'both modified' -``` - -## bash: Verify No Conflict Markers Remain - -```bash -# Check for leftover conflict markers -git grep -l '<<<<<<' - -# If any files show up, you missed editing them -git add -git rebase --continue -``` - -## bash: Compare Your vs Incoming Changes - -```bash -# See only YOUR changes in this conflict -git show :1: # Base version -git show :2: # Your version -git show :3: # Their version - -# Compare two versions: -git show :2: > yours.txt -git show :3: > theirs.txt -diff yours.txt theirs.txt -``` - -## Using Bundled Scripts - -This skill includes helper scripts in `scripts/`: - -- **`pre-rebase-backup.sh`**: Creates a safety backup before rebasing -- **`analyze-conflicts.sh`**: Analyzes current conflicts and provides detailed information -- **`validate-merge.sh`**: Validates that merge/rebase is clean and ready for testing - -See SKILL.md for usage instructions. diff --git a/.agents/skills/git-rebase/references/strategies.md b/.agents/skills/git-rebase/references/strategies.md deleted file mode 100644 index 334d054..0000000 --- a/.agents/skills/git-rebase/references/strategies.md +++ /dev/null @@ -1,367 +0,0 @@ -# Conflict Resolution Strategies Reference - -## Strategy Decision Matrix - -Choose your strategy based on your situation: - -| Scenario | Strategy | Complexity | Risk | Time | Best For | -| ------------------------ | ------------- | ---------- | -------- | --------- | ----------------------- | -| 3+ commits, many changes | Squash First | Medium | Low | Fast | Feature branches, CI/CD | -| 1-2 commits, simple | Simple Rebase | Low | Low | Fast | Quick hotfixes | -| Clean history needed | Interactive | High | Medium | Slow | Production code | -| Same conflicts repeat | Rerere | Low | Very Low | Very Fast | Complex rebases | -| Unknown complexity | Merge instead | Very Low | Very Low | Fast | When in doubt | - ---- - -## Deep Dive: Each Strategy - -### 1. Squash First (Recommended) - -**How it works**: - -```bash -# Squash all commits into one -git rebase -i origin/main # Keeps your branch's history intact -# Mark commits as 's' (squash) to combine them -# Then rebase once against main -``` - -**When to use**: - -- 3+ commits to rebase -- Multiple files changed -- Conflicts expected -- Want clean commit history on main - -**Advantages**: - -- ✅ Resolve conflicts ONCE (not per-commit) -- ✅ Final code is one clean commit -- ✅ Faster for many commits -- ✅ Easy to understand final state - -**Disadvantages**: - -- ❌ Lose intermediate commit history -- ❌ Can't debug individual commits later -- ❌ Harder to bisect if bug is introduced - -**Example**: - -```bash -$ git rebase -i $(git merge-base HEAD origin/main) - -# Editor shows: -pick a1b2c3d feat: add authentication -pick d4e5f6g fix: handle edge case -pick h7i8j9k chore: update dependencies - -# Change to: -pick a1b2c3d feat: add authentication -s d4e5f6g fix: handle edge case -s h7i8j9k chore: update dependencies - -# Save - commits squash into one -# Write new commit message describing entire feature - -$ git rebase origin/main -# Resolve conflicts once -$ git rebase --continue -$ git push origin feature-branch --force-with-lease -``` - ---- - -### 2. Interactive Rebase (Full Control) - -**How it works**: - -```bash -git rebase -i origin/main -# Reorder, edit, squash individual commits before rebasing -``` - -**When to use**: - -- Want to keep some commits, squash others -- Need to reorder commits strategically -- Want to reword commit messages -- History preservation is important - -**Advantages**: - -- ✅ Full control over commit order -- ✅ Can keep important intermediate commits -- ✅ Can edit commit messages -- ✅ Excellent for code review - -**Disadvantages**: - -- ❌ More conflict resolution iterations -- ❌ Takes more time -- ❌ More error-prone - -**Interactive rebase commands**: - -``` -pick = use commit -reword = use commit, but edit message -squash = use commit but combine with previous -fixup = like squash, but discard log message -drop = remove commit -exec = run command between commits -``` - -**Example**: - -```bash -$ git rebase -i origin/main - -# Before: -pick commit1 - feat: part A -pick commit2 - feat: part B -pick commit3 - test: add tests -pick commit4 - chore: lint - -# Edit to: -pick commit1 - feat: part A -squash commit2 - feat: part B # Combine with part A -squash commit3 - test: add tests # Include tests -drop commit4 - chore: lint # Remove lint-only commit - -# Save and edit final message to describe everything -``` - ---- - -### 3. Simple Linear Rebase (Fastest) - -**How it works**: - -```bash -git rebase origin/main -# Git replays all your commits directly, no intermediate edits -``` - -**When to use**: - -- 1-2 commits only -- Simple changes, minimal conflicts -- Don't care about reordering -- Want fastest possible rebase - -**Advantages**: - -- ✅ Fastest to execute -- ✅ Simplest conceptually -- ✅ Preserves commit history as-is - -**Disadvantages**: - -- ❌ Conflict per commit (can be slow) -- ❌ Can't reorder or edit commits -- ❌ Loses opportunity to clean up history - ---- - -### 4. Git Rerere (Smart Replay) - -**How it works**: - -```bash -git config --global rerere.enabled true -# Git records your conflict resolutions -# Automatically replays them when same conflict appears -``` - -**When to use**: - -- Rebasing many commits against target with repeated conflicts -- Same file changed in multiple of your commits -- Want to avoid re-resolving the same conflicts - -**Advantages**: - -- ✅ Automatic conflict replay -- ✅ Huge time savings for complex rebases -- ✅ Consistent resolutions - -**Disadvantages**: - -- ❌ Can hide mistakes if recorded wrong -- ❌ Requires careful first resolution -- ❌ Only works for identical conflict patterns - -**How to use**: - -```bash -# Enable (one-time) -git config --global rerere.enabled true - -# Now when you rebase: -git rebase origin/main - -# First conflict: You resolve it -# [Edit file, git add, git rebase --continue] - -# Second identical conflict: Git automatically applies same resolution -# Just keep going: git rebase --continue - -# Git has learned your conflict pattern and replays it -``` - -**Track rerere database**: - -```bash -# See all recorded resolutions -ls -la .git/rr-cache/ - -# Review a specific resolution -cat .git/rr-cache//preimage -cat .git/rr-cache//postimage -``` - ---- - -### 5. Merge Instead (Safest Alternative) - -**How it works**: - -```bash -git merge origin/main -# Combines two branches, creates merge commit -``` - -**When to use**: - -- Shared branches (never rebase shared branches!) -- Too complex to rebase -- Team collaboration on same branch -- Want to preserve branch history - -**Advantages**: - -- ✅ Preserves both histories -- ✅ Can't lose commits -- ✅ Safe for shared branches -- ✅ Clearer integration point - -**Disadvantages**: - -- ❌ Less clean history -- ❌ Creates merge commit -- ❌ Harder to bisect -- ❌ More verbose history - ---- - -## Automation Strategies (Pipeline Use) - -### Risky: Auto-Accept With `-X` - -```bash -# Accept all INCOMING changes (main's version) -git rebase -X theirs origin/main - -# Accept all YOUR changes (your version) -git rebase -X ours origin/main - -# Three-way recursive merge (safest auto-strategy) -git rebase -X recursive origin/main -``` - -**ONLY use when**: - -1. You have comprehensive tests covering all changes -2. You understand the implications -3. You've verified the strategy on a test branch first -4. Changes are non-critical (not user-facing) - -**Recommendation**: Avoid auto-resolution in production pipelines. - ---- - -## Conflict Complexity Indicators - -### Low Complexity (Safe to Auto-Resolve) - -- ✅ Changes in different files -- ✅ Same file, different functions/methods -- ✅ Whitespace/formatting conflicts -- ✅ Import reordering - -### Medium Complexity (Manual Review Needed) - -- ⚠️ Changes in same function but different branches -- ⚠️ Logic changes (if statements, loops) -- ⚠️ Return values or flow changes -- ⚠️ API changes - -### High Complexity (Very Careful) - -- ❌ Same line changed differently -- ❌ Critical business logic -- ❌ Security/authentication code -- ❌ Database migrations - ---- - -## Decision Tree: Which Strategy? - -``` -START: Do I need to rebase? - | - +-> Is this a shared branch? - |-> YES: Use MERGE instead, don't rebase - |-> NO: Continue - | - +-> How many commits to rebase? - |-> 1-2 commits: - | +-> Use SIMPLE LINEAR REBASE - | +-> Quick, straightforward - | - |-> 3-10 commits: - | +-> Are conflicts expected? - | +-> YES: Use SQUASH FIRST - | +-> NO: Use INTERACTIVE REBASE - | - |-> 10+ commits: - +-> Almost always: Use SQUASH FIRST - +-> Too many conflict iterations otherwise - | - +-> Any other concerns? - |-> I've rebased this file before: Enable RERERE - |-> Feeling uncertain: Use MERGE instead - |-> Need clean history: Use INTERACTIVE - |-> Just want it done: Use SQUASH FIRST - -END: Choose strategy and proceed -``` - ---- - -## Post-Resolution Validation - -After resolving conflicts, always validate: - -```bash -# 1. Check syntax (prevents runtime errors) -npm run lint -npm run type-check # If TypeScript - -# 2. Check logic (review changes) -git diff HEAD origin/main -- - -# 3. Run tests (catches integration issues) -npm test - -# 4. Manual smoke test (try key features) -npm start -# Test the feature manually - -# 5. Only then force push -git push origin branch --force-with-lease -``` - -**Rule**: Never push code you haven't tested. diff --git a/.agents/skills/git-rebase/references/troubleshooting.md b/.agents/skills/git-rebase/references/troubleshooting.md deleted file mode 100644 index 6b65b52..0000000 --- a/.agents/skills/git-rebase/references/troubleshooting.md +++ /dev/null @@ -1,70 +0,0 @@ -# Troubleshooting Common Rebase Issues - -## Problem: "CONFLICT (content): Merge conflict in X" - -**What it means**: File X has conflicting changes from both branches - -**Solution**: - -1. `git status` to see all conflicts -2. Edit each conflicted file (look for `<<<<<<<` markers) -3. `git add ` -4. `git rebase --continue` - -## Problem: "fatal: cannot lock ref 'refs/heads/...'" - -**What it means**: Git can't write to branch (another command is running or permission issue) - -**Solution**: - -```bash -# Check if another git process is running -ps aux | grep git - -# Kill it if safe: kill - -# Or retry after a moment -# If persistent: check file permissions in .git/ -``` - -## Problem: Rebase seems stuck (no prompt) - -**What it means**: Your editor didn't open, or it's waiting for input - -**Solution**: - -```bash -# Check if you have an editor set -git config --global core.editor - -# Set one if not configured -git config --global core.editor "nano" # or "vim", "code", etc. - -# Resume rebase -git rebase --continue -``` - -## Problem: "Your branch has diverged" - -**What it means**: After rebase, local and remote have different history - -**Solution**: - -```bash -# This is EXPECTED after rebase -# Force push safely to update remote -git push origin $(git rev-parse --abbrev-ref HEAD) --force-with-lease -``` - -## Problem: Lost Commits After Force Push - -**Recovery**: - -```bash -# Your backup branch saves you -git reset --hard backup-rebase- - -# Or use reflog to find the SHA -git reflog -git reset --hard -``` diff --git a/.agents/skills/git-rebase/scripts/analyze-conflicts.sh b/.agents/skills/git-rebase/scripts/analyze-conflicts.sh deleted file mode 100755 index 1130d92..0000000 --- a/.agents/skills/git-rebase/scripts/analyze-conflicts.sh +++ /dev/null @@ -1,107 +0,0 @@ -#!/bin/bash - -# analyze-conflicts.sh -# Analyzes git rebase conflicts and provides detailed information -# Usage: bash analyze-conflicts.sh - -set -e - -echo "🔍 Git Rebase Conflict Analyzer" -echo "======================================" -echo "" - -# Check if we're in a rebase -if [ ! -d ".git/rebase-merge" ] && [ ! -d ".git/rebase-apply" ]; then - echo "❌ No active rebase detected" - echo "Start a rebase first: git rebase origin/main" - exit 1 -fi - -echo "✓ Active rebase detected" -echo "" - -# Get conflicting files -echo "📋 Conflicted Files:" -CONFLICTED_FILES=$(git diff --name-only --diff-filter=U) -CONFLICT_COUNT=$(echo "$CONFLICTED_FILES" | wc -l) - -echo " Found $CONFLICT_COUNT file(s) with conflicts:" -echo "$CONFLICTED_FILES" | sed 's/^/ - /' -echo "" - -# Analyze each conflict -echo "🔎 Conflict Analysis:" -echo "" - -for file in $CONFLICTED_FILES; do - echo "📄 File: $file" - - # Count conflict markers - MARKER_COUNT=$(($(grep -c "<<<<<<" "$file" || echo 0))) - echo " Conflict sections: $MARKER_COUNT" - - # Show file size - FILE_SIZE=$(wc -c < "$file") - echo " File size: $FILE_SIZE bytes" - - # Show line count - LINE_COUNT=$(wc -l < "$file") - echo " Total lines: $LINE_COUNT" - - # Check for conflict markers - if grep -q "<<<<<<" "$file"; then - echo " Status: ⚠️ UNRESOLVED (has conflict markers)" - - # Show first conflict - FIRST_CONFLICT=$(grep -n "<<<<<<" "$file" | head -1 | cut -d: -f1) - echo " First conflict at line: $FIRST_CONFLICT" - - # Show context around first conflict (3 lines before and after markers) - echo " Context:" - sed -n "$((FIRST_CONFLICT-3)),$((FIRST_CONFLICT+10))p" "$file" | \ - sed 's/^/ /' - else - echo " Status: ✓ RESOLVED (no conflict markers)" - fi - - echo "" -done - -# Summary -echo "📊 Summary:" -UNRESOLVED=$(for f in $CONFLICTED_FILES; do - grep -l "<<<<<<" "$f" 2>/dev/null || true -done | wc -l) - -RESOLVED=$((CONFLICT_COUNT - UNRESOLVED)) - -echo " ✓ Resolved: $RESOLVED" -echo " ⚠️ Unresolved: $UNRESOLVED" -echo "" - -# Get rebase progress -if [ -f ".git/rebase-merge/msgnum" ]; then - CURRENT=$(cat .git/rebase-merge/msgnum) - TOTAL=$(cat .git/rebase-merge/end) - echo "📈 Rebase Progress: $CURRENT / $TOTAL commits" - echo "" -fi - -# Suggestions -echo "💡 Next Steps:" -if [ "$UNRESOLVED" -gt 0 ]; then - echo " 1. Edit conflicted files and resolve all markers" - echo " 2. Run: bash scripts/validate-merge.sh" - echo " 3. Run: npm run lint && npm test" - echo " 4. Run: git add ." - echo " 5. Run: git rebase --continue" -else - echo " 1. All conflicts appear resolved!" - echo " 2. Run: bash scripts/validate-merge.sh" - echo " 3. Run: npm run lint && npm test" - echo " 4. Run: git add ." - echo " 5. Run: git rebase --continue" -fi - -echo "" -echo "✅ Analysis complete" diff --git a/.agents/skills/git-rebase/scripts/pre-rebase-backup.sh b/.agents/skills/git-rebase/scripts/pre-rebase-backup.sh deleted file mode 100755 index da02cd2..0000000 --- a/.agents/skills/git-rebase/scripts/pre-rebase-backup.sh +++ /dev/null @@ -1,94 +0,0 @@ -#!/bin/bash - -# pre-rebase-backup.sh -# Creates a safe backup before starting a rebase -# Usage: bash pre-rebase-backup.sh [branch-name] - -echo "🔐 Pre-Rebase Safety Backup" -echo "======================================" -echo "" - -# Get current branch -CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD) - -if [ "$CURRENT_BRANCH" = "HEAD" ]; then - echo "❌ You're in detached HEAD state" - echo "Checkout a branch first: git checkout your-branch" - exit 1 -fi - -# Check if clean -if [ -n "$(git status --porcelain)" ]; then - echo "⚠️ Warning: You have uncommitted changes" - echo "Commit or stash them first:" - echo " git add . && git commit -m 'work in progress'" - echo " or" - echo " git stash" - exit 1 -fi - -echo "Current branch: $CURRENT_BRANCH" -echo "" - -# Create timestamped backup -TIMESTAMP=$(date +%Y%m%d_%H%M%S) -BACKUP_BRANCH="backup-rebase-${CURRENT_BRANCH}-${TIMESTAMP}" - -echo "Creating backup branch..." -git branch "$BACKUP_BRANCH" - -# Get current commit -CURRENT_SHA=$(git rev-parse HEAD) - -echo "" -echo "✅ Backup created successfully!" -echo "" -echo "📋 Backup Details:" -echo " Branch name: $BACKUP_BRANCH" -echo " Commit SHA: $CURRENT_SHA" -echo "" - -# Save backup info to file for easy recovery -BACKUP_INFO_FILE=".rebase-backup-info" -cat > "$BACKUP_INFO_FILE" << EOF -# Rebase Backup Information -# Created: $(date) - -BACKUP_BRANCH=$BACKUP_BRANCH -CURRENT_BRANCH=$CURRENT_BRANCH -COMMIT_SHA=$CURRENT_SHA -TIMESTAMP=$TIMESTAMP - -# To recover from this backup: -# git reset --hard backup-rebase-${CURRENT_BRANCH}-${TIMESTAMP} -EOF - -echo "💾 Backup info saved to: $BACKUP_INFO_FILE" -echo "" - -# Show git reflog for additional recovery options -echo "📚 Backup Recovery Instructions:" -echo "" -echo "If something goes wrong, you can recover using:" -echo "" -echo " Option 1 (Recommended): Use the backup branch" -echo " git reset --hard $BACKUP_BRANCH" -echo "" -echo " Option 2: Use git reflog" -echo " git reflog # Find your commit SHA" -echo " git reset --hard " -echo "" - -# List all backup branches -echo "🗂️ All Backup Branches:" -git branch | grep "backup-rebase" | tail -5 | sed 's/^/ - /' -echo "" - -echo "✅ You're ready to rebase safely!" -echo "" -echo "Next steps:" -echo " 1. git fetch origin" -echo " 2. git rebase origin/main" -echo " 3. Resolve any conflicts" -echo " 4. git rebase --continue" -echo " 5. git push origin $CURRENT_BRANCH --force-with-lease" diff --git a/.agents/skills/git-rebase/scripts/validate-merge.sh b/.agents/skills/git-rebase/scripts/validate-merge.sh deleted file mode 100755 index 1997b9f..0000000 --- a/.agents/skills/git-rebase/scripts/validate-merge.sh +++ /dev/null @@ -1,106 +0,0 @@ -#!/bin/bash - -# validate-merge.sh -# Validates that merge/rebase is clean and ready for testing -# Usage: bash validate-merge.sh - -set -e - -echo "🔍 Merge/Rebase Validation" -echo "======================================" -echo "" - -EXIT_CODE=0 - -# 1. Check for remaining conflict markers -echo "1️⃣ Checking for conflict markers..." -if git grep -l '<<<<<<\|======\|>>>>>>' 2>/dev/null | wc -l | grep -q '^0$'; then - echo " ✓ No conflict markers found" -else - echo " ❌ FOUND conflict markers:" - git grep -l '<<<<<<\|======\|>>>>>>' 2>/dev/null || true - echo "" - echo " ⚠️ Please resolve these files before continuing" - EXIT_CODE=1 -fi -echo "" - -# 2. Check for unresolved git conflicts -echo "2️⃣ Checking git status..." -if [ -z "$(git diff --name-only --diff-filter=U)" ]; then - echo " ✓ No unresolved conflicts in git status" -else - echo " ❌ Git still shows unresolved conflicts:" - git diff --name-only --diff-filter=U | sed 's/^/ - /' - EXIT_CODE=1 -fi -echo "" - -# 3. Check if working directory is clean -echo "3️⃣ Checking working directory..." -if git status --porcelain | grep -q '^ M\| ??'; then - echo " ⚠️ Unstaged changes found:" - git status --short | sed 's/^/ /' - echo "" - echo " Tip: Run 'git add .' to stage all changes" -else - echo " ✓ Working directory clean" -fi -echo "" - -# 4. Check for duplicate code (merged sections) -echo "4️⃣ Checking for duplicate code patterns..." -COMMON_DUPES=$(git diff HEAD | grep -c '^+.*{$' || echo 0) -if [ "$COMMON_DUPES" -lt 10 ]; then - echo " ✓ No obvious code duplication detected" -else - echo " ⚠️ Possible code duplication (many new code blocks):" - echo " Manual review recommended" -fi -echo "" - -# 5. Check for common merge markers in comments -echo "5️⃣ Checking for merge message artifacts..." -if git diff HEAD | grep -i "merged\|conflict\|cherry-pick\|rebase"; then - echo " ⚠️ Found merge-related comments in changes" - echo " Review to ensure they're intentional" -else - echo " ✓ No merge artifacts in comments" -fi -echo "" - -# 6. Show changed files -echo "6️⃣ Changed Files Summary:" -CHANGED=$(git diff --name-only) -CHANGED_COUNT=$(echo "$CHANGED" | grep -c . || echo 0) -echo " $CHANGED_COUNT files changed:" -echo "$CHANGED" | sed 's/^/ - /' -echo "" - -# 7. Show stats -echo "7️⃣ Diff Statistics:" -git diff --stat | sed 's/^/ /' -echo "" - -# Final status -echo "======================================" -if [ $EXIT_CODE -eq 0 ]; then - echo "✅ Validation PASSED - Ready for testing!" - echo "" - echo "Next steps:" - echo " 1. Run tests: npm test" - echo " 2. Run linter: npm run lint" - echo " 3. Manual smoke test if needed" - echo " 4. Then: git rebase --continue" -else - echo "❌ Validation FAILED - Fix issues before proceeding" - echo "" - echo "Issues found:" - echo " - Conflict markers remain (resolve in files)" - echo " - Git shows unresolved conflicts" - echo " - Other validation errors above" - echo "" - echo "Fix and run: bash validate-merge.sh" -fi - -exit $EXIT_CODE diff --git a/.claude/ledger/2026-06-27-MEMORY-okf-skill-sync.md b/.claude/ledger/2026-06-27-MEMORY-okf-skill-sync.md new file mode 100644 index 0000000..d15a68b --- /dev/null +++ b/.claude/ledger/2026-06-27-MEMORY-okf-skill-sync.md @@ -0,0 +1,28 @@ +## Memory Ledger — OKF skill sync + +- **Goal:** Update the built-in `kb` skill (`skills/kb/`) to document the OKF dual-mode feature implemented under `.compozy/tasks/okf-dual-mode`. Success = skill accurately covers `mode: wiki|okf`, `kb topic new --mode okf`, `kb promote`, `kb okf check`, `[okf].types`, OKF bundle layout, and the producer-field/relative-link contract. +- **Constraints/Assumptions:** Doc-only change to `skills/kb/`. No code changes. Must match the SHIPPED behavior (verified against source, not just the techspec). Run `make verify` only if any Go is touched (it is not), but at least sanity-check. +- **Key decisions:** + - Skill is at `skills/kb/SKILL.md` (the "built-in kb skill"), not `.agents/skills/...`. + - Add OKF coverage to SKILL.md (frontmatter desc, intro, dedicated section, quick ref, dispatch, errors, constraints) + new `references/okf-mode.md` + OKF concept schema in `references/frontmatter-schemas.md`. +- **State:** DONE. Docs updated and verified against shipped behavior. +- **Done:** Verified impl against source; updated `skills/kb/SKILL.md` (desc, intro mode model, new OKF Dual-Mode section, quick-ref topic `--mode` + OKF block, dispatch rows, Procedure 6 distill loop, auto-log clarification, error rows, constraints); added `skills/kb/references/okf-mode.md`; updated `references/frontmatter-schemas.md` (topic.yaml `mode`, OKF concept schema, quick-ref rows); added OKF note to `references/architecture.md`. Confirmed `topic.yaml` writes `mode`. +- **Now:** — +- **Next:** Optional — user may want a commit (not requested). +- **Open questions:** none (UNCONFIRMED: whether skill is embedded into binary — appears to be a standalone repo skill dir; no embed.FS found referencing skills/kb). +- **Working set:** `skills/kb/SKILL.md`, `skills/kb/references/okf-mode.md` (new), `skills/kb/references/frontmatter-schemas.md`. + +### Shipped OKF facts (ground truth) +- Topic mode: `mode: wiki|okf` in `topic.yaml` (empty→wiki). `kb topic new <domain> --mode wiki|okf` (default wiki). +- OKF scaffold = FLAT: topic dir + `CLAUDE.md` (OKF template) + `topic.yaml` (`mode: okf`) + `AGENTS.md` symlink + `index.md` (frontmatter `okf_version: "0.1"`, body `# OKF Bundle Index`) + `log.md` (`# Directory Update Log` + Initialization entry under ISO date `## YYYY-MM-DD`). No raw/wiki/outputs/bases, no .gitkeep. +- `kb promote <wiki-doc> --to <okf-topic> --type <T> [--description <text>]` (both `--to` and `--type` REQUIRED). Mechanical, non-LLM, non-destructive. Writes JSON ConceptResult. + - Errors if `--to` is not a `mode: okf` topic ("target topic must use mode okf"). + - Concept filename = `<slugify(base(source-path))>.md` at bundle root; collisions get `-2`, `-3`. + - Frontmatter written: `description, tags(optional), timestamp(RFC3339 UTC), title, type` (alphabetical). title←source title or humanized key; description←--description else first body sentence else empty+warning; tags←source tags if present. Wiki stage markers dropped. + - `[[wikilinks]]`→relative markdown links `[label](key.md[#anchor])`. Unresolved targets still emitted + recorded in `unresolvedLinks`. + - Auto regenerates `index.md` (type-grouped bullets) and inserts newest-first `log.md` entry. Source untouched. +- `kb okf check <topic> [--strict] [--format table|json|tsv]`. Lenient per OKF §9. Columns: severity, kind, filePath, target, message. Non-zero exit on errors (and on warnings under --strict). + - ERROR: concept missing/empty `type`, unparseable frontmatter, root non-`index.md` declaring okf_version, root index.md with non-`okf_version` keys, log `## ` heading not YYYY-MM-DD. + - WARNING (→error w/ --strict): missing producer field title/description/timestamp; `type` outside `[okf].types` (only when vocab non-empty). + - Excluded files (never need type): `index.md`, `log.md`, `CLAUDE.md`, `AGENTS.md`, symlinks, README/LICENSE/NOTICE/ATTRIBUTION, dotdirs. +- Config: `[okf].types = []` (empty ⇒ no vocab warnings). Also surfaced in `config.example.toml`. diff --git a/.compozy/tasks/okf-dual-mode/_prd.md b/.compozy/tasks/okf-dual-mode/_prd.md new file mode 100644 index 0000000..b1fd557 --- /dev/null +++ b/.compozy/tasks/okf-dual-mode/_prd.md @@ -0,0 +1,275 @@ +# PRD: OKF Dual-Mode for `kb` + +## Overview + +`kb` today implements one knowledge lifecycle: the Karpathy **LLM-Wiki** — a +personal research *lab* where raw sources are ingested, compiled into a wiki, +queried, and linted, linked with Obsidian `[[wikilinks]]`. + +The **Open Knowledge Format (OKF)** v0.1 (Google Cloud, 2026-06-12) standardizes +the same markdown + YAML-frontmatter substrate for a different purpose: a +portable, vendor-neutral **catalog** that other people's agents and tools consume. +It requires exactly one thing of every concept — a `type` field — uses plain +markdown links, and is meant to be *declared and shared*, not *explored*. + +These are two lifecycles, not one derived from the other: + +- **LLM-Wiki** = a lab: `ingest → compile → query → lint`; for the operator to + research. +- **OKF** = a catalog/API: `declare → consume`; for others (humans and agents) to + consume; portable. + +This duality already exists by hand in the operator's workspace: a `research/` +area (LLM-wiki, where they research) and a `second-brain/` area (OKF, where they +declare "what is mine" for operations). The daily, highest-leverage move is +**distilling research into the operational catalog** — done manually today. + +This feature makes both lifecycles first-class in `kb` via a per-topic +**`mode: wiki | okf`** (default `wiki`, so nothing regresses), and ships the +distill loop as a one-command **`promote`** plus an OKF **conformance check**. It +is valuable because it turns a hand-maintained workflow into a tool-enforced one, +and positions `kb` as an OKF-conformant producer in a brand-new, vendor-neutral +ecosystem. + +## Goals + +- Let an operator maintain LLM-Wiki research and OKF catalogs in the same tool, + with the research → catalog distill loop tool-enforced. +- Ship OKF as a first-class authoring mode (not an export-only projection). +- Produce bundles that are conformant with OKF v0.1 and aligned with Google's real + sample bundles and tooling. +- Preserve a consistent, drift-free local standard for concept types. +- Guarantee zero regression for existing wiki topics. +- Milestone: MVP delivers `mode` + OKF scaffold + `promote` + conformance check; + Phase 2 adds publish/consume interop; Phase 3 adds codebase → OKF. + +## User Stories + +**Primary persona — the Knowledge Operator** (researches in wiki mode, declares +"what's mine" in OKF mode, distills daily): + +- As an operator, I want to mark a topic as an OKF bundle so `kb` scaffolds and + lints it as a portable catalog instead of a research lab. +- As an operator, I want to promote a finished wiki concept into my OKF bundle + with one command so my research compounds into my operational catalog without + manual reformatting or re-linking. +- As an operator, I want my source research to stay untouched when I promote, so + the lab remains the immutable record and the catalog is the distilled view. +- As an operator, I want `kb` to validate my bundle against OKF conformance so my + catalog stays portable and consumable by agents. +- As an operator, I want a canonical list of concept types so my bundle stays + consistent and avoids type drift over time. +- As an operator, I want my existing wiki topics to keep working exactly as before + when OKF mode lands. + +**Secondary personas** (later phases): + +- As a knowledge publisher, I want to export a wiki topic as a portable OKF bundle + so others can consume it (Phase 2). +- As an AI agent / consuming tool, I want to read a `kb`-produced bundle as + standard OKF so I can use it without a custom integration (enabled throughout). + +## Core Features + +Grouped by priority. MVP features first. + +### 1. OKF mode for topics (MVP) + +- A per-topic **`mode: wiki | okf`** setting, defaulting to `wiki`. +- `kb topic new --mode okf` scaffolds an **OKF bundle** (flat typed concepts, + auto-maintained `index.md` and `log.md`) instead of the wiki pyramid + (`raw/` + `wiki/` + `outputs/`). +- An OKF bundle is a `kb` topic; it reuses the topic, vault, lint, and index + machinery. `mode` selects scaffold shape, link format, frontmatter contract, and + lint ruleset. + +### 2. `kb promote` — wiki → OKF distill (MVP) + +- `kb promote <wiki-doc> --to <okf-topic> --type <ConceptType>` creates a new + typed OKF concept in the target bundle from a compiled wiki document. +- **Mechanical and non-LLM**: converts `[[wikilinks]]` to relative markdown links, + remaps wiki frontmatter to the OKF contract (`type`, `title`, `description`, + `timestamp`), and appends a dated entry to the bundle's `log.md`. +- **Non-destructive**: the source wiki document stays in research; a new concept is + born in the catalog. +- Any intelligent rewriting/condensing is left to the operator or an external + agent — `kb` does the structural work only. + +### 3. OKF conformance check (MVP) + +- A conformance command for an OKF bundle that validates OKF v0.1: + - every non-reserved `.md` has parseable frontmatter with a non-empty `type`; + - `index.md` / `log.md` follow their required shapes when present. +- **Lenient by default** (per spec §9): tolerates broken cross-links, unknown + `type` values, and missing optional fields (so externally produced bundles pass). +- **Local standards as warnings** on the operator's own bundles: all four producer + fields present, and `type` within the configured vocabulary. +- A `--strict` option promotes warnings to errors for CI gates. + +### 4. Concept-type governance (MVP) + +- A canonical **type vocabulary** in `kb.toml` (e.g. `[okf].types`). +- `promote` and authoring take `type` explicitly; a value outside the vocabulary + produces a lint warning. The vocabulary is extended by editing `kb.toml`. +- Prevents type drift (`Voice Profile` vs `voice-profile`) and keeps a consistent + local standard, while staying compatible with OKF's "no global registry" model. + +### 5. OKF-native emission (MVP, underpins 1–4) + +- Relative, GitHub-safe markdown links (not `[[wikilinks]]`, not absolute paths). +- The four producer fields on every concept; auto-maintained `index.md` + (type-grouped bullet lists) and `log.md` (ISO-date entries, newest first). + +### Later phases (not MVP) + +- **`kb export okf`** — publish an existing wiki topic as a portable bundle + (Phase 2). +- **`kb ingest okf`** — consume an external OKF bundle into research (Phase 2). +- **Codebase → OKF** — emit the typed symbol/relation graph as a bundle + (Phase 3, differentiator demo). + +## User Experience + +**Operator journey (MVP):** + +1. **Create a catalog.** `kb topic new <name> --mode okf` scaffolds an OKF bundle. + Existing topics are unaffected (they remain `mode: wiki`). +2. **Research as usual.** Ingest and compile in wiki-mode topics — unchanged. +3. **Distill.** When a wiki concept is ready, `kb promote <wiki-doc> --to <bundle> + --type <ConceptType>`. The concept appears in the catalog with relative links, + the four producer fields, and a `log.md` entry; the source stays put. +4. **Verify.** Run the conformance check on the bundle; fix any warnings (missing + fields, off-vocabulary types). Use `--strict` in CI. +5. **Consume / share.** The bundle is plain markdown + frontmatter — readable in + Obsidian/GitHub and parseable by any OKF-aware agent. + +**UX considerations:** + +- `mode` is invisible to existing users; OKF behavior is opt-in per topic. +- `promote` is one command with a predictable, mechanical result (no surprises + from an LLM in the loop). +- Conformance output is actionable (what failed, where, how to fix) and + distinguishes hard errors from local-standard warnings. +- Discoverability: OKF options surface under existing `kb topic` / `kb lint`-style + help; `kb.toml` documents the type vocabulary. + +## High-Level Technical Constraints + +Product boundaries (not implementation): + +- **Conform to OKF v0.1**, and remain aligned with Google's reference samples + (GA4, Stack Overflow, Bitcoin) and producer behavior. +- **Two deliberate, documented deviations** from the written spec, matching real + tooling: emit the four producer fields (not just `type`), and emit relative + links (not the spec-recommended absolute links, which break GitHub rendering). +- **Stay non-LLM.** The core workflow, including `promote`, performs no model + inference; `kb` remains "the non-LLM workflow." +- **Zero regression.** Default `mode: wiki`; existing topics scaffold, lint, and + build identically; no migration required. +- **Portable output.** Bundles are usable without `kb`: plain files renderable in + Obsidian/GitHub and parseable by any OKF consumer. +- OKF is a v0.1 draft; conformance rules must be pinned to v0.1 and isolated so the + spec can evolve without disrupting the rest of the tool. + +## Non-Goals (Out of Scope) + +- **Export and ingest interop** (`kb export okf`, `kb ingest okf`) — Phase 2. +- **Codebase → OKF** emission — Phase 3. +- **LLM-assisted distillation** inside `promote` (no `--distill`) — possibly a + later flag, not now. +- **A global/shared type registry** — only a local per-vault vocabulary. +- **Whole-vault or cross-topic OKF operations** — OKF is per-topic/bundle. +- **An HTML/graph visualizer** for bundles. +- **Migrating existing wiki topics** to OKF automatically. +- **Changing the LLM-Wiki lifecycle** — wiki mode behavior is unchanged. + +## Phased Rollout Plan + +### MVP (Phase 1) — the distill loop + +- OKF mode (`mode: wiki | okf`) + `kb topic new --mode okf` scaffold. +- `kb promote` (mechanical, non-destructive, non-LLM). +- OKF conformance check (lenient + local warnings + `--strict`). +- Concept-type vocabulary in `kb.toml`. +- OKF-native emission (relative links, four producer fields, auto `index.md` / + `log.md`). +- **Success criteria to proceed:** the conformance check passes on all three + official sample bundles; the operator's catalog passes the check; a real wiki + concept promotes end-to-end and the result conforms; existing wiki topics show + zero behavior change. + +### Phase 2 — publish & consume interop + +- `kb export okf` (wiki topic → portable bundle). +- `kb ingest okf` (external bundle → research), accepting real-world bundles under + lenient conformance. +- **Success criteria to proceed:** a wiki topic round-trips through export and back + through ingest without loss of conceptual content; third-party sample bundles + ingest cleanly. + +### Phase 3 — codebase → OKF + +- Emit the existing typed symbol/relation graph as an OKF bundle (concepts = + files/symbols, links = relations). +- **Long-term success:** a real codebase produces a conformant, navigable OKF + bundle usable as agent context. + +## Success Metrics + +- **Spec correctness:** the conformance check passes on all three official OKF + sample bundles (GA4, Stack Overflow, Bitcoin). +- **Loop works end-to-end:** a real wiki concept promotes into a bundle and the + result conforms, with links resolved and the four producer fields present. +- **Operator catalog conformant:** the existing `second-brain/` catalog passes the + conformance check (with local standards) once adopted. +- **Zero regression:** existing wiki topics scaffold, lint, and build identically + to before (verified by the existing suite). +- **No type drift:** every concept type in the operator's bundle is within the + `kb.toml` vocabulary. +- **Adoption:** the operator stops distilling by hand — `promote` replaces the + manual copy/reformat step. + +## Risks and Mitigations + +- **Adoption risk** — if `promote` is not clearly lower-friction than copy/paste, + it won't be used. *Mitigation:* one command, mechanical, predictable output; + non-destructive so it's safe to run freely. +- **Spec-churn risk** — OKF is a v0.1 draft and may change. *Mitigation:* pin to + v0.1; isolate conformance rules; track the spec repo. +- **Scope risk** — the MVP already touches several surfaces. *Mitigation:* strict + phasing; export/ingest/codebase are explicit non-goals for the MVP. +- **Standard-drift risk** — free-text types fragment the catalog. *Mitigation:* + `kb.toml` vocabulary + lint warning + `--strict` in CI. +- **Identity-drift risk** — pressure to add LLM "magic" to `promote`. + *Mitigation:* keep the core non-LLM by decision (ADR-002); defer any + `--distill` flag. +- **Interop-expectation risk** — bundles must be consumable outside `kb`. + *Mitigation:* validate against official samples; emit what real tooling expects + (four fields, relative links). + +## Architecture Decision Records + +- [ADR-001: OKF as a first-class authoring mode, not an export-only projection](adrs/adr-001.md) + — OKF is a first-class per-topic `mode`, an OKF bundle is a `kb` topic, and the + MVP optimizes the research → operations distill loop with phased delivery. +- [ADR-002: promote is a mechanical non-LLM transform; type governance and a spec-compatible conformance posture](adrs/adr-002.md) + — `promote` is mechanical, non-destructive, and non-LLM; `type` is explicit and + governed by a local `kb.toml` vocabulary; conformance emits four fields + relative + links and validates lenient with local warnings and `--strict`. + +## Open Questions + +- **Conformance command surface** — `kb okf check <bundle>` vs `kb lint --okf` + (naming/placement). Defer to TechSpec. +- **Catalog granularity** — within the operator's `second-brain/`, do sub-areas + (`identidade/`, `estudos/`, `conteudo/`, `vendas/`) map to one OKF topic with + nested concept directories, or to multiple OKF topics? Needs confirmation; both + are valid OKF. +- **index.md / log.md ownership** — assumed auto-maintained by `kb` + (generated/updated on scaffold + promote). Confirm whether operators ever + hand-edit them. +- **Off-vocabulary type handling** — assumed "warn + allow + extend via + `kb.toml`". Confirm whether `promote` should suggest the closest existing type. +- **Broken-link reporting on promote** — promoting a concept whose wikilinks point + to not-yet-promoted concepts yields tolerated broken links (per §9). Confirm + whether the operator wants a warning listing them. diff --git a/.compozy/tasks/okf-dual-mode/_techspec.md b/.compozy/tasks/okf-dual-mode/_techspec.md new file mode 100644 index 0000000..42ebb1a --- /dev/null +++ b/.compozy/tasks/okf-dual-mode/_techspec.md @@ -0,0 +1,358 @@ +# TechSpec: OKF Dual-Mode for `kb` + +> Implements `_prd.md`. Business context (WHAT/WHY/WHO) lives there; this document +> covers HOW. Phasing follows the PRD: **MVP** = `mode` + OKF scaffold + +> `kb promote` + `kb okf check`; Phase 2 = export/ingest; Phase 3 = codebase→OKF. + +## Executive Summary + +Add OKF as a first-class topic mode to `kb` by (1) introducing a `mode: wiki|okf` +field on the topic model, (2) abstracting link emission behind a mode-aware +`LinkFormatter`, (3) housing all OKF behavior in a new cohesive `internal/okf` +package (frontmatter contract, wikilink→markdown transform, `index.md`/`log.md` +generation, conformance checker, and the `promote` orchestration), and (4) exposing +a top-level `kb promote` plus a `kb okf` command group. OKF emission stays +**mechanical and non-LLM**, matching `kb`'s identity; conformance is **lenient per +OKF §9** with local-standard **warnings** and a `--strict` gate, emitting the four +producer fields and relative, GitHub-safe links to match Google's real tooling. + +**Primary trade-off:** per the user's decision (ADR-004), the MVP introduces the +full `LinkFormatter` abstraction and migrates **all ~43 link call sites** in the +codebase→wiki render pipeline now, even though that pipeline produces wiki-mode +topics and its OKF branch stays **dormant until Phase 3**. We accept a large +mechanical refactor with no MVP-exercised OKF render path in exchange for a single, +finished mode-aware link seam — guarded against regression by byte-identical golden +tests of wiki output. The MVP's *active* OKF link consumer is `promote` (body +transform) and `index.md` generation. + +## System Architecture + +### Component Overview + +| Component | Home | Responsibility | +| --- | --- | --- | +| `mode` field | `internal/models`, `internal/topic` | Mark a topic as `wiki` (default) or `okf`; normalize empty→`wiki`. | +| `LinkFormatter` | `internal/vault` | Mode-aware link rendering; `WikiLinkFormatter` + `OKFLinkFormatter`. | +| OKF core | `internal/okf` (new) | Frontmatter contract, link transform, `index.md`/`log.md` gen, conformance checker, `promote`. | +| Type vocabulary | `internal/config` | `[okf].types` canonical list for local-standard validation. | +| OKF scaffold | `internal/topic` | `--mode okf` builds a minimal bundle (no wiki pyramid). | +| CLI | `internal/cli` | `kb promote`, `kb okf check`, `kb topic new --mode`. | + +### Data Flow + +- **Scaffold:** `kb topic new --mode okf` → `internal/topic` writes `topic.yaml` + (`mode: okf`), a root `index.md` (`okf_version: "0.1"`), `log.md`, an OKF-flavored + `CLAUDE.md`, and the `AGENTS.md` symlink. No `raw/`/`wiki/`/`outputs/`. +- **Promote:** `kb promote <wiki-doc> --to <topic> --type <T>` → `internal/okf` + reads the source via `vault` + `frontmatter.Parse`, remaps frontmatter to the OKF + contract, transforms `[[wikilinks]]`→relative markdown links, writes the new + concept to `<bundle-root>/<key(base(SourceDocPath))>.md` (see **Concept Path + Model**) via `frontmatter.Generate` + `vault`, appends to `log.md`, and regenerates + `index.md` (preserving the root `okf_version`). Source doc is untouched. +- **Check:** `kb okf check <topic>` → `internal/okf` walks the bundle, **skipping the + reserved/excluded set** (`index.md`, `log.md`, `CLAUDE.md`, `AGENTS.md`, symlinks, + `README`/license files), validates §9 leniently over the remaining concept files, + emits `[]models.LintIssue` rendered by `internal/output`. + +Dependency direction is one-way: `internal/okf` → {`frontmatter`, `vault`, +`config`, `models`}; nothing imports `okf` back (ADR-003). + +## Implementation Design + +### Core Interfaces + +Mode-aware link rendering, defined in `internal/vault` (ADR-004): + +```go +// LinkFormatter renders a link from the document being written (fromDir, +// bundle-relative, forward-slash) to a target document, with an optional label. +type LinkFormatter interface { + Link(fromDir, targetPath, label string) string +} + +// LinkFormatterFor selects the formatter from the topic's mode. +func LinkFormatterFor(topic models.TopicMetadata) LinkFormatter { + if topic.Mode == models.TopicModeOKF { + return OKFLinkFormatter{} + } + return WikiLinkFormatter{Slug: topic.Slug} +} +``` + +OKF promotion and conformance, in `internal/okf`: + +```go +type PromoteInput struct { + SourceDocPath string // wiki doc to read (vault-relative) + TargetTopic models.TopicInfo // an OKF-mode topic (the bundle) + Type string // OKF concept type (validated vs config.OKF.Types) +} + +// ConceptResult reports what promote wrote; UnresolvedLinks tolerated (§9). +type ConceptResult struct { + WrittenPath string // bundle-relative path of the new concept + Type string + LinksRewritten int // [[wikilink]] occurrences converted + UnresolvedLinks []string // targets with no promoted counterpart yet +} + +type Promoter interface { + Promote(ctx context.Context, in PromoteInput) (ConceptResult, error) +} + +type Checker interface { + // Check walks an OKF bundle; strict promotes warnings to errors. + Check(ctx context.Context, bundlePath string, strict bool) ([]models.LintIssue, error) +} +``` + +### Data Models + +Additions only; existing structs are unchanged except for one field each. + +```go +// internal/models +type TopicMode string // defined type (not an alias) per ADR-003 +const ( + TopicModeWiki TopicMode = "wiki" + TopicModeOKF TopicMode = "okf" +) +// TopicMetadata and TopicInfo each gain: Mode TopicMode `json:"mode"` + +// internal/topic topicMetadataFile gains: +// Mode string `yaml:"mode,omitempty"` // empty normalized to "wiki" on read + +// internal/config +type OKFConfig struct { + Types []string `toml:"types"` // local vocabulary; ships EMPTY (no false warnings) +} +// Config gains: OKF OKFConfig `toml:"okf"` +// Empty Types ⇒ the type-vocabulary check is a no-op until the operator opts in. +``` + +**OKF frontmatter contract — source→OKF remap (B-201).** `promote` and authoring +write the four producer fields via this deterministic map; wiki stage markers +(`stage`, wiki `type`) are dropped: + +| OKF field | Source | Fallback | +| --- | --- | --- | +| `type` | `--type` (validated vs `config.OKF.Types`) | none — required; error if absent | +| `title` | source `title` | humanized `base(SourceDocPath)` | +| `description` | `--description` flag | first non-empty body sentence (markdown-stripped) → else empty + warning | +| `timestamp` | promote-time clock, RFC3339 / UTC | — (promotion *is* the "last meaningful change"; source `scraped`/`created` are date-only `2006-01-02`, so they are not the timestamp source) | +| `tags` | source `tags` when present | omitted | + +The clock is injected as a seam (a `Clock` function) so golden/integration tests +assert a fixed `timestamp` instead of freezing a wall-clock literal. + +**Field order:** `frontmatter.Generate` sorts keys alphabetically +(`internal/frontmatter/frontmatter.go:396`), so concepts emit `description, tags, +timestamp, title, type`. This is conformant (YAML maps are unordered); tests MUST +NOT assert a `type`-first producer order. If byte-matching the official samples is +ever required, that needs a dedicated ordered encoder (out of scope). + +### Concept Path Model (resolves B-001 / B-002 / B-203) + +For the MVP an OKF bundle is **one topic**, and promoted concepts land **flat at the +bundle root** (`type`-grouped or nested subdirectories are deferred). A **single +canonical key** drives both the filename and every inbound link, so they can never +diverge (B-203): + +- **Canonical key:** `vault.SlugifySegment(base(path))` — the existing slug primitive + (`internal/vault/pathutils.go:98`; lowercase `[a-z0-9-]`, `"item"` empty fallback, + drops accents so `Conteúdo`→`conte-do`). `internal/okf` reuses it (N-201) rather + than re-implementing a slug. +- **Filename:** a concept from `SourceDocPath` is written to + `<topic-root>/<key(base(SourceDocPath))>.md`. Collisions get a numeric suffix + (`-2`, `-3`, …). +- **Wikilink target mapping:** a body link `[[srcSlug/docPath|label]]` strips the + source slug and maps to `<topic-root>/<key(base(docPath))>.md` — the **same** key + the target's own filename uses, so a link to an already-promoted concept always + resolves to its real file. `OKFLinkFormatter.Link(fromDir, target, label)` renders + it; with a flat root `fromDir == targetDir`, the link is `[label](<key>.md)`. A + `#anchor` fragment is carried through: `[label](<key>.md#anchor)` (N-203). +- **Unresolved targets:** when no promoted counterpart exists yet, the link is still + emitted to the would-be path (tolerated per §9) and recorded in + `ConceptResult.UnresolvedLinks` (logged; a warning under `--strict`). +- **`--to`** names the target OKF topic and **must resolve to a `mode: okf` topic** — + `promote` errors otherwise (N-202), so wiki-formatter output never leaks into a + bundle. `--to <topic>/<subdir>` writes under that subdir (`fromDir` updates + accordingly); the default is the bundle root. + +> Filename trade-off: keying off the source path (not `title`) guarantees +> symmetry/correctness at the cost of less pretty names for ingested docs (a slug of +> a source path/URL). Title-named files with an index/manifest-based link resolver is +> a deliberate Phase-2 option, not MVP. + +> Resolves the PRD open question "catalog granularity": for the MVP a bundle is one +> OKF topic with concepts at its root. Splitting `second-brain/` sub-areas +> (`identidade`, `estudos`, …) into separate OKF topics, or introducing nested +> concept directories, is a deliberate Phase-2 follow-up, not MVP behavior. + +### Command Surface (CLI) + +| Command | Args / Flags | Behavior | +| --- | --- | --- | +| `kb topic new <slug> <title> <domain>` | `--mode wiki\|okf` (default `wiki`) | Wiki scaffold (unchanged) or minimal OKF scaffold. | +| `kb promote <wiki-doc>` | `--to <topic>`, `--type <T>`, `--description <text>` | Mechanical, non-destructive wiki→OKF concept; `--to` must be a `mode: okf` topic. | +| `kb okf check <topic>` | `--strict`, `--format table\|json\|tsv` | OKF v0.1 conformance + local-standard warnings. | + +Phase 2 grows the group: `kb okf export`, `kb okf ingest` (out of MVP scope). + +## Integration Points + +No external services. The only "integration" is the **OKF v0.1 file format** +(`GoogleCloudPlatform/knowledge-catalog` → `okf/SPEC.md`). Two deliberate, +documented deviations from the written spec, matching Google's reference tooling +(ADR-002): emit the four producer fields (spec mandates only `type`) and emit +relative links (spec recommends absolute `/path.md`, which breaks GitHub +rendering). The official sample bundles are vendored as test fixtures (ADR-005), +not a runtime dependency. + +## Impact Analysis + +| Component | Impact Type | Description and Risk | Required Action | +| --- | --- | --- | --- | +| `internal/okf` | new | Cohesive OKF package; isolated, low blast radius. | Build per ADR-003/004/005. | +| `internal/vault` (pathutils, render, render_wiki) | modified | `LinkFormatter` + route ~43 call sites; **regression risk** in wiki output. | Golden byte-identical wiki tests; keep suite green. | +| `internal/models` | modified | Add `Mode` to `TopicMetadata`/`TopicInfo` + mode consts; low risk. | Add fields/consts. | +| `internal/topic` | modified | `Mode` on `topicMetadataFile`; mode-conditional scaffold + OKF templates; medium risk (scaffold branch). | Normalize empty→wiki; add OKF assets. | +| `internal/config` | modified | Add `[okf].types`; low risk (additive). | Add struct + example + defaults. | +| `internal/cli` | modified | `kb promote`, `kb okf` group, `topic new --mode`; low risk. | Wire commands per existing patterns. | +| `internal/lint` | unchanged | OKF uses a separate checker (ADR-005); wiki lint untouched. | None. | +| existing wiki topics | unchanged | Default mode wiki ⇒ zero behavior change. | Verify via existing tests. | + +## Testing Approach + +### Unit Tests + +- **`OKFLinkFormatter.Link`** — table-driven relative-path cases: sibling, child, + parent (`../`), `fromDir == targetDir`, bundle root; never a leading `/`. +- **`WikiLinkFormatter.Link`** — asserts byte-identical output to today's + `ToTopicWikiLink` (with and without label). +- **Wikilink→markdown body transform** — `[[a]]`, `[[a|label]]`, `[[a#anchor]]`, + links to not-yet-promoted concepts (tolerated/flagged). +- **Frontmatter remap (B-201)** — source→OKF per the remap table; `description` + fallback chain (flag → first body sentence → empty + warning); `timestamp` from the + injected clock, asserted as a fixed RFC3339 value; stage markers dropped. +- **Reserved/excluded files (B-202)** — `CLAUDE.md`, `AGENTS.md` (and the symlink), + `index.md`, `log.md`, `README`/license are NOT flagged for a missing `type`; a + freshly scaffolded-then-promoted bundle passes `kb okf check`. +- **Reject non-okf target (N-202)** — `promote --to <wiki-topic>` errors before writing. +- **`--type` vocabulary** — in-vocabulary (ok), out-of-vocabulary (warning), + empty (error). +- **mode normalization** — empty/absent `mode` → `wiki`; invalid explicit value + rejected at scaffold. +- **Conformance rules** — missing `type` (error), reserved files excluded, + `index.md`/`log.md` shape, `--strict` promotion. +- **Cross-concept wikilink mapping** — `[[srcSlug/Foo Bar|Foo]]` → `[Foo](foo-bar.md)`; + assert the exact emitted relative path; filename collision → `-2` suffix; + unresolved target recorded in `ConceptResult.UnresolvedLinks`. +- **OKF render smoke (N-001)** — render one document for a `mode: okf` topic and + assert the OKF link branch and its `fromDir` plumbing execute and emit a relative + markdown link, so the wired render branch is live, not shipped dead. + +### Integration Tests (`//go:build integration`) + +- **Conformance against vendored official bundles** (GA4, Stack Overflow, + crypto_bitcoin) — all pass leniently (ADR-005). +- **Negative fixtures** — missing `type`, unterminated frontmatter, list-not- + mapping frontmatter → expected errors. +- **Must-tolerate fixtures** — broken link, unknown `type`, frontmatter-less + `index.md` → no error under lenient mode. +- **End-to-end promote** — scaffold an OKF topic, ingest+compile a small wiki + topic, `kb promote` a concept, assert: source untouched, concept written with + four fields + relative links, `log.md` appended, `index.md` updated, and + `kb okf check` passes. +- **Zero-regression** — an existing wiki topic renders byte-identically and + `kb lint` output is unchanged. + +Use `t.TempDir()` for filesystem isolation; co-locate tests with their package. + +## Development Sequencing + +### Build Order + +1. **`mode` data model** — add `TopicMode` consts + `Mode` on + `models.TopicMetadata`/`TopicInfo`; add `Mode` to `topicMetadataFile` with + empty→wiki normalization in `readTopicYAMLMetadata`. *No dependencies.* +2. **`internal/config` `[okf].types`** — add `OKFConfig`, defaults, env/TOML load, + `config.example.toml`. *No dependencies.* +3. **`LinkFormatter` in `internal/vault`** — interface + `WikiLinkFormatter` + (wraps current behavior) + `OKFLinkFormatter` + `LinkFormatterFor`. *Depends on + step 1 (reads `topic.Mode`).* +4. **Migrate the ~43 call sites** through `linkFor(topic, fromDir, target, label)`; + add golden wiki-output tests. *Depends on step 3.* +5. **OKF scaffold** — `internal/topic` mode-conditional dirs + OKF `CLAUDE.md`/ + `index.md`(`okf_version`)/`log.md` templates. Extend `topic.New`/`newWithDate` + with a `mode` parameter (or a `NewWithMode` variant) and write an explicit `mode` + value into `topic.yaml`. *Depends on step 1.* +6. **`internal/okf` emission primitives** — frontmatter contract, wikilink→markdown + transform (reusing step 3's `OKFLinkFormatter`), `index.md`/`log.md` generation. + *Depends on steps 2, 3.* +7. **`promote` orchestration** — read source, remap, transform, write concept, + append log, regenerate index. *Depends on steps 5, 6.* +8. **Conformance checker** — recursive walk, §9 lenient + local warnings + + `--strict`, emitting `models.LintIssue`. *Depends on step 2 (vocabulary).* +9. **CLI wiring** — `topic new --mode`, `kb promote`, `kb okf check`. *Depends on + steps 5, 7, 8.* +10. **Fixtures + integration tests** — vendor official bundles, negatives, + must-tolerate, end-to-end promote, zero-regression. *Depends on steps 7, 8, 9.* + +### Technical Dependencies + +- None external. Vendoring the official bundles requires keeping their Apache-2.0 + license/attribution under `testdata/`. + +## Monitoring and Observability + +CLI tool — observability is structured logging via `internal/logger` (slog) and +command output: + +- **Log events** (debug/info): `promote` source/target/type, link-transform count, + broken-link count, `promote rejected: target not okf` (N-202), `description fallback + used`; `okf check` files-scanned, `reserved file skipped` (N-205), errors, warnings, + strict flag. +- **Exit codes**: `okf check` returns non-zero on errors (and on warnings under + `--strict`) for CI gating. +- **Report output**: `okf check` renders via `internal/output` (table/json/tsv), + consistent with `kb lint`. + +## Technical Considerations + +### Key Decisions + +- **Cohesive `internal/okf` package** (ADR-003). *Rationale:* one testable home, + clean phase growth. *Trade-off:* a new package vs maximal reuse. +- **Full `LinkFormatter` migration in the MVP** (ADR-004). *Rationale:* one + finished mode-aware seam. *Trade-off:* large refactor whose OKF render branch is + dormant until Phase 3; mitigated by golden tests. +- **Dedicated conformance checker, lenient + `--strict`** (ADR-002/005). + *Rationale:* OKF is structurally unlike the wiki pyramid and is permissive by + spec. *Trade-off:* two checking engines coexist. +- **Non-LLM mechanical `promote`** (ADR-002). *Rationale:* preserves `kb`'s + identity and determinism. *Trade-off:* distillation stays a human/agent job. +- **`kb promote` top-level + `kb okf` group** (ADR-006). *Rationale:* prominence + for the daily verb, cohesive namespace for the ecosystem. + +### Known Risks + +- **Wiki render regression** during the 43-site migration (medium likelihood, + high impact) → byte-identical golden tests + keep the `vault` suite green; + default formatter is wiki when `mode != okf`. +- **OKF relative-path correctness** (medium) → exhaustive table-driven tests + including parent/sibling/root. +- **OKF v0.1 is a draft** (medium) → isolate the ruleset behind a versioned module; + track the spec repo. +- **Vocabulary ignored in practice** (low) → `--strict` in CI enforces it where it + matters. +- **Vendored-fixture drift / license** (low) → pin a commit, retain attribution. + +## Architecture Decision Records + +- [ADR-001: OKF as a first-class authoring mode, not an export-only projection](adrs/adr-001.md) — OKF is a first-class per-topic mode with phased delivery (product). +- [ADR-002: promote is a mechanical non-LLM transform; type governance and a spec-compatible conformance posture](adrs/adr-002.md) — mechanical/non-destructive promote, `--type` + `kb.toml` vocabulary, emit-four + relative links, lenient validation (product). +- [ADR-003: A cohesive `internal/okf` package and a `mode` field on the topic model](adrs/adr-003.md) — one-way-dependency OKF package + `mode` on the topic model (default wiki). +- [ADR-004: A `LinkFormatter` abstraction and full migration of the link call sites](adrs/adr-004.md) — mode-aware `LinkFormatter` in `vault`; migrate all ~43 sites in the MVP; OKF render branch dormant until Phase 3. +- [ADR-005: A dedicated OKF conformance checker with vendored official fixtures](adrs/adr-005.md) — separate checker in `internal/okf`, lenient §9 + local warnings + `--strict`, vendored official bundles as fixtures. +- [ADR-006: CLI surface — `kb promote` top-level and a `kb okf` command group](adrs/adr-006.md) — top-level `kb promote`, `kb okf check` group, `topic new --mode`. diff --git a/.compozy/tasks/okf-dual-mode/adrs/adr-001.md b/.compozy/tasks/okf-dual-mode/adrs/adr-001.md new file mode 100644 index 0000000..bbe8ea5 --- /dev/null +++ b/.compozy/tasks/okf-dual-mode/adrs/adr-001.md @@ -0,0 +1,124 @@ +# ADR-001: OKF as a first-class authoring mode, not an export-only projection + +## Status + +Accepted + +## Date + +2026-06-27 + +## Context + +The `kb` CLI implements the Karpathy "LLM-wiki" pattern: a research lab where raw +sources are ingested, compiled into a wiki, queried, and linted. Google Cloud +released the Open Knowledge Format (OKF) v0.1 (2026-06-12), which formalizes the +same markdown+YAML-frontmatter substrate but optimizes for a different purpose: a +portable, vendor-neutral **catalog/interchange** artifact that other people's +agents consume. + +These are two distinct knowledge lifecycles on the same substrate: + +- **LLM-Wiki** = a *lab* (ingest → compile → query → lint; `[[wikilinks]]`; + `raw/` + `wiki/`; stage markers; for the operator to explore). +- **OKF** = a *catalog/API* (declare → consume; required `type` per concept; + markdown links; flat typed concepts; for others to consume; portable). + +This mirrors the duality the user already maintains by hand in their workspace: +`research/` (LLM-wiki) where they research, and `second-brain/` (OKF) where they +declare "what is mine" for operations. + +An earlier internal proposal treated OKF as an **export-only projection** of the +wiki. The user found this too thin: it captures only the "publish" direction and +ignores the daily, high-leverage flow of distilling research into the operational +catalog. The question is whether OKF is a derived output of the wiki or a +first-class mode in its own right. + +## Decision + +Make OKF a **first-class authoring mode** of `kb`, selectable per topic, with +the LLM-Wiki mode remaining the default. + +- Introduce a per-topic **`mode: wiki | okf`** (default `wiki`). +- An **OKF bundle is modeled as a `kb` topic with `mode: okf`** — it reuses the + existing topic, vault, lint, and index machinery rather than becoming a parallel + standalone concept. `mode` disambiguates scaffold shape, link format, frontmatter + contract, and lint ruleset. +- The MVP optimizes the **research → operations distill loop**: native OKF + authoring plus a `promote` interop verb (see ADR-002), not export. +- Deliver in phases: + - **MVP (Phase 1):** `mode` field, OKF scaffold (`kb topic new --mode okf`), + `kb promote` (wiki → OKF), `kb okf check` (conformance). + - **Phase 2:** `kb export okf` (publish a wiki topic as a portable bundle) and + `kb ingest okf` (consume an external bundle into research). + - **Phase 3:** codebase → OKF (the typed symbol/relation graph emitted as a + bundle) — the standout differentiator demo. + +## Alternatives Considered + +### Alternative 1: Export-only projection (OKF as a derived output) + +- **Description**: Add `kb export okf` that renders an existing wiki topic into an + OKF bundle; no OKF authoring, no `mode`, no interop loop. +- **Pros**: Smallest surface; no scaffold or link-format refactor; quick to ship. +- **Cons**: Captures only "publish"; cannot author or maintain operational + bundles; ignores the daily research→org distill the user lives; treats OKF as + second-class. +- **Why rejected**: Too thin; misses the highest-leverage use case. + +### Alternative 2: Standalone OKF concept (separate from topics) + +- **Description**: A new `kb okf init`/`kb okf …` surface independent of topics, + with its own metadata and storage. +- **Pros**: Clean conceptual separation between lab and catalog. +- **Cons**: Duplicates topic, vault, lint, and index machinery; two parallel + models to maintain; more user-facing surface to learn. +- **Why rejected**: `mode` on the existing topic achieves the same separation with + far less duplication and a smaller learning curve. + +### Alternative 3: Single unified model (one mode, derive both views) + +- **Description**: Keep one internal model and render either wiki or OKF on demand. +- **Pros**: No mode flag. +- **Cons**: The two lifecycles diverge on link format, frontmatter contract, and + conformance; forcing one model creates leaky, ambiguous behavior (e.g., the + `type` field means "stage" in wiki and "concept kind" in OKF). +- **Why rejected**: `mode` cleanly disambiguates the conflicts instead of hiding + them. + +## Consequences + +### Positive + +- The user's existing `research/` vs `second-brain/` split becomes + tool-enforced rather than maintained by hand. +- Default `mode: wiki` means zero regression for existing topics. +- Reusing the topic model keeps the new surface small and learnable. +- Phasing keeps an already-large MVP focused on the daily loop. + +### Negative + +- The MVP is larger than an export-only effort (mode + scaffold + two new verbs). +- A shared link-emission path must support both wikilinks and OKF markdown links. + +### Risks + +- **OKF is a v0.1 draft** → pin conformance to v0.1 and isolate the ruleset so it + can evolve without touching the rest of the tool. +- **Scope creep** (the MVP touches several surfaces) → strict phasing; export, + ingest, and codebase→OKF are explicit non-goals for the MVP. + +## Implementation Notes + +Implementation detail belongs in the TechSpec. At a product level: the link +emission path is currently wiki-only and must become mode-aware; the topic +scaffold and lint ruleset must branch on `mode`. No existing behavior changes when +`mode` is absent or `wiki`. + +## References + +- PRD: `../_prd.md` +- ADR-002: promote semantics, type governance, and conformance posture +- OKF spec v0.1: `GoogleCloudPlatform/knowledge-catalog` → `okf/SPEC.md` +- Announcement: "How the Open Knowledge Format can improve data sharing" (Google + Cloud Blog, 2026-06-12) diff --git a/.compozy/tasks/okf-dual-mode/adrs/adr-002.md b/.compozy/tasks/okf-dual-mode/adrs/adr-002.md new file mode 100644 index 0000000..0b57f01 --- /dev/null +++ b/.compozy/tasks/okf-dual-mode/adrs/adr-002.md @@ -0,0 +1,147 @@ +# ADR-002: promote is a mechanical non-LLM transform; type governance and a spec-compatible conformance posture + +## Status + +Accepted + +## Date + +2026-06-27 + +## Context + +ADR-001 makes the research → operations distill loop the MVP's north star. That +loop has three behavioral questions the PRD must settle, each with product +consequences: + +1. **What does `kb promote` do to the content?** It can be a mechanical + format transform, or it can re-synthesize the content with an LLM. +2. **Where does the OKF `type` come from?** OKF requires exactly one field — + `type` — and its value drives how consumers route, filter, and present a + concept. Free-text typing risks drift (`Voice Profile` vs `voice-profile`). +3. **How strict is conformance, and what does `kb` emit?** Research surfaced two + contradictions between the OKF spec and Google's own tooling: + - The spec requires only `type`, but Google's reference producer requires + `type` + `title` + `description` + `timestamp`, and all sample bundles carry + them. + - The spec *recommends* absolute `/path.md` links, but Google's tooling forbids + them (they break GitHub rendering) and every sample bundle uses relative + links. + - The spec (§9) requires consumers to *tolerate* broken links, unknown types, + and missing optional fields. + +`kb`'s identity, per its own `CLAUDE.md`, is "the non-LLM workflow": scaffolding, +ingestion, structural linting. The user also explicitly values a consistent local +standard for concept types. + +## Decision + +**1. `promote` is a mechanical, non-destructive, non-LLM transform.** + +`kb promote <wiki-doc> --to <okf-topic>` copies the source content into the OKF +bundle as a new typed concept and performs only structural work: convert +`[[wikilinks]]` to relative markdown links, remap wiki frontmatter to the OKF +contract, set the concept `type`, and append an entry to the bundle's `log.md`. +The source wiki document stays in `research/` (the immutable lab). No LLM is +invoked — any intelligent distillation/rewriting remains the job of the operator +or an external agent calling `kb`, exactly as the user's `second-brain/` was built. + +**2. `type` is supplied explicitly and governed by a local vocabulary.** + +`promote` (and native authoring) takes the concept kind via `--type`; a canonical +list of concept types lives in `kb.toml` (e.g. an `[okf].types` vocabulary). The +vocabulary exists to prevent duplication and enforce a per-vault standard. OKF +deliberately has no *global* type registry; a *local* vocabulary is fully +compatible and adds value. A type outside the vocabulary is a lint warning, not a +hard failure, and the vocabulary is extended by editing `kb.toml`. + +**3. Conformance is compatible with the real-world tooling, lenient by default.** + +- **Emit**: all four producer fields (`type`, `title`, `description`, + `timestamp`) and **relative, GitHub-safe** markdown links. +- **Validate**: lenient per spec §9 — tolerate broken cross-links, unknown + `type` values, and missing optional fields (so externally produced bundles are + accepted in Phase 2). +- **Local standards as warnings** on the operator's own bundles: all four fields + present, and `type` within the `kb.toml` vocabulary. +- **`--strict`** promotes warnings to errors for CI gates. + +## Alternatives Considered + +### Alternative 1: LLM-assisted `promote` + +- **Description**: `promote` re-synthesizes the wiki document into a concise OKF + concept via a configured provider. +- **Pros**: One-command "magic"; less manual rewriting. +- **Cons**: Injects an LLM dependency and nondeterminism into a tool whose + identity is the non-LLM workflow; harder to test; unpredictable output. +- **Why rejected**: Contradicts `kb`'s stated identity; distillation already + belongs to the operator/agent layer. (A future `--distill` flag is possible but + out of scope.) + +### Alternative 2: Derived or defaulted `type` + +- **Description**: Infer `type` from the wiki document's `source_kind`/`domain`, + or default everything to a generic `Reference`. +- **Pros**: Less typing. +- **Cons**: The wiki `type` is a stage marker (always `wiki`/`source`), so + derivation carries no concept semantics; a generic default produces lazy + bundles where every concept is `Reference`, defeating OKF's routing value. +- **Why rejected**: Kills the value of the one field OKF requires. + +### Alternative 3: Spec-minimal or maximum-rigor conformance + +- **Description**: Either emit/validate only `type` (spec-minimal), or make every + rule a hard error by default (maximum rigor). +- **Pros**: Minimal is "pure"; maximum guarantees an iron standard. +- **Cons**: Minimal yields impoverished bundles misaligned with the official + samples; maximum fights OKF's permissive philosophy and would reject + third-party bundles during Phase 2 ingest. +- **Why rejected**: The tooling-compatible middle (emit four, validate lenient, + warn locally, `--strict` opt-in) matches real bundles and the user's desire for + a standard without sacrificing interoperability. + +## Consequences + +### Positive + +- `promote` is deterministic, testable, and true to `kb`'s identity. +- Bundles align with Google's real samples and tooling, maximizing portability. +- The type vocabulary keeps the operator's catalog consistent over time. +- Lenient validation lets Phase 2 ingest accept real-world external bundles. + +### Negative + +- The operator (or an agent) still does the intelligent distillation; `promote` + alone does not condense content. +- Two documented spec contradictions must be encoded as deliberate, documented + deviations (emit four, emit relative links). + +### Risks + +- **Spec churn** (v0.1 draft) → keep emit/validate rules isolated and versioned. +- **Vocabulary friction** (warnings ignored) → `--strict` in CI keeps the + standard enforceable when it matters. + +## Implementation Notes + +Detail belongs in the TechSpec. Product-level notes: `promote` appends to +`log.md` and the bundle's `index.md` is auto-maintained (OKF index files are +type-grouped bullet lists, which are mechanically generatable). Conformance rules +target OKF v0.1 and validate against the official sample bundles (GA4, Stack +Overflow, Bitcoin) as fixtures. + +Two of the four producer fields have no source in `kb`-produced documents +(B-201): `description` comes from a `--description` flag, falling back to the first +body sentence and then to empty + warning; `timestamp` is the promote-time clock in +RFC3339 (promotion is the "last meaningful change"), since the only source dates are +date-only (`2006-01-02`). See the TechSpec remap table. + +## References + +- PRD: `../_prd.md` +- ADR-001: OKF as a first-class authoring mode +- OKF spec v0.1 §4 (frontmatter), §5 (links), §6 (index.md), §7 (log.md), + §9 (conformance): `GoogleCloudPlatform/knowledge-catalog` → `okf/SPEC.md` +- OKF reference producer (the stricter four-key reality): + `okf/src/reference_agent/bundle/document.py` diff --git a/.compozy/tasks/okf-dual-mode/adrs/adr-003.md b/.compozy/tasks/okf-dual-mode/adrs/adr-003.md new file mode 100644 index 0000000..c23dcd3 --- /dev/null +++ b/.compozy/tasks/okf-dual-mode/adrs/adr-003.md @@ -0,0 +1,104 @@ +# ADR-003: A cohesive `internal/okf` package and a `mode` field on the topic model + +## Status + +Accepted + +## Date + +2026-06-27 + +## Context + +OKF dual-mode (ADR-001) introduces a substantial new concern: an OKF frontmatter +contract, a wikilink→markdown transform, `index.md`/`log.md` generation, a +conformance checker, and the `promote` orchestration. The existing packages own +the LLM-Wiki pyramid: + +- `internal/topic` — scaffold + `topic.yaml` (`topicMetadataFile` at + `topic.go:104`; scaffold list `currentTopicDirectories` at `topic.go:38`). +- `internal/vault` — render/read/write of documents. +- `internal/lint` — path-prefix structural validation (`schemaForPath`). +- `internal/ingest` — frontmatter assembly (`buildFrontmatter` at `ingest.go:160`). + +Two structural needs must be settled before any behavior is built: where OKF logic +lives, and how a topic signals it is an OKF bundle versus a wiki. + +## Decision + +**1. A new cohesive `internal/okf` package owns all OKF behavior.** + +It contains: the OKF frontmatter contract (the four producer fields), the +`[[wikilink]]`→relative-markdown transform, `index.md`/`log.md` generation, the +conformance checker (ADR-005), and the `promote` orchestration. Dependency +direction is one-way: `internal/okf` imports `internal/frontmatter`, +`internal/vault` (document read/write), `internal/config` (the type vocabulary), +and `internal/models`; none of those import `internal/okf`. This prevents import +cycles and keeps the wiki packages free of OKF concerns. + +**2. Add a `mode` field to the topic model, defaulting to wiki.** + +- `internal/models`: typed constants `TopicModeWiki = "wiki"` and + `TopicModeOKF = "okf"`, and a `Mode` field on `models.TopicMetadata` + (`models.go:271`) and `models.TopicInfo`. +- `internal/topic`: `Mode string \`yaml:"mode,omitempty"\`` on `topicMetadataFile` + (`topic.go:104`); written by `writeMetadataFile` and read by + `readTopicYAMLMetadata` (`topic.go:770`). +- An absent/empty `mode` is normalized to `wiki` on read, so existing topics and + existing `topic.yaml` files behave identically (zero regression). + +## Alternatives Considered + +### Alternative 1: Spread OKF logic across existing packages + +- **Description**: `mode` in topic, links in vault, conformance extending lint, + `promote` in ingest. +- **Pros**: Maximum reuse; no new package. +- **Cons**: Dilutes OKF across four packages; entangles it with the wiki pyramid; + harder to test and to grow in Phase 2/3. +- **Why rejected**: Cohesion and a clean phase-growth boundary outweigh avoiding a + new package. + +### Alternative 2: Hybrid (okf package + okf logic also in vault/topic) + +- **Description**: Core in `internal/okf`, but the link formatter and `mode` + scattered into vault/topic. +- **Pros**: Avoids some indirection. +- **Cons**: The boundary becomes ambiguous (OKF logic in two homes). +- **Why rejected**: Only the genuinely cross-cutting `LinkFormatter` belongs in + vault (ADR-004); everything else stays in `internal/okf`. The `mode` field is a + data-model fact, not OKF logic, so it correctly lives on the topic model. + +## Consequences + +### Positive + +- One obvious home for OKF; isolated unit/integration testing. +- Clean dependency graph; Phase 2/3 (export/ingest/codebase→OKF) extend one package. +- `mode` is a tiny, backward-compatible addition. + +### Negative + +- A new package and a field threaded through topic load/write. + +### Risks + +- **Mode drift** (a topic with an unknown mode) → normalize unknown/empty to + `wiki` and reject unknown explicit values at scaffold time. + +## Implementation Notes + +`topicMetadataFile` marshals via `yaml.Marshal`. The zero-regression story has two +distinct parts (N-004): (a) **existing** `topic.yaml` files are never rewritten and +read-normalize empty `mode`→`wiki`, so they are byte-stable; (b) **new** scaffolds +write an explicit `mode` value, so a freshly created wiki topic's `topic.yaml` will +contain a visible `mode: wiki` line — a scaffold-output change (update the scaffold +golden expectation accordingly), not a change to old files. `omitempty` only +suppresses a programmatically-empty mode. Topic resolution (`topic.Resolve`/`Info`) +must surface `Mode` so downstream commands (`promote`, `okf check`) can branch. + +## References + +- ADR-001 (OKF as a first-class mode), ADR-004 (LinkFormatter), ADR-005 (checker) +- `internal/topic/topic.go:104`, `internal/models/models.go:271` +- TechSpec: `../_techspec.md` diff --git a/.compozy/tasks/okf-dual-mode/adrs/adr-004.md b/.compozy/tasks/okf-dual-mode/adrs/adr-004.md new file mode 100644 index 0000000..cf2a286 --- /dev/null +++ b/.compozy/tasks/okf-dual-mode/adrs/adr-004.md @@ -0,0 +1,129 @@ +# ADR-004: A `LinkFormatter` abstraction and full migration of the link call sites + +## Status + +Accepted + +## Date + +2026-06-27 + +## Context + +Link emission is hardcoded to Obsidian `[[wikilinks]]`: + +- `internal/vault/pathutils.go:244` `ToTopicWikiLink(topicSlug, documentPath, + label)` → `[[slug/path|label]]`, with a thin wrapper `toSourceWikiLink(topic, + relativePath, label)` at `render.go:186`. +- These are called from **~43 sites**, all in `internal/vault/render.go` and + `render_wiki.go` — the **codebase→wiki** render pipeline. +- There is **no `Renderer`/`Writer` struct** in `internal/vault`; render functions + receive `topic models.TopicMetadata` directly, so there is no constructor to + inject a formatter into. + +OKF requires standard markdown links instead of wikilinks (ADR-002: relative, +GitHub-safe). The question is how the link format becomes mode-aware, and how much +of the refactor lands in the MVP. The user chose to introduce the full abstraction +and migrate all call sites in the MVP, rather than defer the render-pipeline +refactor to Phase 3. + +A key technical subtlety: an OKF **relative** link needs the **source document's +directory** to compute the path to the target. The current call sites pass only +the target path and a label. + +## Decision + +**1. Define a `LinkFormatter` interface in `internal/vault`** (rendering's home; +keeps the `okf`→`vault` dependency one-way and cycle-free): + +```go +// LinkFormatter renders a link from the document being written (fromDir, +// bundle-relative) to a target document, with an optional label. +type LinkFormatter interface { + Link(fromDir, targetPath, label string) string +} +``` + +- `WikiLinkFormatter` — ignores `fromDir`; emits `[[slug/target|label]]` exactly + as `ToTopicWikiLink` does today (`ToTopicWikiLink` becomes its implementation). +- `OKFLinkFormatter` — emits a relative, GitHub-safe markdown link + (`[label](relpath)`), computing `relpath` from `fromDir` to `targetPath`. + +**2. Resolve the formatter from `topic.Mode`** via +`vault.LinkFormatterFor(topic) LinkFormatter`, and route every call site through a +single chokepoint helper `linkFor(topic, fromDir, targetPath, label)`. The direct +`ToTopicWikiLink(topic.Slug, …)` calls in `render_wiki.go` are converted to use this +helper too. (Verified call-site spread: ~7 sites in `render.go` plus ~34 matching +lines in `render_wiki.go`; treat "~43 sites" as the working estimate.) + +**3. Scope the MVP correctly.** The 43 render sites belong to the codebase→wiki +pipeline, which produces **wiki-mode** topics; codebase→OKF is Phase 3. Therefore +in the MVP these sites continue to resolve `WikiLinkFormatter` (mode = wiki) and +emit byte-identical output. The **active** MVP consumer of `OKFLinkFormatter` is +`promote`'s body transform (which knows the concept's own directory) and +`index.md` generation. The OKF branch at the render sites is wired but dormant +until Phase 3. + +## Alternatives Considered + +### Alternative 1: Defer the render-site refactor; body-transform only in MVP + +- **Description**: MVP does only `[[wikilink]]`→markdown in `promote` body text and + index generation; the 43 sites are untouched until Phase 3. +- **Pros**: Smallest MVP; least risk. +- **Cons**: The abstraction lands later; two passes over the render code. +- **Why rejected**: The user chose to do the foundational abstraction now. + +### Alternative 2: Add a `fromDir`/formatter argument to each call site signature + +- **Description**: Thread source context explicitly through 43 signatures. +- **Pros**: Fully explicit. +- **Cons**: Churns 43 signatures for no MVP-exercised benefit. +- **Why rejected**: The chokepoint helper + `topic`-resolved formatter achieves the + same with far less churn. + +### Alternative 3: Package-global formatter strategy + +- **Description**: A mutable package-level formatter set per run. +- **Pros**: No threading. +- **Cons**: Not concurrency-safe; hidden global state; hard to test. +- **Why rejected**: Violates testability. + +## Consequences + +### Positive + +- One mode-aware seam for all link emission; Phase 3 only flips render topics to + OKF mode and supplies `fromDir`. +- `promote` and index generation reuse the same formatter. + +### Negative + +- A large mechanical refactor whose OKF branch is not exercised at render sites in + the MVP. + +### Risks + +- **Silent regression** in wiki output during the refactor → mitigate with golden + tests asserting byte-identical wiki render output before/after, and keep the + existing `vault` test suite green. +- **Relative-path edge cases** (siblings, parents, root) in `OKFLinkFormatter` → + cover with table-driven unit tests including `fromDir == targetDir` and nested + cases. +- **Dormant OKF render branch ships as dead code in the MVP** (N-001) → add one + render-site-in-`okf`-mode smoke test so the wired branch and its `fromDir` + plumbing are exercised, keeping the user's "migrate now" decision without shipping + untested code. + +## Implementation Notes + +`OKFLinkFormatter.Link` uses `filepath.Rel(fromDir, targetPath)` semantics on +forward-slash bundle paths, never emitting a leading `/` (GitHub-safe, per +ADR-002). `WikiLinkFormatter` preserves `StripMarkdownExtension` behavior. The +formatter is selected once per document render from `topic.Mode`. + +## References + +- ADR-002 (relative links), ADR-003 (okf package boundary) +- `internal/vault/pathutils.go:244`, `render.go:186`, `render_wiki.go` +- TechSpec: `../_techspec.md` diff --git a/.compozy/tasks/okf-dual-mode/adrs/adr-005.md b/.compozy/tasks/okf-dual-mode/adrs/adr-005.md new file mode 100644 index 0000000..bf727fa --- /dev/null +++ b/.compozy/tasks/okf-dual-mode/adrs/adr-005.md @@ -0,0 +1,124 @@ +# ADR-005: A dedicated OKF conformance checker with vendored official fixtures + +## Status + +Accepted + +## Date + +2026-06-27 + +## Context + +OKF v0.1 conformance (§9) is small and deliberately permissive: every non-reserved +`.md` must have parseable frontmatter with a non-empty `type`; `index.md`/`log.md` +must follow their shapes when present; consumers MUST tolerate broken cross-links, +unknown `type` values, and missing optional fields. ADR-002 set the product +posture: emit four producer fields and relative links, validate lenient, warn on +local standards (four fields present + `type` in the `kb.toml` vocabulary), and +offer `--strict`. + +The existing lint engine (`internal/lint/lint.go:337` `schemaForPath`) is a +path-prefix switch tuned to the wiki pyramid (`wiki/concepts/`, `raw/github/`, +etc.) and parses `[[wikilinks]]` specifically (`wikilinkPattern` regex). OKF is +structurally different: a recursive tree of freeform directories where `type` is +the only hard requirement and links are markdown. + +## Decision + +**Implement a dedicated conformance checker in `internal/okf`**, not an extension +of `schemaForPath`. + +- Recursively walk the bundle; for every non-reserved `.md`, parse frontmatter via + `internal/frontmatter` and require a non-empty `type` (§9.2). Validate `index.md` + (no frontmatter except an optional bundle-root `okf_version`) and `log.md` + (ISO-date headings) shapes when present (§6/§7). +- **Lenient by default** (§9): broken links, unknown `type`, and missing optional + fields are tolerated (not errors) — so externally produced bundles pass, which + Phase 2 `ingest okf` depends on. +- **Local-standard warnings** on the operator's own bundle: the four producer + fields present, and `type` ∈ `config.OKF.Types`. +- `--strict` promotes warnings to errors (CI gate). +- **Reuse `models.LintIssue` and the `internal/output` formatters** (table/json/ + tsv) for a consistent UX; a severity distinguishes error from warning. + +**Fixtures (per the testing decision):** vendor the three official sample bundles +(GA4, Stack Overflow, crypto_bitcoin; Apache-2.0) into `internal/okf/testdata/` as +positive fixtures, plus synthetic negatives (missing `type`, unterminated +frontmatter, list-not-mapping frontmatter) and must-tolerate cases (broken link, +unknown `type`, frontmatter-less `index.md`). Conformance tests run behind +`//go:build integration`. + +## Alternatives Considered + +### Alternative 1: Extend the existing lint engine (`schemaForPath`) + +- **Description**: Add OKF cases to the path-prefix switch. +- **Pros**: One lint entry point. +- **Cons**: Conflates two structurally different models (path-pyramid wiki vs + recursive type-required OKF); the wikilink parser does not apply; a single engine + grows two divergent rule systems. +- **Why rejected**: Separation keeps both engines simple and correct. + +### Alternative 2: Maximum-rigor default (all rules hard errors) + +- **Description**: Make four-field + vocabulary + link rules errors without + `--strict`. +- **Pros**: Iron standard. +- **Cons**: Fights OKF's permissive model; rejects third-party bundles on ingest. +- **Why rejected**: Superseded by ADR-002's lenient-with-warnings posture. + +### Alternative 3: Synthetic-only fixtures + +- **Description**: Hand-write minimal bundles only. +- **Pros**: Smaller repo; faster tests. +- **Cons**: No validation against the bundles real OKF consumers produce/expect. +- **Why rejected**: The user chose to vendor the official bundles for maximum + signal. + +## Consequences + +### Positive + +- Spec-correct and tooling-compatible; high-signal conformance tests. +- Consistent CLI output via shared `LintIssue`/`output` types. +- Lenient validation unblocks Phase 2 ingest. + +### Negative + +- The repo grows by the vendored bundles (dozens of `.md` files). +- Two checking engines coexist (wiki lint and OKF check). + +### Risks + +- **Spec churn (v0.1 draft)** → isolate rules behind a versioned ruleset in + `internal/okf`. +- **Third-party license** → keep the bundles' Apache-2.0 `LICENSE`/attribution in + `testdata/` and document provenance. + +## Implementation Notes + +`okf check` returns `[]models.LintIssue` with a severity field; the CLI renders via +`internal/output`. The walker skips a full **reserved/excluded set** before applying +the `type` requirement (B-202): OKF-reserved `index.md`/`log.md`, the kb marker files +`CLAUDE.md`/`AGENTS.md` (and it does not follow symlinks, so `AGENTS.md`→`CLAUDE.md` +is not walked), and the non-concept files official bundles legitimately carry +(`README.md`, license/attribution). Without this, a freshly scaffolded bundle — which +writes `CLAUDE.md`/`AGENTS.md` — would hard-fail its own `kb okf check`, contradicting +the end-to-end test and the PRD success metric. The checker takes the resolved bundle +(topic) path; it does not +require `mode: okf` so it can validate arbitrary directories and external bundles. + +**`index.md` / `log.md` ownership (N-003):** both are kb-owned. `index.md` is fully +regenerated by scaffold and by `promote` (type-grouped bullet lists); regeneration +MUST preserve the bundle-root `okf_version` frontmatter — the only place OKF permits +index frontmatter. `log.md` is append-only (kb appends a dated entry on scaffold and +promote; operators may add entries but kb never rewrites prior ones). Operators +should not hand-edit `index.md`, since the next regeneration overwrites it. + +## References + +- ADR-002 (conformance posture), ADR-003 (okf package) +- OKF spec §6/§7/§9; official bundles `okf/bundles/{ga4,stackoverflow,crypto_bitcoin}` +- `internal/lint/lint.go:337`, `internal/frontmatter`, `internal/output` +- TechSpec: `../_techspec.md` diff --git a/.compozy/tasks/okf-dual-mode/adrs/adr-006.md b/.compozy/tasks/okf-dual-mode/adrs/adr-006.md new file mode 100644 index 0000000..c3bd5a1 --- /dev/null +++ b/.compozy/tasks/okf-dual-mode/adrs/adr-006.md @@ -0,0 +1,86 @@ +# ADR-006: CLI surface — `kb promote` top-level and a `kb okf` command group + +## Status + +Accepted + +## Date + +2026-06-27 + +## Context + +The MVP adds three user-facing operations now (`promote`, conformance `check`, and +a `--mode` choice on topic creation), and Phase 2 adds `export okf` and +`ingest okf`. The existing CLI (`internal/cli/root.go:14`) registers top-level +commands (`topic`, `ingest`, `lint`, `inspect`, `index`, `search`, `generate`, +`migrate`, `version`) via `command.AddCommand(...)`, shares a `--vault` persistent +flag, resolves the vault with `resolveCommandVaultPath`, and renders output through +`internal/output`. `topic new` (`topic.go`) takes positional args and has no +`--mode` flag yet. + +`promote` is the daily distill verb (1 wiki doc → 1 OKF concept); `check`, +`export`, and `ingest` are OKF-ecosystem operations. + +## Decision + +- **`kb promote <wiki-doc> --to <topic> --type <Type> [--description <text>]`** as a + **top-level** command — it is the daily verb and deserves prominence. `--to` must + resolve to a `mode: okf` topic or `promote` errors (N-202). +- **`kb okf`** as a parent command **group**: + - MVP: `kb okf check <topic> [--strict] [--format table|json|tsv]`. + - Phase 2: `kb okf export <topic> [...]`, `kb okf ingest <bundle> --to <topic>`. +- **`kb topic new`** gains `--mode wiki|okf` (default `wiki`). + +All new commands follow the existing cobra patterns: built in `internal/cli`, +registered via `AddCommand`, resolving the vault through `resolveCommandVaultPath`, +delegating business logic to `internal/okf`, and rendering via `internal/output`. + +## Alternatives Considered + +### Alternative 1: Everything under `kb okf` + +- **Description**: `kb okf promote|check|export|ingest`. +- **Pros**: Maximum namespace cohesion. +- **Cons**: Buries `promote` — the most-used verb — one level deeper. +- **Why rejected**: Daily ergonomics beat namespace purity for the hot verb. + +### Alternative 2: `kb promote` + `kb lint --okf` + +- **Description**: Conformance as a flag on the existing `lint`. +- **Pros**: Reuses `lint`. +- **Cons**: Mixes two engines (wiki pyramid vs OKF conformance) in one command and + gives no natural home to Phase 2's `export`/`ingest`. +- **Why rejected**: ADR-005 keeps the engines separate; the `okf` group hosts the + whole ecosystem. + +## Consequences + +### Positive + +- Discoverable `kb okf …` namespace that grows cleanly across phases. +- The daily verb stays one keystroke deep. +- Reuses vault resolution, flags, and output formatting conventions. + +### Negative + +- Two entry points for OKF (`kb promote` and `kb okf …`) — mitigated by docs and + `--help` cross-references. + +### Risks + +- **`--mode` typos / invalid values** → validate against `{wiki, okf}` at parse + time with a clear error. + +## Implementation Notes + +`newPromoteCommand` and `newOKFCommand` (with a `check` subcommand) are added to +`newRootCommand`. `topic new` adds `command.Flags().StringVar(&mode, "mode", +"wiki", …)` and passes it into topic creation (ADR-003). Output uses +`output.OutputFormat*` like `lint`. + +## References + +- ADR-003 (mode field), ADR-005 (conformance engine) +- `internal/cli/root.go:14`, `internal/cli/topic.go`, `internal/cli/lint.go` +- TechSpec: `../_techspec.md` diff --git a/.compozy/tasks/okf-dual-mode/qa/impl-review-changed-files-round1.txt b/.compozy/tasks/okf-dual-mode/qa/impl-review-changed-files-round1.txt new file mode 100644 index 0000000..4280e88 --- /dev/null +++ b/.compozy/tasks/okf-dual-mode/qa/impl-review-changed-files-round1.txt @@ -0,0 +1,26 @@ +config.example.toml +internal/models/models.go +internal/models/kb_models.go +internal/config/config.go +internal/config/config_test.go +internal/topic/topic.go +internal/topic/topic_test.go +internal/topic/assets/okf-claude-template.md +internal/generate/generate.go +internal/vault/pathutils.go +internal/vault/pathutils_test.go +internal/vault/render.go +internal/vault/render_wiki.go +internal/vault/writer.go +internal/okf/okf.go +internal/okf/okf_test.go +internal/okf/official_integration_test.go +internal/okf/testdata/official/README.md +internal/okf/testdata/official/LICENSE.md +internal/cli/root.go +internal/cli/topic.go +internal/cli/topic_test.go +internal/cli/promote.go +internal/cli/okf.go +internal/cli/okf_test.go +internal/cli/okf_integration_test.go diff --git a/.compozy/tasks/okf-dual-mode/qa/impl-review-changed-files-round2.txt b/.compozy/tasks/okf-dual-mode/qa/impl-review-changed-files-round2.txt new file mode 100644 index 0000000..44ac215 --- /dev/null +++ b/.compozy/tasks/okf-dual-mode/qa/impl-review-changed-files-round2.txt @@ -0,0 +1,30 @@ +AGENTS.md +CLAUDE.md +README.md +config.example.toml +internal/models/models.go +internal/models/kb_models.go +internal/config/config.go +internal/config/config_test.go +internal/topic/topic.go +internal/topic/topic_test.go +internal/topic/assets/okf-claude-template.md +internal/generate/generate.go +internal/vault/pathutils.go +internal/vault/pathutils_test.go +internal/vault/render.go +internal/vault/render_test.go +internal/vault/render_wiki.go +internal/vault/writer.go +internal/okf/okf.go +internal/okf/okf_test.go +internal/okf/official_integration_test.go +internal/okf/testdata/official/README.md +internal/okf/testdata/official/LICENSE.md +internal/cli/root.go +internal/cli/topic.go +internal/cli/topic_test.go +internal/cli/promote.go +internal/cli/okf.go +internal/cli/okf_test.go +internal/cli/okf_integration_test.go diff --git a/.compozy/tasks/okf-dual-mode/qa/impl-review-diff-round1.patch b/.compozy/tasks/okf-dual-mode/qa/impl-review-diff-round1.patch new file mode 100644 index 0000000..8881a28 --- /dev/null +++ b/.compozy/tasks/okf-dual-mode/qa/impl-review-diff-round1.patch @@ -0,0 +1,2808 @@ +diff --git a/config.example.toml b/config.example.toml +index a9ea428..0c91297 100644 +--- a/config.example.toml ++++ b/config.example.toml +@@ -5,6 +5,7 @@ + # - `[app]` + # - `[log]` + # - `[vault]` ++# - `[okf]` + # - `[firecrawl]` + # - `[openrouter]` + # - `[stt]` +@@ -48,6 +49,11 @@ level = "info" + root = "." + topic_globs = ["*"] + ++[okf] ++# Local OKF concept type vocabulary. Empty means `kb okf check` will not warn ++# about unknown types until the operator opts into a local standard. ++types = [] ++ + [firecrawl] + api_key = "" + api_url = "https://api.firecrawl.dev" +diff --git a/internal/cli/root.go b/internal/cli/root.go +index 2b123a6..22ab258 100644 +--- a/internal/cli/root.go ++++ b/internal/cli/root.go +@@ -23,6 +23,8 @@ func newRootCommand() *cobra.Command { + bindRootPersistentFlags(command) + command.AddCommand(newTopicCommand()) + command.AddCommand(newIngestCommand()) ++ command.AddCommand(newPromoteCommand()) ++ command.AddCommand(newOKFCommand()) + command.AddCommand(newLintCommand()) + command.AddCommand(newGenerateCommand()) + command.AddCommand(newInspectCommand()) +diff --git a/internal/cli/topic.go b/internal/cli/topic.go +index 3746632..d609335 100644 +--- a/internal/cli/topic.go ++++ b/internal/cli/topic.go +@@ -4,6 +4,7 @@ import ( + "encoding/json" + "fmt" + "os" ++ "strings" + + "github.com/spf13/cobra" + +@@ -13,6 +14,7 @@ import ( + ) + + var runTopicNew = ktopic.New ++var runTopicNewWithMode = ktopic.NewWithMode + var runTopicList = ktopic.List + var runTopicInfo = ktopic.Info + var topicGetwd = os.Getwd +@@ -37,7 +39,8 @@ func newTopicCommand() *cobra.Command { + } + + func newTopicNewCommand() *cobra.Command { +- return &cobra.Command{ ++ var mode string ++ command := &cobra.Command{ + Use: "new <slug> <title> <domain>", + Short: "Scaffold a new knowledge base topic", + Args: cobra.ExactArgs(3), +@@ -47,7 +50,16 @@ func newTopicNewCommand() *cobra.Command { + return err + } + +- info, err := runTopicNew(vaultPath, args[0], args[1], args[2]) ++ topicMode, err := parseTopicMode(mode) ++ if err != nil { ++ return err ++ } ++ var info models.TopicInfo ++ if topicMode == models.TopicModeWiki { ++ info, err = runTopicNew(vaultPath, args[0], args[1], args[2]) ++ } else { ++ info, err = runTopicNewWithMode(vaultPath, args[0], args[1], args[2], topicMode) ++ } + if err != nil { + return err + } +@@ -55,6 +67,8 @@ func newTopicNewCommand() *cobra.Command { + return writeTopicInfoJSON(cmd, info) + }, + } ++ command.Flags().StringVar(&mode, "mode", string(models.TopicModeWiki), "Topic mode (wiki|okf)") ++ return command + } + + func newTopicListCommand() *cobra.Command { +@@ -98,6 +112,17 @@ func newTopicListCommand() *cobra.Command { + } + } + ++func parseTopicMode(value string) (models.TopicMode, error) { ++ switch strings.TrimSpace(value) { ++ case "", string(models.TopicModeWiki): ++ return models.TopicModeWiki, nil ++ case string(models.TopicModeOKF): ++ return models.TopicModeOKF, nil ++ default: ++ return "", fmt.Errorf(`invalid --mode %q: expected "wiki" or "okf"`, value) ++ } ++} ++ + func newTopicInfoCommand() *cobra.Command { + return &cobra.Command{ + Use: "info <slug>", +diff --git a/internal/cli/topic_test.go b/internal/cli/topic_test.go +index 44d7b67..1f49e4d 100644 +--- a/internal/cli/topic_test.go ++++ b/internal/cli/topic_test.go +@@ -79,6 +79,66 @@ func TestTopicNewCommandPassesArgsAndPrintsJSON(t *testing.T) { + } + } + ++func TestTopicNewCommandPassesOKFMode(t *testing.T) { ++ originalNewWithMode := runTopicNewWithMode ++ originalGetwd := topicGetwd ++ t.Cleanup(func() { ++ runTopicNewWithMode = originalNewWithMode ++ topicGetwd = originalGetwd ++ }) ++ ++ var gotMode models.TopicMode ++ runTopicNewWithMode = func(vaultPath, slug, title, domain string, mode models.TopicMode) (models.TopicInfo, error) { ++ gotMode = mode ++ return models.TopicInfo{ ++ Slug: slug, ++ Title: title, ++ Domain: domain, ++ Mode: mode, ++ RootPath: filepath.Join(vaultPath, slug), ++ }, nil ++ } ++ topicGetwd = func() (string, error) { ++ return "/workspace/repo", nil ++ } ++ ++ command := newRootCommand() ++ var stdout bytes.Buffer ++ command.SetOut(&stdout) ++ command.SetErr(new(bytes.Buffer)) ++ command.SetArgs([]string{"topic", "new", "ops-catalog", "Ops Catalog", "ops", "--mode", "okf", "--vault", "/tmp/vault"}) ++ ++ if err := command.ExecuteContext(context.Background()); err != nil { ++ t.Fatalf("ExecuteContext returned error: %v", err) ++ } ++ if gotMode != models.TopicModeOKF { ++ t.Fatalf("mode = %q, want okf", gotMode) ++ } ++ ++ var info models.TopicInfo ++ if err := json.Unmarshal(stdout.Bytes(), &info); err != nil { ++ t.Fatalf("stdout did not contain JSON: %v\n%s", err, stdout.String()) ++ } ++ if info.Mode != models.TopicModeOKF { ++ t.Fatalf("payload mode = %q, want okf", info.Mode) ++ } ++} ++ ++func TestTopicNewCommandRejectsInvalidMode(t *testing.T) { ++ command := newRootCommand() ++ command.SetOut(new(bytes.Buffer)) ++ command.SetErr(new(bytes.Buffer)) ++ command.SetArgs([]string{"topic", "new", "ops", "Ops", "ops", "--mode", "catalog", "--vault", "/tmp/vault"}) ++ ++ err := command.ExecuteContext(context.Background()) ++ if err == nil { ++ t.Fatal("expected invalid mode error") ++ } ++ if !strings.Contains(err.Error(), "invalid --mode") { ++ t.Fatalf("unexpected error: %v", err) ++ } ++} ++ + func TestTopicNewCommandRequiresThreeArgs(t *testing.T) { + command := newRootCommand() + command.SetOut(new(bytes.Buffer)) +diff --git a/internal/config/config.go b/internal/config/config.go +index 14d3417..5083362 100644 +--- a/internal/config/config.go ++++ b/internal/config/config.go +@@ -47,6 +47,7 @@ type Config struct { + App AppConfig `toml:"app"` + Log LogConfig `toml:"log"` + Vault VaultConfig `toml:"vault"` ++ OKF OKFConfig `toml:"okf"` + Firecrawl FirecrawlConfig `toml:"firecrawl"` + OpenRouter OpenRouterConfig `toml:"openrouter"` + STT STTConfig `toml:"stt"` +@@ -71,6 +72,11 @@ type VaultConfig struct { + TopicGlobs []string `toml:"topic_globs"` + } + ++// OKFConfig controls local Open Knowledge Format producer standards. ++type OKFConfig struct { ++ Types []string `toml:"types"` ++} ++ + // FirecrawlConfig controls URL scraping API access. + type FirecrawlConfig struct { + APIKey string `toml:"api_key"` +@@ -211,6 +217,7 @@ func (c *Config) applyDefaults() { + if len(c.Vault.TopicGlobs) == 0 { + c.Vault.TopicGlobs = []string{defaultTopicGlob} + } ++ c.OKF.Types = normalizeConfigStringList(c.OKF.Types) + if strings.TrimSpace(c.YouTube.YTDLPPath) == "" { + c.YouTube.YTDLPPath = defaultYouTubeYTDLPPath + } +diff --git a/internal/config/config_test.go b/internal/config/config_test.go +index 9edc0c1..e4c3c2e 100644 +--- a/internal/config/config_test.go ++++ b/internal/config/config_test.go +@@ -59,6 +59,9 @@ func TestDefaultConfigHasValidDefaults(t *testing.T) { + if len(cfg.Vault.TopicGlobs) != 1 || cfg.Vault.TopicGlobs[0] != "*" { + t.Errorf("expected default vault.topic_globs [*], got %#v", cfg.Vault.TopicGlobs) + } ++ if len(cfg.OKF.Types) != 0 { ++ t.Errorf("expected default okf.types to be empty, got %#v", cfg.OKF.Types) ++ } + if cfg.Firecrawl.APIURL != defaultFirecrawlAPIURL { + t.Errorf("expected default firecrawl.api_url %q, got %q", defaultFirecrawlAPIURL, cfg.Firecrawl.APIURL) + } +@@ -118,6 +121,9 @@ level = "debug" + root = "." + topic_globs = ["*", "harness/*"] + ++[okf] ++types = ["Playbook", " Reference ", ""] ++ + [firecrawl] + api_key = "firecrawl-key" + api_url = "https://firecrawl.internal" +@@ -172,6 +178,9 @@ allow_translated_captions = true + if len(cfg.Vault.TopicGlobs) != 2 || cfg.Vault.TopicGlobs[1] != "harness/*" { + t.Errorf("expected vault.topic_globs to include harness/*, got %#v", cfg.Vault.TopicGlobs) + } ++ if !reflect.DeepEqual(cfg.OKF.Types, []string{"Playbook", "Reference"}) { ++ t.Errorf("expected normalized okf.types, got %#v", cfg.OKF.Types) ++ } + if cfg.Firecrawl.APIKey != "firecrawl-key" { + t.Errorf("expected firecrawl.api_key 'firecrawl-key', got %q", cfg.Firecrawl.APIKey) + } +diff --git a/internal/generate/generate.go b/internal/generate/generate.go +index be6a9a1..987e572 100644 +--- a/internal/generate/generate.go ++++ b/internal/generate/generate.go +@@ -453,6 +453,7 @@ func (r runner) createTopicMetadata(target generationTarget, opts models.Generat + Title: title, + Slug: target.TopicSlug, + Domain: vault.DeriveTopicDomain(domainSource), ++ Mode: models.TopicModeWiki, + Today: r.now().Format("2006-01-02"), + VaultPath: target.VaultPath, + TopicPath: filepath.Join(target.VaultPath, target.TopicSlug), +diff --git a/internal/models/kb_models.go b/internal/models/kb_models.go +index 11d0ef2..2087683 100644 +--- a/internal/models/kb_models.go ++++ b/internal/models/kb_models.go +@@ -114,11 +114,12 @@ type LintIssue struct { + + // TopicInfo captures topic metadata for list and info operations. + type TopicInfo struct { +- Slug string `json:"slug"` +- Title string `json:"title"` +- Domain string `json:"domain"` +- RootPath string `json:"rootPath"` +- ArticleCount int `json:"articleCount"` +- SourceCount int `json:"sourceCount"` +- LastLogEntry string `json:"lastLogEntry,omitempty"` ++ Slug string `json:"slug"` ++ Title string `json:"title"` ++ Domain string `json:"domain"` ++ Mode TopicMode `json:"mode"` ++ RootPath string `json:"rootPath"` ++ ArticleCount int `json:"articleCount"` ++ SourceCount int `json:"sourceCount"` ++ LastLogEntry string `json:"lastLogEntry,omitempty"` + } +diff --git a/internal/models/models.go b/internal/models/models.go +index 3352045..061f0aa 100644 +--- a/internal/models/models.go ++++ b/internal/models/models.go +@@ -102,6 +102,16 @@ const ( + DocIndex DocumentKind = "index" + ) + ++// TopicMode identifies the lifecycle and file contract for a topic. ++type TopicMode string ++ ++const ( ++ // TopicModeWiki preserves the existing LLM-wiki topic lifecycle. ++ TopicModeWiki TopicMode = "wiki" ++ // TopicModeOKF uses the Open Knowledge Format bundle lifecycle. ++ TopicModeOKF TopicMode = "okf" ++) ++ + // ManagedArea identifies the managed subtree within a generated topic. + type ManagedArea string + +@@ -269,13 +279,14 @@ type RenderedDocument struct { + + // TopicMetadata captures the derived topic information for a vault render. + type TopicMetadata struct { +- RootPath string `json:"rootPath"` +- Title string `json:"title"` +- Slug string `json:"slug"` +- Domain string `json:"domain"` +- Today string `json:"today"` +- VaultPath string `json:"vaultPath"` +- TopicPath string `json:"topicPath"` ++ RootPath string `json:"rootPath"` ++ Title string `json:"title"` ++ Slug string `json:"slug"` ++ Domain string `json:"domain"` ++ Mode TopicMode `json:"mode"` ++ Today string `json:"today"` ++ VaultPath string `json:"vaultPath"` ++ TopicPath string `json:"topicPath"` + } + + // GenerateOptions configures a full knowledge-base generation run. +diff --git a/internal/topic/topic.go b/internal/topic/topic.go +index 1097c4e..f74467e 100644 +--- a/internal/topic/topic.go ++++ b/internal/topic/topic.go +@@ -23,6 +23,7 @@ const ( + conceptIndexTemplatePath = "assets/concept-index-template.md" + dashboardTemplatePath = "assets/dashboard-template.md" + logTemplatePath = "assets/log-template.md" ++ okfClaudeTemplatePath = "assets/okf-claude-template.md" + sourceIndexTemplatePath = "assets/source-index-template.md" + topicMarkerFile = "CLAUDE.md" + topicMetadataFileName = "topic.yaml" +@@ -105,6 +106,7 @@ type topicMetadataFile struct { + Slug string `yaml:"slug"` + Title string `yaml:"title"` + Domain string `yaml:"domain"` ++ Mode string `yaml:"mode,omitempty"` + Category string `yaml:"category,omitempty"` + Path string `yaml:"path,omitempty"` + QMDCollection string `yaml:"qmd_collection,omitempty"` +@@ -112,7 +114,12 @@ type topicMetadataFile struct { + + // New scaffolds a new topic underneath the provided vault root. + func New(vaultPath, slug, title, domain string) (models.TopicInfo, error) { +- return newWithDate(vaultPath, slug, title, domain, time.Now()) ++ return NewWithMode(vaultPath, slug, title, domain, models.TopicModeWiki) ++} ++ ++// NewWithMode scaffolds a new topic with an explicit lifecycle mode. ++func NewWithMode(vaultPath, slug, title, domain string, mode models.TopicMode) (models.TopicInfo, error) { ++ return newWithDateWithMode(vaultPath, slug, title, domain, mode, time.Now()) + } + + // List returns the topics discovered under the provided vault root. +@@ -184,6 +191,10 @@ func Info(vaultPath, slug string) (models.TopicInfo, error) { + } + + func newWithDate(vaultPath, slug, title, domain string, now time.Time) (models.TopicInfo, error) { ++ return newWithDateWithMode(vaultPath, slug, title, domain, models.TopicModeWiki, now) ++} ++ ++func newWithDateWithMode(vaultPath, slug, title, domain string, mode models.TopicMode, now time.Time) (models.TopicInfo, error) { + cleanVaultPath, err := normalizeVaultPath(vaultPath) + if err != nil { + return models.TopicInfo{}, fmt.Errorf("new topic: %w", err) +@@ -195,6 +206,10 @@ func newWithDate(vaultPath, slug, title, domain string, now time.Time) (models.T + } + cleanTitle := strings.TrimSpace(title) + cleanDomain := strings.TrimSpace(domain) ++ cleanMode, err := normalizeTopicMode(mode) ++ if err != nil { ++ return models.TopicInfo{}, fmt.Errorf("new topic: %w", err) ++ } + + switch { + case cleanTitle == "": +@@ -214,7 +229,7 @@ func newWithDate(vaultPath, slug, title, domain string, now time.Time) (models.T + return models.TopicInfo{}, fmt.Errorf("new topic: inspect topic path %q: %w", topicPath, err) + } + +- if err := createTopicSkeleton(topicPath); err != nil { ++ if err := createTopicSkeleton(topicPath, cleanMode); err != nil { + return models.TopicInfo{}, fmt.Errorf("new topic: create topic skeleton: %w", err) + } + +@@ -225,19 +240,26 @@ func newWithDate(vaultPath, slug, title, domain string, now time.Time) (models.T + Today: now.Format(frontmatter.DateLayout), + } + +- if err := installTemplates(topicPath, context); err != nil { ++ if err := installTemplatesWithMode(topicPath, context, cleanMode); err != nil { + return models.TopicInfo{}, fmt.Errorf("new topic: install templates: %w", err) + } +- if err := writeMetadataFile(topicPath, topicMetadataForRef(topicRef, cleanTitle, cleanDomain)); err != nil { ++ if err := writeMetadataFile(topicPath, topicMetadataForRef(topicRef, cleanTitle, cleanDomain, cleanMode)); err != nil { + return models.TopicInfo{}, fmt.Errorf("new topic: write topic metadata: %w", err) + } + if err := ensureAgentsSymlink(topicPath); err != nil { + return models.TopicInfo{}, fmt.Errorf("new topic: ensure AGENTS.md symlink: %w", err) + } +- if err := ensureGitkeeps(topicPath); err != nil { +- return models.TopicInfo{}, fmt.Errorf("new topic: ensure gitkeep files: %w", err) ++ if cleanMode == models.TopicModeWiki { ++ if err := ensureGitkeeps(topicPath); err != nil { ++ return models.TopicInfo{}, fmt.Errorf("new topic: ensure gitkeep files: %w", err) ++ } + } +- if err := appendScaffoldEntry(filepath.Join(topicPath, "log.md"), context); err != nil { ++ logPath := filepath.Join(topicPath, "log.md") ++ if cleanMode == models.TopicModeOKF { ++ if err := appendOKFScaffoldEntry(logPath, context); err != nil { ++ return models.TopicInfo{}, fmt.Errorf("new topic: append scaffold log entry: %w", err) ++ } ++ } else if err := appendScaffoldEntry(logPath, context); err != nil { + return models.TopicInfo{}, fmt.Errorf("new topic: append scaffold log entry: %w", err) + } + +@@ -412,7 +434,10 @@ func ensureDirectory(path string) error { + return nil + } + +-func createTopicSkeleton(topicPath string) error { ++func createTopicSkeleton(topicPath string, mode models.TopicMode) error { ++ if mode == models.TopicModeOKF { ++ return os.MkdirAll(topicPath, 0o755) ++ } + for _, relativePath := range currentTopicDirectories { + directoryPath := filepath.Join(topicPath, filepath.FromSlash(relativePath)) + if err := os.MkdirAll(directoryPath, 0o755); err != nil { +@@ -430,7 +455,7 @@ func EnsureCurrentSkeleton(topicPath string) error { + return fmt.Errorf("topic path is required") + } + +- if err := createTopicSkeleton(topicPath); err != nil { ++ if err := createTopicSkeleton(topicPath, models.TopicModeWiki); err != nil { + return err + } + if err := ensureTopicLog(topicPath); err != nil { +@@ -485,7 +510,17 @@ func RenderSourceIndexTemplate(slug, title, domain, today string) (string, error + } + + func installTemplates(topicPath string, context templateContext) error { +- for _, file := range topicTemplates { ++ return installTemplatesWithMode(topicPath, context, models.TopicModeWiki) ++} ++ ++func installTemplatesWithMode(topicPath string, context templateContext, mode models.TopicMode) error { ++ templates := topicTemplates ++ if mode == models.TopicModeOKF { ++ templates = []templateFile{ ++ {assetPath: okfClaudeTemplatePath, outputPath: "CLAUDE.md"}, ++ } ++ } ++ for _, file := range templates { + rendered, err := renderTemplate(file.assetPath, context) + if err != nil { + return err +@@ -497,6 +532,19 @@ func installTemplates(topicPath string, context templateContext) error { + } + } + ++ if mode == models.TopicModeOKF { ++ index, err := frontmatter.Generate(map[string]any{"okf_version": "0.1"}, "# OKF Bundle Index\n") ++ if err != nil { ++ return fmt.Errorf("generate OKF index: %w", err) ++ } ++ if err := os.WriteFile(filepath.Join(topicPath, "index.md"), []byte(index), 0o644); err != nil { ++ return fmt.Errorf("write OKF index: %w", err) ++ } ++ if err := os.WriteFile(filepath.Join(topicPath, "log.md"), []byte("# Directory Update Log\n"), 0o644); err != nil { ++ return fmt.Errorf("write OKF log: %w", err) ++ } ++ } ++ + return nil + } + +@@ -506,14 +554,28 @@ func WriteMetadataFile(topicPath, slug, title, domain string) error { + if err != nil { + return fmt.Errorf("validate topic metadata slug: %w", err) + } +- return writeMetadataFile(topicPath, topicMetadataForRef(topicRef, title, domain)) ++ return writeMetadataFile(topicPath, topicMetadataForRef(topicRef, title, domain, "")) ++} ++ ++// WriteMetadataFileWithMode writes structured topic metadata with an explicit mode. ++func WriteMetadataFileWithMode(topicPath, slug, title, domain string, mode models.TopicMode) error { ++ topicRef, err := ParseTopicRef(slug) ++ if err != nil { ++ return fmt.Errorf("validate topic metadata slug: %w", err) ++ } ++ cleanMode, err := normalizeTopicMode(mode) ++ if err != nil { ++ return err ++ } ++ return writeMetadataFile(topicPath, topicMetadataForRef(topicRef, title, domain, cleanMode)) + } + +-func topicMetadataForRef(topicRef TopicRef, title, domain string) topicMetadataFile { ++func topicMetadataForRef(topicRef TopicRef, title, domain string, mode models.TopicMode) topicMetadataFile { + metadata := topicMetadataFile{ + Slug: topicRef.Leaf, + Title: strings.TrimSpace(title), + Domain: strings.TrimSpace(domain), ++ Mode: string(mode), + } + if topicRef.Category != "" { + metadata.Category = topicRef.Category +@@ -694,6 +756,32 @@ func appendScaffoldEntry(logPath string, context templateContext) error { + return nil + } + ++func appendOKFScaffoldEntry(logPath string, context templateContext) error { ++ content, err := os.ReadFile(logPath) ++ if err != nil { ++ return fmt.Errorf("read %q: %w", logPath, err) ++ } ++ ++ entry := strings.Join([]string{ ++ fmt.Sprintf("## %s", context.Today), ++ "", ++ fmt.Sprintf("* **Initialization**: Created OKF bundle `%s` for `%s`.", context.Slug, context.Domain), ++ "", ++ }, "\n") ++ ++ trimmed := strings.TrimSpace(string(content)) ++ var next string ++ if trimmed == "" { ++ next = "# Directory Update Log\n\n" + entry ++ } else { ++ next = trimmed + "\n\n" + entry ++ } ++ if err := os.WriteFile(logPath, []byte(next), 0o644); err != nil { ++ return fmt.Errorf("write %q: %w", logPath, err) ++ } ++ return nil ++} ++ + func hasTopicSkeleton(topicPath string) (bool, error) { + info, err := os.Stat(topicPath) + if errors.Is(err, os.ErrNotExist) { +@@ -719,19 +807,14 @@ func hasTopicSkeleton(topicPath string) (bool, error) { + } + + func infoAtPath(topicPath, slug string) (models.TopicInfo, error) { +- title, domain, err := readTopicMetadata(topicPath, slug) ++ title, domain, mode, err := readTopicMetadata(topicPath, slug) + if err != nil { + return models.TopicInfo{}, fmt.Errorf("read topic metadata: %w", err) + } + +- articleCount, err := countMarkdownFiles(filepath.Join(topicPath, "wiki", "concepts")) +- if err != nil { +- return models.TopicInfo{}, fmt.Errorf("count wiki articles: %w", err) +- } +- +- sourceCount, err := countVisibleFiles(filepath.Join(topicPath, "raw")) ++ articleCount, sourceCount, err := topicCounts(topicPath, mode) + if err != nil { +- return models.TopicInfo{}, fmt.Errorf("count raw sources: %w", err) ++ return models.TopicInfo{}, fmt.Errorf("count topic files: %w", err) + } + + lastLogEntry, err := readLastLogEntry(filepath.Join(topicPath, "log.md")) +@@ -743,6 +826,7 @@ func infoAtPath(topicPath, slug string) (models.TopicInfo, error) { + Slug: slug, + Title: title, + Domain: domain, ++ Mode: mode, + RootPath: topicPath, + ArticleCount: articleCount, + SourceCount: sourceCount, +@@ -750,21 +834,25 @@ func infoAtPath(topicPath, slug string) (models.TopicInfo, error) { + }, nil + } + +-func readTopicMetadata(claudePath, slug string) (string, string, error) { ++func readTopicMetadata(claudePath, slug string) (string, string, models.TopicMode, error) { + yamlMetadata, err := readTopicYAMLMetadata(filepath.Join(claudePath, topicMetadataFileName)) + if err != nil { +- return "", "", err ++ return "", "", "", err + } + + claudeTitle, claudeDomain, err := readClaudeMetadata(filepath.Join(claudePath, topicMarkerFile), slug) + if err != nil { +- return "", "", err ++ return "", "", "", err + } + + title := firstNonEmpty(yamlMetadata.Title, claudeTitle, humanizeSlug(slug)) + domain := firstNonEmpty(yamlMetadata.Domain, claudeDomain, slug) ++ mode, err := normalizeTopicMode(models.TopicMode(yamlMetadata.Mode)) ++ if err != nil { ++ return "", "", "", err ++ } + +- return title, domain, nil ++ return title, domain, mode, nil + } + + func readTopicYAMLMetadata(metadataPath string) (topicMetadataFile, error) { +@@ -784,9 +872,52 @@ func readTopicYAMLMetadata(metadataPath string) (topicMetadataFile, error) { + metadata.Slug = strings.TrimSpace(metadata.Slug) + metadata.Title = strings.TrimSpace(metadata.Title) + metadata.Domain = strings.TrimSpace(metadata.Domain) ++ metadata.Mode = strings.TrimSpace(metadata.Mode) + return metadata, nil + } + ++func normalizeTopicMode(mode models.TopicMode) (models.TopicMode, error) { ++ switch strings.TrimSpace(string(mode)) { ++ case "", string(models.TopicModeWiki): ++ return models.TopicModeWiki, nil ++ case string(models.TopicModeOKF): ++ return models.TopicModeOKF, nil ++ default: ++ return "", fmt.Errorf("topic mode must be wiki or okf: %q", mode) ++ } ++} ++ ++func topicCounts(topicPath string, mode models.TopicMode) (int, int, error) { ++ if mode == models.TopicModeOKF { ++ concepts, err := countOKFConceptFiles(topicPath) ++ return concepts, 0, err ++ } ++ articleCount, err := countMarkdownFiles(filepath.Join(topicPath, "wiki", "concepts")) ++ if err != nil { ++ return 0, 0, fmt.Errorf("count wiki articles: %w", err) ++ } ++ sourceCount, err := countVisibleFiles(filepath.Join(topicPath, "raw")) ++ if err != nil { ++ return 0, 0, fmt.Errorf("count raw sources: %w", err) ++ } ++ return articleCount, sourceCount, nil ++} ++ ++func countOKFConceptFiles(root string) (int, error) { ++ return countFiles(root, func(entry fs.DirEntry) bool { ++ name := entry.Name() ++ if !strings.HasSuffix(strings.ToLower(name), ".md") || strings.HasPrefix(name, ".") { ++ return false ++ } ++ switch strings.ToLower(name) { ++ case "index.md", "log.md", "claude.md", "agents.md", "readme.md", "license.md", "notice.md", "attribution.md": ++ return false ++ default: ++ return true ++ } ++ }) ++} ++ + func readClaudeMetadata(claudePath, slug string) (string, string, error) { + content, err := os.ReadFile(claudePath) + if err != nil { +@@ -827,7 +958,7 @@ func readLastLogEntry(logPath string) (string, error) { + last := "" + for line := range strings.SplitSeq(string(content), "\n") { + trimmed := strings.TrimSpace(line) +- if strings.HasPrefix(trimmed, "## [") { ++ if strings.HasPrefix(trimmed, "## [") || isOKFDateHeading(trimmed) { + last = trimmed + } + } +@@ -835,6 +966,18 @@ func readLastLogEntry(logPath string) (string, error) { + return last, nil + } + ++func isOKFDateHeading(line string) bool { ++ if !strings.HasPrefix(line, "## ") { ++ return false ++ } ++ value := strings.TrimSpace(strings.TrimPrefix(line, "## ")) ++ if len(value) != len(frontmatter.DateLayout) { ++ return false ++ } ++ _, err := time.Parse(frontmatter.DateLayout, value) ++ return err == nil ++} ++ + func countMarkdownFiles(root string) (int, error) { + return countFiles(root, func(entry fs.DirEntry) bool { + name := entry.Name() +diff --git a/internal/topic/topic_test.go b/internal/topic/topic_test.go +index 2730efa..46dbcee 100644 +--- a/internal/topic/topic_test.go ++++ b/internal/topic/topic_test.go +@@ -8,6 +8,7 @@ import ( + "time" + + "github.com/compozy/kb/internal/frontmatter" ++ "github.com/compozy/kb/internal/models" + ) + + func TestNewCreatesTopicSkeletonAndTemplates(t *testing.T) { +@@ -35,6 +36,9 @@ func TestNewCreatesTopicSkeletonAndTemplates(t *testing.T) { + if info.Domain != "rust" { + t.Fatalf("domain = %q, want rust", info.Domain) + } ++ if info.Mode != models.TopicModeWiki { ++ t.Fatalf("mode = %q, want wiki", info.Mode) ++ } + if info.ArticleCount != 0 { + t.Fatalf("article count = %d, want 0", info.ArticleCount) + } +@@ -156,6 +160,7 @@ func TestNewCreatesCategorizedTopic(t *testing.T) { + "slug: asimov-academy", + "title: Asimov Academy", + "domain: youtube-channel", ++ "mode: wiki", + "category: yt-channels", + "path: yt-channels/asimov-academy", + "qmd_collection: asimov-academy", +@@ -207,6 +212,7 @@ func TestNewCreatesClaudeAndAgentsSymlink(t *testing.T) { + "slug: distributed-systems", + "title: Distributed Systems", + "domain: distributed", ++ "mode: wiki", + } { + if !strings.Contains(metadataContent, fragment) { + t.Fatalf("topic.yaml missing %q:\n%s", fragment, metadataContent) +@@ -227,6 +233,83 @@ func TestNewCreatesClaudeAndAgentsSymlink(t *testing.T) { + } + } + ++func TestNewWithModeCreatesOKFTopicSkeleton(t *testing.T) { ++ t.Parallel() ++ ++ vaultPath := t.TempDir() ++ ++ info, err := newWithDateWithMode( ++ vaultPath, ++ "ops-catalog", ++ "Operations Catalog", ++ "operations", ++ models.TopicModeOKF, ++ time.Date(2026, 6, 27, 12, 0, 0, 0, time.UTC), ++ ) ++ if err != nil { ++ t.Fatalf("newWithDateWithMode returned error: %v", err) ++ } ++ if info.Mode != models.TopicModeOKF { ++ t.Fatalf("mode = %q, want okf", info.Mode) ++ } ++ if info.ArticleCount != 0 || info.SourceCount != 0 { ++ t.Fatalf("counts = articles %d sources %d, want 0/0", info.ArticleCount, info.SourceCount) ++ } ++ if info.LastLogEntry != "## 2026-06-27" { ++ t.Fatalf("last log entry = %q, want OKF date heading", info.LastLogEntry) ++ } ++ ++ topicPath := filepath.Join(vaultPath, "ops-catalog") ++ for _, relativePath := range []string{"CLAUDE.md", "AGENTS.md", "index.md", "log.md", "topic.yaml"} { ++ assertFileExists(t, filepath.Join(topicPath, filepath.FromSlash(relativePath))) ++ } ++ for _, relativePath := range []string{"raw", "wiki", "outputs", "bases"} { ++ if _, err := os.Stat(filepath.Join(topicPath, relativePath)); !os.IsNotExist(err) { ++ t.Fatalf("OKF scaffold should not create %s", relativePath) ++ } ++ } ++ ++ metadataContent := readFile(t, filepath.Join(topicPath, "topic.yaml")) ++ for _, fragment := range []string{ ++ "slug: ops-catalog", ++ "title: Operations Catalog", ++ "domain: operations", ++ "mode: okf", ++ } { ++ if !strings.Contains(metadataContent, fragment) { ++ t.Fatalf("topic.yaml missing %q:\n%s", fragment, metadataContent) ++ } ++ } ++ ++ indexValues, indexBody := parseFrontmatterFile(t, filepath.Join(topicPath, "index.md")) ++ if got := indexValues["okf_version"]; got != "0.1" { ++ t.Fatalf("index okf_version = %#v, want 0.1", got) ++ } ++ if !strings.Contains(indexBody, "# OKF Bundle Index") { ++ t.Fatalf("index body missing heading:\n%s", indexBody) ++ } ++ logContent := readFile(t, filepath.Join(topicPath, "log.md")) ++ if !strings.Contains(logContent, "## 2026-06-27") || strings.Contains(logContent, "## [2026-06-27]") { ++ t.Fatalf("log.md does not use OKF date heading:\n%s", logContent) ++ } ++} ++ ++func TestReadTopicMetadataDefaultsMissingModeToWiki(t *testing.T) { ++ t.Parallel() ++ ++ topicPath := t.TempDir() ++ writeFile(t, filepath.Join(topicPath, "topic.yaml"), "slug: old-topic\ntitle: Old Topic\ndomain: legacy\n") ++ writeFile(t, filepath.Join(topicPath, "CLAUDE.md"), "# Old Topic\n\n**Domain:** `legacy`\n") ++ ++ info, err := infoAtPath(topicPath, "old-topic") ++ if err != nil { ++ t.Fatalf("infoAtPath returned error: %v", err) ++ } ++ if info.Mode != models.TopicModeWiki { ++ t.Fatalf("mode = %q, want wiki", info.Mode) ++ } ++} ++ + func TestNewAppendsScaffoldEntryToLog(t *testing.T) { + t.Parallel() + +diff --git a/internal/vault/pathutils.go b/internal/vault/pathutils.go +index d356e3c..002b4df 100644 +--- a/internal/vault/pathutils.go ++++ b/internal/vault/pathutils.go +@@ -4,6 +4,7 @@ package vault + import ( + "fmt" + "path" ++ "path/filepath" + "regexp" + "strings" + +@@ -251,6 +252,73 @@ func ToTopicWikiLink(topicSlug, documentPath, label string) string { + return fmt.Sprintf("[[%s]]", target) + } + ++// LinkFormatter renders a link from one topic-relative document to another. ++type LinkFormatter interface { ++ Link(fromDir, targetPath, label string) string ++} ++ ++// WikiLinkFormatter renders Obsidian topic-scoped wikilinks. ++type WikiLinkFormatter struct { ++ Slug string ++} ++ ++// Link renders a wikilink exactly like ToTopicWikiLink. ++func (f WikiLinkFormatter) Link(_, targetPath, label string) string { ++ return ToTopicWikiLink(f.Slug, targetPath, label) ++} ++ ++// OKFLinkFormatter renders relative Markdown links for OKF bundles. ++type OKFLinkFormatter struct{} ++ ++// Link renders a GitHub-safe relative Markdown link. ++func (OKFLinkFormatter) Link(fromDir, targetPath, label string) string { ++ cleanTarget := strings.TrimPrefix(ToPosixPath(strings.TrimSpace(targetPath)), "/") ++ displayLabel := strings.TrimSpace(label) ++ if displayLabel == "" { ++ displayLabel = HumanizeSlug(SlugifySegment(strings.TrimSuffix(path.Base(cleanTarget), ".md"))) ++ } ++ ++ fragment := "" ++ if hashIndex := strings.Index(cleanTarget, "#"); hashIndex >= 0 { ++ fragment = cleanTarget[hashIndex:] ++ cleanTarget = cleanTarget[:hashIndex] ++ } ++ ++ cleanFromDir := strings.TrimPrefix(ToPosixPath(strings.TrimSpace(fromDir)), "/") ++ if cleanFromDir == "" { ++ cleanFromDir = "." ++ } ++ ++ relativePath, err := filepath.Rel(filepath.FromSlash(cleanFromDir), filepath.FromSlash(cleanTarget)) ++ if err != nil { ++ relativePath = cleanTarget ++ } ++ relativePath = ToPosixPath(relativePath) ++ if relativePath == "." || relativePath == "" { ++ relativePath = path.Base(cleanTarget) ++ } ++ relativePath = strings.TrimPrefix(relativePath, "./") ++ relativePath = markdownLinkTarget(relativePath + fragment) ++ return fmt.Sprintf("[%s](%s)", displayLabel, relativePath) ++} ++ ++// LinkFormatterFor selects the formatter from the topic mode. ++func LinkFormatterFor(topic models.TopicMetadata) LinkFormatter { ++ if topic.Mode == models.TopicModeOKF { ++ return OKFLinkFormatter{} ++ } ++ return WikiLinkFormatter{Slug: topic.Slug} ++} ++ ++func markdownLinkTarget(target string) string { ++ replacer := strings.NewReplacer( ++ " ", "%20", ++ "(", "%28", ++ ")", "%29", ++ ) ++ return replacer.Replace(target) ++} ++ + func stripWikiConceptFilePrefix(articleTitle string) string { + normalizedTitle := strings.TrimSpace(articleTitle) + return strings.TrimSpace(strings.TrimPrefix(normalizedTitle, legacyWikiConceptPrefix)) +diff --git a/internal/vault/pathutils_test.go b/internal/vault/pathutils_test.go +index 927c7d4..6655604 100644 +--- a/internal/vault/pathutils_test.go ++++ b/internal/vault/pathutils_test.go +@@ -196,6 +196,44 @@ func TestTopicWikiLinkHelpers(t *testing.T) { + } + } + ++func TestLinkFormatters(t *testing.T) { ++ t.Parallel() ++ ++ wiki := vault.WikiLinkFormatter{Slug: "topic-slug"} ++ if got := wiki.Link("ignored", "wiki/codebase/concepts/Codebase Overview.md", "Overview"); got != vault.ToTopicWikiLink("topic-slug", "wiki/codebase/concepts/Codebase Overview.md", "Overview") { ++ t.Fatalf("wiki formatter = %q", got) ++ } ++ ++ okf := vault.OKFLinkFormatter{} ++ testCases := []struct { ++ name string ++ from string ++ target string ++ label string ++ want string ++ }{ ++ {name: "same directory", from: "", target: "orders.md", label: "Orders", want: "[Orders](orders.md)"}, ++ {name: "child", from: "", target: "tables/orders.md", label: "Orders", want: "[Orders](tables/orders.md)"}, ++ {name: "parent", from: "references", target: "tables/customer orders.md#schema", label: "Customers", want: "[Customers](../tables/customer%20orders.md#schema)"}, ++ {name: "sibling", from: "tables", target: "references/joins.md", label: "Joins", want: "[Joins](../references/joins.md)"}, ++ } ++ for _, testCase := range testCases { ++ t.Run(testCase.name, func(t *testing.T) { ++ t.Parallel() ++ if got := okf.Link(testCase.from, testCase.target, testCase.label); got != testCase.want { ++ t.Fatalf("OKF link = %q, want %q", got, testCase.want) ++ } ++ }) ++ } ++ ++ if got := vault.LinkFormatterFor(models.TopicMetadata{Slug: "demo", Mode: models.TopicModeWiki}).Link("", "x.md", "X"); got != "[[demo/x|X]]" { ++ t.Fatalf("wiki LinkFormatterFor = %q", got) ++ } ++ if got := vault.LinkFormatterFor(models.TopicMetadata{Mode: models.TopicModeOKF}).Link("", "x.md", "X"); got != "[X](x.md)" { ++ t.Fatalf("OKF LinkFormatterFor = %q", got) ++ } ++} ++ + func TestPathHelpersHandleEmptyInputs(t *testing.T) { + t.Parallel() + +diff --git a/internal/vault/render.go b/internal/vault/render.go +index 374133e..b646514 100644 +--- a/internal/vault/render.go ++++ b/internal/vault/render.go +@@ -184,7 +184,11 @@ func renderMarkdownDocument(document models.RenderedDocument) string { + } + + func toSourceWikiLink(topic models.TopicMetadata, relativePath, label string) string { +- return ToTopicWikiLink(topic.Slug, relativePath, label) ++ return linkFor(topic, "", relativePath, label) ++} ++ ++func linkFor(topic models.TopicMetadata, fromDir, relativePath, label string) string { ++ return LinkFormatterFor(topic).Link(fromDir, relativePath, label) + } + + func createDocumentLookup(graph models.GraphSnapshot) map[string]string { +@@ -217,7 +221,7 @@ func linkForNode( + fallbackLabel string, + ) string { + if documentPath, exists := documentLookup[nodeID]; exists { +- return toSourceWikiLink(topic, documentPath, fallbackLabel) ++ return linkFor(topic, "", documentPath, fallbackLabel) + } + + if externalNode, exists := externalNodes[nodeID]; exists { +diff --git a/internal/vault/render_wiki.go b/internal/vault/render_wiki.go +index 2c3978a..af371b9 100644 +--- a/internal/vault/render_wiki.go ++++ b/internal/vault/render_wiki.go +@@ -105,7 +105,7 @@ func renderDashboard( + for _, article := range articles { + lines = append(lines, fmt.Sprintf( + "- %s - %s", +- ToTopicWikiLink(topic.Slug, GetWikiConceptPath(article.Title), article.Title), ++ linkFor(topic, "", GetWikiConceptPath(article.Title), article.Title), + article.Summary, + )) + } +@@ -113,8 +113,8 @@ func renderDashboard( + lines = append(lines, + "", + "## Navigation", +- "- "+ToTopicWikiLink(topic.Slug, GetWikiIndexPath(CodebaseConceptIndexTitle), CodebaseConceptIndexTitle), +- "- "+ToTopicWikiLink(topic.Slug, GetWikiIndexPath(CodebaseSourceIndexTitle), CodebaseSourceIndexTitle), ++ "- "+linkFor(topic, "", GetWikiIndexPath(CodebaseConceptIndexTitle), CodebaseConceptIndexTitle), ++ "- "+linkFor(topic, "", GetWikiIndexPath(CodebaseSourceIndexTitle), CodebaseSourceIndexTitle), + ) + + return models.RenderedDocument{ +@@ -144,7 +144,7 @@ func renderConceptIndex( + for _, article := range orderedArticles { + rows = append(rows, fmt.Sprintf( + "| %s | %s |", +- ToTopicWikiLink(topic.Slug, GetWikiConceptPath(article.Title), article.Title), ++ linkFor(topic, "", GetWikiConceptPath(article.Title), article.Title), + article.Summary, + )) + } +@@ -191,7 +191,7 @@ func renderSourceIndex(topic models.TopicMetadata, articles []starterWikiArticle + + links := make([]string, 0, len(titles)) + for _, title := range titles { +- links = append(links, ToTopicWikiLink(topic.Slug, GetWikiConceptPath(title), title)) ++ links = append(links, linkFor(topic, "", GetWikiConceptPath(title), title)) + } + + rows = append(rows, fmt.Sprintf( +@@ -311,9 +311,9 @@ func createCodebaseOverviewArticle( + "", + fmt.Sprintf( + "Start with %s for coupling, %s for function-level complexity, and %s for likely cleanup candidates.", +- ToTopicWikiLink(topic.Slug, GetWikiConceptPath("Module Health"), "Module Health"), +- ToTopicWikiLink(topic.Slug, GetWikiConceptPath("Complexity Hotspots"), "Complexity Hotspots"), +- ToTopicWikiLink(topic.Slug, GetWikiConceptPath("Dead Code Report"), "Dead Code Report"), ++ linkFor(topic, "", GetWikiConceptPath("Module Health"), "Module Health"), ++ linkFor(topic, "", GetWikiConceptPath("Complexity Hotspots"), "Complexity Hotspots"), ++ linkFor(topic, "", GetWikiConceptPath("Dead Code Report"), "Dead Code Report"), + ), + "", + "## Language Coverage", +@@ -392,8 +392,8 @@ func createDirectoryMapArticle( + "", + fmt.Sprintf( + "Cross-check unstable directories against %s and hotspots against %s.", +- ToTopicWikiLink(topic.Slug, GetWikiConceptPath("Module Health"), "Module Health"), +- ToTopicWikiLink(topic.Slug, GetWikiConceptPath("Dependency Hotspots"), "Dependency Hotspots"), ++ linkFor(topic, "", GetWikiConceptPath("Module Health"), "Module Health"), ++ linkFor(topic, "", GetWikiConceptPath("Dependency Hotspots"), "Dependency Hotspots"), + ), + "", + "## Sources and Further Reading", +@@ -458,8 +458,8 @@ func createSymbolTaxonomyArticle( + "", + fmt.Sprintf( + "Use %s to find bottlenecks and %s to locate function-level smells.", +- ToTopicWikiLink(topic.Slug, GetWikiConceptPath("High-Impact Symbols"), "High-Impact Symbols"), +- ToTopicWikiLink(topic.Slug, GetWikiConceptPath("Code Smells"), "Code Smells"), ++ linkFor(topic, "", GetWikiConceptPath("High-Impact Symbols"), "High-Impact Symbols"), ++ linkFor(topic, "", GetWikiConceptPath("Code Smells"), "Code Smells"), + ), + "", + "## Sources and Further Reading", +@@ -535,7 +535,7 @@ func createDependencyHotspotsArticle( + "", + fmt.Sprintf( + "A high relation count usually indicates a coordination layer, a shared utility, or an entry point. Cross-check these files against %s to distinguish stable modules from unstable ones.", +- ToTopicWikiLink(topic.Slug, GetWikiConceptPath("Module Health"), "Module Health"), ++ linkFor(topic, "", GetWikiConceptPath("Module Health"), "Module Health"), + ), + "", + "## Sources and Further Reading", +@@ -613,7 +613,7 @@ func createComplexityHotspotsArticle( + "", + fmt.Sprintf( + "Compare these hotspots against %s to distinguish locally complex functions from high-blast-radius functions.", +- ToTopicWikiLink(topic.Slug, GetWikiConceptPath("High-Impact Symbols"), "High-Impact Symbols"), ++ linkFor(topic, "", GetWikiConceptPath("High-Impact Symbols"), "High-Impact Symbols"), + ), + }, "\n"), + } +diff --git a/internal/vault/writer.go b/internal/vault/writer.go +index 571c946..c85f5f3 100644 +--- a/internal/vault/writer.go ++++ b/internal/vault/writer.go +@@ -446,9 +446,9 @@ func buildTopicClaudeManagedBlock( + "", + "### Generated codebase navigation", + "", +- "- " + ToTopicWikiLink(topic.Slug, GetWikiIndexPath(CodebaseDashboardTitle), CodebaseDashboardTitle), +- "- " + ToTopicWikiLink(topic.Slug, GetWikiIndexPath(CodebaseConceptIndexTitle), CodebaseConceptIndexTitle), +- "- " + ToTopicWikiLink(topic.Slug, GetWikiIndexPath(CodebaseSourceIndexTitle), CodebaseSourceIndexTitle), ++ "- " + linkFor(topic, "", GetWikiIndexPath(CodebaseDashboardTitle), CodebaseDashboardTitle), ++ "- " + linkFor(topic, "", GetWikiIndexPath(CodebaseConceptIndexTitle), CodebaseConceptIndexTitle), ++ "- " + linkFor(topic, "", GetWikiIndexPath(CodebaseSourceIndexTitle), CodebaseSourceIndexTitle), + "", + "### Generated codebase articles", + "", +@@ -457,7 +457,7 @@ func buildTopicClaudeManagedBlock( + lines = append(lines, "_None generated._") + } else { + for _, articleTitle := range conceptDocuments { +- lines = append(lines, "- "+ToTopicWikiLink(topic.Slug, GetWikiConceptPath(articleTitle), articleTitle)) ++ lines = append(lines, "- "+linkFor(topic, "", GetWikiConceptPath(articleTitle), articleTitle)) + } + } + lines = append(lines, "", codebaseManagedBlockEnd) +@@ -472,9 +472,9 @@ func buildTopicIndexBridgeManagedBlock(topic models.TopicMetadata) string { + "", + "Latest `kb ingest codebase` output is published under `wiki/codebase/` so the top-level topic indexes can stay manually curated.", + "", +- "- " + ToTopicWikiLink(topic.Slug, GetWikiIndexPath(CodebaseDashboardTitle), CodebaseDashboardTitle), +- "- " + ToTopicWikiLink(topic.Slug, GetWikiIndexPath(CodebaseConceptIndexTitle), CodebaseConceptIndexTitle), +- "- " + ToTopicWikiLink(topic.Slug, GetWikiIndexPath(CodebaseSourceIndexTitle), CodebaseSourceIndexTitle), ++ "- " + linkFor(topic, "", GetWikiIndexPath(CodebaseDashboardTitle), CodebaseDashboardTitle), ++ "- " + linkFor(topic, "", GetWikiIndexPath(CodebaseConceptIndexTitle), CodebaseConceptIndexTitle), ++ "- " + linkFor(topic, "", GetWikiIndexPath(CodebaseSourceIndexTitle), CodebaseSourceIndexTitle), + "", + codebaseIndexBlockEnd, + } +diff --git a/internal/topic/assets/okf-claude-template.md b/internal/topic/assets/okf-claude-template.md +new file mode 100644 +index 0000000..3a18157 +--- /dev/null ++++ b/internal/topic/assets/okf-claude-template.md +@@ -0,0 +1,24 @@ ++# TOPIC_TITLE ++ ++**Topic scope:** portable Open Knowledge Format bundle for `TOPIC_DOMAIN`. ++ ++**Domain:** `TOPIC_DOMAIN` ++ ++This topic is an OKF bundle, not an LLM-wiki research lab. Concepts live as typed markdown files at the bundle root for the MVP, with standard markdown links and YAML frontmatter. ++ ++## Bundle contract ++ ++- Every concept markdown file has `type`, `title`, `description`, and `timestamp` frontmatter. ++- `index.md` is generated by `kb` and groups concepts by type. ++- `log.md` records bundle updates using ISO date headings. ++- Links should be relative markdown links so the bundle renders in GitHub and other markdown tools. ++ ++## Operations ++ ++- Promote a wiki concept into this bundle with `kb promote <wiki-doc> --to TOPIC_SLUG --type <Type>`. ++- Check conformance with `kb okf check TOPIC_SLUG`. ++ ++## Catalog gaps ++ ++- Gap 1 ++- Gap 2 +diff --git a/internal/okf/okf.go b/internal/okf/okf.go +new file mode 100644 +index 0000000..41977b6 +--- /dev/null ++++ b/internal/okf/okf.go +@@ -0,0 +1,760 @@ ++// Package okf implements Open Knowledge Format promotion and conformance checks. ++package okf ++ ++import ( ++ "context" ++ "errors" ++ "fmt" ++ "io/fs" ++ "os" ++ "path" ++ "path/filepath" ++ "regexp" ++ "sort" ++ "strings" ++ "time" ++ ++ "github.com/compozy/kb/internal/frontmatter" ++ "github.com/compozy/kb/internal/models" ++ "github.com/compozy/kb/internal/vault" ++) ++ ++const ( ++ OKFVersion = "0.1" ++) ++ ++var ( ++ wikilinkPattern = regexp.MustCompile(`\[\[([^\[\]]+)\]\]`) ++ nonSentenceSpace = regexp.MustCompile(`\s+`) ++ markdownLinkPattern = regexp.MustCompile(`\[[^\]]+\]\([^)]+\)`) ++ markdownSyntaxRunes = strings.NewReplacer("#", "", "*", "", "`", "", "_", "", ">", "", "|", " ") ++ errTargetNotOKFTopic = errors.New("target topic must use mode okf") ++) ++ ++// PromoteInput carries the data needed to promote one wiki document into an OKF bundle. ++type PromoteInput struct { ++ SourceDocPath string ++ VaultPath string ++ TargetTopic models.TopicInfo ++ Type string ++ Description string ++ Types []string ++ Clock func() time.Time ++} ++ ++// ConceptResult reports the concept written by Promote. ++type ConceptResult struct { ++ WrittenPath string `json:"writtenPath"` ++ Type string `json:"type"` ++ LinksRewritten int `json:"linksRewritten"` ++ UnresolvedLinks []string `json:"unresolvedLinks"` ++ Warnings []string `json:"warnings,omitempty"` ++} ++ ++// CheckOptions configures OKF conformance checking. ++type CheckOptions struct { ++ Types []string ++ Strict bool ++} ++ ++// Promote performs a mechanical, non-destructive wiki-to-OKF conversion. ++func Promote(ctx context.Context, input PromoteInput) (ConceptResult, error) { ++ if ctx == nil { ++ ctx = context.Background() ++ } ++ if err := ctx.Err(); err != nil { ++ return ConceptResult{}, fmt.Errorf("promote: %w", err) ++ } ++ if input.TargetTopic.Mode != models.TopicModeOKF { ++ return ConceptResult{}, fmt.Errorf("promote: %w: %s", errTargetNotOKFTopic, input.TargetTopic.Slug) ++ } ++ conceptType := strings.TrimSpace(input.Type) ++ if conceptType == "" { ++ return ConceptResult{}, fmt.Errorf("promote: --type is required") ++ } ++ if strings.TrimSpace(input.TargetTopic.RootPath) == "" { ++ return ConceptResult{}, fmt.Errorf("promote: target topic root path is required") ++ } ++ ++ sourcePath, sourceTopicRoot, sourceTopicSlug, sourceRelativePath, err := resolveSourceDocument(input) ++ if err != nil { ++ return ConceptResult{}, fmt.Errorf("promote: %w", err) ++ } ++ _ = sourceTopicRoot ++ ++ sourceBytes, err := os.ReadFile(sourcePath) ++ if err != nil { ++ return ConceptResult{}, fmt.Errorf("promote: read source %q: %w", sourcePath, err) ++ } ++ sourceValues, body, err := frontmatter.Parse(string(sourceBytes)) ++ if err != nil { ++ return ConceptResult{}, fmt.Errorf("promote: parse source frontmatter: %w", err) ++ } ++ ++ key := conceptKey(sourceRelativePath) ++ writtenPath, absoluteTargetPath, err := allocateConceptPath(input.TargetTopic.RootPath, key) ++ if err != nil { ++ return ConceptResult{}, fmt.Errorf("promote: allocate concept path: %w", err) ++ } ++ ++ warnings := typeWarnings(conceptType, input.Types) ++ description, descriptionWarnings := resolveDescription(input.Description, body) ++ warnings = append(warnings, descriptionWarnings...) ++ title := strings.TrimSpace(frontmatter.GetString(sourceValues, "title")) ++ if title == "" { ++ title = vault.HumanizeSlug(key) ++ } ++ clock := input.Clock ++ if clock == nil { ++ clock = func() time.Time { return time.Now().UTC() } ++ } ++ now := clock().UTC() ++ ++ transformedBody, rewriteCount, unresolvedLinks := transformWikiLinks(body, sourceTopicSlug, input.TargetTopic.RootPath, path.Dir(writtenPath), writtenPath) ++ values := map[string]any{ ++ "description": description, ++ "timestamp": now.Format(time.RFC3339), ++ "title": title, ++ "type": conceptType, ++ } ++ if tags := frontmatter.GetStringSlice(sourceValues, "tags"); len(tags) > 0 { ++ values["tags"] = tags ++ } ++ ++ document, err := frontmatter.Generate(values, transformedBody) ++ if err != nil { ++ return ConceptResult{}, fmt.Errorf("promote: generate concept frontmatter: %w", err) ++ } ++ if err := os.WriteFile(absoluteTargetPath, []byte(document), 0o644); err != nil { ++ return ConceptResult{}, fmt.Errorf("promote: write concept %q: %w", absoluteTargetPath, err) ++ } ++ ++ if err := RegenerateIndex(input.TargetTopic.RootPath); err != nil { ++ return ConceptResult{}, fmt.Errorf("promote: regenerate index: %w", err) ++ } ++ if err := InsertLogEntry(input.TargetTopic.RootPath, now, title, writtenPath, sourceRelativePath); err != nil { ++ return ConceptResult{}, fmt.Errorf("promote: update log: %w", err) ++ } ++ ++ return ConceptResult{ ++ WrittenPath: writtenPath, ++ Type: conceptType, ++ LinksRewritten: rewriteCount, ++ UnresolvedLinks: unresolvedLinks, ++ Warnings: warnings, ++ }, nil ++} ++ ++// Check validates an OKF bundle and returns diagnostics. ++func Check(ctx context.Context, bundlePath string, options CheckOptions) ([]models.LintIssue, error) { ++ if ctx == nil { ++ ctx = context.Background() ++ } ++ cleanBundlePath := strings.TrimSpace(bundlePath) ++ if cleanBundlePath == "" { ++ return nil, fmt.Errorf("okf check: bundle path is required") ++ } ++ info, err := os.Stat(cleanBundlePath) ++ if err != nil { ++ return nil, fmt.Errorf("okf check: stat bundle path %q: %w", cleanBundlePath, err) ++ } ++ if !info.IsDir() { ++ return nil, fmt.Errorf("okf check: bundle path must be a directory: %s", cleanBundlePath) ++ } ++ ++ typeSet := normalizeTypeSet(options.Types) ++ issues := make([]models.LintIssue, 0) ++ err = filepath.WalkDir(cleanBundlePath, func(currentPath string, entry fs.DirEntry, walkErr error) error { ++ if walkErr != nil { ++ return walkErr ++ } ++ if err := ctx.Err(); err != nil { ++ return err ++ } ++ if currentPath == cleanBundlePath { ++ return nil ++ } ++ if entry.Type()&fs.ModeSymlink != 0 { ++ if entry.IsDir() { ++ return filepath.SkipDir ++ } ++ return nil ++ } ++ if entry.IsDir() { ++ if strings.HasPrefix(entry.Name(), ".") { ++ return filepath.SkipDir ++ } ++ return nil ++ } ++ if !strings.EqualFold(filepath.Ext(entry.Name()), ".md") { ++ return nil ++ } ++ ++ relativePath, err := filepath.Rel(cleanBundlePath, currentPath) ++ if err != nil { ++ return fmt.Errorf("derive relative path for %q: %w", currentPath, err) ++ } ++ relativePath = vault.ToPosixPath(relativePath) ++ switch { ++ case isIndexFile(relativePath): ++ issues = append(issues, checkIndexFile(cleanBundlePath, currentPath, relativePath, options.Strict)...) ++ case isLogFile(relativePath): ++ issues = append(issues, checkLogFile(currentPath, relativePath, options.Strict)...) ++ case isExcludedMarkdown(entry.Name()): ++ return nil ++ default: ++ issues = append(issues, checkConceptFile(currentPath, relativePath, typeSet, options.Strict)...) ++ } ++ return nil ++ }) ++ if err != nil { ++ return nil, fmt.Errorf("okf check: %w", err) ++ } ++ ++ sortIssues(issues) ++ return issues, nil ++} ++ ++// Columns returns the stable output column order for OKF diagnostics. ++func Columns() []string { ++ return []string{"severity", "kind", "filePath", "target", "message"} ++} ++ ++// Rows converts issues into formatter rows. ++func Rows(issues []models.LintIssue) []map[string]any { ++ rows := make([]map[string]any, 0, len(issues)) ++ for _, issue := range issues { ++ rows = append(rows, map[string]any{ ++ "severity": issue.Severity, ++ "kind": issue.Kind, ++ "filePath": issue.FilePath, ++ "target": issue.Target, ++ "message": issue.Message, ++ }) ++ } ++ return rows ++} ++ ++// HasErrors reports whether any diagnostic should fail the command. ++func HasErrors(issues []models.LintIssue) bool { ++ for _, issue := range issues { ++ if issue.Severity == models.SeverityError { ++ return true ++ } ++ } ++ return false ++} ++ ++// RegenerateIndex rewrites the root OKF index from concept frontmatter. ++func RegenerateIndex(bundlePath string) error { ++ concepts, okfVersion, err := loadConcepts(bundlePath) ++ if err != nil { ++ return err ++ } ++ sort.Slice(concepts, func(i, j int) bool { ++ if concepts[i].Type != concepts[j].Type { ++ return concepts[i].Type < concepts[j].Type ++ } ++ if concepts[i].Title != concepts[j].Title { ++ return concepts[i].Title < concepts[j].Title ++ } ++ return concepts[i].RelativePath < concepts[j].RelativePath ++ }) ++ ++ var body strings.Builder ++ body.WriteString("# OKF Bundle Index\n") ++ currentType := "" ++ for _, concept := range concepts { ++ if concept.Type != currentType { ++ currentType = concept.Type ++ body.WriteString("\n## ") ++ body.WriteString(currentType) ++ body.WriteString("\n\n") ++ } ++ body.WriteString("* ") ++ body.WriteString(vault.OKFLinkFormatter{}.Link("", concept.RelativePath, concept.Title)) ++ if concept.Description != "" { ++ body.WriteString(" - ") ++ body.WriteString(concept.Description) ++ } ++ body.WriteString("\n") ++ } ++ ++ values := map[string]any{"okf_version": firstNonEmpty(okfVersion, OKFVersion)} ++ content, err := frontmatter.Generate(values, body.String()) ++ if err != nil { ++ return fmt.Errorf("generate index frontmatter: %w", err) ++ } ++ if err := os.WriteFile(filepath.Join(bundlePath, "index.md"), []byte(content), 0o644); err != nil { ++ return fmt.Errorf("write index.md: %w", err) ++ } ++ return nil ++} ++ ++// InsertLogEntry inserts a newest-first OKF log entry at the bundle root. ++func InsertLogEntry(bundlePath string, when time.Time, title, conceptPath, sourcePath string) error { ++ logPath := filepath.Join(bundlePath, "log.md") ++ content, err := os.ReadFile(logPath) ++ if errors.Is(err, os.ErrNotExist) { ++ content = []byte("# Directory Update Log\n") ++ } else if err != nil { ++ return fmt.Errorf("read log.md: %w", err) ++ } ++ entry := fmt.Sprintf( ++ "* **Creation**: Promoted %s from `%s`.", ++ vault.OKFLinkFormatter{}.Link("", conceptPath, strings.TrimSpace(title)), ++ sourcePath, ++ ) ++ next := insertLogEntry(string(content), when.UTC().Format(frontmatter.DateLayout), entry) ++ if err := os.WriteFile(logPath, []byte(next), 0o644); err != nil { ++ return fmt.Errorf("write log.md: %w", err) ++ } ++ return nil ++} ++ ++type conceptInfo struct { ++ RelativePath string ++ Type string ++ Title string ++ Description string ++} ++ ++func resolveSourceDocument(input PromoteInput) (absolutePath, topicRoot, topicSlug, topicRelativePath string, err error) { ++ candidates := make([]string, 0, 3) ++ source := strings.TrimSpace(input.SourceDocPath) ++ if source == "" { ++ return "", "", "", "", fmt.Errorf("source document path is required") ++ } ++ if filepath.IsAbs(source) { ++ candidates = append(candidates, source) ++ } else { ++ candidates = append(candidates, source) ++ if strings.TrimSpace(input.VaultPath) != "" { ++ candidates = append(candidates, filepath.Join(input.VaultPath, filepath.FromSlash(source))) ++ } ++ } ++ for _, candidate := range candidates { ++ info, statErr := os.Stat(candidate) ++ if statErr != nil { ++ continue ++ } ++ if info.IsDir() { ++ return "", "", "", "", fmt.Errorf("source document %q must be a file", candidate) ++ } ++ absolutePath, err := filepath.Abs(candidate) ++ if err != nil { ++ return "", "", "", "", fmt.Errorf("resolve source path %q: %w", candidate, err) ++ } ++ topicRoot, topicSlug = discoverTopicRoot(input.VaultPath, absolutePath) ++ if topicRoot != "" { ++ relative, relErr := filepath.Rel(topicRoot, absolutePath) ++ if relErr != nil { ++ return "", "", "", "", fmt.Errorf("derive source topic path: %w", relErr) ++ } ++ return absolutePath, topicRoot, topicSlug, vault.ToPosixPath(relative), nil ++ } ++ return absolutePath, "", "", vault.ToPosixPath(filepath.Base(absolutePath)), nil ++ } ++ return "", "", "", "", fmt.Errorf("source document not found: %s", source) ++} ++ ++func discoverTopicRoot(vaultPath, sourcePath string) (string, string) { ++ cleanVault := strings.TrimSpace(vaultPath) ++ current := filepath.Dir(sourcePath) ++ for { ++ if cleanVault != "" && !vault.IsPathInside(cleanVault, current) { ++ return "", "" ++ } ++ if isRegularFile(filepath.Join(current, "CLAUDE.md")) { ++ if cleanVault == "" { ++ return current, filepath.Base(current) ++ } ++ relative, err := filepath.Rel(cleanVault, current) ++ if err != nil { ++ return current, filepath.Base(current) ++ } ++ return current, vault.ToPosixPath(relative) ++ } ++ parent := filepath.Dir(current) ++ if parent == current { ++ break ++ } ++ current = parent ++ } ++ return "", "" ++} ++ ++func isRegularFile(filePath string) bool { ++ info, err := os.Stat(filePath) ++ return err == nil && !info.IsDir() ++} ++ ++func allocateConceptPath(bundleRoot, key string) (string, string, error) { ++ for suffix := 1; ; suffix++ { ++ name := key ++ if suffix > 1 { ++ name = fmt.Sprintf("%s-%d", key, suffix) ++ } ++ relativePath := name + ".md" ++ absolutePath := filepath.Join(bundleRoot, filepath.FromSlash(relativePath)) ++ if _, err := os.Stat(absolutePath); errors.Is(err, os.ErrNotExist) { ++ return relativePath, absolutePath, nil ++ } else if err != nil { ++ return "", "", err ++ } ++ } ++} ++ ++func conceptKey(documentPath string) string { ++ base := path.Base(vault.StripMarkdownExtension(vault.ToPosixPath(documentPath))) ++ return vault.SlugifySegment(base) ++} ++ ++func typeWarnings(conceptType string, types []string) []string { ++ typeSet := normalizeTypeSet(types) ++ if len(typeSet) == 0 { ++ return nil ++ } ++ if _, ok := typeSet[conceptType]; ok { ++ return nil ++ } ++ return []string{fmt.Sprintf("type %q is outside the configured OKF vocabulary", conceptType)} ++} ++ ++func normalizeTypeSet(types []string) map[string]struct{} { ++ typeSet := make(map[string]struct{}) ++ for _, value := range types { ++ value = strings.TrimSpace(value) ++ if value == "" { ++ continue ++ } ++ typeSet[value] = struct{}{} ++ } ++ return typeSet ++} ++ ++func resolveDescription(flagValue, body string) (string, []string) { ++ if description := strings.TrimSpace(flagValue); description != "" { ++ return description, nil ++ } ++ if sentence := firstBodySentence(body); sentence != "" { ++ return sentence, nil ++ } ++ return "", []string{"description is empty because the source body has no sentence fallback"} ++} ++ ++func firstBodySentence(body string) string { ++ normalized := markdownSyntaxRunes.Replace(markdownLinkPattern.ReplaceAllString(body, " ")) ++ normalized = nonSentenceSpace.ReplaceAllString(strings.TrimSpace(normalized), " ") ++ for _, delimiter := range []string{". ", "! ", "? "} { ++ if index := strings.Index(normalized, delimiter); index >= 0 { ++ return strings.TrimSpace(normalized[:index+1]) ++ } ++ } ++ return strings.TrimSpace(normalized) ++} ++ ++func transformWikiLinks(body, sourceTopicSlug, bundleRoot, fromDir, currentPath string) (string, int, []string) { ++ count := 0 ++ unresolved := make([]string, 0) ++ formatter := vault.OKFLinkFormatter{} ++ transformed := wikilinkPattern.ReplaceAllStringFunc(body, func(match string) string { ++ inner := strings.TrimSuffix(strings.TrimPrefix(match, "[["), "]]") ++ target, label := splitWikiLink(inner) ++ targetPath := mappedTargetPath(target, sourceTopicSlug) ++ if strings.TrimSpace(label) == "" { ++ label = fallbackWikiLabel(target) ++ } ++ count++ ++ if stripFragment(targetPath) != stripFragment(currentPath) && !isRegularFile(filepath.Join(bundleRoot, filepath.FromSlash(stripFragment(targetPath)))) { ++ unresolved = append(unresolved, targetPath) ++ } ++ return formatter.Link(fromDir, targetPath, label) ++ }) ++ sort.Strings(unresolved) ++ return transformed, count, dedupeStrings(unresolved) ++} ++ ++func splitWikiLink(inner string) (string, string) { ++ parts := strings.SplitN(inner, "|", 2) ++ target := strings.TrimSpace(parts[0]) ++ if len(parts) == 1 { ++ return target, "" ++ } ++ return target, strings.TrimSpace(parts[1]) ++} ++ ++func mappedTargetPath(target, sourceTopicSlug string) string { ++ target = vault.ToPosixPath(strings.TrimSpace(target)) ++ fragment := "" ++ if before, after, found := strings.Cut(target, "#"); found { ++ target = before ++ fragment = "#" + after ++ } ++ target = strings.TrimPrefix(target, "/") ++ if sourceTopicSlug != "" { ++ prefix := strings.Trim(sourceTopicSlug, "/") + "/" ++ target = strings.TrimPrefix(target, prefix) ++ } ++ return conceptKey(target) + ".md" + fragment ++} ++ ++func stripFragment(target string) string { ++ if before, _, found := strings.Cut(target, "#"); found { ++ return before ++ } ++ return target ++} ++ ++func fallbackWikiLabel(target string) string { ++ target = stripFragment(target) ++ return vault.HumanizeSlug(conceptKey(target)) ++} ++ ++func dedupeStrings(values []string) []string { ++ if len(values) == 0 { ++ return nil ++ } ++ deduped := values[:0] ++ var previous string ++ for index, value := range values { ++ if index > 0 && value == previous { ++ continue ++ } ++ deduped = append(deduped, value) ++ previous = value ++ } ++ return append([]string(nil), deduped...) ++} ++ ++func loadConcepts(bundlePath string) ([]conceptInfo, string, error) { ++ okfVersion := OKFVersion ++ indexPath := filepath.Join(bundlePath, "index.md") ++ if content, err := os.ReadFile(indexPath); err == nil { ++ values, _, parseErr := frontmatter.Parse(string(content)) ++ if parseErr != nil { ++ return nil, "", fmt.Errorf("parse index.md: %w", parseErr) ++ } ++ if version := strings.TrimSpace(frontmatter.GetString(values, "okf_version")); version != "" { ++ okfVersion = version ++ } ++ } else if !errors.Is(err, os.ErrNotExist) { ++ return nil, "", fmt.Errorf("read index.md: %w", err) ++ } ++ ++ concepts := make([]conceptInfo, 0) ++ err := filepath.WalkDir(bundlePath, func(currentPath string, entry fs.DirEntry, walkErr error) error { ++ if walkErr != nil { ++ return walkErr ++ } ++ if currentPath == bundlePath { ++ return nil ++ } ++ if entry.Type()&fs.ModeSymlink != 0 { ++ if entry.IsDir() { ++ return filepath.SkipDir ++ } ++ return nil ++ } ++ if entry.IsDir() { ++ if strings.HasPrefix(entry.Name(), ".") { ++ return filepath.SkipDir ++ } ++ return nil ++ } ++ if !strings.EqualFold(filepath.Ext(entry.Name()), ".md") || isExcludedMarkdown(entry.Name()) { ++ return nil ++ } ++ relativePath, err := filepath.Rel(bundlePath, currentPath) ++ if err != nil { ++ return err ++ } ++ relativePath = vault.ToPosixPath(relativePath) ++ if isIndexFile(relativePath) || isLogFile(relativePath) { ++ return nil ++ } ++ content, err := os.ReadFile(currentPath) ++ if err != nil { ++ return err ++ } ++ values, _, err := frontmatter.Parse(string(content)) ++ if err != nil || len(values) == 0 { ++ return nil ++ } ++ conceptType := strings.TrimSpace(frontmatter.GetString(values, "type")) ++ title := strings.TrimSpace(frontmatter.GetString(values, "title")) ++ if title == "" { ++ title = vault.HumanizeSlug(conceptKey(relativePath)) ++ } ++ concepts = append(concepts, conceptInfo{ ++ RelativePath: relativePath, ++ Type: firstNonEmpty(conceptType, "Concept"), ++ Title: title, ++ Description: strings.TrimSpace(frontmatter.GetString(values, "description")), ++ }) ++ return nil ++ }) ++ if err != nil { ++ return nil, "", fmt.Errorf("scan concepts: %w", err) ++ } ++ return concepts, okfVersion, nil ++} ++ ++func checkConceptFile(filePath, relativePath string, typeSet map[string]struct{}, strict bool) []models.LintIssue { ++ content, err := os.ReadFile(filePath) ++ if err != nil { ++ return []models.LintIssue{newIssue(models.SeverityError, relativePath, "", fmt.Sprintf("read concept: %v", err))} ++ } ++ values, _, err := frontmatter.Parse(string(content)) ++ if err != nil { ++ return []models.LintIssue{newIssue(models.SeverityError, relativePath, "", fmt.Sprintf("frontmatter is invalid: %v", err))} ++ } ++ if len(values) == 0 { ++ return []models.LintIssue{newIssue(models.SeverityError, relativePath, "type", "concept must start with YAML frontmatter")} ++ } ++ issues := make([]models.LintIssue, 0) ++ conceptType := strings.TrimSpace(frontmatter.GetString(values, "type")) ++ if conceptType == "" { ++ issues = append(issues, newIssue(models.SeverityError, relativePath, "type", "concept frontmatter must include non-empty type")) ++ } ++ for _, field := range []string{"title", "description", "timestamp"} { ++ if strings.TrimSpace(frontmatter.GetString(values, field)) == "" { ++ issues = append(issues, newIssue(warningSeverity(strict), relativePath, field, fmt.Sprintf("producer field %q is missing", field))) ++ } ++ } ++ if len(typeSet) > 0 && conceptType != "" { ++ if _, ok := typeSet[conceptType]; !ok { ++ issues = append(issues, newIssue(warningSeverity(strict), relativePath, "type", fmt.Sprintf("type %q is outside the configured OKF vocabulary", conceptType))) ++ } ++ } ++ return issues ++} ++ ++func checkIndexFile(bundlePath, filePath, relativePath string, strict bool) []models.LintIssue { ++ content, err := os.ReadFile(filePath) ++ if err != nil { ++ return []models.LintIssue{newIssue(models.SeverityError, relativePath, "", fmt.Sprintf("read index: %v", err))} ++ } ++ values, _, err := frontmatter.Parse(string(content)) ++ if err != nil { ++ return []models.LintIssue{newIssue(models.SeverityError, relativePath, "", fmt.Sprintf("index frontmatter is invalid: %v", err))} ++ } ++ if len(values) == 0 { ++ return nil ++ } ++ if relativePath != "index.md" { ++ return []models.LintIssue{newIssue(models.SeverityError, relativePath, "frontmatter", "only the bundle-root index.md may declare okf_version frontmatter")} ++ } ++ for key := range values { ++ if key != "okf_version" { ++ return []models.LintIssue{newIssue(models.SeverityError, relativePath, key, "root index.md frontmatter may only contain okf_version")} ++ } ++ } ++ _ = bundlePath ++ _ = strict ++ return nil ++} ++ ++func checkLogFile(filePath, relativePath string, strict bool) []models.LintIssue { ++ content, err := os.ReadFile(filePath) ++ if err != nil { ++ return []models.LintIssue{newIssue(models.SeverityError, relativePath, "", fmt.Sprintf("read log: %v", err))} ++ } ++ issues := make([]models.LintIssue, 0) ++ for line := range strings.SplitSeq(string(content), "\n") { ++ trimmed := strings.TrimSpace(line) ++ if !strings.HasPrefix(trimmed, "## ") { ++ continue ++ } ++ dateValue := strings.TrimSpace(strings.TrimPrefix(trimmed, "## ")) ++ if _, err := time.Parse(frontmatter.DateLayout, dateValue); err != nil { ++ issues = append(issues, newIssue(models.SeverityError, relativePath, "date", fmt.Sprintf("log heading %q must use YYYY-MM-DD", trimmed))) ++ } ++ } ++ _ = strict ++ return issues ++} ++ ++func newIssue(severity models.DiagnosticSeverity, filePath, target, message string) models.LintIssue { ++ return models.LintIssue{ ++ Kind: models.LintIssueKindFormat, ++ Severity: severity, ++ FilePath: filePath, ++ Target: target, ++ Message: message, ++ } ++} ++ ++func warningSeverity(strict bool) models.DiagnosticSeverity { ++ if strict { ++ return models.SeverityError ++ } ++ return models.SeverityWarning ++} ++ ++func sortIssues(issues []models.LintIssue) { ++ sort.Slice(issues, func(i, j int) bool { ++ if issues[i].Severity != issues[j].Severity { ++ return issues[i].Severity < issues[j].Severity ++ } ++ if issues[i].FilePath != issues[j].FilePath { ++ return issues[i].FilePath < issues[j].FilePath ++ } ++ if issues[i].Target != issues[j].Target { ++ return issues[i].Target < issues[j].Target ++ } ++ return issues[i].Message < issues[j].Message ++ }) ++} ++ ++func isIndexFile(relativePath string) bool { ++ return strings.EqualFold(path.Base(vault.ToPosixPath(relativePath)), "index.md") ++} ++ ++func isLogFile(relativePath string) bool { ++ return strings.EqualFold(path.Base(vault.ToPosixPath(relativePath)), "log.md") ++} ++ ++func isExcludedMarkdown(name string) bool { ++ lower := strings.ToLower(strings.TrimSpace(name)) ++ switch lower { ++ case "claude.md", "agents.md", "readme.md", "license.md", "notice.md", "attribution.md": ++ return true ++ default: ++ return strings.HasPrefix(lower, "license.") || strings.HasPrefix(lower, "notice.") || strings.HasPrefix(lower, "attribution.") ++ } ++} ++ ++func insertLogEntry(content, dateHeading, entry string) string { ++ lines := strings.Split(strings.ReplaceAll(content, "\r\n", "\n"), "\n") ++ if len(lines) == 0 || strings.TrimSpace(lines[0]) == "" { ++ lines = []string{"# Directory Update Log"} ++ } ++ heading := "## " + dateHeading ++ for index, line := range lines { ++ if strings.TrimSpace(line) != heading { ++ continue ++ } ++ next := append([]string{}, lines[:index+1]...) ++ next = append(next, entry) ++ next = append(next, lines[index+1:]...) ++ return strings.TrimRight(strings.Join(next, "\n"), "\n") + "\n" ++ } ++ next := []string{strings.TrimRight(strings.Join(lines[:1], "\n"), "\n"), "", heading, "", entry} ++ if len(lines) > 1 { ++ rest := strings.TrimSpace(strings.Join(lines[1:], "\n")) ++ if rest != "" { ++ next = append(next, "", rest) ++ } ++ } ++ return strings.TrimRight(strings.Join(next, "\n"), "\n") + "\n" ++} ++ ++func firstNonEmpty(values ...string) string { ++ for _, value := range values { ++ if trimmed := strings.TrimSpace(value); trimmed != "" { ++ return trimmed ++ } ++ } ++ return "" ++} +diff --git a/internal/okf/okf_test.go b/internal/okf/okf_test.go +new file mode 100644 +index 0000000..92772b6 +--- /dev/null ++++ b/internal/okf/okf_test.go +@@ -0,0 +1,218 @@ ++package okf ++ ++import ( ++ "context" ++ "os" ++ "path/filepath" ++ "strings" ++ "testing" ++ "time" ++ ++ "github.com/compozy/kb/internal/frontmatter" ++ "github.com/compozy/kb/internal/models" ++) ++ ++func TestPromoteWritesConceptIndexAndLog(t *testing.T) { ++ t.Parallel() ++ ++ vaultPath := t.TempDir() ++ sourceTopic := filepath.Join(vaultPath, "research") ++ targetTopic := filepath.Join(vaultPath, "catalog") ++ mkdirAll(t, filepath.Join(sourceTopic, "wiki", "concepts")) ++ mkdirAll(t, targetTopic) ++ writeFile(t, filepath.Join(sourceTopic, "CLAUDE.md"), "# Research\n") ++ writeFile(t, filepath.Join(targetTopic, "CLAUDE.md"), "# Catalog\n") ++ writeFile(t, filepath.Join(targetTopic, "index.md"), "---\nokf_version: \"0.1\"\n---\n# Old Index\n") ++ writeFile(t, filepath.Join(targetTopic, "log.md"), "# Directory Update Log\n") ++ sourcePath := filepath.Join(sourceTopic, "wiki", "concepts", "Alpha Note.md") ++ sourceContent := strings.Join([]string{ ++ "---", ++ "title: Alpha Note", ++ "type: wiki", ++ "stage: compiled", ++ "tags: [systems, alpha]", ++ "---", ++ "Alpha note explains the operational flow. See [[research/wiki/concepts/Beta Note|Beta]] and [[research/wiki/concepts/Alpha Note#details|details]].", ++ "", ++ "## Details", ++ }, "\n") ++ writeFile(t, sourcePath, sourceContent) ++ ++ result, err := Promote(context.Background(), PromoteInput{ ++ SourceDocPath: sourcePath, ++ VaultPath: vaultPath, ++ TargetTopic: models.TopicInfo{ ++ Slug: "catalog", ++ Mode: models.TopicModeOKF, ++ RootPath: targetTopic, ++ }, ++ Type: "Playbook", ++ Description: "Operational alpha note.", ++ Types: []string{"Playbook"}, ++ Clock: func() time.Time { ++ return time.Date(2026, 6, 27, 10, 11, 12, 0, time.UTC) ++ }, ++ }) ++ if err != nil { ++ t.Fatalf("Promote returned error: %v", err) ++ } ++ if result.WrittenPath != "alpha-note.md" { ++ t.Fatalf("written path = %q, want alpha-note.md", result.WrittenPath) ++ } ++ if result.LinksRewritten != 2 { ++ t.Fatalf("links rewritten = %d, want 2", result.LinksRewritten) ++ } ++ if len(result.UnresolvedLinks) != 1 || result.UnresolvedLinks[0] != "beta-note.md" { ++ t.Fatalf("unresolved links = %#v, want beta-note.md", result.UnresolvedLinks) ++ } ++ if got := readFile(t, sourcePath); got != sourceContent { ++ t.Fatalf("source document changed:\n%s", got) ++ } ++ ++ values, body := parseMarkdown(t, filepath.Join(targetTopic, "alpha-note.md")) ++ for key, want := range map[string]any{ ++ "type": "Playbook", ++ "title": "Alpha Note", ++ "description": "Operational alpha note.", ++ "timestamp": "2026-06-27T10:11:12Z", ++ } { ++ if got := values[key]; got != want { ++ t.Fatalf("frontmatter[%s] = %#v, want %#v", key, got, want) ++ } ++ } ++ if _, ok := values["stage"]; ok { ++ t.Fatalf("wiki stage leaked into OKF frontmatter: %#v", values) ++ } ++ if !strings.Contains(body, "[Beta](beta-note.md)") || !strings.Contains(body, "[details](alpha-note.md#details)") { ++ t.Fatalf("body missing transformed links:\n%s", body) ++ } ++ ++ index := readFile(t, filepath.Join(targetTopic, "index.md")) ++ for _, fragment := range []string{"okf_version: \"0.1\"", "## Playbook", "[Alpha Note](alpha-note.md)", "Operational alpha note."} { ++ if !strings.Contains(index, fragment) { ++ t.Fatalf("index.md missing %q:\n%s", fragment, index) ++ } ++ } ++ log := readFile(t, filepath.Join(targetTopic, "log.md")) ++ for _, fragment := range []string{"## 2026-06-27", "**Creation**", "[Alpha Note](alpha-note.md)", "`wiki/concepts/Alpha Note.md`"} { ++ if !strings.Contains(log, fragment) { ++ t.Fatalf("log.md missing %q:\n%s", fragment, log) ++ } ++ } ++} ++ ++func TestPromoteRejectsNonOKFTargetBeforeWriting(t *testing.T) { ++ t.Parallel() ++ ++ targetTopic := t.TempDir() ++ _, err := Promote(context.Background(), PromoteInput{ ++ SourceDocPath: "missing.md", ++ TargetTopic: models.TopicInfo{ ++ Slug: "wiki-topic", ++ Mode: models.TopicModeWiki, ++ RootPath: targetTopic, ++ }, ++ Type: "Reference", ++ }) ++ if err == nil || !strings.Contains(err.Error(), "target topic must use mode okf") { ++ t.Fatalf("error = %v, want non-OKF target rejection", err) ++ } ++ if entries, readErr := os.ReadDir(targetTopic); readErr != nil || len(entries) != 0 { ++ t.Fatalf("target changed before rejection: entries=%d err=%v", len(entries), readErr) ++ } ++} ++ ++func TestCheckReportsConformanceAndStrictWarnings(t *testing.T) { ++ t.Parallel() ++ ++ bundle := t.TempDir() ++ writeFile(t, filepath.Join(bundle, "CLAUDE.md"), "# Catalog\n") ++ writeFile(t, filepath.Join(bundle, "index.md"), "---\nokf_version: \"0.1\"\n---\n# Index\n") ++ writeFile(t, filepath.Join(bundle, "log.md"), "# Directory Update Log\n\n## 2026-06-27\n* **Creation**: Created bundle.\n") ++ writeFile(t, filepath.Join(bundle, "good.md"), "---\ntype: Playbook\ntitle: Good\ndescription: Good concept.\ntimestamp: 2026-06-27T10:00:00Z\n---\nBody with [broken](missing.md).\n") ++ writeFile(t, filepath.Join(bundle, "missing-type.md"), "---\ntitle: Missing\n---\nBody.\n") ++ writeFile(t, filepath.Join(bundle, "missing-fields.md"), "---\ntype: Unknown\n---\nBody.\n") ++ ++ issues, err := Check(context.Background(), bundle, CheckOptions{ ++ Types: []string{"Playbook"}, ++ }) ++ if err != nil { ++ t.Fatalf("Check returned error: %v", err) ++ } ++ assertIssue(t, issues, models.SeverityError, "missing-type.md", "type") ++ assertIssue(t, issues, models.SeverityWarning, "missing-fields.md", "title") ++ assertIssue(t, issues, models.SeverityWarning, "missing-fields.md", "type") ++ if !HasErrors(issues) { ++ t.Fatal("HasErrors = false, want true for missing type") ++ } ++ ++ strictIssues, err := Check(context.Background(), bundle, CheckOptions{ ++ Types: []string{"Playbook"}, ++ Strict: true, ++ }) ++ if err != nil { ++ t.Fatalf("strict Check returned error: %v", err) ++ } ++ assertIssue(t, strictIssues, models.SeverityError, "missing-fields.md", "title") ++} ++ ++func TestCheckAllowsLenientExternalBundleTraits(t *testing.T) { ++ t.Parallel() ++ ++ bundle := t.TempDir() ++ writeFile(t, filepath.Join(bundle, "index.md"), "# Index\n") ++ writeFile(t, filepath.Join(bundle, "concept.md"), "---\ntype: External Type\n---\nSee [missing](missing.md).\n") ++ ++ issues, err := Check(context.Background(), bundle, CheckOptions{}) ++ if err != nil { ++ t.Fatalf("Check returned error: %v", err) ++ } ++ if HasErrors(issues) { ++ t.Fatalf("lenient external bundle should not have errors: %#v", issues) ++ } ++} ++ ++func assertIssue(t *testing.T, issues []models.LintIssue, severity models.DiagnosticSeverity, filePath, target string) { ++ t.Helper() ++ for _, issue := range issues { ++ if issue.Severity == severity && issue.FilePath == filePath && issue.Target == target { ++ return ++ } ++ } ++ t.Fatalf("missing issue severity=%s file=%s target=%s in %#v", severity, filePath, target, issues) ++} ++ ++func parseMarkdown(t *testing.T, filePath string) (map[string]any, string) { ++ t.Helper() ++ values, body, err := frontmatter.Parse(readFile(t, filePath)) ++ if err != nil { ++ t.Fatalf("parse %s: %v", filePath, err) ++ } ++ return values, body ++} ++ ++func mkdirAll(t *testing.T, dir string) { ++ t.Helper() ++ if err := os.MkdirAll(dir, 0o755); err != nil { ++ t.Fatalf("mkdir %s: %v", dir, err) ++ } ++} ++ ++func writeFile(t *testing.T, filePath string, content string) { ++ t.Helper() ++ if err := os.MkdirAll(filepath.Dir(filePath), 0o755); err != nil { ++ t.Fatalf("mkdir parent for %s: %v", filePath, err) ++ } ++ if err := os.WriteFile(filePath, []byte(content), 0o644); err != nil { ++ t.Fatalf("write %s: %v", filePath, err) ++ } ++} ++ ++func readFile(t *testing.T, filePath string) string { ++ t.Helper() ++ content, err := os.ReadFile(filePath) ++ if err != nil { ++ t.Fatalf("read %s: %v", filePath, err) ++ } ++ return string(content) ++} +diff --git a/internal/okf/official_integration_test.go b/internal/okf/official_integration_test.go +new file mode 100644 +index 0000000..6331317 +--- /dev/null ++++ b/internal/okf/official_integration_test.go +@@ -0,0 +1,27 @@ ++//go:build integration ++ ++package okf ++ ++import ( ++ "context" ++ "path/filepath" ++ "testing" ++) ++ ++func TestOfficialBundlesPassLenientConformance(t *testing.T) { ++ t.Parallel() ++ ++ for _, bundle := range []string{"ga4", "stackoverflow", "crypto_bitcoin"} { ++ bundle := bundle ++ t.Run(bundle, func(t *testing.T) { ++ t.Parallel() ++ issues, err := Check(context.Background(), filepath.Join("testdata", "official", bundle), CheckOptions{}) ++ if err != nil { ++ t.Fatalf("Check returned error: %v", err) ++ } ++ if HasErrors(issues) { ++ t.Fatalf("official bundle %s has error issues: %#v", bundle, issues) ++ } ++ }) ++ } ++} +diff --git a/internal/okf/testdata/official/README.md b/internal/okf/testdata/official/README.md +new file mode 100644 +index 0000000..b3caa76 +--- /dev/null ++++ b/internal/okf/testdata/official/README.md +@@ -0,0 +1,10 @@ ++# Official OKF Fixtures ++ ++These fixtures are copied from the Apache-2.0 licensed Google Cloud ++`GoogleCloudPlatform/knowledge-catalog` repository: ++ ++- Source repository: https://github.com/GoogleCloudPlatform/knowledge-catalog ++- Fixture source path: `okf/bundles/{ga4,stackoverflow,crypto_bitcoin}` ++- License: `LICENSE.md` in this directory ++ ++They are used to verify lenient OKF v0.1 conformance against real sample bundles. +diff --git a/internal/okf/testdata/official/LICENSE.md b/internal/okf/testdata/official/LICENSE.md +new file mode 100644 +index 0000000..6b0b127 +--- /dev/null ++++ b/internal/okf/testdata/official/LICENSE.md +@@ -0,0 +1,203 @@ ++ ++ Apache License ++ Version 2.0, January 2004 ++ http://www.apache.org/licenses/ ++ ++ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION ++ ++ 1. Definitions. ++ ++ "License" shall mean the terms and conditions for use, reproduction, ++ and distribution as defined by Sections 1 through 9 of this document. ++ ++ "Licensor" shall mean the copyright owner or entity authorized by ++ the copyright owner that is granting the License. ++ ++ "Legal Entity" shall mean the union of the acting entity and all ++ other entities that control, are controlled by, or are under common ++ control with that entity. For the purposes of this definition, ++ "control" means (i) the power, direct or indirect, to cause the ++ direction or management of such entity, whether by contract or ++ otherwise, or (ii) ownership of fifty percent (50%) or more of the ++ outstanding shares, or (iii) beneficial ownership of such entity. ++ ++ "You" (or "Your") shall mean an individual or Legal Entity ++ exercising permissions granted by this License. ++ ++ "Source" form shall mean the preferred form for making modifications, ++ including but not limited to software source code, documentation ++ source, and configuration files. ++ ++ "Object" form shall mean any form resulting from mechanical ++ transformation or translation of a Source form, including but ++ not limited to compiled object code, generated documentation, ++ and conversions to other media types. ++ ++ "Work" shall mean the work of authorship, whether in Source or ++ Object form, made available under the License, as indicated by a ++ copyright notice that is included in or attached to the work ++ (an example is provided in the Appendix below). ++ ++ "Derivative Works" shall mean any work, whether in Source or Object ++ form, that is based on (or derived from) the Work and for which the ++ editorial revisions, annotations, elaborations, or other modifications ++ represent, as a whole, an original work of authorship. For the purposes ++ of this License, Derivative Works shall not include works that remain ++ separable from, or merely link (or bind by name) to the interfaces of, ++ the Work and Derivative Works thereof. ++ ++ "Contribution" shall mean any work of authorship, including ++ the original version of the Work and any modifications or additions ++ to that Work or Derivative Works thereof, that is intentionally ++ submitted to Licensor for inclusion in the Work by the copyright owner ++ or by an individual or Legal Entity authorized to submit on behalf of ++ the copyright owner. For the purposes of this definition, "submitted" ++ means any form of electronic, verbal, or written communication sent ++ to the Licensor or its representatives, including but not limited to ++ communication on electronic mailing lists, source code control systems, ++ and issue tracking systems that are managed by, or on behalf of, the ++ Licensor for the purpose of discussing and improving the Work, but ++ excluding communication that is conspicuously marked or otherwise ++ designated in writing by the copyright owner as "Not a Contribution." ++ ++ "Contributor" shall mean Licensor and any individual or Legal Entity ++ on behalf of whom a Contribution has been received by Licensor and ++ subsequently incorporated within the Work. ++ ++ 2. Grant of Copyright License. Subject to the terms and conditions of ++ this License, each Contributor hereby grants to You a perpetual, ++ worldwide, non-exclusive, no-charge, royalty-free, irrevocable ++ copyright license to reproduce, prepare Derivative Works of, ++ publicly display, publicly perform, sublicense, and distribute the ++ Work and such Derivative Works in Source or Object form. ++ ++ 3. Grant of Patent License. Subject to the terms and conditions of ++ this License, each Contributor hereby grants to You a perpetual, ++ worldwide, non-exclusive, no-charge, royalty-free, irrevocable ++ (except as stated in this section) patent license to make, have made, ++ use, offer to sell, sell, import, and otherwise transfer the Work, ++ where such license applies only to those patent claims licensable ++ by such Contributor that are necessarily infringed by their ++ Contribution(s) alone or by combination of their Contribution(s) ++ with the Work to which such Contribution(s) was submitted. If You ++ institute patent litigation against any entity (including a ++ cross-claim or counterclaim in a lawsuit) alleging that the Work ++ or a Contribution incorporated within the Work constitutes direct ++ or contributory patent infringement, then any patent licenses ++ granted to You under this License for that Work shall terminate ++ as of the date such litigation is filed. ++ ++ 4. Redistribution. You may reproduce and distribute copies of the ++ Work or Derivative Works thereof in any medium, with or without ++ modifications, and in Source or Object form, provided that You ++ meet the following conditions: ++ ++ (a) You must give any other recipients of the Work or ++ Derivative Works a copy of this License; and ++ ++ (b) You must cause any modified files to carry prominent notices ++ stating that You changed the files; and ++ ++ (c) You must retain, in the Source form of any Derivative Works ++ that You distribute, all copyright, patent, trademark, and ++ attribution notices from the Source form of the Work, ++ excluding those notices that do not pertain to any part of ++ the Derivative Works; and ++ ++ (d) If the Work includes a "NOTICE" text file as part of its ++ distribution, then any Derivative Works that You distribute must ++ include a readable copy of the attribution notices contained ++ within such NOTICE file, excluding those notices that do not ++ pertain to any part of the Derivative Works, in at least one ++ of the following places: within a NOTICE text file distributed ++ as part of the Derivative Works; within the Source form or ++ documentation, if provided along with the Derivative Works; or, ++ within a display generated by the Derivative Works, if and ++ wherever such third-party notices normally appear. The contents ++ of the NOTICE file are for informational purposes only and ++ do not modify the License. You may add Your own attribution ++ notices within Derivative Works that You distribute, alongside ++ or as an addendum to the NOTICE text from the Work, provided ++ that such additional attribution notices cannot be construed ++ as modifying the License. ++ ++ You may add Your own copyright statement to Your modifications and ++ may provide additional or different license terms and conditions ++ for use, reproduction, or distribution of Your modifications, or ++ for any such Derivative Works as a whole, provided Your use, ++ reproduction, and distribution of the Work otherwise complies with ++ the conditions stated in this License. ++ ++ 5. Submission of Contributions. Unless You explicitly state otherwise, ++ any Contribution intentionally submitted for inclusion in the Work ++ by You to the Licensor shall be under the terms and conditions of ++ this License, without any additional terms or conditions. ++ Notwithstanding the above, nothing herein shall supersede or modify ++ the terms of any separate license agreement you may have executed ++ with Licensor regarding such Contributions. ++ ++ 6. Trademarks. This License does not grant permission to use the trade ++ names, trademarks, service marks, or product names of the Licensor, ++ except as required for reasonable and customary use in describing the ++ origin of the Work and reproducing the content of the NOTICE file. ++ ++ 7. Disclaimer of Warranty. Unless required by applicable law or ++ agreed to in writing, Licensor provides the Work (and each ++ Contributor provides its Contributions) on an "AS IS" BASIS, ++ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or ++ implied, including, without limitation, any warranties or conditions ++ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A ++ PARTICULAR PURPOSE. You are solely responsible for determining the ++ appropriateness of using or redistributing the Work and assume any ++ risks associated with Your exercise of permissions under this License. ++ ++ 8. Limitation of Liability. In no event and under no legal theory, ++ whether in tort (including negligence), contract, or otherwise, ++ unless required by applicable law (such as deliberate and grossly ++ negligent acts) or agreed to in writing, shall any Contributor be ++ liable to You for damages, including any direct, indirect, special, ++ incidental, or consequential damages of any character arising as a ++ result of this License or out of the use or inability to use the ++ Work (including but not limited to damages for loss of goodwill, ++ work stoppage, computer failure or malfunction, or any and all ++ other commercial damages or losses), even if such Contributor ++ has been advised of the possibility of such damages. ++ ++ 9. Accepting Warranty or Additional Liability. While redistributing ++ the Work or Derivative Works thereof, You may choose to offer, ++ and charge a fee for, acceptance of support, warranty, indemnity, ++ or other liability obligations and/or rights consistent with this ++ License. However, in accepting such obligations, You may act only ++ on Your own behalf and on Your sole responsibility, not on behalf ++ of any other Contributor, and only if You agree to indemnify, ++ defend, and hold each Contributor harmless for any liability ++ incurred by, or claims asserted against, such Contributor by reason ++ of your accepting any such warranty or additional liability. ++ ++ END OF TERMS AND CONDITIONS ++ ++ APPENDIX: How to apply the Apache License to your work. ++ ++ To apply the Apache License to your work, attach the following ++ boilerplate notice, with the fields enclosed by brackets "[]" ++ replaced with your own identifying information. (Don't include ++ the brackets!) The text should be enclosed in the appropriate ++ comment syntax for the file format. We also recommend that a ++ file or class name and description of purpose be included on the ++ same "printed page" as the copyright notice for easier ++ identification within third-party archives. ++ ++ Copyright [yyyy] [name of copyright owner] ++ ++ Licensed under the Apache License, Version 2.0 (the "License"); ++ you may not use this file except in compliance with the License. ++ You may obtain a copy of the License at ++ ++ http://www.apache.org/licenses/LICENSE-2.0 ++ ++ Unless required by applicable law or agreed to in writing, software ++ distributed under the License is distributed on an "AS IS" BASIS, ++ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. ++ See the License for the specific language governing permissions and ++ limitations under the License. ++ +diff --git a/internal/cli/promote.go b/internal/cli/promote.go +new file mode 100644 +index 0000000..9b332bb +--- /dev/null ++++ b/internal/cli/promote.go +@@ -0,0 +1,61 @@ ++package cli ++ ++import ( ++ "fmt" ++ "os" ++ ++ "github.com/spf13/cobra" ++ ++ kokf "github.com/compozy/kb/internal/okf" ++ ktopic "github.com/compozy/kb/internal/topic" ++) ++ ++var runPromote = kokf.Promote ++var runPromoteTopicInfo = ktopic.Info ++var promoteGetwd = os.Getwd ++ ++func newPromoteCommand() *cobra.Command { ++ var targetTopic string ++ var conceptType string ++ var description string ++ ++ command := &cobra.Command{ ++ Use: "promote <wiki-doc>", ++ Short: "Promote a wiki document into an OKF topic", ++ Args: cobra.ExactArgs(1), ++ RunE: func(cmd *cobra.Command, args []string) error { ++ vaultPath, err := resolveCommandVaultPath(cmd, promoteGetwd, "promote") ++ if err != nil { ++ return err ++ } ++ cfg, err := loadCLIConfig() ++ if err != nil { ++ return fmt.Errorf("promote: %w", err) ++ } ++ target, err := runPromoteTopicInfo(vaultPath, targetTopic) ++ if err != nil { ++ return fmt.Errorf("promote: %w", err) ++ } ++ ++ result, err := runPromote(commandContext(cmd), kokf.PromoteInput{ ++ SourceDocPath: args[0], ++ VaultPath: vaultPath, ++ TargetTopic: target, ++ Type: conceptType, ++ Description: description, ++ Types: cfg.OKF.Types, ++ }) ++ if err != nil { ++ return err ++ } ++ return writeJSON(cmd, result) ++ }, ++ } ++ ++ command.Flags().StringVar(&targetTopic, "to", "", "Target OKF topic slug") ++ command.Flags().StringVar(&conceptType, "type", "", "OKF concept type") ++ command.Flags().StringVar(&description, "description", "", "OKF concept description") ++ _ = command.MarkFlagRequired("to") ++ _ = command.MarkFlagRequired("type") ++ return command ++} +diff --git a/internal/cli/okf.go b/internal/cli/okf.go +new file mode 100644 +index 0000000..c3dca3a +--- /dev/null ++++ b/internal/cli/okf.go +@@ -0,0 +1,90 @@ ++package cli ++ ++import ( ++ "fmt" ++ "os" ++ ++ "github.com/spf13/cobra" ++ ++ kokf "github.com/compozy/kb/internal/okf" ++ "github.com/compozy/kb/internal/output" ++ ktopic "github.com/compozy/kb/internal/topic" ++) ++ ++type okfCheckOptions struct { ++ Format string ++ Strict bool ++} ++ ++var runOKFCheck = kokf.Check ++var runOKFTopicInfo = ktopic.Info ++var okfGetwd = os.Getwd ++ ++func newOKFCommand() *cobra.Command { ++ command := &cobra.Command{ ++ Use: "okf", ++ Short: "Work with Open Knowledge Format bundles", ++ Args: cobra.NoArgs, ++ RunE: func(cmd *cobra.Command, args []string) error { ++ return cmd.Help() ++ }, ++ } ++ command.AddCommand(newOKFCheckCommand()) ++ return command ++} ++ ++func newOKFCheckCommand() *cobra.Command { ++ options := &okfCheckOptions{ ++ Format: string(output.OutputFormatTable), ++ } ++ command := &cobra.Command{ ++ Use: "check <topic>", ++ Short: "Check an OKF topic for conformance", ++ Args: cobra.ExactArgs(1), ++ RunE: func(cmd *cobra.Command, args []string) error { ++ return runOKFCheckCommand(cmd, options, args[0]) ++ }, ++ } ++ command.Flags().StringVar(&options.Format, "format", string(output.OutputFormatTable), "Output format (table|json|tsv)") ++ command.Flags().BoolVar(&options.Strict, "strict", false, "Promote local-standard warnings to errors") ++ return command ++} ++ ++func runOKFCheckCommand(cmd *cobra.Command, options *okfCheckOptions, topicSlug string) error { ++ format, err := parseInspectOutputFormat(options.Format) ++ if err != nil { ++ return err ++ } ++ vaultPath, err := resolveCommandVaultPath(cmd, okfGetwd, "okf check") ++ if err != nil { ++ return err ++ } ++ cfg, err := loadCLIConfig() ++ if err != nil { ++ return fmt.Errorf("okf check: %w", err) ++ } ++ topicInfo, err := runOKFTopicInfo(vaultPath, topicSlug) ++ if err != nil { ++ return fmt.Errorf("okf check: %w", err) ++ } ++ ++ issues, err := runOKFCheck(commandContext(cmd), topicInfo.RootPath, kokf.CheckOptions{ ++ Types: cfg.OKF.Types, ++ Strict: options.Strict, ++ }) ++ if err != nil { ++ return err ++ } ++ _, writeErr := cmd.OutOrStdout().Write([]byte(output.FormatOutput(output.FormatOptions{ ++ Format: format, ++ Columns: kokf.Columns(), ++ Data: kokf.Rows(issues), ++ }))) ++ if writeErr != nil { ++ return fmt.Errorf("okf check: write output: %w", writeErr) ++ } ++ if kokf.HasErrors(issues) { ++ return fmt.Errorf("okf check: found %d issue(s)", len(issues)) ++ } ++ return nil ++} +diff --git a/internal/cli/okf_test.go b/internal/cli/okf_test.go +new file mode 100644 +index 0000000..dc9d6ba +--- /dev/null ++++ b/internal/cli/okf_test.go +@@ -0,0 +1,135 @@ ++package cli ++ ++import ( ++ "bytes" ++ "context" ++ "encoding/json" ++ "fmt" ++ "os" ++ "path/filepath" ++ "strings" ++ "testing" ++ ++ kconfig "github.com/compozy/kb/internal/config" ++ "github.com/compozy/kb/internal/models" ++ kokf "github.com/compozy/kb/internal/okf" ++) ++ ++func TestPromoteCommandResolvesTargetAndPrintsJSON(t *testing.T) { ++ originalPromote := runPromote ++ originalTopicInfo := runPromoteTopicInfo ++ t.Cleanup(func() { ++ runPromote = originalPromote ++ runPromoteTopicInfo = originalTopicInfo ++ }) ++ t.Setenv(kconfig.EnvConfigPath, writeCLIConfig(t, "[okf]\ntypes = [\"Playbook\"]\n")) ++ ++ var gotInput kokf.PromoteInput ++ runPromoteTopicInfo = func(vaultPath, slug string) (models.TopicInfo, error) { ++ return models.TopicInfo{ ++ Slug: slug, ++ Mode: models.TopicModeOKF, ++ RootPath: filepath.Join(vaultPath, slug), ++ }, nil ++ } ++ runPromote = func(ctx context.Context, input kokf.PromoteInput) (kokf.ConceptResult, error) { ++ gotInput = input ++ return kokf.ConceptResult{ ++ WrittenPath: "alpha.md", ++ Type: input.Type, ++ LinksRewritten: 1, ++ }, nil ++ } ++ ++ command := newRootCommand() ++ var stdout bytes.Buffer ++ command.SetOut(&stdout) ++ command.SetErr(new(bytes.Buffer)) ++ command.SetArgs([]string{ ++ "promote", "research/wiki/concepts/Alpha.md", ++ "--to", "catalog", ++ "--type", "Playbook", ++ "--description", "Alpha description.", ++ "--vault", "/tmp/vault", ++ }) ++ ++ if err := command.ExecuteContext(context.Background()); err != nil { ++ t.Fatalf("ExecuteContext returned error: %v", err) ++ } ++ if gotInput.VaultPath != "/tmp/vault" || gotInput.TargetTopic.Slug != "catalog" || gotInput.Type != "Playbook" { ++ t.Fatalf("unexpected promote input: %#v", gotInput) ++ } ++ if gotInput.Description != "Alpha description." { ++ t.Fatalf("description = %q", gotInput.Description) ++ } ++ if len(gotInput.Types) != 1 || gotInput.Types[0] != "Playbook" { ++ t.Fatalf("types = %#v, want Playbook", gotInput.Types) ++ } ++ ++ var result kokf.ConceptResult ++ if err := json.Unmarshal(stdout.Bytes(), &result); err != nil { ++ t.Fatalf("stdout did not contain JSON: %v\n%s", err, stdout.String()) ++ } ++ if result.WrittenPath != "alpha.md" || result.Type != "Playbook" { ++ t.Fatalf("unexpected result: %#v", result) ++ } ++} ++ ++func TestOKFCheckCommandRendersIssuesAndFailsOnErrors(t *testing.T) { ++ originalCheck := runOKFCheck ++ originalTopicInfo := runOKFTopicInfo ++ t.Cleanup(func() { ++ runOKFCheck = originalCheck ++ runOKFTopicInfo = originalTopicInfo ++ }) ++ t.Setenv(kconfig.EnvConfigPath, writeCLIConfig(t, "[okf]\ntypes = [\"Playbook\"]\n")) ++ ++ runOKFTopicInfo = func(vaultPath, slug string) (models.TopicInfo, error) { ++ return models.TopicInfo{ ++ Slug: slug, ++ Mode: models.TopicModeOKF, ++ RootPath: filepath.Join(vaultPath, slug), ++ }, nil ++ } ++ runOKFCheck = func(ctx context.Context, bundlePath string, options kokf.CheckOptions) ([]models.LintIssue, error) { ++ if bundlePath != "/tmp/vault/catalog" { ++ return nil, fmt.Errorf("bundle path = %q", bundlePath) ++ } ++ if !options.Strict || len(options.Types) != 1 || options.Types[0] != "Playbook" { ++ return nil, fmt.Errorf("unexpected options: %#v", options) ++ } ++ return []models.LintIssue{{ ++ Kind: models.LintIssueKindFormat, ++ Severity: models.SeverityError, ++ FilePath: "bad.md", ++ Target: "type", ++ Message: "missing type", ++ }}, nil ++ } ++ ++ command := newRootCommand() ++ var stdout bytes.Buffer ++ command.SetOut(&stdout) ++ command.SetErr(new(bytes.Buffer)) ++ command.SetArgs([]string{"okf", "check", "catalog", "--strict", "--format", "json", "--vault", "/tmp/vault"}) ++ ++ err := command.ExecuteContext(context.Background()) ++ if err == nil { ++ t.Fatal("expected okf check to fail on error issues") ++ } ++ if !strings.Contains(err.Error(), "found 1 issue") { ++ t.Fatalf("unexpected error: %v", err) ++ } ++ if !strings.Contains(stdout.String(), `"filePath": "bad.md"`) { ++ t.Fatalf("stdout missing issue JSON:\n%s", stdout.String()) ++ } ++} ++ ++func writeCLIConfig(t *testing.T, content string) string { ++ t.Helper() ++ path := filepath.Join(t.TempDir(), "kb.toml") ++ if err := os.WriteFile(path, []byte(content), 0o644); err != nil { ++ t.Fatalf("write config: %v", err) ++ } ++ return path ++} +diff --git a/internal/cli/okf_integration_test.go b/internal/cli/okf_integration_test.go +new file mode 100644 +index 0000000..2f65552 +--- /dev/null ++++ b/internal/cli/okf_integration_test.go +@@ -0,0 +1,112 @@ ++//go:build integration ++ ++package cli ++ ++import ( ++ "encoding/json" ++ "os" ++ "path/filepath" ++ "strings" ++ "testing" ++ ++ "github.com/compozy/kb/internal/frontmatter" ++ "github.com/compozy/kb/internal/models" ++ kokf "github.com/compozy/kb/internal/okf" ++) ++ ++func TestCLIIntegrationOKFPromoteAndCheck(t *testing.T) { ++ vaultRoot := t.TempDir() ++ wikiTopic := scaffoldTopicForIntegration(t, vaultRoot, "research", "Research", "ops") ++ okfTopic := runCLIJSON[models.TopicInfo](t, ++ "topic", "new", "catalog", "Catalog", "ops", ++ "--mode", "okf", ++ "--vault", vaultRoot, ++ ) ++ if okfTopic.Mode != models.TopicModeOKF { ++ t.Fatalf("OKF topic mode = %q, want okf", okfTopic.Mode) ++ } ++ ++ sourcePath := filepath.Join(wikiTopic.RootPath, "wiki", "concepts", "Alpha Note.md") ++ writeFile(t, sourcePath, strings.Join([]string{ ++ "---", ++ "title: Alpha Note", ++ "type: wiki", ++ "stage: compiled", ++ "tags: [ops]", ++ "---", ++ "Alpha note explains the operations flow. See [[research/wiki/concepts/Beta Note|Beta]].", ++ }, "\n")) ++ ++ promoteOutput := runCLI(t, ++ "promote", sourcePath, ++ "--to", okfTopic.Slug, ++ "--type", "Playbook", ++ "--description", "Operational flow.", ++ "--vault", vaultRoot, ++ ) ++ var result kokf.ConceptResult ++ if err := json.Unmarshal([]byte(promoteOutput), &result); err != nil { ++ t.Fatalf("promote output is not JSON: %v\n%s", err, promoteOutput) ++ } ++ if result.WrittenPath != "alpha-note.md" { ++ t.Fatalf("written path = %q, want alpha-note.md", result.WrittenPath) ++ } ++ ++ sourceAfter := readFile(t, sourcePath) ++ if !strings.Contains(sourceAfter, "stage: compiled") { ++ t.Fatalf("source was unexpectedly changed:\n%s", sourceAfter) ++ } ++ conceptPath := filepath.Join(okfTopic.RootPath, "alpha-note.md") ++ values, body := readMarkdownDocument(t, conceptPath) ++ if values["type"] != "Playbook" || values["description"] != "Operational flow." { ++ t.Fatalf("unexpected concept frontmatter: %#v", values) ++ } ++ if !strings.Contains(body, "[Beta](beta-note.md)") { ++ t.Fatalf("body missing relative OKF link:\n%s", body) ++ } ++ if _, ok := values["stage"]; ok { ++ t.Fatalf("wiki stage leaked into OKF concept: %#v", values) ++ } ++ ++ checkOutput := runCLI(t, "okf", "check", okfTopic.Slug, "--format", "json", "--vault", vaultRoot) ++ var issues []models.LintIssue ++ if err := json.Unmarshal([]byte(checkOutput), &issues); err != nil { ++ t.Fatalf("check output is not JSON: %v\n%s", err, checkOutput) ++ } ++ if len(issues) != 0 { ++ t.Fatalf("freshly promoted bundle has issues: %#v", issues) ++ } ++ ++ index := readFile(t, filepath.Join(okfTopic.RootPath, "index.md")) ++ if !strings.Contains(index, "## Playbook") || !strings.Contains(index, "[Alpha Note](alpha-note.md)") { ++ t.Fatalf("index.md missing promoted concept:\n%s", index) ++ } ++ logContent := readFile(t, filepath.Join(okfTopic.RootPath, "log.md")) ++ if !strings.Contains(logContent, "## "+frontmatter.DateLayout[:4]) && !strings.Contains(logContent, "**Creation**") { ++ t.Fatalf("log.md missing promotion entry:\n%s", logContent) ++ } ++} ++ ++func TestCLIIntegrationOKFCheckFailsStrictWarnings(t *testing.T) { ++ vaultRoot := t.TempDir() ++ okfTopic := runCLIJSON[models.TopicInfo](t, ++ "topic", "new", "catalog", "Catalog", "ops", ++ "--mode", "okf", ++ "--vault", vaultRoot, ++ ) ++ writeFile(t, filepath.Join(okfTopic.RootPath, "concept.md"), "---\ntype: Unknown\n---\nBody.\n") ++ ++ errText := runCLIError(t, "okf", "check", okfTopic.Slug, "--strict", "--vault", vaultRoot) ++ if !strings.Contains(errText, "found") { ++ t.Fatalf("strict check error = %q", errText) ++ } ++} ++ ++func readFile(t *testing.T, filePath string) string { ++ t.Helper() ++ content, err := os.ReadFile(filePath) ++ if err != nil { ++ t.Fatalf("read %s: %v", filePath, err) ++ } ++ return string(content) ++} diff --git a/.compozy/tasks/okf-dual-mode/qa/impl-review-diff-round2.patch b/.compozy/tasks/okf-dual-mode/qa/impl-review-diff-round2.patch new file mode 100644 index 0000000..0fdb625 --- /dev/null +++ b/.compozy/tasks/okf-dual-mode/qa/impl-review-diff-round2.patch @@ -0,0 +1,2996 @@ +diff --git a/AGENTS.md b/AGENTS.md +index ead6156..d1f2170 100644 +--- a/AGENTS.md ++++ b/AGENTS.md +@@ -63,9 +63,11 @@ make help # mage target list + + | Command | Purpose | + | --- | --- | +-| `kb topic new <slug> <title> <domain>` | Scaffold a new knowledge base topic | ++| `kb topic new <slug> <title> <domain>` | Scaffold a new knowledge base topic (`--mode wiki\|okf`) | + | `kb topic list` | List scaffolded topics | + | `kb topic info <slug>` | Show metadata for one topic | ++| `kb promote <wiki-doc> --to <okf-topic> --type <type>` | Promote a compiled wiki document into an OKF topic | ++| `kb okf check <topic>` | Check an OKF topic for bundle conformance | + | `kb ingest url <url> --topic <slug>` | Scrape a web URL and ingest into a topic | + | `kb ingest file <path> --topic <slug>` | Convert a local file and ingest into a topic | + | `kb ingest youtube <url> --topic <slug>` | Extract a YouTube transcript and ingest into a topic | +@@ -88,7 +90,9 @@ make help # mage target list + + ### Command Notes + +-- `topic` subcommands share the root `--vault` flag for vault path resolution. ++- `topic` subcommands share the root `--vault` flag for vault path resolution; `topic new --mode okf` creates a root-level OKF bundle instead of the wiki/raw scaffold. ++- `promote` requires `--to <okf-topic>` and `--type <type>`; `--description` overrides the generated concept description. ++- `okf check` accepts `--strict` and `--format table|json|tsv`. + - `ingest` subcommands require `--topic <slug>` to identify the target topic. + - `ingest codebase` accepts `--include`, `--exclude`, `--semantic`, `--progress`, and `--log-format`. + - `ingest youtube` accepts `--transcribe captions|auto|stt`; `yt-dlp` is required for YouTube metadata, captions, and audio extraction. +@@ -104,6 +108,7 @@ make help # mage target list + - `APP_CONFIG` overrides the config file path. + - `.env` is loaded automatically when present. + - `FIRECRAWL_API_KEY` and `FIRECRAWL_API_URL` configure the Firecrawl client for `ingest url`. ++- `[okf].types` configures the optional OKF type vocabulary used by `promote` and `okf check`. + - `OPENAI_API_KEY`, `OPENAI_API_URL`, `STT_PROVIDER`, and `STT_MODEL` configure the default OpenAI STT provider for `ingest youtube --transcribe auto|stt`. + - `OPENROUTER_API_KEY`, `OPENROUTER_API_URL`, and `openrouter.stt_model` (TOML-only) configure the optional OpenRouter STT provider when `stt.provider = "openrouter"`. + - Generation, inspect, search, and index behavior is configured by CLI flags rather than TOML keys. +diff --git a/CLAUDE.md b/CLAUDE.md +index d0ab51d..dfeaf44 100644 +--- a/CLAUDE.md ++++ b/CLAUDE.md +@@ -78,7 +78,9 @@ make help + + ## CLI Surface + +-- `kb topic {new,list,info}` ++- `kb topic {new,list,info}` (`topic new --mode wiki|okf`) ++- `kb promote <wiki-doc> --to <okf-topic> --type <type>` ++- `kb okf check <topic>` + - `kb ingest {url,file,youtube,channel,instagram,codebase,bookmarks}` + - `kb lint [<slug>]` + - `kb inspect {smells|dead-code|complexity|blast-radius|coupling|symbol|file|backlinks|deps|circular-deps}` +@@ -89,7 +91,7 @@ make help + + ## Runtime Config Notes + +-- `config.example.toml` documents every TOML section currently accepted by `internal/config`: `[app]`, `[log]`, `[vault]`, `[firecrawl]`, `[openrouter]`, `[stt]`, `[youtube]`, and `[instagram]`. ++- `config.example.toml` documents every TOML section currently accepted by `internal/config`: `[app]`, `[log]`, `[vault]`, `[firecrawl]`, `[openrouter]`, `[stt]`, `[youtube]`, `[instagram]`, and `[okf]`. + - `APP_CONFIG` selects the TOML file path. + - `kb ingest youtube` requires `yt-dlp` for metadata, captions, and audio extraction. `[youtube].yt_dlp_path`, `[youtube].proxy`, `[youtube].cookies_file`, `[youtube].user_agent`, `[youtube].retry_attempts`, `[youtube].retry_backoff`, and `[youtube].transcription` configure that path. + - YouTube transcription policy is `captions|auto|stt`: `captions` uses YouTube captions only, `auto` uses manual captions when present and STT when only automatic captions or no captions are available, and `stt` forces audio transcription. +@@ -97,6 +99,7 @@ make help + - OpenAI is the default STT provider through `/v1/audio/transcriptions`. Configure it with `[stt]` plus `OPENAI_API_KEY`, `OPENAI_API_URL`, `STT_PROVIDER`, and `STT_MODEL`. + - OpenRouter is an optional STT provider when `stt.provider = "openrouter"`; configure it with `OPENROUTER_API_KEY`, `OPENROUTER_API_URL`, and `openrouter.stt_model`. + - Long STT audio is segmented with `ffmpeg`; keep `[stt].ffmpeg_path`, `[stt].chunk_duration`, `[stt].max_chunk_bytes`, and `[stt].concurrency` aligned with provider upload limits. ++- `[okf].types` configures the optional OKF type vocabulary used by `kb promote` and `kb okf check`. + + ## Testing Conventions + +diff --git a/README.md b/README.md +index 29464f9..70e85cb 100644 +--- a/README.md ++++ b/README.md +@@ -87,6 +87,9 @@ $ kb topic new rust-lang "Rust Language" programming + "domain": "programming" + } + ++# scaffold an OKF bundle topic ++$ kb topic new rust-catalog "Rust Catalog" programming --mode okf ++ + # ingest a web article + $ kb ingest url https://doc.rust-lang.org/book/ch04-01-what-is-ownership.html --topic rust-lang + +@@ -102,6 +105,12 @@ $ kb ingest codebase ./my-rust-project --topic rust-lang --progress never + + # lint the topic for structural issues + $ kb lint rust-lang ++ ++# promote a compiled wiki document into the OKF bundle ++$ kb promote .kb/vault/rust-lang/wiki/concepts/Ownership.md --to rust-catalog --type Concept ++ ++# check the OKF bundle ++$ kb okf check rust-catalog --strict + ``` + + Analyze codebase snapshots from the terminal: +@@ -181,11 +190,29 @@ The key difference: `kb` outputs compound. A SonarQube scan from last month is s + Scaffold and manage knowledge base topics. + + ```bash +-kb topic new <slug> <title> <domain> # Create a new topic ++kb topic new <slug> <title> <domain> [--mode wiki|okf] # Create a new topic + kb topic list # List all topics in the vault + kb topic info <slug> # Show metadata for a topic + ``` + ++`--mode wiki` is the default scaffold. `--mode okf` creates a root-level Open Knowledge File bundle with `index.md`, `log.md`, and OKF authoring guidance. ++ ++### `kb promote` ++ ++Promote a compiled wiki document into an OKF topic without modifying the source document. ++ ++```bash ++kb promote <wiki-doc> --to <okf-topic> --type <type> [--description <text>] ++``` ++ ++### `kb okf` ++ ++Check an OKF topic for bundle conformance. ++ ++```bash ++kb okf check <topic> [--strict] [--format table|json|tsv] ++``` ++ + ### `kb ingest` + + Ingest source material into a topic. `url`, `file`, `youtube`, and `bookmarks` require an existing topic; `codebase` can bootstrap one on first run. +diff --git a/config.example.toml b/config.example.toml +index a9ea428..0c91297 100644 +--- a/config.example.toml ++++ b/config.example.toml +@@ -5,6 +5,7 @@ + # - `[app]` + # - `[log]` + # - `[vault]` ++# - `[okf]` + # - `[firecrawl]` + # - `[openrouter]` + # - `[stt]` +@@ -48,6 +49,11 @@ level = "info" + root = "." + topic_globs = ["*"] + ++[okf] ++# Local OKF concept type vocabulary. Empty means `kb okf check` will not warn ++# about unknown types until the operator opts into a local standard. ++types = [] ++ + [firecrawl] + api_key = "" + api_url = "https://api.firecrawl.dev" +diff --git a/internal/cli/root.go b/internal/cli/root.go +index 2b123a6..22ab258 100644 +--- a/internal/cli/root.go ++++ b/internal/cli/root.go +@@ -23,6 +23,8 @@ func newRootCommand() *cobra.Command { + bindRootPersistentFlags(command) + command.AddCommand(newTopicCommand()) + command.AddCommand(newIngestCommand()) ++ command.AddCommand(newPromoteCommand()) ++ command.AddCommand(newOKFCommand()) + command.AddCommand(newLintCommand()) + command.AddCommand(newGenerateCommand()) + command.AddCommand(newInspectCommand()) +diff --git a/internal/cli/topic.go b/internal/cli/topic.go +index 3746632..d609335 100644 +--- a/internal/cli/topic.go ++++ b/internal/cli/topic.go +@@ -4,6 +4,7 @@ import ( + "encoding/json" + "fmt" + "os" ++ "strings" + + "github.com/spf13/cobra" + +@@ -13,6 +14,7 @@ import ( + ) + + var runTopicNew = ktopic.New ++var runTopicNewWithMode = ktopic.NewWithMode + var runTopicList = ktopic.List + var runTopicInfo = ktopic.Info + var topicGetwd = os.Getwd +@@ -37,7 +39,8 @@ func newTopicCommand() *cobra.Command { + } + + func newTopicNewCommand() *cobra.Command { +- return &cobra.Command{ ++ var mode string ++ command := &cobra.Command{ + Use: "new <slug> <title> <domain>", + Short: "Scaffold a new knowledge base topic", + Args: cobra.ExactArgs(3), +@@ -47,7 +50,16 @@ func newTopicNewCommand() *cobra.Command { + return err + } + +- info, err := runTopicNew(vaultPath, args[0], args[1], args[2]) ++ topicMode, err := parseTopicMode(mode) ++ if err != nil { ++ return err ++ } ++ var info models.TopicInfo ++ if topicMode == models.TopicModeWiki { ++ info, err = runTopicNew(vaultPath, args[0], args[1], args[2]) ++ } else { ++ info, err = runTopicNewWithMode(vaultPath, args[0], args[1], args[2], topicMode) ++ } + if err != nil { + return err + } +@@ -55,6 +67,8 @@ func newTopicNewCommand() *cobra.Command { + return writeTopicInfoJSON(cmd, info) + }, + } ++ command.Flags().StringVar(&mode, "mode", string(models.TopicModeWiki), "Topic mode (wiki|okf)") ++ return command + } + + func newTopicListCommand() *cobra.Command { +@@ -98,6 +112,17 @@ func newTopicListCommand() *cobra.Command { + } + } + ++func parseTopicMode(value string) (models.TopicMode, error) { ++ switch strings.TrimSpace(value) { ++ case "", string(models.TopicModeWiki): ++ return models.TopicModeWiki, nil ++ case string(models.TopicModeOKF): ++ return models.TopicModeOKF, nil ++ default: ++ return "", fmt.Errorf(`invalid --mode %q: expected "wiki" or "okf"`, value) ++ } ++} ++ + func newTopicInfoCommand() *cobra.Command { + return &cobra.Command{ + Use: "info <slug>", +diff --git a/internal/cli/topic_test.go b/internal/cli/topic_test.go +index 44d7b67..1f49e4d 100644 +--- a/internal/cli/topic_test.go ++++ b/internal/cli/topic_test.go +@@ -79,6 +79,66 @@ func TestTopicNewCommandPassesArgsAndPrintsJSON(t *testing.T) { + } + } + ++func TestTopicNewCommandPassesOKFMode(t *testing.T) { ++ originalNewWithMode := runTopicNewWithMode ++ originalGetwd := topicGetwd ++ t.Cleanup(func() { ++ runTopicNewWithMode = originalNewWithMode ++ topicGetwd = originalGetwd ++ }) ++ ++ var gotMode models.TopicMode ++ runTopicNewWithMode = func(vaultPath, slug, title, domain string, mode models.TopicMode) (models.TopicInfo, error) { ++ gotMode = mode ++ return models.TopicInfo{ ++ Slug: slug, ++ Title: title, ++ Domain: domain, ++ Mode: mode, ++ RootPath: filepath.Join(vaultPath, slug), ++ }, nil ++ } ++ topicGetwd = func() (string, error) { ++ return "/workspace/repo", nil ++ } ++ ++ command := newRootCommand() ++ var stdout bytes.Buffer ++ command.SetOut(&stdout) ++ command.SetErr(new(bytes.Buffer)) ++ command.SetArgs([]string{"topic", "new", "ops-catalog", "Ops Catalog", "ops", "--mode", "okf", "--vault", "/tmp/vault"}) ++ ++ if err := command.ExecuteContext(context.Background()); err != nil { ++ t.Fatalf("ExecuteContext returned error: %v", err) ++ } ++ if gotMode != models.TopicModeOKF { ++ t.Fatalf("mode = %q, want okf", gotMode) ++ } ++ ++ var info models.TopicInfo ++ if err := json.Unmarshal(stdout.Bytes(), &info); err != nil { ++ t.Fatalf("stdout did not contain JSON: %v\n%s", err, stdout.String()) ++ } ++ if info.Mode != models.TopicModeOKF { ++ t.Fatalf("payload mode = %q, want okf", info.Mode) ++ } ++} ++ ++func TestTopicNewCommandRejectsInvalidMode(t *testing.T) { ++ command := newRootCommand() ++ command.SetOut(new(bytes.Buffer)) ++ command.SetErr(new(bytes.Buffer)) ++ command.SetArgs([]string{"topic", "new", "ops", "Ops", "ops", "--mode", "catalog", "--vault", "/tmp/vault"}) ++ ++ err := command.ExecuteContext(context.Background()) ++ if err == nil { ++ t.Fatal("expected invalid mode error") ++ } ++ if !strings.Contains(err.Error(), "invalid --mode") { ++ t.Fatalf("unexpected error: %v", err) ++ } ++} ++ + func TestTopicNewCommandRequiresThreeArgs(t *testing.T) { + command := newRootCommand() + command.SetOut(new(bytes.Buffer)) +diff --git a/internal/config/config.go b/internal/config/config.go +index 14d3417..5083362 100644 +--- a/internal/config/config.go ++++ b/internal/config/config.go +@@ -47,6 +47,7 @@ type Config struct { + App AppConfig `toml:"app"` + Log LogConfig `toml:"log"` + Vault VaultConfig `toml:"vault"` ++ OKF OKFConfig `toml:"okf"` + Firecrawl FirecrawlConfig `toml:"firecrawl"` + OpenRouter OpenRouterConfig `toml:"openrouter"` + STT STTConfig `toml:"stt"` +@@ -71,6 +72,11 @@ type VaultConfig struct { + TopicGlobs []string `toml:"topic_globs"` + } + ++// OKFConfig controls local Open Knowledge Format producer standards. ++type OKFConfig struct { ++ Types []string `toml:"types"` ++} ++ + // FirecrawlConfig controls URL scraping API access. + type FirecrawlConfig struct { + APIKey string `toml:"api_key"` +@@ -211,6 +217,7 @@ func (c *Config) applyDefaults() { + if len(c.Vault.TopicGlobs) == 0 { + c.Vault.TopicGlobs = []string{defaultTopicGlob} + } ++ c.OKF.Types = normalizeConfigStringList(c.OKF.Types) + if strings.TrimSpace(c.YouTube.YTDLPPath) == "" { + c.YouTube.YTDLPPath = defaultYouTubeYTDLPPath + } +diff --git a/internal/config/config_test.go b/internal/config/config_test.go +index 9edc0c1..e4c3c2e 100644 +--- a/internal/config/config_test.go ++++ b/internal/config/config_test.go +@@ -59,6 +59,9 @@ func TestDefaultConfigHasValidDefaults(t *testing.T) { + if len(cfg.Vault.TopicGlobs) != 1 || cfg.Vault.TopicGlobs[0] != "*" { + t.Errorf("expected default vault.topic_globs [*], got %#v", cfg.Vault.TopicGlobs) + } ++ if len(cfg.OKF.Types) != 0 { ++ t.Errorf("expected default okf.types to be empty, got %#v", cfg.OKF.Types) ++ } + if cfg.Firecrawl.APIURL != defaultFirecrawlAPIURL { + t.Errorf("expected default firecrawl.api_url %q, got %q", defaultFirecrawlAPIURL, cfg.Firecrawl.APIURL) + } +@@ -118,6 +121,9 @@ level = "debug" + root = "." + topic_globs = ["*", "harness/*"] + ++[okf] ++types = ["Playbook", " Reference ", ""] ++ + [firecrawl] + api_key = "firecrawl-key" + api_url = "https://firecrawl.internal" +@@ -172,6 +178,9 @@ allow_translated_captions = true + if len(cfg.Vault.TopicGlobs) != 2 || cfg.Vault.TopicGlobs[1] != "harness/*" { + t.Errorf("expected vault.topic_globs to include harness/*, got %#v", cfg.Vault.TopicGlobs) + } ++ if !reflect.DeepEqual(cfg.OKF.Types, []string{"Playbook", "Reference"}) { ++ t.Errorf("expected normalized okf.types, got %#v", cfg.OKF.Types) ++ } + if cfg.Firecrawl.APIKey != "firecrawl-key" { + t.Errorf("expected firecrawl.api_key 'firecrawl-key', got %q", cfg.Firecrawl.APIKey) + } +diff --git a/internal/generate/generate.go b/internal/generate/generate.go +index be6a9a1..987e572 100644 +--- a/internal/generate/generate.go ++++ b/internal/generate/generate.go +@@ -453,6 +453,7 @@ func (r runner) createTopicMetadata(target generationTarget, opts models.Generat + Title: title, + Slug: target.TopicSlug, + Domain: vault.DeriveTopicDomain(domainSource), ++ Mode: models.TopicModeWiki, + Today: r.now().Format("2006-01-02"), + VaultPath: target.VaultPath, + TopicPath: filepath.Join(target.VaultPath, target.TopicSlug), +diff --git a/internal/models/kb_models.go b/internal/models/kb_models.go +index 11d0ef2..2087683 100644 +--- a/internal/models/kb_models.go ++++ b/internal/models/kb_models.go +@@ -114,11 +114,12 @@ type LintIssue struct { + + // TopicInfo captures topic metadata for list and info operations. + type TopicInfo struct { +- Slug string `json:"slug"` +- Title string `json:"title"` +- Domain string `json:"domain"` +- RootPath string `json:"rootPath"` +- ArticleCount int `json:"articleCount"` +- SourceCount int `json:"sourceCount"` +- LastLogEntry string `json:"lastLogEntry,omitempty"` ++ Slug string `json:"slug"` ++ Title string `json:"title"` ++ Domain string `json:"domain"` ++ Mode TopicMode `json:"mode"` ++ RootPath string `json:"rootPath"` ++ ArticleCount int `json:"articleCount"` ++ SourceCount int `json:"sourceCount"` ++ LastLogEntry string `json:"lastLogEntry,omitempty"` + } +diff --git a/internal/models/models.go b/internal/models/models.go +index 3352045..061f0aa 100644 +--- a/internal/models/models.go ++++ b/internal/models/models.go +@@ -102,6 +102,16 @@ const ( + DocIndex DocumentKind = "index" + ) + ++// TopicMode identifies the lifecycle and file contract for a topic. ++type TopicMode string ++ ++const ( ++ // TopicModeWiki preserves the existing LLM-wiki topic lifecycle. ++ TopicModeWiki TopicMode = "wiki" ++ // TopicModeOKF uses the Open Knowledge Format bundle lifecycle. ++ TopicModeOKF TopicMode = "okf" ++) ++ + // ManagedArea identifies the managed subtree within a generated topic. + type ManagedArea string + +@@ -269,13 +279,14 @@ type RenderedDocument struct { + + // TopicMetadata captures the derived topic information for a vault render. + type TopicMetadata struct { +- RootPath string `json:"rootPath"` +- Title string `json:"title"` +- Slug string `json:"slug"` +- Domain string `json:"domain"` +- Today string `json:"today"` +- VaultPath string `json:"vaultPath"` +- TopicPath string `json:"topicPath"` ++ RootPath string `json:"rootPath"` ++ Title string `json:"title"` ++ Slug string `json:"slug"` ++ Domain string `json:"domain"` ++ Mode TopicMode `json:"mode"` ++ Today string `json:"today"` ++ VaultPath string `json:"vaultPath"` ++ TopicPath string `json:"topicPath"` + } + + // GenerateOptions configures a full knowledge-base generation run. +diff --git a/internal/topic/topic.go b/internal/topic/topic.go +index 1097c4e..74b6323 100644 +--- a/internal/topic/topic.go ++++ b/internal/topic/topic.go +@@ -23,6 +23,7 @@ const ( + conceptIndexTemplatePath = "assets/concept-index-template.md" + dashboardTemplatePath = "assets/dashboard-template.md" + logTemplatePath = "assets/log-template.md" ++ okfClaudeTemplatePath = "assets/okf-claude-template.md" + sourceIndexTemplatePath = "assets/source-index-template.md" + topicMarkerFile = "CLAUDE.md" + topicMetadataFileName = "topic.yaml" +@@ -105,6 +106,7 @@ type topicMetadataFile struct { + Slug string `yaml:"slug"` + Title string `yaml:"title"` + Domain string `yaml:"domain"` ++ Mode string `yaml:"mode,omitempty"` + Category string `yaml:"category,omitempty"` + Path string `yaml:"path,omitempty"` + QMDCollection string `yaml:"qmd_collection,omitempty"` +@@ -112,7 +114,12 @@ type topicMetadataFile struct { + + // New scaffolds a new topic underneath the provided vault root. + func New(vaultPath, slug, title, domain string) (models.TopicInfo, error) { +- return newWithDate(vaultPath, slug, title, domain, time.Now()) ++ return NewWithMode(vaultPath, slug, title, domain, models.TopicModeWiki) ++} ++ ++// NewWithMode scaffolds a new topic with an explicit lifecycle mode. ++func NewWithMode(vaultPath, slug, title, domain string, mode models.TopicMode) (models.TopicInfo, error) { ++ return newWithDateWithMode(vaultPath, slug, title, domain, mode, time.Now()) + } + + // List returns the topics discovered under the provided vault root. +@@ -184,6 +191,10 @@ func Info(vaultPath, slug string) (models.TopicInfo, error) { + } + + func newWithDate(vaultPath, slug, title, domain string, now time.Time) (models.TopicInfo, error) { ++ return newWithDateWithMode(vaultPath, slug, title, domain, models.TopicModeWiki, now) ++} ++ ++func newWithDateWithMode(vaultPath, slug, title, domain string, mode models.TopicMode, now time.Time) (models.TopicInfo, error) { + cleanVaultPath, err := normalizeVaultPath(vaultPath) + if err != nil { + return models.TopicInfo{}, fmt.Errorf("new topic: %w", err) +@@ -195,6 +206,10 @@ func newWithDate(vaultPath, slug, title, domain string, now time.Time) (models.T + } + cleanTitle := strings.TrimSpace(title) + cleanDomain := strings.TrimSpace(domain) ++ cleanMode, err := normalizeTopicMode(mode) ++ if err != nil { ++ return models.TopicInfo{}, fmt.Errorf("new topic: %w", err) ++ } + + switch { + case cleanTitle == "": +@@ -214,7 +229,7 @@ func newWithDate(vaultPath, slug, title, domain string, now time.Time) (models.T + return models.TopicInfo{}, fmt.Errorf("new topic: inspect topic path %q: %w", topicPath, err) + } + +- if err := createTopicSkeleton(topicPath); err != nil { ++ if err := createTopicSkeleton(topicPath, cleanMode); err != nil { + return models.TopicInfo{}, fmt.Errorf("new topic: create topic skeleton: %w", err) + } + +@@ -225,19 +240,26 @@ func newWithDate(vaultPath, slug, title, domain string, now time.Time) (models.T + Today: now.Format(frontmatter.DateLayout), + } + +- if err := installTemplates(topicPath, context); err != nil { ++ if err := installTemplatesWithMode(topicPath, context, cleanMode); err != nil { + return models.TopicInfo{}, fmt.Errorf("new topic: install templates: %w", err) + } +- if err := writeMetadataFile(topicPath, topicMetadataForRef(topicRef, cleanTitle, cleanDomain)); err != nil { ++ if err := writeMetadataFile(topicPath, topicMetadataForRef(topicRef, cleanTitle, cleanDomain, cleanMode)); err != nil { + return models.TopicInfo{}, fmt.Errorf("new topic: write topic metadata: %w", err) + } + if err := ensureAgentsSymlink(topicPath); err != nil { + return models.TopicInfo{}, fmt.Errorf("new topic: ensure AGENTS.md symlink: %w", err) + } +- if err := ensureGitkeeps(topicPath); err != nil { +- return models.TopicInfo{}, fmt.Errorf("new topic: ensure gitkeep files: %w", err) ++ if cleanMode == models.TopicModeWiki { ++ if err := ensureGitkeeps(topicPath); err != nil { ++ return models.TopicInfo{}, fmt.Errorf("new topic: ensure gitkeep files: %w", err) ++ } + } +- if err := appendScaffoldEntry(filepath.Join(topicPath, "log.md"), context); err != nil { ++ logPath := filepath.Join(topicPath, "log.md") ++ if cleanMode == models.TopicModeOKF { ++ if err := appendOKFScaffoldEntry(logPath, context); err != nil { ++ return models.TopicInfo{}, fmt.Errorf("new topic: append scaffold log entry: %w", err) ++ } ++ } else if err := appendScaffoldEntry(logPath, context); err != nil { + return models.TopicInfo{}, fmt.Errorf("new topic: append scaffold log entry: %w", err) + } + +@@ -412,7 +434,10 @@ func ensureDirectory(path string) error { + return nil + } + +-func createTopicSkeleton(topicPath string) error { ++func createTopicSkeleton(topicPath string, mode models.TopicMode) error { ++ if mode == models.TopicModeOKF { ++ return os.MkdirAll(topicPath, 0o755) ++ } + for _, relativePath := range currentTopicDirectories { + directoryPath := filepath.Join(topicPath, filepath.FromSlash(relativePath)) + if err := os.MkdirAll(directoryPath, 0o755); err != nil { +@@ -430,7 +455,7 @@ func EnsureCurrentSkeleton(topicPath string) error { + return fmt.Errorf("topic path is required") + } + +- if err := createTopicSkeleton(topicPath); err != nil { ++ if err := createTopicSkeleton(topicPath, models.TopicModeWiki); err != nil { + return err + } + if err := ensureTopicLog(topicPath); err != nil { +@@ -485,7 +510,17 @@ func RenderSourceIndexTemplate(slug, title, domain, today string) (string, error + } + + func installTemplates(topicPath string, context templateContext) error { +- for _, file := range topicTemplates { ++ return installTemplatesWithMode(topicPath, context, models.TopicModeWiki) ++} ++ ++func installTemplatesWithMode(topicPath string, context templateContext, mode models.TopicMode) error { ++ templates := topicTemplates ++ if mode == models.TopicModeOKF { ++ templates = []templateFile{ ++ {assetPath: okfClaudeTemplatePath, outputPath: "CLAUDE.md"}, ++ } ++ } ++ for _, file := range templates { + rendered, err := renderTemplate(file.assetPath, context) + if err != nil { + return err +@@ -497,6 +532,19 @@ func installTemplates(topicPath string, context templateContext) error { + } + } + ++ if mode == models.TopicModeOKF { ++ index, err := frontmatter.Generate(map[string]any{"okf_version": "0.1"}, "# OKF Bundle Index\n") ++ if err != nil { ++ return fmt.Errorf("generate OKF index: %w", err) ++ } ++ if err := os.WriteFile(filepath.Join(topicPath, "index.md"), []byte(index), 0o644); err != nil { ++ return fmt.Errorf("write OKF index: %w", err) ++ } ++ if err := os.WriteFile(filepath.Join(topicPath, "log.md"), []byte("# Directory Update Log\n"), 0o644); err != nil { ++ return fmt.Errorf("write OKF log: %w", err) ++ } ++ } ++ + return nil + } + +@@ -506,14 +554,15 @@ func WriteMetadataFile(topicPath, slug, title, domain string) error { + if err != nil { + return fmt.Errorf("validate topic metadata slug: %w", err) + } +- return writeMetadataFile(topicPath, topicMetadataForRef(topicRef, title, domain)) ++ return writeMetadataFile(topicPath, topicMetadataForRef(topicRef, title, domain, "")) + } + +-func topicMetadataForRef(topicRef TopicRef, title, domain string) topicMetadataFile { ++func topicMetadataForRef(topicRef TopicRef, title, domain string, mode models.TopicMode) topicMetadataFile { + metadata := topicMetadataFile{ + Slug: topicRef.Leaf, + Title: strings.TrimSpace(title), + Domain: strings.TrimSpace(domain), ++ Mode: string(mode), + } + if topicRef.Category != "" { + metadata.Category = topicRef.Category +@@ -694,6 +743,32 @@ func appendScaffoldEntry(logPath string, context templateContext) error { + return nil + } + ++func appendOKFScaffoldEntry(logPath string, context templateContext) error { ++ content, err := os.ReadFile(logPath) ++ if err != nil { ++ return fmt.Errorf("read %q: %w", logPath, err) ++ } ++ ++ entry := strings.Join([]string{ ++ fmt.Sprintf("## %s", context.Today), ++ "", ++ fmt.Sprintf("* **Initialization**: Created OKF bundle `%s` for `%s`.", context.Slug, context.Domain), ++ "", ++ }, "\n") ++ ++ trimmed := strings.TrimSpace(string(content)) ++ var next string ++ if trimmed == "" { ++ next = "# Directory Update Log\n\n" + entry ++ } else { ++ next = trimmed + "\n\n" + entry ++ } ++ if err := os.WriteFile(logPath, []byte(next), 0o644); err != nil { ++ return fmt.Errorf("write %q: %w", logPath, err) ++ } ++ return nil ++} ++ + func hasTopicSkeleton(topicPath string) (bool, error) { + info, err := os.Stat(topicPath) + if errors.Is(err, os.ErrNotExist) { +@@ -719,19 +794,14 @@ func hasTopicSkeleton(topicPath string) (bool, error) { + } + + func infoAtPath(topicPath, slug string) (models.TopicInfo, error) { +- title, domain, err := readTopicMetadata(topicPath, slug) ++ title, domain, mode, err := readTopicMetadata(topicPath, slug) + if err != nil { + return models.TopicInfo{}, fmt.Errorf("read topic metadata: %w", err) + } + +- articleCount, err := countMarkdownFiles(filepath.Join(topicPath, "wiki", "concepts")) ++ articleCount, sourceCount, err := topicCounts(topicPath, mode) + if err != nil { +- return models.TopicInfo{}, fmt.Errorf("count wiki articles: %w", err) +- } +- +- sourceCount, err := countVisibleFiles(filepath.Join(topicPath, "raw")) +- if err != nil { +- return models.TopicInfo{}, fmt.Errorf("count raw sources: %w", err) ++ return models.TopicInfo{}, fmt.Errorf("count topic files: %w", err) + } + + lastLogEntry, err := readLastLogEntry(filepath.Join(topicPath, "log.md")) +@@ -743,6 +813,7 @@ func infoAtPath(topicPath, slug string) (models.TopicInfo, error) { + Slug: slug, + Title: title, + Domain: domain, ++ Mode: mode, + RootPath: topicPath, + ArticleCount: articleCount, + SourceCount: sourceCount, +@@ -750,21 +821,25 @@ func infoAtPath(topicPath, slug string) (models.TopicInfo, error) { + }, nil + } + +-func readTopicMetadata(claudePath, slug string) (string, string, error) { ++func readTopicMetadata(claudePath, slug string) (string, string, models.TopicMode, error) { + yamlMetadata, err := readTopicYAMLMetadata(filepath.Join(claudePath, topicMetadataFileName)) + if err != nil { +- return "", "", err ++ return "", "", "", err + } + + claudeTitle, claudeDomain, err := readClaudeMetadata(filepath.Join(claudePath, topicMarkerFile), slug) + if err != nil { +- return "", "", err ++ return "", "", "", err + } + + title := firstNonEmpty(yamlMetadata.Title, claudeTitle, humanizeSlug(slug)) + domain := firstNonEmpty(yamlMetadata.Domain, claudeDomain, slug) ++ mode, err := normalizeTopicMode(models.TopicMode(yamlMetadata.Mode)) ++ if err != nil { ++ return "", "", "", err ++ } + +- return title, domain, nil ++ return title, domain, mode, nil + } + + func readTopicYAMLMetadata(metadataPath string) (topicMetadataFile, error) { +@@ -784,9 +859,52 @@ func readTopicYAMLMetadata(metadataPath string) (topicMetadataFile, error) { + metadata.Slug = strings.TrimSpace(metadata.Slug) + metadata.Title = strings.TrimSpace(metadata.Title) + metadata.Domain = strings.TrimSpace(metadata.Domain) ++ metadata.Mode = strings.TrimSpace(metadata.Mode) + return metadata, nil + } + ++func normalizeTopicMode(mode models.TopicMode) (models.TopicMode, error) { ++ switch strings.TrimSpace(string(mode)) { ++ case "", string(models.TopicModeWiki): ++ return models.TopicModeWiki, nil ++ case string(models.TopicModeOKF): ++ return models.TopicModeOKF, nil ++ default: ++ return "", fmt.Errorf("topic mode must be wiki or okf: %q", mode) ++ } ++} ++ ++func topicCounts(topicPath string, mode models.TopicMode) (int, int, error) { ++ if mode == models.TopicModeOKF { ++ concepts, err := countOKFConceptFiles(topicPath) ++ return concepts, 0, err ++ } ++ articleCount, err := countMarkdownFiles(filepath.Join(topicPath, "wiki", "concepts")) ++ if err != nil { ++ return 0, 0, fmt.Errorf("count wiki articles: %w", err) ++ } ++ sourceCount, err := countVisibleFiles(filepath.Join(topicPath, "raw")) ++ if err != nil { ++ return 0, 0, fmt.Errorf("count raw sources: %w", err) ++ } ++ return articleCount, sourceCount, nil ++} ++ ++func countOKFConceptFiles(root string) (int, error) { ++ return countFiles(root, func(entry fs.DirEntry) bool { ++ name := entry.Name() ++ if !strings.HasSuffix(strings.ToLower(name), ".md") || strings.HasPrefix(name, ".") { ++ return false ++ } ++ switch strings.ToLower(name) { ++ case "index.md", "log.md", "claude.md", "agents.md", "readme.md", "license.md", "notice.md", "attribution.md": ++ return false ++ default: ++ return true ++ } ++ }) ++} ++ + func readClaudeMetadata(claudePath, slug string) (string, string, error) { + content, err := os.ReadFile(claudePath) + if err != nil { +@@ -827,7 +945,7 @@ func readLastLogEntry(logPath string) (string, error) { + last := "" + for line := range strings.SplitSeq(string(content), "\n") { + trimmed := strings.TrimSpace(line) +- if strings.HasPrefix(trimmed, "## [") { ++ if strings.HasPrefix(trimmed, "## [") || isOKFDateHeading(trimmed) { + last = trimmed + } + } +@@ -835,6 +953,18 @@ func readLastLogEntry(logPath string) (string, error) { + return last, nil + } + ++func isOKFDateHeading(line string) bool { ++ if !strings.HasPrefix(line, "## ") { ++ return false ++ } ++ value := strings.TrimSpace(strings.TrimPrefix(line, "## ")) ++ if len(value) != len(frontmatter.DateLayout) { ++ return false ++ } ++ _, err := time.Parse(frontmatter.DateLayout, value) ++ return err == nil ++} ++ + func countMarkdownFiles(root string) (int, error) { + return countFiles(root, func(entry fs.DirEntry) bool { + name := entry.Name() +diff --git a/internal/topic/topic_test.go b/internal/topic/topic_test.go +index 2730efa..46dbcee 100644 +--- a/internal/topic/topic_test.go ++++ b/internal/topic/topic_test.go +@@ -8,6 +8,7 @@ import ( + "time" + + "github.com/compozy/kb/internal/frontmatter" ++ "github.com/compozy/kb/internal/models" + ) + + func TestNewCreatesTopicSkeletonAndTemplates(t *testing.T) { +@@ -35,6 +36,9 @@ func TestNewCreatesTopicSkeletonAndTemplates(t *testing.T) { + if info.Domain != "rust" { + t.Fatalf("domain = %q, want rust", info.Domain) + } ++ if info.Mode != models.TopicModeWiki { ++ t.Fatalf("mode = %q, want wiki", info.Mode) ++ } + if info.ArticleCount != 0 { + t.Fatalf("article count = %d, want 0", info.ArticleCount) + } +@@ -156,6 +160,7 @@ func TestNewCreatesCategorizedTopic(t *testing.T) { + "slug: asimov-academy", + "title: Asimov Academy", + "domain: youtube-channel", ++ "mode: wiki", + "category: yt-channels", + "path: yt-channels/asimov-academy", + "qmd_collection: asimov-academy", +@@ -207,6 +212,7 @@ func TestNewCreatesClaudeAndAgentsSymlink(t *testing.T) { + "slug: distributed-systems", + "title: Distributed Systems", + "domain: distributed", ++ "mode: wiki", + } { + if !strings.Contains(metadataContent, fragment) { + t.Fatalf("topic.yaml missing %q:\n%s", fragment, metadataContent) +@@ -227,6 +233,83 @@ func TestNewCreatesClaudeAndAgentsSymlink(t *testing.T) { + } + } + ++func TestNewWithModeCreatesOKFTopicSkeleton(t *testing.T) { ++ t.Parallel() ++ ++ vaultPath := t.TempDir() ++ ++ info, err := newWithDateWithMode( ++ vaultPath, ++ "ops-catalog", ++ "Operations Catalog", ++ "operations", ++ models.TopicModeOKF, ++ time.Date(2026, 6, 27, 12, 0, 0, 0, time.UTC), ++ ) ++ if err != nil { ++ t.Fatalf("newWithDateWithMode returned error: %v", err) ++ } ++ if info.Mode != models.TopicModeOKF { ++ t.Fatalf("mode = %q, want okf", info.Mode) ++ } ++ if info.ArticleCount != 0 || info.SourceCount != 0 { ++ t.Fatalf("counts = articles %d sources %d, want 0/0", info.ArticleCount, info.SourceCount) ++ } ++ if info.LastLogEntry != "## 2026-06-27" { ++ t.Fatalf("last log entry = %q, want OKF date heading", info.LastLogEntry) ++ } ++ ++ topicPath := filepath.Join(vaultPath, "ops-catalog") ++ for _, relativePath := range []string{"CLAUDE.md", "AGENTS.md", "index.md", "log.md", "topic.yaml"} { ++ assertFileExists(t, filepath.Join(topicPath, filepath.FromSlash(relativePath))) ++ } ++ for _, relativePath := range []string{"raw", "wiki", "outputs", "bases"} { ++ if _, err := os.Stat(filepath.Join(topicPath, relativePath)); !os.IsNotExist(err) { ++ t.Fatalf("OKF scaffold should not create %s", relativePath) ++ } ++ } ++ ++ metadataContent := readFile(t, filepath.Join(topicPath, "topic.yaml")) ++ for _, fragment := range []string{ ++ "slug: ops-catalog", ++ "title: Operations Catalog", ++ "domain: operations", ++ "mode: okf", ++ } { ++ if !strings.Contains(metadataContent, fragment) { ++ t.Fatalf("topic.yaml missing %q:\n%s", fragment, metadataContent) ++ } ++ } ++ ++ indexValues, indexBody := parseFrontmatterFile(t, filepath.Join(topicPath, "index.md")) ++ if got := indexValues["okf_version"]; got != "0.1" { ++ t.Fatalf("index okf_version = %#v, want 0.1", got) ++ } ++ if !strings.Contains(indexBody, "# OKF Bundle Index") { ++ t.Fatalf("index body missing heading:\n%s", indexBody) ++ } ++ logContent := readFile(t, filepath.Join(topicPath, "log.md")) ++ if !strings.Contains(logContent, "## 2026-06-27") || strings.Contains(logContent, "## [2026-06-27]") { ++ t.Fatalf("log.md does not use OKF date heading:\n%s", logContent) ++ } ++} ++ ++func TestReadTopicMetadataDefaultsMissingModeToWiki(t *testing.T) { ++ t.Parallel() ++ ++ topicPath := t.TempDir() ++ writeFile(t, filepath.Join(topicPath, "topic.yaml"), "slug: old-topic\ntitle: Old Topic\ndomain: legacy\n") ++ writeFile(t, filepath.Join(topicPath, "CLAUDE.md"), "# Old Topic\n\n**Domain:** `legacy`\n") ++ ++ info, err := infoAtPath(topicPath, "old-topic") ++ if err != nil { ++ t.Fatalf("infoAtPath returned error: %v", err) ++ } ++ if info.Mode != models.TopicModeWiki { ++ t.Fatalf("mode = %q, want wiki", info.Mode) ++ } ++} ++ + func TestNewAppendsScaffoldEntryToLog(t *testing.T) { + t.Parallel() + +diff --git a/internal/vault/pathutils.go b/internal/vault/pathutils.go +index d356e3c..002b4df 100644 +--- a/internal/vault/pathutils.go ++++ b/internal/vault/pathutils.go +@@ -4,6 +4,7 @@ package vault + import ( + "fmt" + "path" ++ "path/filepath" + "regexp" + "strings" + +@@ -251,6 +252,73 @@ func ToTopicWikiLink(topicSlug, documentPath, label string) string { + return fmt.Sprintf("[[%s]]", target) + } + ++// LinkFormatter renders a link from one topic-relative document to another. ++type LinkFormatter interface { ++ Link(fromDir, targetPath, label string) string ++} ++ ++// WikiLinkFormatter renders Obsidian topic-scoped wikilinks. ++type WikiLinkFormatter struct { ++ Slug string ++} ++ ++// Link renders a wikilink exactly like ToTopicWikiLink. ++func (f WikiLinkFormatter) Link(_, targetPath, label string) string { ++ return ToTopicWikiLink(f.Slug, targetPath, label) ++} ++ ++// OKFLinkFormatter renders relative Markdown links for OKF bundles. ++type OKFLinkFormatter struct{} ++ ++// Link renders a GitHub-safe relative Markdown link. ++func (OKFLinkFormatter) Link(fromDir, targetPath, label string) string { ++ cleanTarget := strings.TrimPrefix(ToPosixPath(strings.TrimSpace(targetPath)), "/") ++ displayLabel := strings.TrimSpace(label) ++ if displayLabel == "" { ++ displayLabel = HumanizeSlug(SlugifySegment(strings.TrimSuffix(path.Base(cleanTarget), ".md"))) ++ } ++ ++ fragment := "" ++ if hashIndex := strings.Index(cleanTarget, "#"); hashIndex >= 0 { ++ fragment = cleanTarget[hashIndex:] ++ cleanTarget = cleanTarget[:hashIndex] ++ } ++ ++ cleanFromDir := strings.TrimPrefix(ToPosixPath(strings.TrimSpace(fromDir)), "/") ++ if cleanFromDir == "" { ++ cleanFromDir = "." ++ } ++ ++ relativePath, err := filepath.Rel(filepath.FromSlash(cleanFromDir), filepath.FromSlash(cleanTarget)) ++ if err != nil { ++ relativePath = cleanTarget ++ } ++ relativePath = ToPosixPath(relativePath) ++ if relativePath == "." || relativePath == "" { ++ relativePath = path.Base(cleanTarget) ++ } ++ relativePath = strings.TrimPrefix(relativePath, "./") ++ relativePath = markdownLinkTarget(relativePath + fragment) ++ return fmt.Sprintf("[%s](%s)", displayLabel, relativePath) ++} ++ ++// LinkFormatterFor selects the formatter from the topic mode. ++func LinkFormatterFor(topic models.TopicMetadata) LinkFormatter { ++ if topic.Mode == models.TopicModeOKF { ++ return OKFLinkFormatter{} ++ } ++ return WikiLinkFormatter{Slug: topic.Slug} ++} ++ ++func markdownLinkTarget(target string) string { ++ replacer := strings.NewReplacer( ++ " ", "%20", ++ "(", "%28", ++ ")", "%29", ++ ) ++ return replacer.Replace(target) ++} ++ + func stripWikiConceptFilePrefix(articleTitle string) string { + normalizedTitle := strings.TrimSpace(articleTitle) + return strings.TrimSpace(strings.TrimPrefix(normalizedTitle, legacyWikiConceptPrefix)) +diff --git a/internal/vault/pathutils_test.go b/internal/vault/pathutils_test.go +index 927c7d4..6655604 100644 +--- a/internal/vault/pathutils_test.go ++++ b/internal/vault/pathutils_test.go +@@ -196,6 +196,44 @@ func TestTopicWikiLinkHelpers(t *testing.T) { + } + } + ++func TestLinkFormatters(t *testing.T) { ++ t.Parallel() ++ ++ wiki := vault.WikiLinkFormatter{Slug: "topic-slug"} ++ if got := wiki.Link("ignored", "wiki/codebase/concepts/Codebase Overview.md", "Overview"); got != vault.ToTopicWikiLink("topic-slug", "wiki/codebase/concepts/Codebase Overview.md", "Overview") { ++ t.Fatalf("wiki formatter = %q", got) ++ } ++ ++ okf := vault.OKFLinkFormatter{} ++ testCases := []struct { ++ name string ++ from string ++ target string ++ label string ++ want string ++ }{ ++ {name: "same directory", from: "", target: "orders.md", label: "Orders", want: "[Orders](orders.md)"}, ++ {name: "child", from: "", target: "tables/orders.md", label: "Orders", want: "[Orders](tables/orders.md)"}, ++ {name: "parent", from: "references", target: "tables/customer orders.md#schema", label: "Customers", want: "[Customers](../tables/customer%20orders.md#schema)"}, ++ {name: "sibling", from: "tables", target: "references/joins.md", label: "Joins", want: "[Joins](../references/joins.md)"}, ++ } ++ for _, testCase := range testCases { ++ t.Run(testCase.name, func(t *testing.T) { ++ t.Parallel() ++ if got := okf.Link(testCase.from, testCase.target, testCase.label); got != testCase.want { ++ t.Fatalf("OKF link = %q, want %q", got, testCase.want) ++ } ++ }) ++ } ++ ++ if got := vault.LinkFormatterFor(models.TopicMetadata{Slug: "demo", Mode: models.TopicModeWiki}).Link("", "x.md", "X"); got != "[[demo/x|X]]" { ++ t.Fatalf("wiki LinkFormatterFor = %q", got) ++ } ++ if got := vault.LinkFormatterFor(models.TopicMetadata{Mode: models.TopicModeOKF}).Link("", "x.md", "X"); got != "[X](x.md)" { ++ t.Fatalf("OKF LinkFormatterFor = %q", got) ++ } ++} ++ + func TestPathHelpersHandleEmptyInputs(t *testing.T) { + t.Parallel() + +diff --git a/internal/vault/render.go b/internal/vault/render.go +index 374133e..b646514 100644 +--- a/internal/vault/render.go ++++ b/internal/vault/render.go +@@ -184,7 +184,11 @@ func renderMarkdownDocument(document models.RenderedDocument) string { + } + + func toSourceWikiLink(topic models.TopicMetadata, relativePath, label string) string { +- return ToTopicWikiLink(topic.Slug, relativePath, label) ++ return linkFor(topic, "", relativePath, label) ++} ++ ++func linkFor(topic models.TopicMetadata, fromDir, relativePath, label string) string { ++ return LinkFormatterFor(topic).Link(fromDir, relativePath, label) + } + + func createDocumentLookup(graph models.GraphSnapshot) map[string]string { +@@ -217,7 +221,7 @@ func linkForNode( + fallbackLabel string, + ) string { + if documentPath, exists := documentLookup[nodeID]; exists { +- return toSourceWikiLink(topic, documentPath, fallbackLabel) ++ return linkFor(topic, "", documentPath, fallbackLabel) + } + + if externalNode, exists := externalNodes[nodeID]; exists { +diff --git a/internal/vault/render_test.go b/internal/vault/render_test.go +index fb6ba13..10cd72d 100644 +--- a/internal/vault/render_test.go ++++ b/internal/vault/render_test.go +@@ -263,6 +263,32 @@ func TestRenderDocumentsUseTopicWikiLinkSyntax(t *testing.T) { + } + } + ++func TestRenderDocumentsUseOKFMarkdownLinkSyntax(t *testing.T) { ++ t.Parallel() ++ ++ graph := testGraphFixture() ++ metricResult := metrics.ComputeMetrics(graph) ++ topic := testTopicFixture() ++ topic.Mode = models.TopicModeOKF ++ documents := vault.RenderDocuments(graph, metricResult, topic) ++ ++ rawDocument := findDocument(t, documents, "raw/codebase/files/commands/run.ts.md") ++ if strings.Contains(rawDocument.Body, "[[") { ++ t.Fatalf("expected OKF raw document to avoid wikilinks, got:\n%s", rawDocument.Body) ++ } ++ if !strings.Contains(rawDocument.Body, "[main (function)](raw/codebase/symbols/main--commands-run-ts-l1.md)") { ++ t.Fatalf("expected OKF raw document to contain relative markdown link, got:\n%s", rawDocument.Body) ++ } ++ ++ dashboard := findDocument(t, documents, vault.GetWikiIndexPath(vault.CodebaseDashboardTitle)) ++ if strings.Contains(dashboard.Body, "[[") { ++ t.Fatalf("expected OKF dashboard to avoid wikilinks, got:\n%s", dashboard.Body) ++ } ++ if !strings.Contains(dashboard.Body, "[Codebase Overview](wiki/codebase/concepts/Codebase%20Overview.md)") { ++ t.Fatalf("expected OKF dashboard to link with escaped relative markdown path, got:\n%s", dashboard.Body) ++ } ++} ++ + func renderFixtureDocuments(t *testing.T) []models.RenderedDocument { + t.Helper() + +diff --git a/internal/vault/render_wiki.go b/internal/vault/render_wiki.go +index 2c3978a..af371b9 100644 +--- a/internal/vault/render_wiki.go ++++ b/internal/vault/render_wiki.go +@@ -105,7 +105,7 @@ func renderDashboard( + for _, article := range articles { + lines = append(lines, fmt.Sprintf( + "- %s - %s", +- ToTopicWikiLink(topic.Slug, GetWikiConceptPath(article.Title), article.Title), ++ linkFor(topic, "", GetWikiConceptPath(article.Title), article.Title), + article.Summary, + )) + } +@@ -113,8 +113,8 @@ func renderDashboard( + lines = append(lines, + "", + "## Navigation", +- "- "+ToTopicWikiLink(topic.Slug, GetWikiIndexPath(CodebaseConceptIndexTitle), CodebaseConceptIndexTitle), +- "- "+ToTopicWikiLink(topic.Slug, GetWikiIndexPath(CodebaseSourceIndexTitle), CodebaseSourceIndexTitle), ++ "- "+linkFor(topic, "", GetWikiIndexPath(CodebaseConceptIndexTitle), CodebaseConceptIndexTitle), ++ "- "+linkFor(topic, "", GetWikiIndexPath(CodebaseSourceIndexTitle), CodebaseSourceIndexTitle), + ) + + return models.RenderedDocument{ +@@ -144,7 +144,7 @@ func renderConceptIndex( + for _, article := range orderedArticles { + rows = append(rows, fmt.Sprintf( + "| %s | %s |", +- ToTopicWikiLink(topic.Slug, GetWikiConceptPath(article.Title), article.Title), ++ linkFor(topic, "", GetWikiConceptPath(article.Title), article.Title), + article.Summary, + )) + } +@@ -191,7 +191,7 @@ func renderSourceIndex(topic models.TopicMetadata, articles []starterWikiArticle + + links := make([]string, 0, len(titles)) + for _, title := range titles { +- links = append(links, ToTopicWikiLink(topic.Slug, GetWikiConceptPath(title), title)) ++ links = append(links, linkFor(topic, "", GetWikiConceptPath(title), title)) + } + + rows = append(rows, fmt.Sprintf( +@@ -311,9 +311,9 @@ func createCodebaseOverviewArticle( + "", + fmt.Sprintf( + "Start with %s for coupling, %s for function-level complexity, and %s for likely cleanup candidates.", +- ToTopicWikiLink(topic.Slug, GetWikiConceptPath("Module Health"), "Module Health"), +- ToTopicWikiLink(topic.Slug, GetWikiConceptPath("Complexity Hotspots"), "Complexity Hotspots"), +- ToTopicWikiLink(topic.Slug, GetWikiConceptPath("Dead Code Report"), "Dead Code Report"), ++ linkFor(topic, "", GetWikiConceptPath("Module Health"), "Module Health"), ++ linkFor(topic, "", GetWikiConceptPath("Complexity Hotspots"), "Complexity Hotspots"), ++ linkFor(topic, "", GetWikiConceptPath("Dead Code Report"), "Dead Code Report"), + ), + "", + "## Language Coverage", +@@ -392,8 +392,8 @@ func createDirectoryMapArticle( + "", + fmt.Sprintf( + "Cross-check unstable directories against %s and hotspots against %s.", +- ToTopicWikiLink(topic.Slug, GetWikiConceptPath("Module Health"), "Module Health"), +- ToTopicWikiLink(topic.Slug, GetWikiConceptPath("Dependency Hotspots"), "Dependency Hotspots"), ++ linkFor(topic, "", GetWikiConceptPath("Module Health"), "Module Health"), ++ linkFor(topic, "", GetWikiConceptPath("Dependency Hotspots"), "Dependency Hotspots"), + ), + "", + "## Sources and Further Reading", +@@ -458,8 +458,8 @@ func createSymbolTaxonomyArticle( + "", + fmt.Sprintf( + "Use %s to find bottlenecks and %s to locate function-level smells.", +- ToTopicWikiLink(topic.Slug, GetWikiConceptPath("High-Impact Symbols"), "High-Impact Symbols"), +- ToTopicWikiLink(topic.Slug, GetWikiConceptPath("Code Smells"), "Code Smells"), ++ linkFor(topic, "", GetWikiConceptPath("High-Impact Symbols"), "High-Impact Symbols"), ++ linkFor(topic, "", GetWikiConceptPath("Code Smells"), "Code Smells"), + ), + "", + "## Sources and Further Reading", +@@ -535,7 +535,7 @@ func createDependencyHotspotsArticle( + "", + fmt.Sprintf( + "A high relation count usually indicates a coordination layer, a shared utility, or an entry point. Cross-check these files against %s to distinguish stable modules from unstable ones.", +- ToTopicWikiLink(topic.Slug, GetWikiConceptPath("Module Health"), "Module Health"), ++ linkFor(topic, "", GetWikiConceptPath("Module Health"), "Module Health"), + ), + "", + "## Sources and Further Reading", +@@ -613,7 +613,7 @@ func createComplexityHotspotsArticle( + "", + fmt.Sprintf( + "Compare these hotspots against %s to distinguish locally complex functions from high-blast-radius functions.", +- ToTopicWikiLink(topic.Slug, GetWikiConceptPath("High-Impact Symbols"), "High-Impact Symbols"), ++ linkFor(topic, "", GetWikiConceptPath("High-Impact Symbols"), "High-Impact Symbols"), + ), + }, "\n"), + } +diff --git a/internal/vault/writer.go b/internal/vault/writer.go +index 571c946..c85f5f3 100644 +--- a/internal/vault/writer.go ++++ b/internal/vault/writer.go +@@ -446,9 +446,9 @@ func buildTopicClaudeManagedBlock( + "", + "### Generated codebase navigation", + "", +- "- " + ToTopicWikiLink(topic.Slug, GetWikiIndexPath(CodebaseDashboardTitle), CodebaseDashboardTitle), +- "- " + ToTopicWikiLink(topic.Slug, GetWikiIndexPath(CodebaseConceptIndexTitle), CodebaseConceptIndexTitle), +- "- " + ToTopicWikiLink(topic.Slug, GetWikiIndexPath(CodebaseSourceIndexTitle), CodebaseSourceIndexTitle), ++ "- " + linkFor(topic, "", GetWikiIndexPath(CodebaseDashboardTitle), CodebaseDashboardTitle), ++ "- " + linkFor(topic, "", GetWikiIndexPath(CodebaseConceptIndexTitle), CodebaseConceptIndexTitle), ++ "- " + linkFor(topic, "", GetWikiIndexPath(CodebaseSourceIndexTitle), CodebaseSourceIndexTitle), + "", + "### Generated codebase articles", + "", +@@ -457,7 +457,7 @@ func buildTopicClaudeManagedBlock( + lines = append(lines, "_None generated._") + } else { + for _, articleTitle := range conceptDocuments { +- lines = append(lines, "- "+ToTopicWikiLink(topic.Slug, GetWikiConceptPath(articleTitle), articleTitle)) ++ lines = append(lines, "- "+linkFor(topic, "", GetWikiConceptPath(articleTitle), articleTitle)) + } + } + lines = append(lines, "", codebaseManagedBlockEnd) +@@ -472,9 +472,9 @@ func buildTopicIndexBridgeManagedBlock(topic models.TopicMetadata) string { + "", + "Latest `kb ingest codebase` output is published under `wiki/codebase/` so the top-level topic indexes can stay manually curated.", + "", +- "- " + ToTopicWikiLink(topic.Slug, GetWikiIndexPath(CodebaseDashboardTitle), CodebaseDashboardTitle), +- "- " + ToTopicWikiLink(topic.Slug, GetWikiIndexPath(CodebaseConceptIndexTitle), CodebaseConceptIndexTitle), +- "- " + ToTopicWikiLink(topic.Slug, GetWikiIndexPath(CodebaseSourceIndexTitle), CodebaseSourceIndexTitle), ++ "- " + linkFor(topic, "", GetWikiIndexPath(CodebaseDashboardTitle), CodebaseDashboardTitle), ++ "- " + linkFor(topic, "", GetWikiIndexPath(CodebaseConceptIndexTitle), CodebaseConceptIndexTitle), ++ "- " + linkFor(topic, "", GetWikiIndexPath(CodebaseSourceIndexTitle), CodebaseSourceIndexTitle), + "", + codebaseIndexBlockEnd, + } +diff --git a/internal/topic/assets/okf-claude-template.md b/internal/topic/assets/okf-claude-template.md +new file mode 100644 +index 0000000..3a18157 +--- /dev/null ++++ b/internal/topic/assets/okf-claude-template.md +@@ -0,0 +1,24 @@ ++# TOPIC_TITLE ++ ++**Topic scope:** portable Open Knowledge Format bundle for `TOPIC_DOMAIN`. ++ ++**Domain:** `TOPIC_DOMAIN` ++ ++This topic is an OKF bundle, not an LLM-wiki research lab. Concepts live as typed markdown files at the bundle root for the MVP, with standard markdown links and YAML frontmatter. ++ ++## Bundle contract ++ ++- Every concept markdown file has `type`, `title`, `description`, and `timestamp` frontmatter. ++- `index.md` is generated by `kb` and groups concepts by type. ++- `log.md` records bundle updates using ISO date headings. ++- Links should be relative markdown links so the bundle renders in GitHub and other markdown tools. ++ ++## Operations ++ ++- Promote a wiki concept into this bundle with `kb promote <wiki-doc> --to TOPIC_SLUG --type <Type>`. ++- Check conformance with `kb okf check TOPIC_SLUG`. ++ ++## Catalog gaps ++ ++- Gap 1 ++- Gap 2 +diff --git a/internal/okf/okf.go b/internal/okf/okf.go +new file mode 100644 +index 0000000..55c4122 +--- /dev/null ++++ b/internal/okf/okf.go +@@ -0,0 +1,757 @@ ++// Package okf implements Open Knowledge Format promotion and conformance checks. ++package okf ++ ++import ( ++ "context" ++ "errors" ++ "fmt" ++ "io/fs" ++ "os" ++ "path" ++ "path/filepath" ++ "regexp" ++ "sort" ++ "strings" ++ "time" ++ ++ "github.com/compozy/kb/internal/frontmatter" ++ "github.com/compozy/kb/internal/models" ++ "github.com/compozy/kb/internal/vault" ++) ++ ++const ( ++ OKFVersion = "0.1" ++) ++ ++var ( ++ wikilinkPattern = regexp.MustCompile(`\[\[([^\[\]]+)\]\]`) ++ nonSentenceSpace = regexp.MustCompile(`\s+`) ++ markdownLinkPattern = regexp.MustCompile(`\[[^\]]+\]\([^)]+\)`) ++ markdownSyntaxRunes = strings.NewReplacer("#", "", "*", "", "`", "", "_", "", ">", "", "|", " ") ++ errTargetNotOKFTopic = errors.New("target topic must use mode okf") ++) ++ ++// PromoteInput carries the data needed to promote one wiki document into an OKF bundle. ++type PromoteInput struct { ++ SourceDocPath string ++ VaultPath string ++ TargetTopic models.TopicInfo ++ Type string ++ Description string ++ Types []string ++ Clock func() time.Time ++} ++ ++// ConceptResult reports the concept written by Promote. ++type ConceptResult struct { ++ WrittenPath string `json:"writtenPath"` ++ Type string `json:"type"` ++ LinksRewritten int `json:"linksRewritten"` ++ UnresolvedLinks []string `json:"unresolvedLinks"` ++ Warnings []string `json:"warnings,omitempty"` ++} ++ ++// CheckOptions configures OKF conformance checking. ++type CheckOptions struct { ++ Types []string ++ Strict bool ++} ++ ++// Promote performs a mechanical, non-destructive wiki-to-OKF conversion. ++func Promote(ctx context.Context, input PromoteInput) (ConceptResult, error) { ++ if ctx == nil { ++ ctx = context.Background() ++ } ++ if err := ctx.Err(); err != nil { ++ return ConceptResult{}, fmt.Errorf("promote: %w", err) ++ } ++ if input.TargetTopic.Mode != models.TopicModeOKF { ++ return ConceptResult{}, fmt.Errorf("promote: %w: %s", errTargetNotOKFTopic, input.TargetTopic.Slug) ++ } ++ conceptType := strings.TrimSpace(input.Type) ++ if conceptType == "" { ++ return ConceptResult{}, fmt.Errorf("promote: --type is required") ++ } ++ if strings.TrimSpace(input.TargetTopic.RootPath) == "" { ++ return ConceptResult{}, fmt.Errorf("promote: target topic root path is required") ++ } ++ ++ sourcePath, sourceTopicRoot, sourceTopicSlug, sourceRelativePath, err := resolveSourceDocument(input) ++ if err != nil { ++ return ConceptResult{}, fmt.Errorf("promote: %w", err) ++ } ++ _ = sourceTopicRoot ++ ++ sourceBytes, err := os.ReadFile(sourcePath) ++ if err != nil { ++ return ConceptResult{}, fmt.Errorf("promote: read source %q: %w", sourcePath, err) ++ } ++ sourceValues, body, err := frontmatter.Parse(string(sourceBytes)) ++ if err != nil { ++ return ConceptResult{}, fmt.Errorf("promote: parse source frontmatter: %w", err) ++ } ++ ++ key := conceptKey(sourceRelativePath) ++ writtenPath, absoluteTargetPath, err := allocateConceptPath(input.TargetTopic.RootPath, key) ++ if err != nil { ++ return ConceptResult{}, fmt.Errorf("promote: allocate concept path: %w", err) ++ } ++ ++ warnings := typeWarnings(conceptType, input.Types) ++ description, descriptionWarnings := resolveDescription(input.Description, body) ++ warnings = append(warnings, descriptionWarnings...) ++ title := strings.TrimSpace(frontmatter.GetString(sourceValues, "title")) ++ if title == "" { ++ title = vault.HumanizeSlug(key) ++ } ++ clock := input.Clock ++ if clock == nil { ++ clock = func() time.Time { return time.Now().UTC() } ++ } ++ now := clock().UTC() ++ ++ transformedBody, rewriteCount, unresolvedLinks := transformWikiLinks(body, sourceTopicSlug, input.TargetTopic.RootPath, path.Dir(writtenPath), writtenPath) ++ values := map[string]any{ ++ "description": description, ++ "timestamp": now.Format(time.RFC3339), ++ "title": title, ++ "type": conceptType, ++ } ++ if tags := frontmatter.GetStringSlice(sourceValues, "tags"); len(tags) > 0 { ++ values["tags"] = tags ++ } ++ ++ document, err := frontmatter.Generate(values, transformedBody) ++ if err != nil { ++ return ConceptResult{}, fmt.Errorf("promote: generate concept frontmatter: %w", err) ++ } ++ if err := os.WriteFile(absoluteTargetPath, []byte(document), 0o644); err != nil { ++ return ConceptResult{}, fmt.Errorf("promote: write concept %q: %w", absoluteTargetPath, err) ++ } ++ ++ if err := RegenerateIndex(input.TargetTopic.RootPath); err != nil { ++ return ConceptResult{}, fmt.Errorf("promote: regenerate index: %w", err) ++ } ++ if err := InsertLogEntry(input.TargetTopic.RootPath, now, title, writtenPath, sourceRelativePath); err != nil { ++ return ConceptResult{}, fmt.Errorf("promote: update log: %w", err) ++ } ++ ++ return ConceptResult{ ++ WrittenPath: writtenPath, ++ Type: conceptType, ++ LinksRewritten: rewriteCount, ++ UnresolvedLinks: unresolvedLinks, ++ Warnings: warnings, ++ }, nil ++} ++ ++// Check validates an OKF bundle and returns diagnostics. ++func Check(ctx context.Context, bundlePath string, options CheckOptions) ([]models.LintIssue, error) { ++ if ctx == nil { ++ ctx = context.Background() ++ } ++ cleanBundlePath := strings.TrimSpace(bundlePath) ++ if cleanBundlePath == "" { ++ return nil, fmt.Errorf("okf check: bundle path is required") ++ } ++ info, err := os.Stat(cleanBundlePath) ++ if err != nil { ++ return nil, fmt.Errorf("okf check: stat bundle path %q: %w", cleanBundlePath, err) ++ } ++ if !info.IsDir() { ++ return nil, fmt.Errorf("okf check: bundle path must be a directory: %s", cleanBundlePath) ++ } ++ ++ typeSet := normalizeTypeSet(options.Types) ++ issues := make([]models.LintIssue, 0) ++ err = filepath.WalkDir(cleanBundlePath, func(currentPath string, entry fs.DirEntry, walkErr error) error { ++ if walkErr != nil { ++ return walkErr ++ } ++ if err := ctx.Err(); err != nil { ++ return err ++ } ++ if currentPath == cleanBundlePath { ++ return nil ++ } ++ if entry.Type()&fs.ModeSymlink != 0 { ++ return nil ++ } ++ if entry.IsDir() { ++ if strings.HasPrefix(entry.Name(), ".") { ++ return filepath.SkipDir ++ } ++ return nil ++ } ++ if !strings.EqualFold(filepath.Ext(entry.Name()), ".md") { ++ return nil ++ } ++ ++ relativePath, err := filepath.Rel(cleanBundlePath, currentPath) ++ if err != nil { ++ return fmt.Errorf("derive relative path for %q: %w", currentPath, err) ++ } ++ relativePath = vault.ToPosixPath(relativePath) ++ switch { ++ case isIndexFile(relativePath): ++ issues = append(issues, checkIndexFile(cleanBundlePath, currentPath, relativePath, options.Strict)...) ++ case isLogFile(relativePath): ++ issues = append(issues, checkLogFile(currentPath, relativePath, options.Strict)...) ++ case isExcludedMarkdown(entry.Name()): ++ return nil ++ default: ++ issues = append(issues, checkConceptFile(currentPath, relativePath, typeSet, options.Strict)...) ++ } ++ return nil ++ }) ++ if err != nil { ++ return nil, fmt.Errorf("okf check: %w", err) ++ } ++ ++ sortIssues(issues) ++ return issues, nil ++} ++ ++// Columns returns the stable output column order for OKF diagnostics. ++func Columns() []string { ++ return []string{"severity", "kind", "filePath", "target", "message"} ++} ++ ++// Rows converts issues into formatter rows. ++func Rows(issues []models.LintIssue) []map[string]any { ++ rows := make([]map[string]any, 0, len(issues)) ++ for _, issue := range issues { ++ rows = append(rows, map[string]any{ ++ "severity": issue.Severity, ++ "kind": issue.Kind, ++ "filePath": issue.FilePath, ++ "target": issue.Target, ++ "message": issue.Message, ++ }) ++ } ++ return rows ++} ++ ++// HasErrors reports whether any diagnostic should fail the command. ++func HasErrors(issues []models.LintIssue) bool { ++ for _, issue := range issues { ++ if issue.Severity == models.SeverityError { ++ return true ++ } ++ } ++ return false ++} ++ ++// RegenerateIndex rewrites the root OKF index from concept frontmatter. ++func RegenerateIndex(bundlePath string) error { ++ concepts, okfVersion, err := loadConcepts(bundlePath) ++ if err != nil { ++ return err ++ } ++ sort.Slice(concepts, func(i, j int) bool { ++ if concepts[i].Type != concepts[j].Type { ++ return concepts[i].Type < concepts[j].Type ++ } ++ if concepts[i].Title != concepts[j].Title { ++ return concepts[i].Title < concepts[j].Title ++ } ++ return concepts[i].RelativePath < concepts[j].RelativePath ++ }) ++ ++ var body strings.Builder ++ body.WriteString("# OKF Bundle Index\n") ++ currentType := "" ++ for _, concept := range concepts { ++ if concept.Type != currentType { ++ currentType = concept.Type ++ body.WriteString("\n## ") ++ body.WriteString(currentType) ++ body.WriteString("\n\n") ++ } ++ body.WriteString("* ") ++ body.WriteString(vault.OKFLinkFormatter{}.Link("", concept.RelativePath, concept.Title)) ++ if concept.Description != "" { ++ body.WriteString(" - ") ++ body.WriteString(concept.Description) ++ } ++ body.WriteString("\n") ++ } ++ ++ values := map[string]any{"okf_version": firstNonEmpty(okfVersion, OKFVersion)} ++ content, err := frontmatter.Generate(values, body.String()) ++ if err != nil { ++ return fmt.Errorf("generate index frontmatter: %w", err) ++ } ++ if err := os.WriteFile(filepath.Join(bundlePath, "index.md"), []byte(content), 0o644); err != nil { ++ return fmt.Errorf("write index.md: %w", err) ++ } ++ return nil ++} ++ ++// InsertLogEntry inserts a newest-first OKF log entry at the bundle root. ++func InsertLogEntry(bundlePath string, when time.Time, title, conceptPath, sourcePath string) error { ++ logPath := filepath.Join(bundlePath, "log.md") ++ content, err := os.ReadFile(logPath) ++ if errors.Is(err, os.ErrNotExist) { ++ content = []byte("# Directory Update Log\n") ++ } else if err != nil { ++ return fmt.Errorf("read log.md: %w", err) ++ } ++ entry := fmt.Sprintf( ++ "* **Creation**: Promoted %s from `%s`.", ++ vault.OKFLinkFormatter{}.Link("", conceptPath, strings.TrimSpace(title)), ++ sourcePath, ++ ) ++ next := insertLogEntry(string(content), when.UTC().Format(frontmatter.DateLayout), entry) ++ if err := os.WriteFile(logPath, []byte(next), 0o644); err != nil { ++ return fmt.Errorf("write log.md: %w", err) ++ } ++ return nil ++} ++ ++type conceptInfo struct { ++ RelativePath string ++ Type string ++ Title string ++ Description string ++} ++ ++func resolveSourceDocument(input PromoteInput) (absolutePath, topicRoot, topicSlug, topicRelativePath string, err error) { ++ candidates := make([]string, 0, 3) ++ source := strings.TrimSpace(input.SourceDocPath) ++ if source == "" { ++ return "", "", "", "", fmt.Errorf("source document path is required") ++ } ++ if filepath.IsAbs(source) { ++ candidates = append(candidates, source) ++ } else { ++ candidates = append(candidates, source) ++ if strings.TrimSpace(input.VaultPath) != "" { ++ candidates = append(candidates, filepath.Join(input.VaultPath, filepath.FromSlash(source))) ++ } ++ } ++ for _, candidate := range candidates { ++ info, statErr := os.Stat(candidate) ++ if statErr != nil { ++ continue ++ } ++ if info.IsDir() { ++ return "", "", "", "", fmt.Errorf("source document %q must be a file", candidate) ++ } ++ absolutePath, err := filepath.Abs(candidate) ++ if err != nil { ++ return "", "", "", "", fmt.Errorf("resolve source path %q: %w", candidate, err) ++ } ++ topicRoot, topicSlug = discoverTopicRoot(input.VaultPath, absolutePath) ++ if topicRoot != "" { ++ relative, relErr := filepath.Rel(topicRoot, absolutePath) ++ if relErr != nil { ++ return "", "", "", "", fmt.Errorf("derive source topic path: %w", relErr) ++ } ++ return absolutePath, topicRoot, topicSlug, vault.ToPosixPath(relative), nil ++ } ++ return absolutePath, "", "", vault.ToPosixPath(filepath.Base(absolutePath)), nil ++ } ++ return "", "", "", "", fmt.Errorf("source document not found: %s", source) ++} ++ ++func discoverTopicRoot(vaultPath, sourcePath string) (string, string) { ++ cleanVault := strings.TrimSpace(vaultPath) ++ current := filepath.Dir(sourcePath) ++ for { ++ if cleanVault != "" && !vault.IsPathInside(cleanVault, current) { ++ return "", "" ++ } ++ if isRegularFile(filepath.Join(current, "CLAUDE.md")) { ++ if cleanVault == "" { ++ return current, filepath.Base(current) ++ } ++ relative, err := filepath.Rel(cleanVault, current) ++ if err != nil { ++ return current, filepath.Base(current) ++ } ++ return current, vault.ToPosixPath(relative) ++ } ++ parent := filepath.Dir(current) ++ if parent == current { ++ break ++ } ++ current = parent ++ } ++ return "", "" ++} ++ ++func isRegularFile(filePath string) bool { ++ info, err := os.Stat(filePath) ++ return err == nil && !info.IsDir() ++} ++ ++func allocateConceptPath(bundleRoot, key string) (string, string, error) { ++ for suffix := 1; ; suffix++ { ++ name := key ++ if suffix > 1 { ++ name = fmt.Sprintf("%s-%d", key, suffix) ++ } ++ relativePath := name + ".md" ++ absolutePath := filepath.Join(bundleRoot, filepath.FromSlash(relativePath)) ++ if _, err := os.Stat(absolutePath); errors.Is(err, os.ErrNotExist) { ++ return relativePath, absolutePath, nil ++ } else if err != nil { ++ return "", "", err ++ } ++ } ++} ++ ++func conceptKey(documentPath string) string { ++ base := path.Base(vault.StripMarkdownExtension(vault.ToPosixPath(documentPath))) ++ return vault.SlugifySegment(base) ++} ++ ++func typeWarnings(conceptType string, types []string) []string { ++ typeSet := normalizeTypeSet(types) ++ if len(typeSet) == 0 { ++ return nil ++ } ++ if _, ok := typeSet[conceptType]; ok { ++ return nil ++ } ++ return []string{fmt.Sprintf("type %q is outside the configured OKF vocabulary", conceptType)} ++} ++ ++func normalizeTypeSet(types []string) map[string]struct{} { ++ typeSet := make(map[string]struct{}) ++ for _, value := range types { ++ value = strings.TrimSpace(value) ++ if value == "" { ++ continue ++ } ++ typeSet[value] = struct{}{} ++ } ++ return typeSet ++} ++ ++func resolveDescription(flagValue, body string) (string, []string) { ++ if description := strings.TrimSpace(flagValue); description != "" { ++ return description, nil ++ } ++ if sentence := firstBodySentence(body); sentence != "" { ++ return sentence, nil ++ } ++ return "", []string{"description is empty because the source body has no sentence fallback"} ++} ++ ++func firstBodySentence(body string) string { ++ normalized := markdownSyntaxRunes.Replace(markdownLinkPattern.ReplaceAllString(body, " ")) ++ normalized = nonSentenceSpace.ReplaceAllString(strings.TrimSpace(normalized), " ") ++ for index := 0; index < len(normalized); index++ { ++ switch normalized[index] { ++ case '.', '!', '?': ++ if index == len(normalized)-1 || normalized[index+1] == ' ' { ++ return strings.TrimSpace(normalized[:index+1]) ++ } ++ } ++ } ++ return "" ++} ++ ++func transformWikiLinks(body, sourceTopicSlug, bundleRoot, fromDir, currentPath string) (string, int, []string) { ++ count := 0 ++ unresolved := make([]string, 0) ++ formatter := vault.OKFLinkFormatter{} ++ transformed := wikilinkPattern.ReplaceAllStringFunc(body, func(match string) string { ++ inner := strings.TrimSuffix(strings.TrimPrefix(match, "[["), "]]") ++ target, label := splitWikiLink(inner) ++ targetPath := mappedTargetPath(target, sourceTopicSlug) ++ if strings.TrimSpace(label) == "" { ++ label = fallbackWikiLabel(target) ++ } ++ count++ ++ if stripFragment(targetPath) != stripFragment(currentPath) && !isRegularFile(filepath.Join(bundleRoot, filepath.FromSlash(stripFragment(targetPath)))) { ++ unresolved = append(unresolved, targetPath) ++ } ++ return formatter.Link(fromDir, targetPath, label) ++ }) ++ sort.Strings(unresolved) ++ return transformed, count, dedupeStrings(unresolved) ++} ++ ++func splitWikiLink(inner string) (string, string) { ++ parts := strings.SplitN(inner, "|", 2) ++ target := strings.TrimSpace(parts[0]) ++ if len(parts) == 1 { ++ return target, "" ++ } ++ return target, strings.TrimSpace(parts[1]) ++} ++ ++func mappedTargetPath(target, sourceTopicSlug string) string { ++ target = vault.ToPosixPath(strings.TrimSpace(target)) ++ fragment := "" ++ if before, after, found := strings.Cut(target, "#"); found { ++ target = before ++ fragment = "#" + after ++ } ++ target = strings.TrimPrefix(target, "/") ++ if sourceTopicSlug != "" { ++ prefix := strings.Trim(sourceTopicSlug, "/") + "/" ++ target = strings.TrimPrefix(target, prefix) ++ } ++ return conceptKey(target) + ".md" + fragment ++} ++ ++func stripFragment(target string) string { ++ if before, _, found := strings.Cut(target, "#"); found { ++ return before ++ } ++ return target ++} ++ ++func fallbackWikiLabel(target string) string { ++ target = stripFragment(target) ++ return vault.HumanizeSlug(conceptKey(target)) ++} ++ ++func dedupeStrings(values []string) []string { ++ if len(values) == 0 { ++ return nil ++ } ++ deduped := values[:0] ++ var previous string ++ for index, value := range values { ++ if index > 0 && value == previous { ++ continue ++ } ++ deduped = append(deduped, value) ++ previous = value ++ } ++ return append([]string(nil), deduped...) ++} ++ ++func loadConcepts(bundlePath string) ([]conceptInfo, string, error) { ++ okfVersion := OKFVersion ++ indexPath := filepath.Join(bundlePath, "index.md") ++ if content, err := os.ReadFile(indexPath); err == nil { ++ values, _, parseErr := frontmatter.Parse(string(content)) ++ if parseErr != nil { ++ return nil, "", fmt.Errorf("parse index.md: %w", parseErr) ++ } ++ if version := strings.TrimSpace(frontmatter.GetString(values, "okf_version")); version != "" { ++ okfVersion = version ++ } ++ } else if !errors.Is(err, os.ErrNotExist) { ++ return nil, "", fmt.Errorf("read index.md: %w", err) ++ } ++ ++ concepts := make([]conceptInfo, 0) ++ err := filepath.WalkDir(bundlePath, func(currentPath string, entry fs.DirEntry, walkErr error) error { ++ if walkErr != nil { ++ return walkErr ++ } ++ if currentPath == bundlePath { ++ return nil ++ } ++ if entry.Type()&fs.ModeSymlink != 0 { ++ return nil ++ } ++ if entry.IsDir() { ++ if strings.HasPrefix(entry.Name(), ".") { ++ return filepath.SkipDir ++ } ++ return nil ++ } ++ if !strings.EqualFold(filepath.Ext(entry.Name()), ".md") || isExcludedMarkdown(entry.Name()) { ++ return nil ++ } ++ relativePath, err := filepath.Rel(bundlePath, currentPath) ++ if err != nil { ++ return err ++ } ++ relativePath = vault.ToPosixPath(relativePath) ++ if isIndexFile(relativePath) || isLogFile(relativePath) { ++ return nil ++ } ++ content, err := os.ReadFile(currentPath) ++ if err != nil { ++ return err ++ } ++ values, _, err := frontmatter.Parse(string(content)) ++ if err != nil || len(values) == 0 { ++ return nil ++ } ++ conceptType := strings.TrimSpace(frontmatter.GetString(values, "type")) ++ title := strings.TrimSpace(frontmatter.GetString(values, "title")) ++ if title == "" { ++ title = vault.HumanizeSlug(conceptKey(relativePath)) ++ } ++ concepts = append(concepts, conceptInfo{ ++ RelativePath: relativePath, ++ Type: firstNonEmpty(conceptType, "Concept"), ++ Title: title, ++ Description: strings.TrimSpace(frontmatter.GetString(values, "description")), ++ }) ++ return nil ++ }) ++ if err != nil { ++ return nil, "", fmt.Errorf("scan concepts: %w", err) ++ } ++ return concepts, okfVersion, nil ++} ++ ++func checkConceptFile(filePath, relativePath string, typeSet map[string]struct{}, strict bool) []models.LintIssue { ++ content, err := os.ReadFile(filePath) ++ if err != nil { ++ return []models.LintIssue{newIssue(models.SeverityError, relativePath, "", fmt.Sprintf("read concept: %v", err))} ++ } ++ values, _, err := frontmatter.Parse(string(content)) ++ if err != nil { ++ return []models.LintIssue{newIssue(models.SeverityError, relativePath, "", fmt.Sprintf("frontmatter is invalid: %v", err))} ++ } ++ if len(values) == 0 { ++ return []models.LintIssue{newIssue(models.SeverityError, relativePath, "type", "concept must start with YAML frontmatter")} ++ } ++ issues := make([]models.LintIssue, 0) ++ conceptType := strings.TrimSpace(frontmatter.GetString(values, "type")) ++ if conceptType == "" { ++ issues = append(issues, newIssue(models.SeverityError, relativePath, "type", "concept frontmatter must include non-empty type")) ++ } ++ for _, field := range []string{"title", "description", "timestamp"} { ++ if strings.TrimSpace(frontmatter.GetString(values, field)) == "" { ++ issues = append(issues, newIssue(warningSeverity(strict), relativePath, field, fmt.Sprintf("producer field %q is missing", field))) ++ } ++ } ++ if len(typeSet) > 0 && conceptType != "" { ++ if _, ok := typeSet[conceptType]; !ok { ++ issues = append(issues, newIssue(warningSeverity(strict), relativePath, "type", fmt.Sprintf("type %q is outside the configured OKF vocabulary", conceptType))) ++ } ++ } ++ return issues ++} ++ ++func checkIndexFile(bundlePath, filePath, relativePath string, strict bool) []models.LintIssue { ++ content, err := os.ReadFile(filePath) ++ if err != nil { ++ return []models.LintIssue{newIssue(models.SeverityError, relativePath, "", fmt.Sprintf("read index: %v", err))} ++ } ++ values, _, err := frontmatter.Parse(string(content)) ++ if err != nil { ++ return []models.LintIssue{newIssue(models.SeverityError, relativePath, "", fmt.Sprintf("index frontmatter is invalid: %v", err))} ++ } ++ if len(values) == 0 { ++ return nil ++ } ++ if relativePath != "index.md" { ++ return []models.LintIssue{newIssue(models.SeverityError, relativePath, "frontmatter", "only the bundle-root index.md may declare okf_version frontmatter")} ++ } ++ for key := range values { ++ if key != "okf_version" { ++ return []models.LintIssue{newIssue(models.SeverityError, relativePath, key, "root index.md frontmatter may only contain okf_version")} ++ } ++ } ++ _ = bundlePath ++ _ = strict ++ return nil ++} ++ ++func checkLogFile(filePath, relativePath string, strict bool) []models.LintIssue { ++ content, err := os.ReadFile(filePath) ++ if err != nil { ++ return []models.LintIssue{newIssue(models.SeverityError, relativePath, "", fmt.Sprintf("read log: %v", err))} ++ } ++ issues := make([]models.LintIssue, 0) ++ for line := range strings.SplitSeq(string(content), "\n") { ++ trimmed := strings.TrimSpace(line) ++ if !strings.HasPrefix(trimmed, "## ") { ++ continue ++ } ++ dateValue := strings.TrimSpace(strings.TrimPrefix(trimmed, "## ")) ++ if _, err := time.Parse(frontmatter.DateLayout, dateValue); err != nil { ++ issues = append(issues, newIssue(models.SeverityError, relativePath, "date", fmt.Sprintf("log heading %q must use YYYY-MM-DD", trimmed))) ++ } ++ } ++ _ = strict ++ return issues ++} ++ ++func newIssue(severity models.DiagnosticSeverity, filePath, target, message string) models.LintIssue { ++ return models.LintIssue{ ++ Kind: models.LintIssueKindFormat, ++ Severity: severity, ++ FilePath: filePath, ++ Target: target, ++ Message: message, ++ } ++} ++ ++func warningSeverity(strict bool) models.DiagnosticSeverity { ++ if strict { ++ return models.SeverityError ++ } ++ return models.SeverityWarning ++} ++ ++func sortIssues(issues []models.LintIssue) { ++ sort.Slice(issues, func(i, j int) bool { ++ if issues[i].Severity != issues[j].Severity { ++ return issues[i].Severity < issues[j].Severity ++ } ++ if issues[i].FilePath != issues[j].FilePath { ++ return issues[i].FilePath < issues[j].FilePath ++ } ++ if issues[i].Target != issues[j].Target { ++ return issues[i].Target < issues[j].Target ++ } ++ return issues[i].Message < issues[j].Message ++ }) ++} ++ ++func isIndexFile(relativePath string) bool { ++ return strings.EqualFold(path.Base(vault.ToPosixPath(relativePath)), "index.md") ++} ++ ++func isLogFile(relativePath string) bool { ++ return strings.EqualFold(path.Base(vault.ToPosixPath(relativePath)), "log.md") ++} ++ ++func isExcludedMarkdown(name string) bool { ++ lower := strings.ToLower(strings.TrimSpace(name)) ++ switch lower { ++ case "claude.md", "agents.md", "readme.md", "license.md", "notice.md", "attribution.md": ++ return true ++ default: ++ return strings.HasPrefix(lower, "license.") || strings.HasPrefix(lower, "notice.") || strings.HasPrefix(lower, "attribution.") ++ } ++} ++ ++func insertLogEntry(content, dateHeading, entry string) string { ++ lines := strings.Split(strings.ReplaceAll(content, "\r\n", "\n"), "\n") ++ if len(lines) == 0 || strings.TrimSpace(lines[0]) == "" { ++ lines = []string{"# Directory Update Log"} ++ } ++ heading := "## " + dateHeading ++ for index, line := range lines { ++ if strings.TrimSpace(line) != heading { ++ continue ++ } ++ next := append([]string{}, lines[:index+1]...) ++ next = append(next, entry) ++ next = append(next, lines[index+1:]...) ++ return strings.TrimRight(strings.Join(next, "\n"), "\n") + "\n" ++ } ++ next := []string{strings.TrimRight(strings.Join(lines[:1], "\n"), "\n"), "", heading, "", entry} ++ if len(lines) > 1 { ++ rest := strings.TrimSpace(strings.Join(lines[1:], "\n")) ++ if rest != "" { ++ next = append(next, "", rest) ++ } ++ } ++ return strings.TrimRight(strings.Join(next, "\n"), "\n") + "\n" ++} ++ ++func firstNonEmpty(values ...string) string { ++ for _, value := range values { ++ if trimmed := strings.TrimSpace(value); trimmed != "" { ++ return trimmed ++ } ++ } ++ return "" ++} +diff --git a/internal/okf/okf_test.go b/internal/okf/okf_test.go +new file mode 100644 +index 0000000..051c01e +--- /dev/null ++++ b/internal/okf/okf_test.go +@@ -0,0 +1,259 @@ ++package okf ++ ++import ( ++ "context" ++ "os" ++ "path/filepath" ++ "strings" ++ "testing" ++ "time" ++ ++ "github.com/compozy/kb/internal/frontmatter" ++ "github.com/compozy/kb/internal/models" ++) ++ ++func TestPromoteWritesConceptIndexAndLog(t *testing.T) { ++ t.Parallel() ++ ++ vaultPath := t.TempDir() ++ sourceTopic := filepath.Join(vaultPath, "research") ++ targetTopic := filepath.Join(vaultPath, "catalog") ++ mkdirAll(t, filepath.Join(sourceTopic, "wiki", "concepts")) ++ mkdirAll(t, targetTopic) ++ writeFile(t, filepath.Join(sourceTopic, "CLAUDE.md"), "# Research\n") ++ writeFile(t, filepath.Join(targetTopic, "CLAUDE.md"), "# Catalog\n") ++ writeFile(t, filepath.Join(targetTopic, "index.md"), "---\nokf_version: \"0.1\"\n---\n# Old Index\n") ++ writeFile(t, filepath.Join(targetTopic, "log.md"), "# Directory Update Log\n") ++ sourcePath := filepath.Join(sourceTopic, "wiki", "concepts", "Alpha Note.md") ++ sourceContent := strings.Join([]string{ ++ "---", ++ "title: Alpha Note", ++ "type: wiki", ++ "stage: compiled", ++ "tags: [systems, alpha]", ++ "---", ++ "Alpha note explains the operational flow. See [[research/wiki/concepts/Beta Note|Beta]] and [[research/wiki/concepts/Alpha Note#details|details]].", ++ "", ++ "## Details", ++ }, "\n") ++ writeFile(t, sourcePath, sourceContent) ++ ++ result, err := Promote(context.Background(), PromoteInput{ ++ SourceDocPath: sourcePath, ++ VaultPath: vaultPath, ++ TargetTopic: models.TopicInfo{ ++ Slug: "catalog", ++ Mode: models.TopicModeOKF, ++ RootPath: targetTopic, ++ }, ++ Type: "Playbook", ++ Description: "Operational alpha note.", ++ Types: []string{"Playbook"}, ++ Clock: func() time.Time { ++ return time.Date(2026, 6, 27, 10, 11, 12, 0, time.UTC) ++ }, ++ }) ++ if err != nil { ++ t.Fatalf("Promote returned error: %v", err) ++ } ++ if result.WrittenPath != "alpha-note.md" { ++ t.Fatalf("written path = %q, want alpha-note.md", result.WrittenPath) ++ } ++ if result.LinksRewritten != 2 { ++ t.Fatalf("links rewritten = %d, want 2", result.LinksRewritten) ++ } ++ if len(result.UnresolvedLinks) != 1 || result.UnresolvedLinks[0] != "beta-note.md" { ++ t.Fatalf("unresolved links = %#v, want beta-note.md", result.UnresolvedLinks) ++ } ++ if got := readFile(t, sourcePath); got != sourceContent { ++ t.Fatalf("source document changed:\n%s", got) ++ } ++ ++ values, body := parseMarkdown(t, filepath.Join(targetTopic, "alpha-note.md")) ++ for key, want := range map[string]any{ ++ "type": "Playbook", ++ "title": "Alpha Note", ++ "description": "Operational alpha note.", ++ "timestamp": "2026-06-27T10:11:12Z", ++ } { ++ if got := values[key]; got != want { ++ t.Fatalf("frontmatter[%s] = %#v, want %#v", key, got, want) ++ } ++ } ++ if _, ok := values["stage"]; ok { ++ t.Fatalf("wiki stage leaked into OKF frontmatter: %#v", values) ++ } ++ if !strings.Contains(body, "[Beta](beta-note.md)") || !strings.Contains(body, "[details](alpha-note.md#details)") { ++ t.Fatalf("body missing transformed links:\n%s", body) ++ } ++ ++ index := readFile(t, filepath.Join(targetTopic, "index.md")) ++ for _, fragment := range []string{"okf_version: \"0.1\"", "## Playbook", "[Alpha Note](alpha-note.md)", "Operational alpha note."} { ++ if !strings.Contains(index, fragment) { ++ t.Fatalf("index.md missing %q:\n%s", fragment, index) ++ } ++ } ++ log := readFile(t, filepath.Join(targetTopic, "log.md")) ++ for _, fragment := range []string{"## 2026-06-27", "**Creation**", "[Alpha Note](alpha-note.md)", "`wiki/concepts/Alpha Note.md`"} { ++ if !strings.Contains(log, fragment) { ++ t.Fatalf("log.md missing %q:\n%s", fragment, log) ++ } ++ } ++} ++ ++func TestPromoteRejectsNonOKFTargetBeforeWriting(t *testing.T) { ++ t.Parallel() ++ ++ targetTopic := t.TempDir() ++ _, err := Promote(context.Background(), PromoteInput{ ++ SourceDocPath: "missing.md", ++ TargetTopic: models.TopicInfo{ ++ Slug: "wiki-topic", ++ Mode: models.TopicModeWiki, ++ RootPath: targetTopic, ++ }, ++ Type: "Reference", ++ }) ++ if err == nil || !strings.Contains(err.Error(), "target topic must use mode okf") { ++ t.Fatalf("error = %v, want non-OKF target rejection", err) ++ } ++ if entries, readErr := os.ReadDir(targetTopic); readErr != nil || len(entries) != 0 { ++ t.Fatalf("target changed before rejection: entries=%d err=%v", len(entries), readErr) ++ } ++} ++ ++func TestPromoteWarnsWhenSourceBodyHasNoSentenceFallback(t *testing.T) { ++ t.Parallel() ++ ++ vaultPath := t.TempDir() ++ sourceTopic := filepath.Join(vaultPath, "research") ++ targetTopic := filepath.Join(vaultPath, "catalog") ++ mkdirAll(t, filepath.Join(sourceTopic, "wiki", "concepts")) ++ mkdirAll(t, targetTopic) ++ writeFile(t, filepath.Join(sourceTopic, "CLAUDE.md"), "# Research\n") ++ writeFile(t, filepath.Join(targetTopic, "CLAUDE.md"), "# Catalog\n") ++ writeFile(t, filepath.Join(targetTopic, "index.md"), "---\nokf_version: \"0.1\"\n---\n# Index\n") ++ writeFile(t, filepath.Join(targetTopic, "log.md"), "# Directory Update Log\n") ++ sourcePath := filepath.Join(sourceTopic, "wiki", "concepts", "No Sentence.md") ++ writeFile(t, sourcePath, "---\ntitle: No Sentence\n---\nbody text without punctuation") ++ ++ result, err := Promote(context.Background(), PromoteInput{ ++ SourceDocPath: sourcePath, ++ VaultPath: vaultPath, ++ TargetTopic: models.TopicInfo{ ++ Slug: "catalog", ++ Mode: models.TopicModeOKF, ++ RootPath: targetTopic, ++ }, ++ Type: "Reference", ++ Clock: func() time.Time { ++ return time.Date(2026, 6, 27, 10, 11, 12, 0, time.UTC) ++ }, ++ }) ++ if err != nil { ++ t.Fatalf("Promote returned error: %v", err) ++ } ++ if len(result.Warnings) != 1 || !strings.Contains(result.Warnings[0], "source body has no sentence fallback") { ++ t.Fatalf("warnings = %#v, want no sentence fallback warning", result.Warnings) ++ } ++ ++ values, _ := parseMarkdown(t, filepath.Join(targetTopic, "no-sentence.md")) ++ if got := values["description"]; got != "" { ++ t.Fatalf("description = %#v, want empty string", got) ++ } ++} ++ ++func TestCheckReportsConformanceAndStrictWarnings(t *testing.T) { ++ t.Parallel() ++ ++ bundle := t.TempDir() ++ writeFile(t, filepath.Join(bundle, "CLAUDE.md"), "# Catalog\n") ++ writeFile(t, filepath.Join(bundle, "index.md"), "---\nokf_version: \"0.1\"\n---\n# Index\n") ++ writeFile(t, filepath.Join(bundle, "log.md"), "# Directory Update Log\n\n## 2026-06-27\n* **Creation**: Created bundle.\n") ++ writeFile(t, filepath.Join(bundle, "good.md"), "---\ntype: Playbook\ntitle: Good\ndescription: Good concept.\ntimestamp: 2026-06-27T10:00:00Z\n---\nBody with [broken](missing.md).\n") ++ writeFile(t, filepath.Join(bundle, "missing-type.md"), "---\ntitle: Missing\n---\nBody.\n") ++ writeFile(t, filepath.Join(bundle, "missing-fields.md"), "---\ntype: Unknown\n---\nBody.\n") ++ ++ issues, err := Check(context.Background(), bundle, CheckOptions{ ++ Types: []string{"Playbook"}, ++ }) ++ if err != nil { ++ t.Fatalf("Check returned error: %v", err) ++ } ++ assertIssue(t, issues, models.SeverityError, "missing-type.md", "type") ++ assertIssue(t, issues, models.SeverityWarning, "missing-fields.md", "title") ++ assertIssue(t, issues, models.SeverityWarning, "missing-fields.md", "type") ++ if !HasErrors(issues) { ++ t.Fatal("HasErrors = false, want true for missing type") ++ } ++ ++ strictIssues, err := Check(context.Background(), bundle, CheckOptions{ ++ Types: []string{"Playbook"}, ++ Strict: true, ++ }) ++ if err != nil { ++ t.Fatalf("strict Check returned error: %v", err) ++ } ++ assertIssue(t, strictIssues, models.SeverityError, "missing-fields.md", "title") ++} ++ ++func TestCheckAllowsLenientExternalBundleTraits(t *testing.T) { ++ t.Parallel() ++ ++ bundle := t.TempDir() ++ writeFile(t, filepath.Join(bundle, "index.md"), "# Index\n") ++ writeFile(t, filepath.Join(bundle, "concept.md"), "---\ntype: External Type\n---\nSee [missing](missing.md).\n") ++ ++ issues, err := Check(context.Background(), bundle, CheckOptions{}) ++ if err != nil { ++ t.Fatalf("Check returned error: %v", err) ++ } ++ if HasErrors(issues) { ++ t.Fatalf("lenient external bundle should not have errors: %#v", issues) ++ } ++} ++ ++func assertIssue(t *testing.T, issues []models.LintIssue, severity models.DiagnosticSeverity, filePath, target string) { ++ t.Helper() ++ for _, issue := range issues { ++ if issue.Severity == severity && issue.FilePath == filePath && issue.Target == target { ++ return ++ } ++ } ++ t.Fatalf("missing issue severity=%s file=%s target=%s in %#v", severity, filePath, target, issues) ++} ++ ++func parseMarkdown(t *testing.T, filePath string) (map[string]any, string) { ++ t.Helper() ++ values, body, err := frontmatter.Parse(readFile(t, filePath)) ++ if err != nil { ++ t.Fatalf("parse %s: %v", filePath, err) ++ } ++ return values, body ++} ++ ++func mkdirAll(t *testing.T, dir string) { ++ t.Helper() ++ if err := os.MkdirAll(dir, 0o755); err != nil { ++ t.Fatalf("mkdir %s: %v", dir, err) ++ } ++} ++ ++func writeFile(t *testing.T, filePath string, content string) { ++ t.Helper() ++ if err := os.MkdirAll(filepath.Dir(filePath), 0o755); err != nil { ++ t.Fatalf("mkdir parent for %s: %v", filePath, err) ++ } ++ if err := os.WriteFile(filePath, []byte(content), 0o644); err != nil { ++ t.Fatalf("write %s: %v", filePath, err) ++ } ++} ++ ++func readFile(t *testing.T, filePath string) string { ++ t.Helper() ++ content, err := os.ReadFile(filePath) ++ if err != nil { ++ t.Fatalf("read %s: %v", filePath, err) ++ } ++ return string(content) ++} +diff --git a/internal/okf/official_integration_test.go b/internal/okf/official_integration_test.go +new file mode 100644 +index 0000000..6331317 +--- /dev/null ++++ b/internal/okf/official_integration_test.go +@@ -0,0 +1,27 @@ ++//go:build integration ++ ++package okf ++ ++import ( ++ "context" ++ "path/filepath" ++ "testing" ++) ++ ++func TestOfficialBundlesPassLenientConformance(t *testing.T) { ++ t.Parallel() ++ ++ for _, bundle := range []string{"ga4", "stackoverflow", "crypto_bitcoin"} { ++ bundle := bundle ++ t.Run(bundle, func(t *testing.T) { ++ t.Parallel() ++ issues, err := Check(context.Background(), filepath.Join("testdata", "official", bundle), CheckOptions{}) ++ if err != nil { ++ t.Fatalf("Check returned error: %v", err) ++ } ++ if HasErrors(issues) { ++ t.Fatalf("official bundle %s has error issues: %#v", bundle, issues) ++ } ++ }) ++ } ++} +diff --git a/internal/okf/testdata/official/README.md b/internal/okf/testdata/official/README.md +new file mode 100644 +index 0000000..b3caa76 +--- /dev/null ++++ b/internal/okf/testdata/official/README.md +@@ -0,0 +1,10 @@ ++# Official OKF Fixtures ++ ++These fixtures are copied from the Apache-2.0 licensed Google Cloud ++`GoogleCloudPlatform/knowledge-catalog` repository: ++ ++- Source repository: https://github.com/GoogleCloudPlatform/knowledge-catalog ++- Fixture source path: `okf/bundles/{ga4,stackoverflow,crypto_bitcoin}` ++- License: `LICENSE.md` in this directory ++ ++They are used to verify lenient OKF v0.1 conformance against real sample bundles. +diff --git a/internal/okf/testdata/official/LICENSE.md b/internal/okf/testdata/official/LICENSE.md +new file mode 100644 +index 0000000..6b0b127 +--- /dev/null ++++ b/internal/okf/testdata/official/LICENSE.md +@@ -0,0 +1,203 @@ ++ ++ Apache License ++ Version 2.0, January 2004 ++ http://www.apache.org/licenses/ ++ ++ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION ++ ++ 1. Definitions. ++ ++ "License" shall mean the terms and conditions for use, reproduction, ++ and distribution as defined by Sections 1 through 9 of this document. ++ ++ "Licensor" shall mean the copyright owner or entity authorized by ++ the copyright owner that is granting the License. ++ ++ "Legal Entity" shall mean the union of the acting entity and all ++ other entities that control, are controlled by, or are under common ++ control with that entity. For the purposes of this definition, ++ "control" means (i) the power, direct or indirect, to cause the ++ direction or management of such entity, whether by contract or ++ otherwise, or (ii) ownership of fifty percent (50%) or more of the ++ outstanding shares, or (iii) beneficial ownership of such entity. ++ ++ "You" (or "Your") shall mean an individual or Legal Entity ++ exercising permissions granted by this License. ++ ++ "Source" form shall mean the preferred form for making modifications, ++ including but not limited to software source code, documentation ++ source, and configuration files. ++ ++ "Object" form shall mean any form resulting from mechanical ++ transformation or translation of a Source form, including but ++ not limited to compiled object code, generated documentation, ++ and conversions to other media types. ++ ++ "Work" shall mean the work of authorship, whether in Source or ++ Object form, made available under the License, as indicated by a ++ copyright notice that is included in or attached to the work ++ (an example is provided in the Appendix below). ++ ++ "Derivative Works" shall mean any work, whether in Source or Object ++ form, that is based on (or derived from) the Work and for which the ++ editorial revisions, annotations, elaborations, or other modifications ++ represent, as a whole, an original work of authorship. For the purposes ++ of this License, Derivative Works shall not include works that remain ++ separable from, or merely link (or bind by name) to the interfaces of, ++ the Work and Derivative Works thereof. ++ ++ "Contribution" shall mean any work of authorship, including ++ the original version of the Work and any modifications or additions ++ to that Work or Derivative Works thereof, that is intentionally ++ submitted to Licensor for inclusion in the Work by the copyright owner ++ or by an individual or Legal Entity authorized to submit on behalf of ++ the copyright owner. For the purposes of this definition, "submitted" ++ means any form of electronic, verbal, or written communication sent ++ to the Licensor or its representatives, including but not limited to ++ communication on electronic mailing lists, source code control systems, ++ and issue tracking systems that are managed by, or on behalf of, the ++ Licensor for the purpose of discussing and improving the Work, but ++ excluding communication that is conspicuously marked or otherwise ++ designated in writing by the copyright owner as "Not a Contribution." ++ ++ "Contributor" shall mean Licensor and any individual or Legal Entity ++ on behalf of whom a Contribution has been received by Licensor and ++ subsequently incorporated within the Work. ++ ++ 2. Grant of Copyright License. Subject to the terms and conditions of ++ this License, each Contributor hereby grants to You a perpetual, ++ worldwide, non-exclusive, no-charge, royalty-free, irrevocable ++ copyright license to reproduce, prepare Derivative Works of, ++ publicly display, publicly perform, sublicense, and distribute the ++ Work and such Derivative Works in Source or Object form. ++ ++ 3. Grant of Patent License. Subject to the terms and conditions of ++ this License, each Contributor hereby grants to You a perpetual, ++ worldwide, non-exclusive, no-charge, royalty-free, irrevocable ++ (except as stated in this section) patent license to make, have made, ++ use, offer to sell, sell, import, and otherwise transfer the Work, ++ where such license applies only to those patent claims licensable ++ by such Contributor that are necessarily infringed by their ++ Contribution(s) alone or by combination of their Contribution(s) ++ with the Work to which such Contribution(s) was submitted. If You ++ institute patent litigation against any entity (including a ++ cross-claim or counterclaim in a lawsuit) alleging that the Work ++ or a Contribution incorporated within the Work constitutes direct ++ or contributory patent infringement, then any patent licenses ++ granted to You under this License for that Work shall terminate ++ as of the date such litigation is filed. ++ ++ 4. Redistribution. You may reproduce and distribute copies of the ++ Work or Derivative Works thereof in any medium, with or without ++ modifications, and in Source or Object form, provided that You ++ meet the following conditions: ++ ++ (a) You must give any other recipients of the Work or ++ Derivative Works a copy of this License; and ++ ++ (b) You must cause any modified files to carry prominent notices ++ stating that You changed the files; and ++ ++ (c) You must retain, in the Source form of any Derivative Works ++ that You distribute, all copyright, patent, trademark, and ++ attribution notices from the Source form of the Work, ++ excluding those notices that do not pertain to any part of ++ the Derivative Works; and ++ ++ (d) If the Work includes a "NOTICE" text file as part of its ++ distribution, then any Derivative Works that You distribute must ++ include a readable copy of the attribution notices contained ++ within such NOTICE file, excluding those notices that do not ++ pertain to any part of the Derivative Works, in at least one ++ of the following places: within a NOTICE text file distributed ++ as part of the Derivative Works; within the Source form or ++ documentation, if provided along with the Derivative Works; or, ++ within a display generated by the Derivative Works, if and ++ wherever such third-party notices normally appear. The contents ++ of the NOTICE file are for informational purposes only and ++ do not modify the License. You may add Your own attribution ++ notices within Derivative Works that You distribute, alongside ++ or as an addendum to the NOTICE text from the Work, provided ++ that such additional attribution notices cannot be construed ++ as modifying the License. ++ ++ You may add Your own copyright statement to Your modifications and ++ may provide additional or different license terms and conditions ++ for use, reproduction, or distribution of Your modifications, or ++ for any such Derivative Works as a whole, provided Your use, ++ reproduction, and distribution of the Work otherwise complies with ++ the conditions stated in this License. ++ ++ 5. Submission of Contributions. Unless You explicitly state otherwise, ++ any Contribution intentionally submitted for inclusion in the Work ++ by You to the Licensor shall be under the terms and conditions of ++ this License, without any additional terms or conditions. ++ Notwithstanding the above, nothing herein shall supersede or modify ++ the terms of any separate license agreement you may have executed ++ with Licensor regarding such Contributions. ++ ++ 6. Trademarks. This License does not grant permission to use the trade ++ names, trademarks, service marks, or product names of the Licensor, ++ except as required for reasonable and customary use in describing the ++ origin of the Work and reproducing the content of the NOTICE file. ++ ++ 7. Disclaimer of Warranty. Unless required by applicable law or ++ agreed to in writing, Licensor provides the Work (and each ++ Contributor provides its Contributions) on an "AS IS" BASIS, ++ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or ++ implied, including, without limitation, any warranties or conditions ++ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A ++ PARTICULAR PURPOSE. You are solely responsible for determining the ++ appropriateness of using or redistributing the Work and assume any ++ risks associated with Your exercise of permissions under this License. ++ ++ 8. Limitation of Liability. In no event and under no legal theory, ++ whether in tort (including negligence), contract, or otherwise, ++ unless required by applicable law (such as deliberate and grossly ++ negligent acts) or agreed to in writing, shall any Contributor be ++ liable to You for damages, including any direct, indirect, special, ++ incidental, or consequential damages of any character arising as a ++ result of this License or out of the use or inability to use the ++ Work (including but not limited to damages for loss of goodwill, ++ work stoppage, computer failure or malfunction, or any and all ++ other commercial damages or losses), even if such Contributor ++ has been advised of the possibility of such damages. ++ ++ 9. Accepting Warranty or Additional Liability. While redistributing ++ the Work or Derivative Works thereof, You may choose to offer, ++ and charge a fee for, acceptance of support, warranty, indemnity, ++ or other liability obligations and/or rights consistent with this ++ License. However, in accepting such obligations, You may act only ++ on Your own behalf and on Your sole responsibility, not on behalf ++ of any other Contributor, and only if You agree to indemnify, ++ defend, and hold each Contributor harmless for any liability ++ incurred by, or claims asserted against, such Contributor by reason ++ of your accepting any such warranty or additional liability. ++ ++ END OF TERMS AND CONDITIONS ++ ++ APPENDIX: How to apply the Apache License to your work. ++ ++ To apply the Apache License to your work, attach the following ++ boilerplate notice, with the fields enclosed by brackets "[]" ++ replaced with your own identifying information. (Don't include ++ the brackets!) The text should be enclosed in the appropriate ++ comment syntax for the file format. We also recommend that a ++ file or class name and description of purpose be included on the ++ same "printed page" as the copyright notice for easier ++ identification within third-party archives. ++ ++ Copyright [yyyy] [name of copyright owner] ++ ++ Licensed under the Apache License, Version 2.0 (the "License"); ++ you may not use this file except in compliance with the License. ++ You may obtain a copy of the License at ++ ++ http://www.apache.org/licenses/LICENSE-2.0 ++ ++ Unless required by applicable law or agreed to in writing, software ++ distributed under the License is distributed on an "AS IS" BASIS, ++ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. ++ See the License for the specific language governing permissions and ++ limitations under the License. ++ +diff --git a/internal/cli/promote.go b/internal/cli/promote.go +new file mode 100644 +index 0000000..9b332bb +--- /dev/null ++++ b/internal/cli/promote.go +@@ -0,0 +1,61 @@ ++package cli ++ ++import ( ++ "fmt" ++ "os" ++ ++ "github.com/spf13/cobra" ++ ++ kokf "github.com/compozy/kb/internal/okf" ++ ktopic "github.com/compozy/kb/internal/topic" ++) ++ ++var runPromote = kokf.Promote ++var runPromoteTopicInfo = ktopic.Info ++var promoteGetwd = os.Getwd ++ ++func newPromoteCommand() *cobra.Command { ++ var targetTopic string ++ var conceptType string ++ var description string ++ ++ command := &cobra.Command{ ++ Use: "promote <wiki-doc>", ++ Short: "Promote a wiki document into an OKF topic", ++ Args: cobra.ExactArgs(1), ++ RunE: func(cmd *cobra.Command, args []string) error { ++ vaultPath, err := resolveCommandVaultPath(cmd, promoteGetwd, "promote") ++ if err != nil { ++ return err ++ } ++ cfg, err := loadCLIConfig() ++ if err != nil { ++ return fmt.Errorf("promote: %w", err) ++ } ++ target, err := runPromoteTopicInfo(vaultPath, targetTopic) ++ if err != nil { ++ return fmt.Errorf("promote: %w", err) ++ } ++ ++ result, err := runPromote(commandContext(cmd), kokf.PromoteInput{ ++ SourceDocPath: args[0], ++ VaultPath: vaultPath, ++ TargetTopic: target, ++ Type: conceptType, ++ Description: description, ++ Types: cfg.OKF.Types, ++ }) ++ if err != nil { ++ return err ++ } ++ return writeJSON(cmd, result) ++ }, ++ } ++ ++ command.Flags().StringVar(&targetTopic, "to", "", "Target OKF topic slug") ++ command.Flags().StringVar(&conceptType, "type", "", "OKF concept type") ++ command.Flags().StringVar(&description, "description", "", "OKF concept description") ++ _ = command.MarkFlagRequired("to") ++ _ = command.MarkFlagRequired("type") ++ return command ++} +diff --git a/internal/cli/okf.go b/internal/cli/okf.go +new file mode 100644 +index 0000000..c3dca3a +--- /dev/null ++++ b/internal/cli/okf.go +@@ -0,0 +1,90 @@ ++package cli ++ ++import ( ++ "fmt" ++ "os" ++ ++ "github.com/spf13/cobra" ++ ++ kokf "github.com/compozy/kb/internal/okf" ++ "github.com/compozy/kb/internal/output" ++ ktopic "github.com/compozy/kb/internal/topic" ++) ++ ++type okfCheckOptions struct { ++ Format string ++ Strict bool ++} ++ ++var runOKFCheck = kokf.Check ++var runOKFTopicInfo = ktopic.Info ++var okfGetwd = os.Getwd ++ ++func newOKFCommand() *cobra.Command { ++ command := &cobra.Command{ ++ Use: "okf", ++ Short: "Work with Open Knowledge Format bundles", ++ Args: cobra.NoArgs, ++ RunE: func(cmd *cobra.Command, args []string) error { ++ return cmd.Help() ++ }, ++ } ++ command.AddCommand(newOKFCheckCommand()) ++ return command ++} ++ ++func newOKFCheckCommand() *cobra.Command { ++ options := &okfCheckOptions{ ++ Format: string(output.OutputFormatTable), ++ } ++ command := &cobra.Command{ ++ Use: "check <topic>", ++ Short: "Check an OKF topic for conformance", ++ Args: cobra.ExactArgs(1), ++ RunE: func(cmd *cobra.Command, args []string) error { ++ return runOKFCheckCommand(cmd, options, args[0]) ++ }, ++ } ++ command.Flags().StringVar(&options.Format, "format", string(output.OutputFormatTable), "Output format (table|json|tsv)") ++ command.Flags().BoolVar(&options.Strict, "strict", false, "Promote local-standard warnings to errors") ++ return command ++} ++ ++func runOKFCheckCommand(cmd *cobra.Command, options *okfCheckOptions, topicSlug string) error { ++ format, err := parseInspectOutputFormat(options.Format) ++ if err != nil { ++ return err ++ } ++ vaultPath, err := resolveCommandVaultPath(cmd, okfGetwd, "okf check") ++ if err != nil { ++ return err ++ } ++ cfg, err := loadCLIConfig() ++ if err != nil { ++ return fmt.Errorf("okf check: %w", err) ++ } ++ topicInfo, err := runOKFTopicInfo(vaultPath, topicSlug) ++ if err != nil { ++ return fmt.Errorf("okf check: %w", err) ++ } ++ ++ issues, err := runOKFCheck(commandContext(cmd), topicInfo.RootPath, kokf.CheckOptions{ ++ Types: cfg.OKF.Types, ++ Strict: options.Strict, ++ }) ++ if err != nil { ++ return err ++ } ++ _, writeErr := cmd.OutOrStdout().Write([]byte(output.FormatOutput(output.FormatOptions{ ++ Format: format, ++ Columns: kokf.Columns(), ++ Data: kokf.Rows(issues), ++ }))) ++ if writeErr != nil { ++ return fmt.Errorf("okf check: write output: %w", writeErr) ++ } ++ if kokf.HasErrors(issues) { ++ return fmt.Errorf("okf check: found %d issue(s)", len(issues)) ++ } ++ return nil ++} +diff --git a/internal/cli/okf_test.go b/internal/cli/okf_test.go +new file mode 100644 +index 0000000..dc9d6ba +--- /dev/null ++++ b/internal/cli/okf_test.go +@@ -0,0 +1,135 @@ ++package cli ++ ++import ( ++ "bytes" ++ "context" ++ "encoding/json" ++ "fmt" ++ "os" ++ "path/filepath" ++ "strings" ++ "testing" ++ ++ kconfig "github.com/compozy/kb/internal/config" ++ "github.com/compozy/kb/internal/models" ++ kokf "github.com/compozy/kb/internal/okf" ++) ++ ++func TestPromoteCommandResolvesTargetAndPrintsJSON(t *testing.T) { ++ originalPromote := runPromote ++ originalTopicInfo := runPromoteTopicInfo ++ t.Cleanup(func() { ++ runPromote = originalPromote ++ runPromoteTopicInfo = originalTopicInfo ++ }) ++ t.Setenv(kconfig.EnvConfigPath, writeCLIConfig(t, "[okf]\ntypes = [\"Playbook\"]\n")) ++ ++ var gotInput kokf.PromoteInput ++ runPromoteTopicInfo = func(vaultPath, slug string) (models.TopicInfo, error) { ++ return models.TopicInfo{ ++ Slug: slug, ++ Mode: models.TopicModeOKF, ++ RootPath: filepath.Join(vaultPath, slug), ++ }, nil ++ } ++ runPromote = func(ctx context.Context, input kokf.PromoteInput) (kokf.ConceptResult, error) { ++ gotInput = input ++ return kokf.ConceptResult{ ++ WrittenPath: "alpha.md", ++ Type: input.Type, ++ LinksRewritten: 1, ++ }, nil ++ } ++ ++ command := newRootCommand() ++ var stdout bytes.Buffer ++ command.SetOut(&stdout) ++ command.SetErr(new(bytes.Buffer)) ++ command.SetArgs([]string{ ++ "promote", "research/wiki/concepts/Alpha.md", ++ "--to", "catalog", ++ "--type", "Playbook", ++ "--description", "Alpha description.", ++ "--vault", "/tmp/vault", ++ }) ++ ++ if err := command.ExecuteContext(context.Background()); err != nil { ++ t.Fatalf("ExecuteContext returned error: %v", err) ++ } ++ if gotInput.VaultPath != "/tmp/vault" || gotInput.TargetTopic.Slug != "catalog" || gotInput.Type != "Playbook" { ++ t.Fatalf("unexpected promote input: %#v", gotInput) ++ } ++ if gotInput.Description != "Alpha description." { ++ t.Fatalf("description = %q", gotInput.Description) ++ } ++ if len(gotInput.Types) != 1 || gotInput.Types[0] != "Playbook" { ++ t.Fatalf("types = %#v, want Playbook", gotInput.Types) ++ } ++ ++ var result kokf.ConceptResult ++ if err := json.Unmarshal(stdout.Bytes(), &result); err != nil { ++ t.Fatalf("stdout did not contain JSON: %v\n%s", err, stdout.String()) ++ } ++ if result.WrittenPath != "alpha.md" || result.Type != "Playbook" { ++ t.Fatalf("unexpected result: %#v", result) ++ } ++} ++ ++func TestOKFCheckCommandRendersIssuesAndFailsOnErrors(t *testing.T) { ++ originalCheck := runOKFCheck ++ originalTopicInfo := runOKFTopicInfo ++ t.Cleanup(func() { ++ runOKFCheck = originalCheck ++ runOKFTopicInfo = originalTopicInfo ++ }) ++ t.Setenv(kconfig.EnvConfigPath, writeCLIConfig(t, "[okf]\ntypes = [\"Playbook\"]\n")) ++ ++ runOKFTopicInfo = func(vaultPath, slug string) (models.TopicInfo, error) { ++ return models.TopicInfo{ ++ Slug: slug, ++ Mode: models.TopicModeOKF, ++ RootPath: filepath.Join(vaultPath, slug), ++ }, nil ++ } ++ runOKFCheck = func(ctx context.Context, bundlePath string, options kokf.CheckOptions) ([]models.LintIssue, error) { ++ if bundlePath != "/tmp/vault/catalog" { ++ return nil, fmt.Errorf("bundle path = %q", bundlePath) ++ } ++ if !options.Strict || len(options.Types) != 1 || options.Types[0] != "Playbook" { ++ return nil, fmt.Errorf("unexpected options: %#v", options) ++ } ++ return []models.LintIssue{{ ++ Kind: models.LintIssueKindFormat, ++ Severity: models.SeverityError, ++ FilePath: "bad.md", ++ Target: "type", ++ Message: "missing type", ++ }}, nil ++ } ++ ++ command := newRootCommand() ++ var stdout bytes.Buffer ++ command.SetOut(&stdout) ++ command.SetErr(new(bytes.Buffer)) ++ command.SetArgs([]string{"okf", "check", "catalog", "--strict", "--format", "json", "--vault", "/tmp/vault"}) ++ ++ err := command.ExecuteContext(context.Background()) ++ if err == nil { ++ t.Fatal("expected okf check to fail on error issues") ++ } ++ if !strings.Contains(err.Error(), "found 1 issue") { ++ t.Fatalf("unexpected error: %v", err) ++ } ++ if !strings.Contains(stdout.String(), `"filePath": "bad.md"`) { ++ t.Fatalf("stdout missing issue JSON:\n%s", stdout.String()) ++ } ++} ++ ++func writeCLIConfig(t *testing.T, content string) string { ++ t.Helper() ++ path := filepath.Join(t.TempDir(), "kb.toml") ++ if err := os.WriteFile(path, []byte(content), 0o644); err != nil { ++ t.Fatalf("write config: %v", err) ++ } ++ return path ++} +diff --git a/internal/cli/okf_integration_test.go b/internal/cli/okf_integration_test.go +new file mode 100644 +index 0000000..2f65552 +--- /dev/null ++++ b/internal/cli/okf_integration_test.go +@@ -0,0 +1,112 @@ ++//go:build integration ++ ++package cli ++ ++import ( ++ "encoding/json" ++ "os" ++ "path/filepath" ++ "strings" ++ "testing" ++ ++ "github.com/compozy/kb/internal/frontmatter" ++ "github.com/compozy/kb/internal/models" ++ kokf "github.com/compozy/kb/internal/okf" ++) ++ ++func TestCLIIntegrationOKFPromoteAndCheck(t *testing.T) { ++ vaultRoot := t.TempDir() ++ wikiTopic := scaffoldTopicForIntegration(t, vaultRoot, "research", "Research", "ops") ++ okfTopic := runCLIJSON[models.TopicInfo](t, ++ "topic", "new", "catalog", "Catalog", "ops", ++ "--mode", "okf", ++ "--vault", vaultRoot, ++ ) ++ if okfTopic.Mode != models.TopicModeOKF { ++ t.Fatalf("OKF topic mode = %q, want okf", okfTopic.Mode) ++ } ++ ++ sourcePath := filepath.Join(wikiTopic.RootPath, "wiki", "concepts", "Alpha Note.md") ++ writeFile(t, sourcePath, strings.Join([]string{ ++ "---", ++ "title: Alpha Note", ++ "type: wiki", ++ "stage: compiled", ++ "tags: [ops]", ++ "---", ++ "Alpha note explains the operations flow. See [[research/wiki/concepts/Beta Note|Beta]].", ++ }, "\n")) ++ ++ promoteOutput := runCLI(t, ++ "promote", sourcePath, ++ "--to", okfTopic.Slug, ++ "--type", "Playbook", ++ "--description", "Operational flow.", ++ "--vault", vaultRoot, ++ ) ++ var result kokf.ConceptResult ++ if err := json.Unmarshal([]byte(promoteOutput), &result); err != nil { ++ t.Fatalf("promote output is not JSON: %v\n%s", err, promoteOutput) ++ } ++ if result.WrittenPath != "alpha-note.md" { ++ t.Fatalf("written path = %q, want alpha-note.md", result.WrittenPath) ++ } ++ ++ sourceAfter := readFile(t, sourcePath) ++ if !strings.Contains(sourceAfter, "stage: compiled") { ++ t.Fatalf("source was unexpectedly changed:\n%s", sourceAfter) ++ } ++ conceptPath := filepath.Join(okfTopic.RootPath, "alpha-note.md") ++ values, body := readMarkdownDocument(t, conceptPath) ++ if values["type"] != "Playbook" || values["description"] != "Operational flow." { ++ t.Fatalf("unexpected concept frontmatter: %#v", values) ++ } ++ if !strings.Contains(body, "[Beta](beta-note.md)") { ++ t.Fatalf("body missing relative OKF link:\n%s", body) ++ } ++ if _, ok := values["stage"]; ok { ++ t.Fatalf("wiki stage leaked into OKF concept: %#v", values) ++ } ++ ++ checkOutput := runCLI(t, "okf", "check", okfTopic.Slug, "--format", "json", "--vault", vaultRoot) ++ var issues []models.LintIssue ++ if err := json.Unmarshal([]byte(checkOutput), &issues); err != nil { ++ t.Fatalf("check output is not JSON: %v\n%s", err, checkOutput) ++ } ++ if len(issues) != 0 { ++ t.Fatalf("freshly promoted bundle has issues: %#v", issues) ++ } ++ ++ index := readFile(t, filepath.Join(okfTopic.RootPath, "index.md")) ++ if !strings.Contains(index, "## Playbook") || !strings.Contains(index, "[Alpha Note](alpha-note.md)") { ++ t.Fatalf("index.md missing promoted concept:\n%s", index) ++ } ++ logContent := readFile(t, filepath.Join(okfTopic.RootPath, "log.md")) ++ if !strings.Contains(logContent, "## "+frontmatter.DateLayout[:4]) && !strings.Contains(logContent, "**Creation**") { ++ t.Fatalf("log.md missing promotion entry:\n%s", logContent) ++ } ++} ++ ++func TestCLIIntegrationOKFCheckFailsStrictWarnings(t *testing.T) { ++ vaultRoot := t.TempDir() ++ okfTopic := runCLIJSON[models.TopicInfo](t, ++ "topic", "new", "catalog", "Catalog", "ops", ++ "--mode", "okf", ++ "--vault", vaultRoot, ++ ) ++ writeFile(t, filepath.Join(okfTopic.RootPath, "concept.md"), "---\ntype: Unknown\n---\nBody.\n") ++ ++ errText := runCLIError(t, "okf", "check", okfTopic.Slug, "--strict", "--vault", vaultRoot) ++ if !strings.Contains(errText, "found") { ++ t.Fatalf("strict check error = %q", errText) ++ } ++} ++ ++func readFile(t *testing.T, filePath string) string { ++ t.Helper() ++ content, err := os.ReadFile(filePath) ++ if err != nil { ++ t.Fatalf("read %s: %v", filePath, err) ++ } ++ return string(content) ++} diff --git a/.compozy/tasks/okf-dual-mode/qa/impl-review-diff-stat-round1.txt b/.compozy/tasks/okf-dual-mode/qa/impl-review-diff-stat-round1.txt new file mode 100644 index 0000000..39a01ad --- /dev/null +++ b/.compozy/tasks/okf-dual-mode/qa/impl-review-diff-stat-round1.txt @@ -0,0 +1,17 @@ + config.example.toml | 6 ++ + internal/cli/root.go | 2 + + internal/cli/topic.go | 29 +++++- + internal/cli/topic_test.go | 60 ++++++++++++ + internal/config/config.go | 7 ++ + internal/config/config_test.go | 9 ++ + internal/generate/generate.go | 1 + + internal/models/kb_models.go | 15 +-- + internal/models/models.go | 25 +++-- + internal/topic/topic.go | 193 ++++++++++++++++++++++++++++++++++----- + internal/topic/topic_test.go | 83 +++++++++++++++++ + internal/vault/pathutils.go | 68 ++++++++++++++ + internal/vault/pathutils_test.go | 38 ++++++++ + internal/vault/render.go | 8 +- + internal/vault/render_wiki.go | 28 +++--- + internal/vault/writer.go | 14 +-- + 16 files changed, 522 insertions(+), 64 deletions(-) diff --git a/.compozy/tasks/okf-dual-mode/qa/impl-review-diff-stat-round2.txt b/.compozy/tasks/okf-dual-mode/qa/impl-review-diff-stat-round2.txt new file mode 100644 index 0000000..5fb867c --- /dev/null +++ b/.compozy/tasks/okf-dual-mode/qa/impl-review-diff-stat-round2.txt @@ -0,0 +1,21 @@ + AGENTS.md | 9 +- + CLAUDE.md | 7 +- + README.md | 29 ++++++- + config.example.toml | 6 ++ + internal/cli/root.go | 2 + + internal/cli/topic.go | 29 ++++++- + internal/cli/topic_test.go | 60 +++++++++++++ + internal/config/config.go | 7 ++ + internal/config/config_test.go | 9 ++ + internal/generate/generate.go | 1 + + internal/models/kb_models.go | 15 ++-- + internal/models/models.go | 25 ++++-- + internal/topic/topic.go | 180 +++++++++++++++++++++++++++++++++------ + internal/topic/topic_test.go | 83 ++++++++++++++++++ + internal/vault/pathutils.go | 68 +++++++++++++++ + internal/vault/pathutils_test.go | 38 +++++++++ + internal/vault/render.go | 8 +- + internal/vault/render_test.go | 26 ++++++ + internal/vault/render_wiki.go | 28 +++--- + internal/vault/writer.go | 14 +-- + 20 files changed, 575 insertions(+), 69 deletions(-) diff --git a/.compozy/tasks/okf-dual-mode/qa/impl-review-events-round1.jsonl b/.compozy/tasks/okf-dual-mode/qa/impl-review-events-round1.jsonl new file mode 100644 index 0000000..01f12ad --- /dev/null +++ b/.compozy/tasks/okf-dual-mode/qa/impl-review-events-round1.jsonl @@ -0,0 +1,228 @@ +{"dry_run":false,"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","status":"running","time":"2026-06-28T01:20:34.986756Z","type":"run.started"} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","session":{"acp_session_id":"6a2e8708-3fe0-4a29-a300-e29413dc7958","agent_session_id":"","resumed":false},"time":"2026-06-28T01:20:35.732072Z","turn":1,"type":"session.attached"} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:20:40.331838Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":""}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:20:40.332034Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":"I"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:20:40.852747Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":"'ll start by reading all the context files and project rules, then the changed files. Let me batch the initial reads."}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:20:41.188648Z","turn":1,"type":"session.update","update":{"kind":"tool_call_started","tool_call_id":"toolu_018kdczYynigZuupYwTduDi1","tool_call_state":"pending","blocks":[{"type":"tool_use","id":"toolu_018kdczYynigZuupYwTduDi1","name":"Read","title":"Read File","raw_input":{}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:20:41.676333Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_018kdczYynigZuupYwTduDi1","blocks":[{"type":"tool_use","id":"toolu_018kdczYynigZuupYwTduDi1","name":"Read","title":"Read .compozy/tasks/okf-dual-mode/_prd.md","input":{"file_path":"/Users/pedronauck/Dev/compozy/kb/.compozy/tasks/okf-dual-mode/_prd.md"},"raw_input":{"file_path":"/Users/pedronauck/Dev/compozy/kb/.compozy/tasks/okf-dual-mode/_prd.md"}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:20:41.686884Z","turn":1,"type":"session.update","update":{"kind":"tool_call_started","tool_call_id":"toolu_01VU977q6cpSK8XF81rQDxn6","tool_call_state":"pending","blocks":[{"type":"tool_use","id":"toolu_01VU977q6cpSK8XF81rQDxn6","name":"Read","title":"Read File","raw_input":{}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:20:41.699076Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_018kdczYynigZuupYwTduDi1","usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:20:41.706267Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_018kdczYynigZuupYwTduDi1","tool_call_state":"completed","blocks":[{"type":"tool_result","tool_use_id":"toolu_018kdczYynigZuupYwTduDi1","content":"```\n1\t# PRD: OKF Dual-Mode for `kb`\n2\t\n3\t## Overview\n4\t\n5\t`kb` today implements one knowledge lifecycle: the Karpathy **LLM-Wiki** — a\n6\tpersonal research *lab* where raw sources are ingested, compiled into a wiki,\n7\tqueried, and linted, linked with Obsidian `[[wikilinks]]`.\n8\t\n9\tThe **Open Knowledge Format (OKF)** v0.1 (Google Cloud, 2026-06-12) standardizes\n10\tthe same markdown + YAML-frontmatter substrate for a different purpose: a\n11\tportable, vendor-neutral **catalog** that other people's agents and tools consume.\n12\tIt requires exactly one thing of every concept — a `type` field — uses plain\n13\tmarkdown links, and is meant to be *declared and shared*, not *explored*.\n14\t\n15\tThese are two lifecycles, not one derived from the other:\n16\t\n17\t- **LLM-Wiki** = a lab: `ingest → compile → query → lint`; for the operator to\n18\t research.\n19\t- **OKF** = a catalog/API: `declare → consume`; for others (humans and agents) to\n20\t consume; portable.\n21\t\n22\tThis duality already exists by hand in the operator's workspace: a `research/`\n23\tarea (LLM-wiki, where they research) and a `second-brain/` area (OKF, where they\n24\tdeclare \"what is mine\" for operations). The daily, highest-leverage move is\n25\t**distilling research into the operational catalog** — done manually today.\n26\t\n27\tThis feature makes both lifecycles first-class in `kb` via a per-topic\n28\t**`mode: wiki | okf`** (default `wiki`, so nothing regresses), and ships the\n29\tdistill loop as a one-command **`promote`** plus an OKF **conformance check**. It\n30\tis valuable because it turns a hand-maintained workflow into a tool-enforced one,\n31\tand positions `kb` as an OKF-conformant producer in a brand-new, vendor-neutral\n32\tecosystem.\n33\t\n34\t## Goals\n35\t\n36\t- Let an operator maintain LLM-Wiki research and OKF catalogs in the same tool,\n37\t with the research → catalog distill loop tool-enforced.\n38\t- Ship OKF as a first-class authoring mode (not an export-only projection).\n39\t- Produce bundles that are conformant with OKF v0.1 and aligned with Google's real\n40\t sample bundles and tooling.\n41\t- Preserve a consistent, drift-free local standard for concept types.\n42\t- Guarantee zero regression for existing wiki topics.\n43\t- Milestone: MVP delivers `mode` + OKF scaffold + `promote` + conformance check;\n44\t Phase 2 adds publish/consume interop; Phase 3 adds codebase → OKF.\n45\t\n46\t## User Stories\n47\t\n48\t**Primary persona — the Knowledge Operator** (researches in wiki mode, declares\n49\t\"what's mine\" in OKF mode, distills daily):\n50\t\n51\t- As an operator, I want to mark a topic as an OKF bundle so `kb` scaffolds and\n52\t lints it as a portable catalog instead of a research lab.\n53\t- As an operator, I want to promote a finished wiki concept into my OKF bundle\n54\t with one command so my research compounds into my operational catalog without\n55\t manual reformatting or re-linking.\n56\t- As an operator, I want my source research to stay untouched when I promote, so\n57\t the lab remains the immutable record and the catalog is the distilled view.\n58\t- As an operator, I want `kb` to validate my bundle against OKF conformance so my\n59\t catalog stays portable and consumable by agents.\n60\t- As an operator, I want a canonical list of concept types so my bundle stays\n61\t consistent and avoids type drift over time.\n62\t- As an operator, I want my existing wiki topics to keep working exactly as before\n63\t when OKF mode lands.\n64\t\n65\t**Secondary personas** (later phases):\n66\t\n67\t- As a knowledge publisher, I want to export a wiki topic as a portable OKF bundle\n68\t so others can consume it (Phase 2).\n69\t- As an AI agent / consuming tool, I want to read a `kb`-produced bundle as\n70\t standard OKF so I can use it without a custom integration (enabled throughout).\n71\t\n72\t## Core Features\n73\t\n74\tGrouped by priority. MVP features first.\n75\t\n76\t### 1. OKF mode for topics (MVP)\n77\t\n78\t- A per-topic **`mode: wiki | okf`** setting, defaulting to `wiki`.\n79\t- `kb topic new --mode okf` scaffolds an **OKF bundle** (flat typed concepts,\n80\t auto-maintained `index.md` and `log.md`) instead of the wiki pyramid\n81\t (`raw/` + `wiki/` + `outputs/`).\n82\t- An OKF bundle is a `kb` topic; it reuses the topic, vault, lint, and index\n83\t machinery. `mode` selects scaffold shape, link format, frontmatter contract, and\n84\t lint ruleset.\n85\t\n86\t### 2. `kb promote` — wiki → OKF distill (MVP)\n87\t\n88\t- `kb promote \u003cwiki-doc\u003e --to \u003cokf-topic\u003e --type \u003cConceptType\u003e` creates a new\n89\t typed OKF concept in the target bundle from a compiled wiki document.\n90\t- **Mechanical and non-LLM**: converts `[[wikilinks]]` to relative markdown links,\n91\t remaps wiki frontmatter to the OKF contract (`type`, `title`, `description`,\n92\t `timestamp`), and appends a dated entry to the bundle's `log.md`.\n93\t- **Non-destructive**: the source wiki document stays in research; a new concept is\n94\t born in the catalog.\n95\t- Any intelligent rewriting/condensing is left to the operator or an external\n96\t agent — `kb` does the structural work only.\n97\t\n98\t### 3. OKF conformance check (MVP)\n99\t\n100\t- A conformance command for an OKF bundle that validates OKF v0.1:\n101\t - every non-reserved `.md` has parseable frontmatter with a non-empty `type`;\n102\t - `index.md` / `log.md` follow their required shapes when present.\n103\t- **Lenient by default** (per spec §9): tolerates broken cross-links, unknown\n104\t `type` values, and missing optional fields (so externally produced bundles pass).\n105\t- **Local standards as warnings** on the operator's own bundles: all four producer\n106\t fields present, and `type` within the configured vocabulary.\n107\t- A `--strict` option promotes warnings to errors for CI gates.\n108\t\n109\t### 4. Concept-type governance (MVP)\n110\t\n111\t- A canonical **type vocabulary** in `kb.toml` (e.g. `[okf].types`).\n112\t- `promote` and authoring take `type` explicitly; a value outside the vocabulary\n113\t produces a lint warning. The vocabulary is extended by editing `kb.toml`.\n114\t- Prevents type drift (`Voice Profile` vs `voice-profile`) and keeps a consistent\n115\t local standard, while staying compatible with OKF's \"no global registry\" model.\n116\t\n117\t### 5. OKF-native emission (MVP, underpins 1–4)\n118\t\n119\t- Relative, GitHub-safe markdown links (not `[[wikilinks]]`, not absolute paths).\n120\t- The four producer fields on every concept; auto-maintained `index.md`\n121\t (type-grouped bullet lists) and `log.md` (ISO-date entries, newest first).\n122\t\n123\t### Later phases (not MVP)\n124\t\n125\t- **`kb export okf`** — publish an existing wiki topic as a portable bundle\n126\t (Phase 2).\n127\t- **`kb ingest okf`** — consume an external OKF bundle into research (Phase 2).\n128\t- **Codebase → OKF** — emit the typed symbol/relation graph as a bundle\n129\t (Phase 3, differentiator demo).\n130\t\n131\t## User Experience\n132\t\n133\t**Operator journey (MVP):**\n134\t\n135\t1. **Create a catalog.** `kb topic new \u003cname\u003e --mode okf` scaffolds an OKF bundle.\n136\t Existing topics are unaffected (they remain `mode: wiki`).\n137\t2. **Research as usual.** Ingest and compile in wiki-mode topics — unchanged.\n138\t3. **Distill.** When a wiki concept is ready, `kb promote \u003cwiki-doc\u003e --to \u003cbundle\u003e\n139\t --type \u003cConceptType\u003e`. The concept appears in the catalog with relative links,\n140\t the four producer fields, and a `log.md` entry; the source stays put.\n141\t4. **Verify.** Run the conformance check on the bundle; fix any warnings (missing\n142\t fields, off-vocabulary types). Use `--strict` in CI.\n143\t5. **Consume / share.** The bundle is plain markdown + frontmatter — readable in\n144\t Obsidian/GitHub and parseable by any OKF-aware agent.\n145\t\n146\t**UX considerations:**\n147\t\n148\t- `mode` is invisible to existing users; OKF behavior is opt-in per topic.\n149\t- `promote` is one command with a predictable, mechanical result (no surprises\n150\t from an LLM in the loop).\n151\t- Conformance output is actionable (what failed, where, how to fix) and\n152\t distinguishes hard errors from local-standard warnings.\n153\t- Discoverability: OKF options surface under existing `kb topic` / `kb lint`-style\n154\t help; `kb.toml` documents the type vocabulary.\n155\t\n156\t## High-Level Technical Constraints\n157\t\n158\tProduct boundaries (not implementation):\n159\t\n160\t- **Conform to OKF v0.1**, and remain aligned with Google's reference samples\n161\t (GA4, Stack Overflow, Bitcoin) and producer behavior.\n162\t- **Two deliberate, documented deviations** from the written spec, matching real\n163\t tooling: emit the four producer fields (not just `type`), and emit relative\n164\t links (not the spec-recommended absolute links, which break GitHub rendering).\n165\t- **Stay non-LLM.** The core workflow, including `promote`, performs no model\n166\t inference; `kb` remains \"the non-LLM workflow.\"\n167\t- **Zero regression.** Default `mode: wiki`; existing topics scaffold, lint, and\n168\t build identically; no migration required.\n169\t- **Portable output.** Bundles are usable without `kb`: plain files renderable in\n170\t Obsidian/GitHub and parseable by any OKF consumer.\n171\t- OKF is a v0.1 draft; conformance rules must be pinned to v0.1 and isolated so the\n172\t spec can evolve without disrupting the rest of the tool.\n173\t\n174\t## Non-Goals (Out of Scope)\n175\t\n176\t- **Export and ingest interop** (`kb export okf`, `kb ingest okf`) — Phase 2.\n177\t- **Codebase → OKF** emission — Phase 3.\n178\t- **LLM-assisted distillation** inside `promote` (no `--distill`) — possibly a\n179\t later flag, not now.\n180\t- **A global/shared type registry** — only a local per-vault vocabulary.\n181\t- **Whole-vault or cross-topic OKF operations** — OKF is per-topic/bundle.\n182\t- **An HTML/graph visualizer** for bundles.\n183\t- **Migrating existing wiki topics** to OKF automatically.\n184\t- **Changing the LLM-Wiki lifecycle** — wiki mode behavior is unchanged.\n185\t\n186\t## Phased Rollout Plan\n187\t\n188\t### MVP (Phase 1) — the distill loop\n189\t\n190\t- OKF mode (`mode: wiki | okf`) + `kb topic new --mode okf` scaffold.\n191\t- `kb promote` (mechanical, non-destructive, non-LLM).\n192\t- OKF conformance check (lenient + local warnings + `--strict`).\n193\t- Concept-type vocabulary in `kb.toml`.\n194\t- OKF-native emission (relative links, four producer fields, auto `index.md` /\n195\t `log.md`).\n196\t- **Success criteria to proceed:** the conformance check passes on all three\n197\t official sample bundles; the operator's catalog passes the check; a real wiki\n198\t concept promotes end-to-end and the result conforms; existing wiki topics show\n199\t zero behavior change.\n200\t\n201\t### Phase 2 — publish \u0026 consume interop\n202\t\n203\t- `kb export okf` (wiki topic → portable bundle).\n204\t- `kb ingest okf` (external bundle → research), accepting real-world bundles under\n205\t lenient conformance.\n206\t- **Success criteria to proceed:** a wiki topic round-trips through export and back\n207\t through ingest without loss of conceptual content; third-party sample bundles\n208\t ingest cleanly.\n209\t\n210\t### Phase 3 — codebase → OKF\n211\t\n212\t- Emit the existing typed symbol/relation graph as an OKF bundle (concepts =\n213\t files/symbols, links = relations).\n214\t- **Long-term success:** a real codebase produces a conformant, navigable OKF\n215\t bundle usable as agent context.\n216\t\n217\t## Success Metrics\n218\t\n219\t- **Spec correctness:** the conformance check passes on all three official OKF\n220\t sample bundles (GA4, Stack Overflow, Bitcoin).\n221\t- **Loop works end-to-end:** a real wiki concept promotes into a bundle and the\n222\t result conforms, with links resolved and the four producer fields present.\n223\t- **Operator catalog conformant:** the existing `second-brain/` catalog passes the\n224\t conformance check (with local standards) once adopted.\n225\t- **Zero regression:** existing wiki topics scaffold, lint, and build identically\n226\t to before (verified by the existing suite).\n227\t- **No type drift:** every concept type in the operator's bundle is within the\n228\t `kb.toml` vocabulary.\n229\t- **Adoption:** the operator stops distilling by hand — `promote` replaces the\n230\t manual copy/reformat step.\n231\t\n232\t## Risks and Mitigations\n233\t\n234\t- **Adoption risk** — if `promote` is not clearly lower-friction than copy/paste,\n235\t it won't be used. *Mitigation:* one command, mechanical, predictable output;\n236\t non-destructive so it's safe to run freely.\n237\t- **Spec-churn risk** — OKF is a v0.1 draft and may change. *Mitigation:* pin to\n238\t v0.1; isolate conformance rules; track the spec repo.\n239\t- **Scope risk** — the MVP already touches several surfaces. *Mitigation:* strict\n240\t phasing; export/ingest/codebase are explicit non-goals for the MVP.\n241\t- **Standard-drift risk** — free-text types fragment the catalog. *Mitigation:*\n242\t `kb.toml` vocabulary + lint warning + `--strict` in CI.\n243\t- **Identity-drift risk** — pressure to add LLM \"magic\" to `promote`.\n244\t *Mitigation:* keep the core non-LLM by decision (ADR-002); defer any\n245\t `--distill` flag.\n246\t- **Interop-expectation risk** — bundles must be consumable outside `kb`.\n247\t *Mitigation:* validate against official samples; emit what real tooling expects\n248\t (four fields, relative links).\n249\t\n250\t## Architecture Decision Records\n251\t\n252\t- [ADR-001: OKF as a first-class authoring mode, not an export-only projection](adrs/adr-001.md)\n253\t — OKF is a first-class per-topic `mode`, an OKF bundle is a `kb` topic, and the\n254\t MVP optimizes the research → operations distill loop with phased delivery.\n255\t- [ADR-002: promote is a mechanical non-LLM transform; type governance and a spec-compatible conformance posture](adrs/adr-002.md)\n256\t — `promote` is mechanical, non-destructive, and non-LLM; `type` is explicit and\n257\t governed by a local `kb.toml` vocabulary; conformance emits four fields + relative\n258\t links and validates lenient with local warnings and `--strict`.\n259\t\n260\t## Open Questions\n261\t\n262\t- **Conformance command surface** — `kb okf check \u003cbundle\u003e` vs `kb lint --okf`\n263\t (naming/placement). Defer to TechSpec.\n264\t- **Catalog granularity** — within the operator's `second-brain/`, do sub-areas\n265\t (`identidade/`, `estudos/`, `conteudo/`, `vendas/`) map to one OKF topic with\n266\t nested concept directories, or to multiple OKF topics? Needs confirmation; both\n267\t are valid OKF.\n268\t- **index.md / log.md ownership** — assumed auto-maintained by `kb`\n269\t (generated/updated on scaffold + promote). Confirm whether operators ever\n270\t hand-edit them.\n271\t- **Off-vocabulary type handling** — assumed \"warn + allow + extend via\n272\t `kb.toml`\". Confirm whether `promote` should suggest the closest existing type.\n273\t- **Broken-link reporting on promote** — promoting a concept whose wikilinks point\n274\t to not-yet-promoted concepts yields tolerated broken links (per §9). Confirm\n275\t whether the operator wants a warning listing them.\n276\t\n```"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:20:42.624844Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01VU977q6cpSK8XF81rQDxn6","blocks":[{"type":"tool_use","id":"toolu_01VU977q6cpSK8XF81rQDxn6","name":"Read","title":"Read .compozy/tasks/okf-dual-mode/_techspec.md","input":{"file_path":"/Users/pedronauck/Dev/compozy/kb/.compozy/tasks/okf-dual-mode/_techspec.md"},"raw_input":{"file_path":"/Users/pedronauck/Dev/compozy/kb/.compozy/tasks/okf-dual-mode/_techspec.md"}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:20:42.629617Z","turn":1,"type":"session.update","update":{"kind":"tool_call_started","tool_call_id":"toolu_01UYZU6YDaD66XAKQfp5cEMw","tool_call_state":"pending","blocks":[{"type":"tool_use","id":"toolu_01UYZU6YDaD66XAKQfp5cEMw","name":"Read","title":"Read File","raw_input":{}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:20:42.63499Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01VU977q6cpSK8XF81rQDxn6","usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:20:42.64895Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01VU977q6cpSK8XF81rQDxn6","tool_call_state":"completed","blocks":[{"type":"tool_result","tool_use_id":"toolu_01VU977q6cpSK8XF81rQDxn6","content":"```\n1\t# TechSpec: OKF Dual-Mode for `kb`\n2\t\n3\t\u003e Implements `_prd.md`. Business context (WHAT/WHY/WHO) lives there; this document\n4\t\u003e covers HOW. Phasing follows the PRD: **MVP** = `mode` + OKF scaffold +\n5\t\u003e `kb promote` + `kb okf check`; Phase 2 = export/ingest; Phase 3 = codebase→OKF.\n6\t\n7\t## Executive Summary\n8\t\n9\tAdd OKF as a first-class topic mode to `kb` by (1) introducing a `mode: wiki|okf`\n10\tfield on the topic model, (2) abstracting link emission behind a mode-aware\n11\t`LinkFormatter`, (3) housing all OKF behavior in a new cohesive `internal/okf`\n12\tpackage (frontmatter contract, wikilink→markdown transform, `index.md`/`log.md`\n13\tgeneration, conformance checker, and the `promote` orchestration), and (4) exposing\n14\ta top-level `kb promote` plus a `kb okf` command group. OKF emission stays\n15\t**mechanical and non-LLM**, matching `kb`'s identity; conformance is **lenient per\n16\tOKF §9** with local-standard **warnings** and a `--strict` gate, emitting the four\n17\tproducer fields and relative, GitHub-safe links to match Google's real tooling.\n18\t\n19\t**Primary trade-off:** per the user's decision (ADR-004), the MVP introduces the\n20\tfull `LinkFormatter` abstraction and migrates **all ~43 link call sites** in the\n21\tcodebase→wiki render pipeline now, even though that pipeline produces wiki-mode\n22\ttopics and its OKF branch stays **dormant until Phase 3**. We accept a large\n23\tmechanical refactor with no MVP-exercised OKF render path in exchange for a single,\n24\tfinished mode-aware link seam — guarded against regression by byte-identical golden\n25\ttests of wiki output. The MVP's *active* OKF link consumer is `promote` (body\n26\ttransform) and `index.md` generation.\n27\t\n28\t## System Architecture\n29\t\n30\t### Component Overview\n31\t\n32\t| Component | Home | Responsibility |\n33\t| --- | --- | --- |\n34\t| `mode` field | `internal/models`, `internal/topic` | Mark a topic as `wiki` (default) or `okf`; normalize empty→`wiki`. |\n35\t| `LinkFormatter` | `internal/vault` | Mode-aware link rendering; `WikiLinkFormatter` + `OKFLinkFormatter`. |\n36\t| OKF core | `internal/okf` (new) | Frontmatter contract, link transform, `index.md`/`log.md` gen, conformance checker, `promote`. |\n37\t| Type vocabulary | `internal/config` | `[okf].types` canonical list for local-standard validation. |\n38\t| OKF scaffold | `internal/topic` | `--mode okf` builds a minimal bundle (no wiki pyramid). |\n39\t| CLI | `internal/cli` | `kb promote`, `kb okf check`, `kb topic new --mode`. |\n40\t\n41\t### Data Flow\n42\t\n43\t- **Scaffold:** `kb topic new --mode okf` → `internal/topic` writes `topic.yaml`\n44\t (`mode: okf`), a root `index.md` (`okf_version: \"0.1\"`), `log.md`, an OKF-flavored\n45\t `CLAUDE.md`, and the `AGENTS.md` symlink. No `raw/`/`wiki/`/`outputs/`.\n46\t- **Promote:** `kb promote \u003cwiki-doc\u003e --to \u003ctopic\u003e --type \u003cT\u003e` → `internal/okf`\n47\t reads the source via `vault` + `frontmatter.Parse`, remaps frontmatter to the OKF\n48\t contract, transforms `[[wikilinks]]`→relative markdown links, writes the new\n49\t concept to `\u003cbundle-root\u003e/\u003ckey(base(SourceDocPath))\u003e.md` (see **Concept Path\n50\t Model**) via `frontmatter.Generate` + `vault`, appends to `log.md`, and regenerates\n51\t `index.md` (preserving the root `okf_version`). Source doc is untouched.\n52\t- **Check:** `kb okf check \u003ctopic\u003e` → `internal/okf` walks the bundle, **skipping the\n53\t reserved/excluded set** (`index.md`, `log.md`, `CLAUDE.md`, `AGENTS.md`, symlinks,\n54\t `README`/license files), validates §9 leniently over the remaining concept files,\n55\t emits `[]models.LintIssue` rendered by `internal/output`.\n56\t\n57\tDependency direction is one-way: `internal/okf` → {`frontmatter`, `vault`,\n58\t`config`, `models`}; nothing imports `okf` back (ADR-003).\n59\t\n60\t## Implementation Design\n61\t\n62\t### Core Interfaces\n63\t\n64\tMode-aware link rendering, defined in `internal/vault` (ADR-004):\n65\t\n66\t```go\n67\t// LinkFormatter renders a link from the document being written (fromDir,\n68\t// bundle-relative, forward-slash) to a target document, with an optional label.\n69\ttype LinkFormatter interface {\n70\t Link(fromDir, targetPath, label string) string\n71\t}\n72\t\n73\t// LinkFormatterFor selects the formatter from the topic's mode.\n74\tfunc LinkFormatterFor(topic models.TopicMetadata) LinkFormatter {\n75\t if topic.Mode == models.TopicModeOKF {\n76\t return OKFLinkFormatter{}\n77\t }\n78\t return WikiLinkFormatter{Slug: topic.Slug}\n79\t}\n80\t```\n81\t\n82\tOKF promotion and conformance, in `internal/okf`:\n83\t\n84\t```go\n85\ttype PromoteInput struct {\n86\t SourceDocPath string // wiki doc to read (vault-relative)\n87\t TargetTopic models.TopicInfo // an OKF-mode topic (the bundle)\n88\t Type string // OKF concept type (validated vs config.OKF.Types)\n89\t}\n90\t\n91\t// ConceptResult reports what promote wrote; UnresolvedLinks tolerated (§9).\n92\ttype ConceptResult struct {\n93\t WrittenPath string // bundle-relative path of the new concept\n94\t Type string\n95\t LinksRewritten int // [[wikilink]] occurrences converted\n96\t UnresolvedLinks []string // targets with no promoted counterpart yet\n97\t}\n98\t\n99\ttype Promoter interface {\n100\t Promote(ctx context.Context, in PromoteInput) (ConceptResult, error)\n101\t}\n102\t\n103\ttype Checker interface {\n104\t // Check walks an OKF bundle; strict promotes warnings to errors.\n105\t Check(ctx context.Context, bundlePath string, strict bool) ([]models.LintIssue, error)\n106\t}\n107\t```\n108\t\n109\t### Data Models\n110\t\n111\tAdditions only; existing structs are unchanged except for one field each.\n112\t\n113\t```go\n114\t// internal/models\n115\ttype TopicMode string // defined type (not an alias) per ADR-003\n116\tconst (\n117\t TopicModeWiki TopicMode = \"wiki\"\n118\t TopicModeOKF TopicMode = \"okf\"\n119\t)\n120\t// TopicMetadata and TopicInfo each gain: Mode TopicMode `json:\"mode\"`\n121\t\n122\t// internal/topic topicMetadataFile gains:\n123\t// Mode string `yaml:\"mode,omitempty\"` // empty normalized to \"wiki\" on read\n124\t\n125\t// internal/config\n126\ttype OKFConfig struct {\n127\t Types []string `toml:\"types\"` // local vocabulary; ships EMPTY (no false warnings)\n128\t}\n129\t// Config gains: OKF OKFConfig `toml:\"okf\"`\n130\t// Empty Types ⇒ the type-vocabulary check is a no-op until the operator opts in.\n131\t```\n132\t\n133\t**OKF frontmatter contract — source→OKF remap (B-201).** `promote` and authoring\n134\twrite the four producer fields via this deterministic map; wiki stage markers\n135\t(`stage`, wiki `type`) are dropped:\n136\t\n137\t| OKF field | Source | Fallback |\n138\t| --- | --- | --- |\n139\t| `type` | `--type` (validated vs `config.OKF.Types`) | none — required; error if absent |\n140\t| `title` | source `title` | humanized `base(SourceDocPath)` |\n141\t| `description` | `--description` flag | first non-empty body sentence (markdown-stripped) → else empty + warning |\n142\t| `timestamp` | promote-time clock, RFC3339 / UTC | — (promotion *is* the \"last meaningful change\"; source `scraped`/`created` are date-only `2006-01-02`, so they are not the timestamp source) |\n143\t| `tags` | source `tags` when present | omitted |\n144\t\n145\tThe clock is injected as a seam (a `Clock` function) so golden/integration tests\n146\tassert a fixed `timestamp` instead of freezing a wall-clock literal.\n147\t\n148\t**Field order:** `frontmatter.Generate` sorts keys alphabetically\n149\t(`internal/frontmatter/frontmatter.go:396`), so concepts emit `description, tags,\n150\ttimestamp, title, type`. This is conformant (YAML maps are unordered); tests MUST\n151\tNOT assert a `type`-first producer order. If byte-matching the official samples is\n152\tever required, that needs a dedicated ordered encoder (out of scope).\n153\t\n154\t### Concept Path Model (resolves B-001 / B-002 / B-203)\n155\t\n156\tFor the MVP an OKF bundle is **one topic**, and promoted concepts land **flat at the\n157\tbundle root** (`type`-grouped or nested subdirectories are deferred). A **single\n158\tcanonical key** drives both the filename and every inbound link, so they can never\n159\tdiverge (B-203):\n160\t\n161\t- **Canonical key:** `vault.SlugifySegment(base(path))` — the existing slug primitive\n162\t (`internal/vault/pathutils.go:98`; lowercase `[a-z0-9-]`, `\"item\"` empty fallback,\n163\t drops accents so `Conteúdo`→`conte-do`). `internal/okf` reuses it (N-201) rather\n164\t than re-implementing a slug.\n165\t- **Filename:** a concept from `SourceDocPath` is written to\n166\t `\u003ctopic-root\u003e/\u003ckey(base(SourceDocPath))\u003e.md`. Collisions get a numeric suffix\n167\t (`-2`, `-3`, …).\n168\t- **Wikilink target mapping:** a body link `[[srcSlug/docPath|label]]` strips the\n169\t source slug and maps to `\u003ctopic-root\u003e/\u003ckey(base(docPath))\u003e.md` — the **same** key\n170\t the target's own filename uses, so a link to an already-promoted concept always\n171\t resolves to its real file. `OKFLinkFormatter.Link(fromDir, target, label)` renders\n172\t it; with a flat root `fromDir == targetDir`, the link is `[label](\u003ckey\u003e.md)`. A\n173\t `#anchor` fragment is carried through: `[label](\u003ckey\u003e.md#anchor)` (N-203).\n174\t- **Unresolved targets:** when no promoted counterpart exists yet, the link is still\n175\t emitted to the would-be path (tolerated per §9) and recorded in\n176\t `ConceptResult.UnresolvedLinks` (logged; a warning under `--strict`).\n177\t- **`--to`** names the target OKF topic and **must resolve to a `mode: okf` topic** —\n178\t `promote` errors otherwise (N-202), so wiki-formatter output never leaks into a\n179\t bundle. `--to \u003ctopic\u003e/\u003csubdir\u003e` writes under that subdir (`fromDir` updates\n180\t accordingly); the default is the bundle root.\n181\t\n182\t\u003e Filename trade-off: keying off the source path (not `title`) guarantees\n183\t\u003e symmetry/correctness at the cost of less pretty names for ingested docs (a slug of\n184\t\u003e a source path/URL). Title-named files with an index/manifest-based link resolver is\n185\t\u003e a deliberate Phase-2 option, not MVP.\n186\t\n187\t\u003e Resolves the PRD open question \"catalog granularity\": for the MVP a bundle is one\n188\t\u003e OKF topic with concepts at its root. Splitting `second-brain/` sub-areas\n189\t\u003e (`identidade`, `estudos`, …) into separate OKF topics, or introducing nested\n190\t\u003e concept directories, is a deliberate Phase-2 follow-up, not MVP behavior.\n191\t\n192\t### Command Surface (CLI)\n193\t\n194\t| Command | Args / Flags | Behavior |\n195\t| --- | --- | --- |\n196\t| `kb topic new \u003cslug\u003e \u003ctitle\u003e \u003cdomain\u003e` | `--mode wiki\\|okf` (default `wiki`) | Wiki scaffold (unchanged) or minimal OKF scaffold. |\n197\t| `kb promote \u003cwiki-doc\u003e` | `--to \u003ctopic\u003e`, `--type \u003cT\u003e`, `--description \u003ctext\u003e` | Mechanical, non-destructive wiki→OKF concept; `--to` must be a `mode: okf` topic. |\n198\t| `kb okf check \u003ctopic\u003e` | `--strict`, `--format table\\|json\\|tsv` | OKF v0.1 conformance + local-standard warnings. |\n199\t\n200\tPhase 2 grows the group: `kb okf export`, `kb okf ingest` (out of MVP scope).\n201\t\n202\t## Integration Points\n203\t\n204\tNo external services. The only \"integration\" is the **OKF v0.1 file format**\n205\t(`GoogleCloudPlatform/knowledge-catalog` → `okf/SPEC.md`). Two deliberate,\n206\tdocumented deviations from the written spec, matching Google's reference tooling\n207\t(ADR-002): emit the four producer fields (spec mandates only `type`) and emit\n208\trelative links (spec recommends absolute `/path.md`, which breaks GitHub\n209\trendering). The official sample bundles are vendored as test fixtures (ADR-005),\n210\tnot a runtime dependency.\n211\t\n212\t## Impact Analysis\n213\t\n214\t| Component | Impact Type | Description and Risk | Required Action |\n215\t| --- | --- | --- | --- |\n216\t| `internal/okf` | new | Cohesive OKF package; isolated, low blast radius. | Build per ADR-003/004/005. |\n217\t| `internal/vault` (pathutils, render, render_wiki) | modified | `LinkFormatter` + route ~43 call sites; **regression risk** in wiki output. | Golden byte-identical wiki tests; keep suite green. |\n218\t| `internal/models` | modified | Add `Mode` to `TopicMetadata`/`TopicInfo` + mode consts; low risk. | Add fields/consts. |\n219\t| `internal/topic` | modified | `Mode` on `topicMetadataFile`; mode-conditional scaffold + OKF templates; medium risk (scaffold branch). | Normalize empty→wiki; add OKF assets. |\n220\t| `internal/config` | modified | Add `[okf].types`; low risk (additive). | Add struct + example + defaults. |\n221\t| `internal/cli` | modified | `kb promote`, `kb okf` group, `topic new --mode`; low risk. | Wire commands per existing patterns. |\n222\t| `internal/lint` | unchanged | OKF uses a separate checker (ADR-005); wiki lint untouched. | None. |\n223\t| existing wiki topics | unchanged | Default mode wiki ⇒ zero behavior change. | Verify via existing tests. |\n224\t\n225\t## Testing Approach\n226\t\n227\t### Unit Tests\n228\t\n229\t- **`OKFLinkFormatter.Link`** — table-driven relative-path cases: sibling, child,\n230\t parent (`../`), `fromDir == targetDir`, bundle root; never a leading `/`.\n231\t- **`WikiLinkFormatter.Link`** — asserts byte-identical output to today's\n232\t `ToTopicWikiLink` (with and without label).\n233\t- **Wikilink→markdown body transform** — `[[a]]`, `[[a|label]]`, `[[a#anchor]]`,\n234\t links to not-yet-promoted concepts (tolerated/flagged).\n235\t- **Frontmatter remap (B-201)** — source→OKF per the remap table; `description`\n236\t fallback chain (flag → first body sentence → empty + warning); `timestamp` from the\n237\t injected clock, asserted as a fixed RFC3339 value; stage markers dropped.\n238\t- **Reserved/excluded files (B-202)** — `CLAUDE.md`, `AGENTS.md` (and the symlink),\n239\t `index.md`, `log.md`, `README`/license are NOT flagged for a missing `type`; a\n240\t freshly scaffolded-then-promoted bundle passes `kb okf check`.\n241\t- **Reject non-okf target (N-202)** — `promote --to \u003cwiki-topic\u003e` errors before writing.\n242\t- **`--type` vocabulary** — in-vocabulary (ok), out-of-vocabulary (warning),\n243\t empty (error).\n244\t- **mode normalization** — empty/absent `mode` → `wiki`; invalid explicit value\n245\t rejected at scaffold.\n246\t- **Conformance rules** — missing `type` (error), reserved files excluded,\n247\t `index.md`/`log.md` shape, `--strict` promotion.\n248\t- **Cross-concept wikilink mapping** — `[[srcSlug/Foo Bar|Foo]]` → `[Foo](foo-bar.md)`;\n249\t assert the exact emitted relative path; filename collision → `-2` suffix;\n250\t unresolved target recorded in `ConceptResult.UnresolvedLinks`.\n251\t- **OKF render smoke (N-001)** — render one document for a `mode: okf` topic and\n252\t assert the OKF link branch and its `fromDir` plumbing execute and emit a relative\n253\t markdown link, so the wired render branch is live, not shipped dead.\n254\t\n255\t### Integration Tests (`//go:build integration`)\n256\t\n257\t- **Conformance against vendored official bundles** (GA4, Stack Overflow,\n258\t crypto_bitcoin) — all pass leniently (ADR-005).\n259\t- **Negative fixtures** — missing `type`, unterminated frontmatter, list-not-\n260\t mapping frontmatter → expected errors.\n261\t- **Must-tolerate fixtures** — broken link, unknown `type`, frontmatter-less\n262\t `index.md` → no error under lenient mode.\n263\t- **End-to-end promote** — scaffold an OKF topic, ingest+compile a small wiki\n264\t topic, `kb promote` a concept, assert: source untouched, concept written with\n265\t four fields + relative links, `log.md` appended, `index.md` updated, and\n266\t `kb okf check` passes.\n267\t- **Zero-regression** — an existing wiki topic renders byte-identically and\n268\t `kb lint` output is unchanged.\n269\t\n270\tUse `t.TempDir()` for filesystem isolation; co-locate tests with their package.\n271\t\n272\t## Development Sequencing\n273\t\n274\t### Build Order\n275\t\n276\t1. **`mode` data model** — add `TopicMode` consts + `Mode` on\n277\t `models.TopicMetadata`/`TopicInfo`; add `Mode` to `topicMetadataFile` with\n278\t empty→wiki normalization in `readTopicYAMLMetadata`. *No dependencies.*\n279\t2. **`internal/config` `[okf].types`** — add `OKFConfig`, defaults, env/TOML load,\n280\t `config.example.toml`. *No dependencies.*\n281\t3. **`LinkFormatter` in `internal/vault`** — interface + `WikiLinkFormatter`\n282\t (wraps current behavior) + `OKFLinkFormatter` + `LinkFormatterFor`. *Depends on\n283\t step 1 (reads `topic.Mode`).*\n284\t4. **Migrate the ~43 call sites** through `linkFor(topic, fromDir, target, label)`;\n285\t add golden wiki-output tests. *Depends on step 3.*\n286\t5. **OKF scaffold** — `internal/topic` mode-conditional dirs + OKF `CLAUDE.md`/\n287\t `index.md`(`okf_version`)/`log.md` templates. Extend `topic.New`/`newWithDate`\n288\t with a `mode` parameter (or a `NewWithMode` variant) and write an explicit `mode`\n289\t value into `topic.yaml`. *Depends on step 1.*\n290\t6. **`internal/okf` emission primitives** — frontmatter contract, wikilink→markdown\n291\t transform (reusing step 3's `OKFLinkFormatter`), `index.md`/`log.md` generation.\n292\t *Depends on steps 2, 3.*\n293\t7. **`promote` orchestration** — read source, remap, transform, write concept,\n294\t append log, regenerate index. *Depends on steps 5, 6.*\n295\t8. **Conformance checker** — recursive walk, §9 lenient + local warnings +\n296\t `--strict`, emitting `models.LintIssue`. *Depends on step 2 (vocabulary).*\n297\t9. **CLI wiring** — `topic new --mode`, `kb promote`, `kb okf check`. *Depends on\n298\t steps 5, 7, 8.*\n299\t10. **Fixtures + integration tests** — vendor official bundles, negatives,\n300\t must-tolerate, end-to-end promote, zero-regression. *Depends on steps 7, 8, 9.*\n301\t\n302\t### Technical Dependencies\n303\t\n304\t- None external. Vendoring the official bundles requires keeping their Apache-2.0\n305\t license/attribution under `testdata/`.\n306\t\n307\t## Monitoring and Observability\n308\t\n309\tCLI tool — observability is structured logging via `internal/logger` (slog) and\n310\tcommand output:\n311\t\n312\t- **Log events** (debug/info): `promote` source/target/type, link-transform count,\n313\t broken-link count, `promote rejected: target not okf` (N-202), `description fallback\n314\t used`; `okf check` files-scanned, `reserved file skipped` (N-205), errors, warnings,\n315\t strict flag.\n316\t- **Exit codes**: `okf check` returns non-zero on errors (and on warnings under\n317\t `--strict`) for CI gating.\n318\t- **Report output**: `okf check` renders via `internal/output` (table/json/tsv),\n319\t consistent with `kb lint`.\n320\t\n321\t## Technical Considerations\n322\t\n323\t### Key Decisions\n324\t\n325\t- **Cohesive `internal/okf` package** (ADR-003). *Rationale:* one testable home,\n326\t clean phase growth. *Trade-off:* a new package vs maximal reuse.\n327\t- **Full `LinkFormatter` migration in the MVP** (ADR-004). *Rationale:* one\n328\t finished mode-aware seam. *Trade-off:* large refactor whose OKF render branch is\n329\t dormant until Phase 3; mitigated by golden tests.\n330\t- **Dedicated conformance checker, lenient + `--strict`** (ADR-002/005).\n331\t *Rationale:* OKF is structurally unlike the wiki pyramid and is permissive by\n332\t spec. *Trade-off:* two checking engines coexist.\n333\t- **Non-LLM mechanical `promote`** (ADR-002). *Rationale:* preserves `kb`'s\n334\t identity and determinism. *Trade-off:* distillation stays a human/agent job.\n335\t- **`kb promote` top-level + `kb okf` group** (ADR-006). *Rationale:* prominence\n336\t for the daily verb, cohesive namespace for the ecosystem.\n337\t\n338\t### Known Risks\n339\t\n340\t- **Wiki render regression** during the 43-site migration (medium likelihood,\n341\t high impact) → byte-identical golden tests + keep the `vault` suite green;\n342\t default formatter is wiki when `mode != okf`.\n343\t- **OKF relative-path correctness** (medium) → exhaustive table-driven tests\n344\t including parent/sibling/root.\n345\t- **OKF v0.1 is a draft** (medium) → isolate the ruleset behind a versioned module;\n346\t track the spec repo.\n347\t- **Vocabulary ignored in practice** (low) → `--strict` in CI enforces it where it\n348\t matters.\n349\t- **Vendored-fixture drift / license** (low) → pin a commit, retain attribution.\n350\t\n351\t## Architecture Decision Records\n352\t\n353\t- [ADR-001: OKF as a first-class authoring mode, not an export-only projection](adrs/adr-001.md) — OKF is a first-class per-topic mode with phased delivery (product).\n354\t- [ADR-002: promote is a mechanical non-LLM transform; type governance and a spec-compatible conformance posture](adrs/adr-002.md) — mechanical/non-destructive promote, `--type` + `kb.toml` vocabulary, emit-four + relative links, lenient validation (product).\n355\t- [ADR-003: A cohesive `internal/okf` package and a `mode` field on the topic model](adrs/adr-003.md) — one-way-dependency OKF package + `mode` on the topic model (default wiki).\n356\t- [ADR-004: A `LinkFormatter` abstraction and full migration of the link call sites](adrs/adr-004.md) — mode-aware `LinkFormatter` in `vault`; migrate all ~43 sites in the MVP; OKF render branch dormant until Phase 3.\n357\t- [ADR-005: A dedicated OKF conformance checker with vendored official fixtures](adrs/adr-005.md) — separate checker in `internal/okf`, lenient §9 + local warnings + `--strict`, vendored official bundles as fixtures.\n358\t- [ADR-006: CLI surface — `kb promote` top-level and a `kb okf` command group](adrs/adr-006.md) — top-level `kb promote`, `kb okf check` group, `topic new --mode`.\n359\t\n```"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:20:42.987682Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01UYZU6YDaD66XAKQfp5cEMw","blocks":[{"type":"tool_use","id":"toolu_01UYZU6YDaD66XAKQfp5cEMw","name":"Read","title":"Read .codex/plans/20260627-220335-okf-dual-mode.md","input":{"file_path":"/Users/pedronauck/Dev/compozy/kb/.codex/plans/20260627-220335-okf-dual-mode.md"},"raw_input":{"file_path":"/Users/pedronauck/Dev/compozy/kb/.codex/plans/20260627-220335-okf-dual-mode.md"}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:20:43.000671Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01UYZU6YDaD66XAKQfp5cEMw","usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:20:43.008693Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01UYZU6YDaD66XAKQfp5cEMw","tool_call_state":"completed","blocks":[{"type":"tool_result","tool_use_id":"toolu_01UYZU6YDaD66XAKQfp5cEMw","content":"```\n1\t# OKF Dual-Mode MVP Implementation Plan\n2\t\n3\t## Summary\n4\t\n5\t- Add OKF as a first-class topic mode while keeping existing wiki behavior as the default and preserving current wiki outputs.\n6\t- Root-cause/no-workarounds framing: the current code is wiki-only because topic metadata, scaffold shape, link emission, and linting have no lifecycle mode. Fix the source by modeling mode explicitly, routing links through a real formatter seam, and isolating OKF behavior in `internal/okf`; do not patch symptoms with scattered missing-directory fallbacks, lint suppressions, broad error swallowing, or duplicated slug/link logic.\n7\t- Implement only Phase 1 from the PRD/TechSpec: no `okf export`, no `okf ingest`, no codebase-to-OKF, and no LLM-assisted distillation.\n8\t- Current baseline: `go test ./...` passes. `make test` is blocked locally by the `mise` `mage` shim having no Go version selected; final completion still requires `make verify` once that environment issue is fixed.\n9\t\n10\t## Public Interfaces And Contracts\n11\t\n12\t- Topic metadata:\n13\t - Add `models.TopicMode` as a defined type with `wiki` and `okf`.\n14\t - Add `Mode models.TopicMode` to `models.TopicMetadata` and `models.TopicInfo` with JSON field `mode`.\n15\t - Empty or absent `mode` in existing `topic.yaml` reads as `wiki`; explicit invalid mode from CLI/scaffold is rejected with a clear error.\n16\t- Topic creation:\n17\t - Keep `topic.New(vaultPath, slug, title, domain)` as the backwards-compatible wiki default.\n18\t - Add `topic.NewWithMode(vaultPath, slug, title, domain, mode)` and internal dated helper support.\n19\t - Add `kb topic new \u003cslug\u003e \u003ctitle\u003e \u003cdomain\u003e --mode wiki|okf`, defaulting to `wiki`.\n20\t- Config:\n21\t - Add `[okf].types` to `internal/config` and `config.example.toml`.\n22\t - Default type vocabulary is empty, meaning vocabulary validation is disabled until the operator opts in.\n23\t- CLI:\n24\t - Add top-level `kb promote \u003cwiki-doc\u003e --to \u003cokf-topic\u003e --type \u003cType\u003e [--description \u003ctext\u003e]`.\n25\t - Add `kb okf check \u003ctopic\u003e [--strict] [--format table|json|tsv]`.\n26\t - `promote --to` must resolve to a `mode: okf` topic and rejects wiki targets before writing.\n27\t - `promote` outputs JSON `ConceptResult` including written path, type, rewritten-link count, unresolved links, and non-fatal warnings.\n28\t\n29\t## Implementation Changes\n30\t\n31\t- Topic scaffold:\n32\t - Wiki scaffolds keep the existing `raw/`, `wiki/`, `outputs/`, and `bases/` structure and write explicit `mode: wiki` in newly created `topic.yaml`.\n33\t - OKF scaffolds write only the OKF bundle surface: `topic.yaml` with `mode: okf`, root `index.md` with `okf_version: \"0.1\"` frontmatter, `log.md`, OKF-flavored `CLAUDE.md`, and `AGENTS.md` symlink.\n34\t - Topic info/counting becomes mode-aware: wiki topics count current raw/wiki files; OKF topics count non-reserved concept markdown files and do not assume `raw/` or `wiki/` exists.\n35\t- Link formatting:\n36\t - Add `vault.LinkFormatter`, `WikiLinkFormatter`, `OKFLinkFormatter`, and `LinkFormatterFor(topic)`.\n37\t - Migrate all current `ToTopicWikiLink`/`toSourceWikiLink` render call sites through one helper that accepts `fromDir`, target path, and label.\n38\t - `WikiLinkFormatter` must remain byte-identical to current `[[topic/path|label]]` behavior.\n39\t - `OKFLinkFormatter` emits relative Markdown links, never leading `/`; empty labels derive from the target basename without `.md`.\n40\t- OKF package:\n41\t - Create `internal/okf` for promotion, frontmatter remapping, wikilink transformation, index/log generation, conformance checking, and test fixtures.\n42\t - Dependencies stay one-way: `internal/okf` may import `frontmatter`, `vault`, `config`, and `models`; those packages must not import `okf`.\n43\t- Promote:\n44\t - Resolve `\u003cwiki-doc\u003e` as an existing Markdown file, accepting absolute/relative filesystem paths and vault-relative paths where possible.\n45\t - Parse source frontmatter with `frontmatter.Parse`; invalid frontmatter is an error, not ignored.\n46\t - Canonical concept key is `vault.SlugifySegment(vault.StripMarkdownExtension(path.Base(sourceTopicRelativePath)))`; output file is `\u003ckey\u003e.md`, with `-2`, `-3`, etc. only for collisions.\n47\t - Frontmatter mapping: `type` from `--type`; `title` from source title or humanized source basename; `description` from `--description`, then first markdown-stripped body sentence, then empty plus warning; `timestamp` from injected UTC clock in RFC3339; source `tags` preserved when present; wiki `stage` and wiki `type` dropped.\n48\t - Convert `[[target]]`, `[[target|label]]`, and `[[target#anchor|label]]` to relative Markdown links using the same canonical key derivation. Links to not-yet-promoted concepts point to the would-be path and are recorded as unresolved, not failed.\n49\t - Source file remains untouched.\n50\t- Index and log:\n51\t - Regenerate root `index.md` from concept files, grouped by `type`, sorted deterministically, preserving root `okf_version`.\n52\t - Use relative links in index entries and include descriptions when available.\n53\t - Maintain `log.md` in OKF §7 shape with `## YYYY-MM-DD` date headings and newest entries first. This supersedes loose “append” wording because the official OKF spec requires newest-first logs.\n54\t- Conformance:\n55\t - Walk recursively, skip symlinks, and exclude reserved/non-concept files: `index.md`, `log.md`, `CLAUDE.md`, `AGENTS.md`, README files, license/notice/attribution files.\n56\t - For each concept `.md`, require parseable YAML frontmatter and non-empty `type`.\n57\t - Warn, but do not error by default, for missing producer fields (`title`, `description`, `timestamp`) and out-of-vocabulary `type` when `[okf].types` is non-empty.\n58\t - Broken links, unknown types, unknown frontmatter keys, and missing optional fields remain tolerated under lenient mode.\n59\t - `--strict` promotes warnings to errors and returns a non-zero exit code if any error-level issue exists.\n60\t- Official fixtures:\n61\t - Vendor the official sample bundles under `internal/okf/testdata/official/` and keep Apache-2.0 attribution/license material.\n62\t - Verified upstream sources:\n63\t - OKF spec: https://github.com/GoogleCloudPlatform/knowledge-catalog/blob/main/okf/SPEC.md\n64\t - OKF README and sample bundle references: https://github.com/GoogleCloudPlatform/knowledge-catalog/blob/main/okf/README.md\n65\t - Bundles: https://github.com/GoogleCloudPlatform/knowledge-catalog/tree/main/okf/bundles\n66\t - License: https://github.com/GoogleCloudPlatform/knowledge-catalog/blob/main/okf/LICENSE.md\n67\t\n68\t## Test Plan\n69\t\n70\t- Unit tests:\n71\t - `internal/topic`: wiki scaffold unchanged except explicit new `mode: wiki`; OKF scaffold has no wiki pyramid; absent mode reads as wiki; invalid CLI mode errors.\n72\t - `internal/config`: `[okf].types` loads, trims, defaults empty, and rejects unknown TOML keys normally.\n73\t - `internal/vault`: `WikiLinkFormatter` byte-identical to current `ToTopicWikiLink`; `OKFLinkFormatter` covers root, sibling, child, parent, fragment, and no-leading-slash cases.\n74\t - `internal/okf`: frontmatter remap, injected clock timestamp, description fallback warning, canonical key derivation, collision suffixing, wikilink transform, unresolved-link reporting, index regeneration, and newest-first log insertion.\n75\t - `internal/okf` checker: missing/invalid frontmatter, missing type, reserved-file skipping, producer warnings, vocabulary warnings, strict promotion, index/log shape checks.\n76\t - `internal/cli`: `topic new --mode`, `promote` flag validation and non-OKF target rejection, `okf check` format/strict handling and exit behavior.\n77\t- Integration tests:\n78\t - `kb topic new --mode okf` then `kb okf check` passes.\n79\t - Create a wiki topic/source doc, run `kb promote`, assert source unchanged, OKF concept written with four producer fields, relative links, log entry, regenerated index, and passing conformance.\n80\t - Official bundles `ga4`, `stackoverflow`, and `crypto_bitcoin` pass lenient conformance.\n81\t - Must-tolerate fixtures for broken links, unknown types, and missing optional fields pass lenient mode.\n82\t - Zero-regression wiki flow: existing render golden output and `kb lint` behavior stay unchanged.\n83\t- Verification commands:\n84\t - Run `go test ./...` during development.\n85\t - Run `make verify` as the non-negotiable completion gate after fixing the local `mise`/`mage` shim configuration.\n86\t - Run integration tests with `make test-integration` or the equivalent tagged Go command if the Make wrapper remains unavailable.\n87\t - After implementation and verification, run the requested `$cy-impl-peer-review`/implementation peer-review loop until the verdict is `SHIP`, fixing any blockers before completion.\n88\t\n89\t## Assumptions And Defaults\n90\t\n91\t- `--to` is topic-only for MVP. The TechSpec sentence about `--to \u003ctopic\u003e/\u003csubdir\u003e` is deferred because nested topic slugs already use `/`, making a combined topic/subdir parser ambiguous without a new interface.\n92\t- OKF concepts promoted by MVP land flat at the OKF bundle root.\n93\t- `index.md` is kb-owned and fully regenerated; operators should not hand-edit it expecting preservation.\n94\t- `log.md` preserves existing content but inserts new entries newest-first to satisfy OKF §7.\n95\t- No destructive git commands are needed. Unrelated dirty worktree entries are ignored.\n96\t- If the accepted plan changes, update this persisted plan file and ledger before implementation continues.\n97\t\n```"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:20:45.572119Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":""}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:20:45.572461Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":"Now the ADRs and project rule files."}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:20:45.574143Z","turn":1,"type":"session.update","update":{"kind":"tool_call_started","tool_call_id":"toolu_014PvPYf5pSFHi5aj4Hwah1z","tool_call_state":"pending","blocks":[{"type":"tool_use","id":"toolu_014PvPYf5pSFHi5aj4Hwah1z","name":"Read","title":"Read File","raw_input":{}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:20:46.340597Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_014PvPYf5pSFHi5aj4Hwah1z","blocks":[{"type":"tool_use","id":"toolu_014PvPYf5pSFHi5aj4Hwah1z","name":"Read","title":"Read .compozy/tasks/okf-dual-mode/adrs/adr-001.md","input":{"file_path":"/Users/pedronauck/Dev/compozy/kb/.compozy/tasks/okf-dual-mode/adrs/adr-001.md"},"raw_input":{"file_path":"/Users/pedronauck/Dev/compozy/kb/.compozy/tasks/okf-dual-mode/adrs/adr-001.md"}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:20:46.345584Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_014PvPYf5pSFHi5aj4Hwah1z","usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:20:46.353601Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_014PvPYf5pSFHi5aj4Hwah1z","tool_call_state":"completed","blocks":[{"type":"tool_result","tool_use_id":"toolu_014PvPYf5pSFHi5aj4Hwah1z","content":"```\n1\t# ADR-001: OKF as a first-class authoring mode, not an export-only projection\n2\t\n3\t## Status\n4\t\n5\tAccepted\n6\t\n7\t## Date\n8\t\n9\t2026-06-27\n10\t\n11\t## Context\n12\t\n13\tThe `kb` CLI implements the Karpathy \"LLM-wiki\" pattern: a research lab where raw\n14\tsources are ingested, compiled into a wiki, queried, and linted. Google Cloud\n15\treleased the Open Knowledge Format (OKF) v0.1 (2026-06-12), which formalizes the\n16\tsame markdown+YAML-frontmatter substrate but optimizes for a different purpose: a\n17\tportable, vendor-neutral **catalog/interchange** artifact that other people's\n18\tagents consume.\n19\t\n20\tThese are two distinct knowledge lifecycles on the same substrate:\n21\t\n22\t- **LLM-Wiki** = a *lab* (ingest → compile → query → lint; `[[wikilinks]]`;\n23\t `raw/` + `wiki/`; stage markers; for the operator to explore).\n24\t- **OKF** = a *catalog/API* (declare → consume; required `type` per concept;\n25\t markdown links; flat typed concepts; for others to consume; portable).\n26\t\n27\tThis mirrors the duality the user already maintains by hand in their workspace:\n28\t`research/` (LLM-wiki) where they research, and `second-brain/` (OKF) where they\n29\tdeclare \"what is mine\" for operations.\n30\t\n31\tAn earlier internal proposal treated OKF as an **export-only projection** of the\n32\twiki. The user found this too thin: it captures only the \"publish\" direction and\n33\tignores the daily, high-leverage flow of distilling research into the operational\n34\tcatalog. The question is whether OKF is a derived output of the wiki or a\n35\tfirst-class mode in its own right.\n36\t\n37\t## Decision\n38\t\n39\tMake OKF a **first-class authoring mode** of `kb`, selectable per topic, with\n40\tthe LLM-Wiki mode remaining the default.\n41\t\n42\t- Introduce a per-topic **`mode: wiki | okf`** (default `wiki`).\n43\t- An **OKF bundle is modeled as a `kb` topic with `mode: okf`** — it reuses the\n44\t existing topic, vault, lint, and index machinery rather than becoming a parallel\n45\t standalone concept. `mode` disambiguates scaffold shape, link format, frontmatter\n46\t contract, and lint ruleset.\n47\t- The MVP optimizes the **research → operations distill loop**: native OKF\n48\t authoring plus a `promote` interop verb (see ADR-002), not export.\n49\t- Deliver in phases:\n50\t - **MVP (Phase 1):** `mode` field, OKF scaffold (`kb topic new --mode okf`),\n51\t `kb promote` (wiki → OKF), `kb okf check` (conformance).\n52\t - **Phase 2:** `kb export okf` (publish a wiki topic as a portable bundle) and\n53\t `kb ingest okf` (consume an external bundle into research).\n54\t - **Phase 3:** codebase → OKF (the typed symbol/relation graph emitted as a\n55\t bundle) — the standout differentiator demo.\n56\t\n57\t## Alternatives Considered\n58\t\n59\t### Alternative 1: Export-only projection (OKF as a derived output)\n60\t\n61\t- **Description**: Add `kb export okf` that renders an existing wiki topic into an\n62\t OKF bundle; no OKF authoring, no `mode`, no interop loop.\n63\t- **Pros**: Smallest surface; no scaffold or link-format refactor; quick to ship.\n64\t- **Cons**: Captures only \"publish\"; cannot author or maintain operational\n65\t bundles; ignores the daily research→org distill the user lives; treats OKF as\n66\t second-class.\n67\t- **Why rejected**: Too thin; misses the highest-leverage use case.\n68\t\n69\t### Alternative 2: Standalone OKF concept (separate from topics)\n70\t\n71\t- **Description**: A new `kb okf init`/`kb okf …` surface independent of topics,\n72\t with its own metadata and storage.\n73\t- **Pros**: Clean conceptual separation between lab and catalog.\n74\t- **Cons**: Duplicates topic, vault, lint, and index machinery; two parallel\n75\t models to maintain; more user-facing surface to learn.\n76\t- **Why rejected**: `mode` on the existing topic achieves the same separation with\n77\t far less duplication and a smaller learning curve.\n78\t\n79\t### Alternative 3: Single unified model (one mode, derive both views)\n80\t\n81\t- **Description**: Keep one internal model and render either wiki or OKF on demand.\n82\t- **Pros**: No mode flag.\n83\t- **Cons**: The two lifecycles diverge on link format, frontmatter contract, and\n84\t conformance; forcing one model creates leaky, ambiguous behavior (e.g., the\n85\t `type` field means \"stage\" in wiki and \"concept kind\" in OKF).\n86\t- **Why rejected**: `mode` cleanly disambiguates the conflicts instead of hiding\n87\t them.\n88\t\n89\t## Consequences\n90\t\n91\t### Positive\n92\t\n93\t- The user's existing `research/` vs `second-brain/` split becomes\n94\t tool-enforced rather than maintained by hand.\n95\t- Default `mode: wiki` means zero regression for existing topics.\n96\t- Reusing the topic model keeps the new surface small and learnable.\n97\t- Phasing keeps an already-large MVP focused on the daily loop.\n98\t\n99\t### Negative\n100\t\n101\t- The MVP is larger than an export-only effort (mode + scaffold + two new verbs).\n102\t- A shared link-emission path must support both wikilinks and OKF markdown links.\n103\t\n104\t### Risks\n105\t\n106\t- **OKF is a v0.1 draft** → pin conformance to v0.1 and isolate the ruleset so it\n107\t can evolve without touching the rest of the tool.\n108\t- **Scope creep** (the MVP touches several surfaces) → strict phasing; export,\n109\t ingest, and codebase→OKF are explicit non-goals for the MVP.\n110\t\n111\t## Implementation Notes\n112\t\n113\tImplementation detail belongs in the TechSpec. At a product level: the link\n114\temission path is currently wiki-only and must become mode-aware; the topic\n115\tscaffold and lint ruleset must branch on `mode`. No existing behavior changes when\n116\t`mode` is absent or `wiki`.\n117\t\n118\t## References\n119\t\n120\t- PRD: `../_prd.md`\n121\t- ADR-002: promote semantics, type governance, and conformance posture\n122\t- OKF spec v0.1: `GoogleCloudPlatform/knowledge-catalog` → `okf/SPEC.md`\n123\t- Announcement: \"How the Open Knowledge Format can improve data sharing\" (Google\n124\t Cloud Blog, 2026-06-12)\n125\t\n```"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:20:46.467907Z","turn":1,"type":"session.update","update":{"kind":"tool_call_started","tool_call_id":"toolu_01SSJKezLaWCo5Avt6dK1r5M","tool_call_state":"pending","blocks":[{"type":"tool_use","id":"toolu_01SSJKezLaWCo5Avt6dK1r5M","name":"Read","title":"Read File","raw_input":{}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:20:47.294049Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01SSJKezLaWCo5Avt6dK1r5M","blocks":[{"type":"tool_use","id":"toolu_01SSJKezLaWCo5Avt6dK1r5M","name":"Read","title":"Read .compozy/tasks/okf-dual-mode/adrs/adr-002.md","input":{"file_path":"/Users/pedronauck/Dev/compozy/kb/.compozy/tasks/okf-dual-mode/adrs/adr-002.md"},"raw_input":{"file_path":"/Users/pedronauck/Dev/compozy/kb/.compozy/tasks/okf-dual-mode/adrs/adr-002.md"}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:20:47.297Z","turn":1,"type":"session.update","update":{"kind":"tool_call_started","tool_call_id":"toolu_011k3B26SLdigrbTkepFPQyc","tool_call_state":"pending","blocks":[{"type":"tool_use","id":"toolu_011k3B26SLdigrbTkepFPQyc","name":"Read","title":"Read File","raw_input":{}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:20:47.299663Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01SSJKezLaWCo5Avt6dK1r5M","usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:20:47.308011Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01SSJKezLaWCo5Avt6dK1r5M","tool_call_state":"completed","blocks":[{"type":"tool_result","tool_use_id":"toolu_01SSJKezLaWCo5Avt6dK1r5M","content":"```\n1\t# ADR-002: promote is a mechanical non-LLM transform; type governance and a spec-compatible conformance posture\n2\t\n3\t## Status\n4\t\n5\tAccepted\n6\t\n7\t## Date\n8\t\n9\t2026-06-27\n10\t\n11\t## Context\n12\t\n13\tADR-001 makes the research → operations distill loop the MVP's north star. That\n14\tloop has three behavioral questions the PRD must settle, each with product\n15\tconsequences:\n16\t\n17\t1. **What does `kb promote` do to the content?** It can be a mechanical\n18\t format transform, or it can re-synthesize the content with an LLM.\n19\t2. **Where does the OKF `type` come from?** OKF requires exactly one field —\n20\t `type` — and its value drives how consumers route, filter, and present a\n21\t concept. Free-text typing risks drift (`Voice Profile` vs `voice-profile`).\n22\t3. **How strict is conformance, and what does `kb` emit?** Research surfaced two\n23\t contradictions between the OKF spec and Google's own tooling:\n24\t - The spec requires only `type`, but Google's reference producer requires\n25\t `type` + `title` + `description` + `timestamp`, and all sample bundles carry\n26\t them.\n27\t - The spec *recommends* absolute `/path.md` links, but Google's tooling forbids\n28\t them (they break GitHub rendering) and every sample bundle uses relative\n29\t links.\n30\t - The spec (§9) requires consumers to *tolerate* broken links, unknown types,\n31\t and missing optional fields.\n32\t\n33\t`kb`'s identity, per its own `CLAUDE.md`, is \"the non-LLM workflow\": scaffolding,\n34\tingestion, structural linting. The user also explicitly values a consistent local\n35\tstandard for concept types.\n36\t\n37\t## Decision\n38\t\n39\t**1. `promote` is a mechanical, non-destructive, non-LLM transform.**\n40\t\n41\t`kb promote \u003cwiki-doc\u003e --to \u003cokf-topic\u003e` copies the source content into the OKF\n42\tbundle as a new typed concept and performs only structural work: convert\n43\t`[[wikilinks]]` to relative markdown links, remap wiki frontmatter to the OKF\n44\tcontract, set the concept `type`, and append an entry to the bundle's `log.md`.\n45\tThe source wiki document stays in `research/` (the immutable lab). No LLM is\n46\tinvoked — any intelligent distillation/rewriting remains the job of the operator\n47\tor an external agent calling `kb`, exactly as the user's `second-brain/` was built.\n48\t\n49\t**2. `type` is supplied explicitly and governed by a local vocabulary.**\n50\t\n51\t`promote` (and native authoring) takes the concept kind via `--type`; a canonical\n52\tlist of concept types lives in `kb.toml` (e.g. an `[okf].types` vocabulary). The\n53\tvocabulary exists to prevent duplication and enforce a per-vault standard. OKF\n54\tdeliberately has no *global* type registry; a *local* vocabulary is fully\n55\tcompatible and adds value. A type outside the vocabulary is a lint warning, not a\n56\thard failure, and the vocabulary is extended by editing `kb.toml`.\n57\t\n58\t**3. Conformance is compatible with the real-world tooling, lenient by default.**\n59\t\n60\t- **Emit**: all four producer fields (`type`, `title`, `description`,\n61\t `timestamp`) and **relative, GitHub-safe** markdown links.\n62\t- **Validate**: lenient per spec §9 — tolerate broken cross-links, unknown\n63\t `type` values, and missing optional fields (so externally produced bundles are\n64\t accepted in Phase 2).\n65\t- **Local standards as warnings** on the operator's own bundles: all four fields\n66\t present, and `type` within the `kb.toml` vocabulary.\n67\t- **`--strict`** promotes warnings to errors for CI gates.\n68\t\n69\t## Alternatives Considered\n70\t\n71\t### Alternative 1: LLM-assisted `promote`\n72\t\n73\t- **Description**: `promote` re-synthesizes the wiki document into a concise OKF\n74\t concept via a configured provider.\n75\t- **Pros**: One-command \"magic\"; less manual rewriting.\n76\t- **Cons**: Injects an LLM dependency and nondeterminism into a tool whose\n77\t identity is the non-LLM workflow; harder to test; unpredictable output.\n78\t- **Why rejected**: Contradicts `kb`'s stated identity; distillation already\n79\t belongs to the operator/agent layer. (A future `--distill` flag is possible but\n80\t out of scope.)\n81\t\n82\t### Alternative 2: Derived or defaulted `type`\n83\t\n84\t- **Description**: Infer `type` from the wiki document's `source_kind`/`domain`,\n85\t or default everything to a generic `Reference`.\n86\t- **Pros**: Less typing.\n87\t- **Cons**: The wiki `type` is a stage marker (always `wiki`/`source`), so\n88\t derivation carries no concept semantics; a generic default produces lazy\n89\t bundles where every concept is `Reference`, defeating OKF's routing value.\n90\t- **Why rejected**: Kills the value of the one field OKF requires.\n91\t\n92\t### Alternative 3: Spec-minimal or maximum-rigor conformance\n93\t\n94\t- **Description**: Either emit/validate only `type` (spec-minimal), or make every\n95\t rule a hard error by default (maximum rigor).\n96\t- **Pros**: Minimal is \"pure\"; maximum guarantees an iron standard.\n97\t- **Cons**: Minimal yields impoverished bundles misaligned with the official\n98\t samples; maximum fights OKF's permissive philosophy and would reject\n99\t third-party bundles during Phase 2 ingest.\n100\t- **Why rejected**: The tooling-compatible middle (emit four, validate lenient,\n101\t warn locally, `--strict` opt-in) matches real bundles and the user's desire for\n102\t a standard without sacrificing interoperability.\n103\t\n104\t## Consequences\n105\t\n106\t### Positive\n107\t\n108\t- `promote` is deterministic, testable, and true to `kb`'s identity.\n109\t- Bundles align with Google's real samples and tooling, maximizing portability.\n110\t- The type vocabulary keeps the operator's catalog consistent over time.\n111\t- Lenient validation lets Phase 2 ingest accept real-world external bundles.\n112\t\n113\t### Negative\n114\t\n115\t- The operator (or an agent) still does the intelligent distillation; `promote`\n116\t alone does not condense content.\n117\t- Two documented spec contradictions must be encoded as deliberate, documented\n118\t deviations (emit four, emit relative links).\n119\t\n120\t### Risks\n121\t\n122\t- **Spec churn** (v0.1 draft) → keep emit/validate rules isolated and versioned.\n123\t- **Vocabulary friction** (warnings ignored) → `--strict` in CI keeps the\n124\t standard enforceable when it matters.\n125\t\n126\t## Implementation Notes\n127\t\n128\tDetail belongs in the TechSpec. Product-level notes: `promote` appends to\n129\t`log.md` and the bundle's `index.md` is auto-maintained (OKF index files are\n130\ttype-grouped bullet lists, which are mechanically generatable). Conformance rules\n131\ttarget OKF v0.1 and validate against the official sample bundles (GA4, Stack\n132\tOverflow, Bitcoin) as fixtures.\n133\t\n134\tTwo of the four producer fields have no source in `kb`-produced documents\n135\t(B-201): `description` comes from a `--description` flag, falling back to the first\n136\tbody sentence and then to empty + warning; `timestamp` is the promote-time clock in\n137\tRFC3339 (promotion is the \"last meaningful change\"), since the only source dates are\n138\tdate-only (`2006-01-02`). See the TechSpec remap table.\n139\t\n140\t## References\n141\t\n142\t- PRD: `../_prd.md`\n143\t- ADR-001: OKF as a first-class authoring mode\n144\t- OKF spec v0.1 §4 (frontmatter), §5 (links), §6 (index.md), §7 (log.md),\n145\t §9 (conformance): `GoogleCloudPlatform/knowledge-catalog` → `okf/SPEC.md`\n146\t- OKF reference producer (the stricter four-key reality):\n147\t `okf/src/reference_agent/bundle/document.py`\n148\t\n```"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:20:47.764968Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_011k3B26SLdigrbTkepFPQyc","blocks":[{"type":"tool_use","id":"toolu_011k3B26SLdigrbTkepFPQyc","name":"Read","title":"Read .compozy/tasks/okf-dual-mode/adrs/adr-003.md","input":{"file_path":"/Users/pedronauck/Dev/compozy/kb/.compozy/tasks/okf-dual-mode/adrs/adr-003.md"},"raw_input":{"file_path":"/Users/pedronauck/Dev/compozy/kb/.compozy/tasks/okf-dual-mode/adrs/adr-003.md"}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:20:47.768066Z","turn":1,"type":"session.update","update":{"kind":"tool_call_started","tool_call_id":"toolu_01JRTbVtPoKewGaR5zrAASUv","tool_call_state":"pending","blocks":[{"type":"tool_use","id":"toolu_01JRTbVtPoKewGaR5zrAASUv","name":"Read","title":"Read File","raw_input":{}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:20:47.76982Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_011k3B26SLdigrbTkepFPQyc","usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:20:47.778859Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_011k3B26SLdigrbTkepFPQyc","tool_call_state":"completed","blocks":[{"type":"tool_result","tool_use_id":"toolu_011k3B26SLdigrbTkepFPQyc","content":"```\n1\t# ADR-003: A cohesive `internal/okf` package and a `mode` field on the topic model\n2\t\n3\t## Status\n4\t\n5\tAccepted\n6\t\n7\t## Date\n8\t\n9\t2026-06-27\n10\t\n11\t## Context\n12\t\n13\tOKF dual-mode (ADR-001) introduces a substantial new concern: an OKF frontmatter\n14\tcontract, a wikilink→markdown transform, `index.md`/`log.md` generation, a\n15\tconformance checker, and the `promote` orchestration. The existing packages own\n16\tthe LLM-Wiki pyramid:\n17\t\n18\t- `internal/topic` — scaffold + `topic.yaml` (`topicMetadataFile` at\n19\t `topic.go:104`; scaffold list `currentTopicDirectories` at `topic.go:38`).\n20\t- `internal/vault` — render/read/write of documents.\n21\t- `internal/lint` — path-prefix structural validation (`schemaForPath`).\n22\t- `internal/ingest` — frontmatter assembly (`buildFrontmatter` at `ingest.go:160`).\n23\t\n24\tTwo structural needs must be settled before any behavior is built: where OKF logic\n25\tlives, and how a topic signals it is an OKF bundle versus a wiki.\n26\t\n27\t## Decision\n28\t\n29\t**1. A new cohesive `internal/okf` package owns all OKF behavior.**\n30\t\n31\tIt contains: the OKF frontmatter contract (the four producer fields), the\n32\t`[[wikilink]]`→relative-markdown transform, `index.md`/`log.md` generation, the\n33\tconformance checker (ADR-005), and the `promote` orchestration. Dependency\n34\tdirection is one-way: `internal/okf` imports `internal/frontmatter`,\n35\t`internal/vault` (document read/write), `internal/config` (the type vocabulary),\n36\tand `internal/models`; none of those import `internal/okf`. This prevents import\n37\tcycles and keeps the wiki packages free of OKF concerns.\n38\t\n39\t**2. Add a `mode` field to the topic model, defaulting to wiki.**\n40\t\n41\t- `internal/models`: typed constants `TopicModeWiki = \"wiki\"` and\n42\t `TopicModeOKF = \"okf\"`, and a `Mode` field on `models.TopicMetadata`\n43\t (`models.go:271`) and `models.TopicInfo`.\n44\t- `internal/topic`: `Mode string \\`yaml:\"mode,omitempty\"\\`` on `topicMetadataFile`\n45\t (`topic.go:104`); written by `writeMetadataFile` and read by\n46\t `readTopicYAMLMetadata` (`topic.go:770`).\n47\t- An absent/empty `mode` is normalized to `wiki` on read, so existing topics and\n48\t existing `topic.yaml` files behave identically (zero regression).\n49\t\n50\t## Alternatives Considered\n51\t\n52\t### Alternative 1: Spread OKF logic across existing packages\n53\t\n54\t- **Description**: `mode` in topic, links in vault, conformance extending lint,\n55\t `promote` in ingest.\n56\t- **Pros**: Maximum reuse; no new package.\n57\t- **Cons**: Dilutes OKF across four packages; entangles it with the wiki pyramid;\n58\t harder to test and to grow in Phase 2/3.\n59\t- **Why rejected**: Cohesion and a clean phase-growth boundary outweigh avoiding a\n60\t new package.\n61\t\n62\t### Alternative 2: Hybrid (okf package + okf logic also in vault/topic)\n63\t\n64\t- **Description**: Core in `internal/okf`, but the link formatter and `mode`\n65\t scattered into vault/topic.\n66\t- **Pros**: Avoids some indirection.\n67\t- **Cons**: The boundary becomes ambiguous (OKF logic in two homes).\n68\t- **Why rejected**: Only the genuinely cross-cutting `LinkFormatter` belongs in\n69\t vault (ADR-004); everything else stays in `internal/okf`. The `mode` field is a\n70\t data-model fact, not OKF logic, so it correctly lives on the topic model.\n71\t\n72\t## Consequences\n73\t\n74\t### Positive\n75\t\n76\t- One obvious home for OKF; isolated unit/integration testing.\n77\t- Clean dependency graph; Phase 2/3 (export/ingest/codebase→OKF) extend one package.\n78\t- `mode` is a tiny, backward-compatible addition.\n79\t\n80\t### Negative\n81\t\n82\t- A new package and a field threaded through topic load/write.\n83\t\n84\t### Risks\n85\t\n86\t- **Mode drift** (a topic with an unknown mode) → normalize unknown/empty to\n87\t `wiki` and reject unknown explicit values at scaffold time.\n88\t\n89\t## Implementation Notes\n90\t\n91\t`topicMetadataFile` marshals via `yaml.Marshal`. The zero-regression story has two\n92\tdistinct parts (N-004): (a) **existing** `topic.yaml` files are never rewritten and\n93\tread-normalize empty `mode`→`wiki`, so they are byte-stable; (b) **new** scaffolds\n94\twrite an explicit `mode` value, so a freshly created wiki topic's `topic.yaml` will\n95\tcontain a visible `mode: wiki` line — a scaffold-output change (update the scaffold\n96\tgolden expectation accordingly), not a change to old files. `omitempty` only\n97\tsuppresses a programmatically-empty mode. Topic resolution (`topic.Resolve`/`Info`)\n98\tmust surface `Mode` so downstream commands (`promote`, `okf check`) can branch.\n99\t\n100\t## References\n101\t\n102\t- ADR-001 (OKF as a first-class mode), ADR-004 (LinkFormatter), ADR-005 (checker)\n103\t- `internal/topic/topic.go:104`, `internal/models/models.go:271`\n104\t- TechSpec: `../_techspec.md`\n105\t\n```"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:20:48.744077Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01JRTbVtPoKewGaR5zrAASUv","blocks":[{"type":"tool_use","id":"toolu_01JRTbVtPoKewGaR5zrAASUv","name":"Read","title":"Read .compozy/tasks/okf-dual-mode/adrs/adr-004.md","input":{"file_path":"/Users/pedronauck/Dev/compozy/kb/.compozy/tasks/okf-dual-mode/adrs/adr-004.md"},"raw_input":{"file_path":"/Users/pedronauck/Dev/compozy/kb/.compozy/tasks/okf-dual-mode/adrs/adr-004.md"}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:20:48.74757Z","turn":1,"type":"session.update","update":{"kind":"tool_call_started","tool_call_id":"toolu_01AuNQMteUUXKKjCbGA7dQaL","tool_call_state":"pending","blocks":[{"type":"tool_use","id":"toolu_01AuNQMteUUXKKjCbGA7dQaL","name":"Read","title":"Read File","raw_input":{}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:20:48.749742Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01JRTbVtPoKewGaR5zrAASUv","usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:20:48.759837Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01JRTbVtPoKewGaR5zrAASUv","tool_call_state":"completed","blocks":[{"type":"tool_result","tool_use_id":"toolu_01JRTbVtPoKewGaR5zrAASUv","content":"```\n1\t# ADR-004: A `LinkFormatter` abstraction and full migration of the link call sites\n2\t\n3\t## Status\n4\t\n5\tAccepted\n6\t\n7\t## Date\n8\t\n9\t2026-06-27\n10\t\n11\t## Context\n12\t\n13\tLink emission is hardcoded to Obsidian `[[wikilinks]]`:\n14\t\n15\t- `internal/vault/pathutils.go:244` `ToTopicWikiLink(topicSlug, documentPath,\n16\t label)` → `[[slug/path|label]]`, with a thin wrapper `toSourceWikiLink(topic,\n17\t relativePath, label)` at `render.go:186`.\n18\t- These are called from **~43 sites**, all in `internal/vault/render.go` and\n19\t `render_wiki.go` — the **codebase→wiki** render pipeline.\n20\t- There is **no `Renderer`/`Writer` struct** in `internal/vault`; render functions\n21\t receive `topic models.TopicMetadata` directly, so there is no constructor to\n22\t inject a formatter into.\n23\t\n24\tOKF requires standard markdown links instead of wikilinks (ADR-002: relative,\n25\tGitHub-safe). The question is how the link format becomes mode-aware, and how much\n26\tof the refactor lands in the MVP. The user chose to introduce the full abstraction\n27\tand migrate all call sites in the MVP, rather than defer the render-pipeline\n28\trefactor to Phase 3.\n29\t\n30\tA key technical subtlety: an OKF **relative** link needs the **source document's\n31\tdirectory** to compute the path to the target. The current call sites pass only\n32\tthe target path and a label.\n33\t\n34\t## Decision\n35\t\n36\t**1. Define a `LinkFormatter` interface in `internal/vault`** (rendering's home;\n37\tkeeps the `okf`→`vault` dependency one-way and cycle-free):\n38\t\n39\t```go\n40\t// LinkFormatter renders a link from the document being written (fromDir,\n41\t// bundle-relative) to a target document, with an optional label.\n42\ttype LinkFormatter interface {\n43\t Link(fromDir, targetPath, label string) string\n44\t}\n45\t```\n46\t\n47\t- `WikiLinkFormatter` — ignores `fromDir`; emits `[[slug/target|label]]` exactly\n48\t as `ToTopicWikiLink` does today (`ToTopicWikiLink` becomes its implementation).\n49\t- `OKFLinkFormatter` — emits a relative, GitHub-safe markdown link\n50\t (`[label](relpath)`), computing `relpath` from `fromDir` to `targetPath`.\n51\t\n52\t**2. Resolve the formatter from `topic.Mode`** via\n53\t`vault.LinkFormatterFor(topic) LinkFormatter`, and route every call site through a\n54\tsingle chokepoint helper `linkFor(topic, fromDir, targetPath, label)`. The direct\n55\t`ToTopicWikiLink(topic.Slug, …)` calls in `render_wiki.go` are converted to use this\n56\thelper too. (Verified call-site spread: ~7 sites in `render.go` plus ~34 matching\n57\tlines in `render_wiki.go`; treat \"~43 sites\" as the working estimate.)\n58\t\n59\t**3. Scope the MVP correctly.** The 43 render sites belong to the codebase→wiki\n60\tpipeline, which produces **wiki-mode** topics; codebase→OKF is Phase 3. Therefore\n61\tin the MVP these sites continue to resolve `WikiLinkFormatter` (mode = wiki) and\n62\temit byte-identical output. The **active** MVP consumer of `OKFLinkFormatter` is\n63\t`promote`'s body transform (which knows the concept's own directory) and\n64\t`index.md` generation. The OKF branch at the render sites is wired but dormant\n65\tuntil Phase 3.\n66\t\n67\t## Alternatives Considered\n68\t\n69\t### Alternative 1: Defer the render-site refactor; body-transform only in MVP\n70\t\n71\t- **Description**: MVP does only `[[wikilink]]`→markdown in `promote` body text and\n72\t index generation; the 43 sites are untouched until Phase 3.\n73\t- **Pros**: Smallest MVP; least risk.\n74\t- **Cons**: The abstraction lands later; two passes over the render code.\n75\t- **Why rejected**: The user chose to do the foundational abstraction now.\n76\t\n77\t### Alternative 2: Add a `fromDir`/formatter argument to each call site signature\n78\t\n79\t- **Description**: Thread source context explicitly through 43 signatures.\n80\t- **Pros**: Fully explicit.\n81\t- **Cons**: Churns 43 signatures for no MVP-exercised benefit.\n82\t- **Why rejected**: The chokepoint helper + `topic`-resolved formatter achieves the\n83\t same with far less churn.\n84\t\n85\t### Alternative 3: Package-global formatter strategy\n86\t\n87\t- **Description**: A mutable package-level formatter set per run.\n88\t- **Pros**: No threading.\n89\t- **Cons**: Not concurrency-safe; hidden global state; hard to test.\n90\t- **Why rejected**: Violates testability.\n91\t\n92\t## Consequences\n93\t\n94\t### Positive\n95\t\n96\t- One mode-aware seam for all link emission; Phase 3 only flips render topics to\n97\t OKF mode and supplies `fromDir`.\n98\t- `promote` and index generation reuse the same formatter.\n99\t\n100\t### Negative\n101\t\n102\t- A large mechanical refactor whose OKF branch is not exercised at render sites in\n103\t the MVP.\n104\t\n105\t### Risks\n106\t\n107\t- **Silent regression** in wiki output during the refactor → mitigate with golden\n108\t tests asserting byte-identical wiki render output before/after, and keep the\n109\t existing `vault` test suite green.\n110\t- **Relative-path edge cases** (siblings, parents, root) in `OKFLinkFormatter` →\n111\t cover with table-driven unit tests including `fromDir == targetDir` and nested\n112\t cases.\n113\t- **Dormant OKF render branch ships as dead code in the MVP** (N-001) → add one\n114\t render-site-in-`okf`-mode smoke test so the wired branch and its `fromDir`\n115\t plumbing are exercised, keeping the user's \"migrate now\" decision without shipping\n116\t untested code.\n117\t\n118\t## Implementation Notes\n119\t\n120\t`OKFLinkFormatter.Link` uses `filepath.Rel(fromDir, targetPath)` semantics on\n121\tforward-slash bundle paths, never emitting a leading `/` (GitHub-safe, per\n122\tADR-002). `WikiLinkFormatter` preserves `StripMarkdownExtension` behavior. The\n123\tformatter is selected once per document render from `topic.Mode`.\n124\t\n125\t## References\n126\t\n127\t- ADR-002 (relative links), ADR-003 (okf package boundary)\n128\t- `internal/vault/pathutils.go:244`, `render.go:186`, `render_wiki.go`\n129\t- TechSpec: `../_techspec.md`\n130\t\n```"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:20:49.217404Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01AuNQMteUUXKKjCbGA7dQaL","blocks":[{"type":"tool_use","id":"toolu_01AuNQMteUUXKKjCbGA7dQaL","name":"Read","title":"Read .compozy/tasks/okf-dual-mode/adrs/adr-005.md","input":{"file_path":"/Users/pedronauck/Dev/compozy/kb/.compozy/tasks/okf-dual-mode/adrs/adr-005.md"},"raw_input":{"file_path":"/Users/pedronauck/Dev/compozy/kb/.compozy/tasks/okf-dual-mode/adrs/adr-005.md"}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:20:49.221043Z","turn":1,"type":"session.update","update":{"kind":"tool_call_started","tool_call_id":"toolu_01QtpYZi2wEf6yXj43QKUYep","tool_call_state":"pending","blocks":[{"type":"tool_use","id":"toolu_01QtpYZi2wEf6yXj43QKUYep","name":"Read","title":"Read File","raw_input":{}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:20:49.222771Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01AuNQMteUUXKKjCbGA7dQaL","usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:20:49.231406Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01AuNQMteUUXKKjCbGA7dQaL","tool_call_state":"completed","blocks":[{"type":"tool_result","tool_use_id":"toolu_01AuNQMteUUXKKjCbGA7dQaL","content":"```\n1\t# ADR-005: A dedicated OKF conformance checker with vendored official fixtures\n2\t\n3\t## Status\n4\t\n5\tAccepted\n6\t\n7\t## Date\n8\t\n9\t2026-06-27\n10\t\n11\t## Context\n12\t\n13\tOKF v0.1 conformance (§9) is small and deliberately permissive: every non-reserved\n14\t`.md` must have parseable frontmatter with a non-empty `type`; `index.md`/`log.md`\n15\tmust follow their shapes when present; consumers MUST tolerate broken cross-links,\n16\tunknown `type` values, and missing optional fields. ADR-002 set the product\n17\tposture: emit four producer fields and relative links, validate lenient, warn on\n18\tlocal standards (four fields present + `type` in the `kb.toml` vocabulary), and\n19\toffer `--strict`.\n20\t\n21\tThe existing lint engine (`internal/lint/lint.go:337` `schemaForPath`) is a\n22\tpath-prefix switch tuned to the wiki pyramid (`wiki/concepts/`, `raw/github/`,\n23\tetc.) and parses `[[wikilinks]]` specifically (`wikilinkPattern` regex). OKF is\n24\tstructurally different: a recursive tree of freeform directories where `type` is\n25\tthe only hard requirement and links are markdown.\n26\t\n27\t## Decision\n28\t\n29\t**Implement a dedicated conformance checker in `internal/okf`**, not an extension\n30\tof `schemaForPath`.\n31\t\n32\t- Recursively walk the bundle; for every non-reserved `.md`, parse frontmatter via\n33\t `internal/frontmatter` and require a non-empty `type` (§9.2). Validate `index.md`\n34\t (no frontmatter except an optional bundle-root `okf_version`) and `log.md`\n35\t (ISO-date headings) shapes when present (§6/§7).\n36\t- **Lenient by default** (§9): broken links, unknown `type`, and missing optional\n37\t fields are tolerated (not errors) — so externally produced bundles pass, which\n38\t Phase 2 `ingest okf` depends on.\n39\t- **Local-standard warnings** on the operator's own bundle: the four producer\n40\t fields present, and `type` ∈ `config.OKF.Types`.\n41\t- `--strict` promotes warnings to errors (CI gate).\n42\t- **Reuse `models.LintIssue` and the `internal/output` formatters** (table/json/\n43\t tsv) for a consistent UX; a severity distinguishes error from warning.\n44\t\n45\t**Fixtures (per the testing decision):** vendor the three official sample bundles\n46\t(GA4, Stack Overflow, crypto_bitcoin; Apache-2.0) into `internal/okf/testdata/` as\n47\tpositive fixtures, plus synthetic negatives (missing `type`, unterminated\n48\tfrontmatter, list-not-mapping frontmatter) and must-tolerate cases (broken link,\n49\tunknown `type`, frontmatter-less `index.md`). Conformance tests run behind\n50\t`//go:build integration`.\n51\t\n52\t## Alternatives Considered\n53\t\n54\t### Alternative 1: Extend the existing lint engine (`schemaForPath`)\n55\t\n56\t- **Description**: Add OKF cases to the path-prefix switch.\n57\t- **Pros**: One lint entry point.\n58\t- **Cons**: Conflates two structurally different models (path-pyramid wiki vs\n59\t recursive type-required OKF); the wikilink parser does not apply; a single engine\n60\t grows two divergent rule systems.\n61\t- **Why rejected**: Separation keeps both engines simple and correct.\n62\t\n63\t### Alternative 2: Maximum-rigor default (all rules hard errors)\n64\t\n65\t- **Description**: Make four-field + vocabulary + link rules errors without\n66\t `--strict`.\n67\t- **Pros**: Iron standard.\n68\t- **Cons**: Fights OKF's permissive model; rejects third-party bundles on ingest.\n69\t- **Why rejected**: Superseded by ADR-002's lenient-with-warnings posture.\n70\t\n71\t### Alternative 3: Synthetic-only fixtures\n72\t\n73\t- **Description**: Hand-write minimal bundles only.\n74\t- **Pros**: Smaller repo; faster tests.\n75\t- **Cons**: No validation against the bundles real OKF consumers produce/expect.\n76\t- **Why rejected**: The user chose to vendor the official bundles for maximum\n77\t signal.\n78\t\n79\t## Consequences\n80\t\n81\t### Positive\n82\t\n83\t- Spec-correct and tooling-compatible; high-signal conformance tests.\n84\t- Consistent CLI output via shared `LintIssue`/`output` types.\n85\t- Lenient validation unblocks Phase 2 ingest.\n86\t\n87\t### Negative\n88\t\n89\t- The repo grows by the vendored bundles (dozens of `.md` files).\n90\t- Two checking engines coexist (wiki lint and OKF check).\n91\t\n92\t### Risks\n93\t\n94\t- **Spec churn (v0.1 draft)** → isolate rules behind a versioned ruleset in\n95\t `internal/okf`.\n96\t- **Third-party license** → keep the bundles' Apache-2.0 `LICENSE`/attribution in\n97\t `testdata/` and document provenance.\n98\t\n99\t## Implementation Notes\n100\t\n101\t`okf check` returns `[]models.LintIssue` with a severity field; the CLI renders via\n102\t`internal/output`. The walker skips a full **reserved/excluded set** before applying\n103\tthe `type` requirement (B-202): OKF-reserved `index.md`/`log.md`, the kb marker files\n104\t`CLAUDE.md`/`AGENTS.md` (and it does not follow symlinks, so `AGENTS.md`→`CLAUDE.md`\n105\tis not walked), and the non-concept files official bundles legitimately carry\n106\t(`README.md`, license/attribution). Without this, a freshly scaffolded bundle — which\n107\twrites `CLAUDE.md`/`AGENTS.md` — would hard-fail its own `kb okf check`, contradicting\n108\tthe end-to-end test and the PRD success metric. The checker takes the resolved bundle\n109\t(topic) path; it does not\n110\trequire `mode: okf` so it can validate arbitrary directories and external bundles.\n111\t\n112\t**`index.md` / `log.md` ownership (N-003):** both are kb-owned. `index.md` is fully\n113\tregenerated by scaffold and by `promote` (type-grouped bullet lists); regeneration\n114\tMUST preserve the bundle-root `okf_version` frontmatter — the only place OKF permits\n115\tindex frontmatter. `log.md` is append-only (kb appends a dated entry on scaffold and\n116\tpromote; operators may add entries but kb never rewrites prior ones). Operators\n117\tshould not hand-edit `index.md`, since the next regeneration overwrites it.\n118\t\n119\t## References\n120\t\n121\t- ADR-002 (conformance posture), ADR-003 (okf package)\n122\t- OKF spec §6/§7/§9; official bundles `okf/bundles/{ga4,stackoverflow,crypto_bitcoin}`\n123\t- `internal/lint/lint.go:337`, `internal/frontmatter`, `internal/output`\n124\t- TechSpec: `../_techspec.md`\n125\t\n```"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:20:49.732754Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01QtpYZi2wEf6yXj43QKUYep","blocks":[{"type":"tool_use","id":"toolu_01QtpYZi2wEf6yXj43QKUYep","name":"Read","title":"Read .compozy/tasks/okf-dual-mode/adrs/adr-006.md","input":{"file_path":"/Users/pedronauck/Dev/compozy/kb/.compozy/tasks/okf-dual-mode/adrs/adr-006.md"},"raw_input":{"file_path":"/Users/pedronauck/Dev/compozy/kb/.compozy/tasks/okf-dual-mode/adrs/adr-006.md"}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:20:49.740247Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01QtpYZi2wEf6yXj43QKUYep","usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:20:49.747042Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01QtpYZi2wEf6yXj43QKUYep","tool_call_state":"completed","blocks":[{"type":"tool_result","tool_use_id":"toolu_01QtpYZi2wEf6yXj43QKUYep","content":"```\n1\t# ADR-006: CLI surface — `kb promote` top-level and a `kb okf` command group\n2\t\n3\t## Status\n4\t\n5\tAccepted\n6\t\n7\t## Date\n8\t\n9\t2026-06-27\n10\t\n11\t## Context\n12\t\n13\tThe MVP adds three user-facing operations now (`promote`, conformance `check`, and\n14\ta `--mode` choice on topic creation), and Phase 2 adds `export okf` and\n15\t`ingest okf`. The existing CLI (`internal/cli/root.go:14`) registers top-level\n16\tcommands (`topic`, `ingest`, `lint`, `inspect`, `index`, `search`, `generate`,\n17\t`migrate`, `version`) via `command.AddCommand(...)`, shares a `--vault` persistent\n18\tflag, resolves the vault with `resolveCommandVaultPath`, and renders output through\n19\t`internal/output`. `topic new` (`topic.go`) takes positional args and has no\n20\t`--mode` flag yet.\n21\t\n22\t`promote` is the daily distill verb (1 wiki doc → 1 OKF concept); `check`,\n23\t`export`, and `ingest` are OKF-ecosystem operations.\n24\t\n25\t## Decision\n26\t\n27\t- **`kb promote \u003cwiki-doc\u003e --to \u003ctopic\u003e --type \u003cType\u003e [--description \u003ctext\u003e]`** as a\n28\t **top-level** command — it is the daily verb and deserves prominence. `--to` must\n29\t resolve to a `mode: okf` topic or `promote` errors (N-202).\n30\t- **`kb okf`** as a parent command **group**:\n31\t - MVP: `kb okf check \u003ctopic\u003e [--strict] [--format table|json|tsv]`.\n32\t - Phase 2: `kb okf export \u003ctopic\u003e [...]`, `kb okf ingest \u003cbundle\u003e --to \u003ctopic\u003e`.\n33\t- **`kb topic new`** gains `--mode wiki|okf` (default `wiki`).\n34\t\n35\tAll new commands follow the existing cobra patterns: built in `internal/cli`,\n36\tregistered via `AddCommand`, resolving the vault through `resolveCommandVaultPath`,\n37\tdelegating business logic to `internal/okf`, and rendering via `internal/output`.\n38\t\n39\t## Alternatives Considered\n40\t\n41\t### Alternative 1: Everything under `kb okf`\n42\t\n43\t- **Description**: `kb okf promote|check|export|ingest`.\n44\t- **Pros**: Maximum namespace cohesion.\n45\t- **Cons**: Buries `promote` — the most-used verb — one level deeper.\n46\t- **Why rejected**: Daily ergonomics beat namespace purity for the hot verb.\n47\t\n48\t### Alternative 2: `kb promote` + `kb lint --okf`\n49\t\n50\t- **Description**: Conformance as a flag on the existing `lint`.\n51\t- **Pros**: Reuses `lint`.\n52\t- **Cons**: Mixes two engines (wiki pyramid vs OKF conformance) in one command and\n53\t gives no natural home to Phase 2's `export`/`ingest`.\n54\t- **Why rejected**: ADR-005 keeps the engines separate; the `okf` group hosts the\n55\t whole ecosystem.\n56\t\n57\t## Consequences\n58\t\n59\t### Positive\n60\t\n61\t- Discoverable `kb okf …` namespace that grows cleanly across phases.\n62\t- The daily verb stays one keystroke deep.\n63\t- Reuses vault resolution, flags, and output formatting conventions.\n64\t\n65\t### Negative\n66\t\n67\t- Two entry points for OKF (`kb promote` and `kb okf …`) — mitigated by docs and\n68\t `--help` cross-references.\n69\t\n70\t### Risks\n71\t\n72\t- **`--mode` typos / invalid values** → validate against `{wiki, okf}` at parse\n73\t time with a clear error.\n74\t\n75\t## Implementation Notes\n76\t\n77\t`newPromoteCommand` and `newOKFCommand` (with a `check` subcommand) are added to\n78\t`newRootCommand`. `topic new` adds `command.Flags().StringVar(\u0026mode, \"mode\",\n79\t\"wiki\", …)` and passes it into topic creation (ADR-003). Output uses\n80\t`output.OutputFormat*` like `lint`.\n81\t\n82\t## References\n83\t\n84\t- ADR-003 (mode field), ADR-005 (conformance engine)\n85\t- `internal/cli/root.go:14`, `internal/cli/topic.go`, `internal/cli/lint.go`\n86\t- TechSpec: `../_techspec.md`\n87\t\n```"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:20:52.605185Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":""}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:20:52.605204Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":"Now the project rule files and the diff patch."}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:20:52.606118Z","turn":1,"type":"session.update","update":{"kind":"tool_call_started","tool_call_id":"toolu_01XEJ8eTbZRwwN4cMBo21S3p","tool_call_state":"pending","blocks":[{"type":"tool_use","id":"toolu_01XEJ8eTbZRwwN4cMBo21S3p","name":"Read","title":"Read File","raw_input":{}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:20:53.552566Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01XEJ8eTbZRwwN4cMBo21S3p","blocks":[{"type":"tool_use","id":"toolu_01XEJ8eTbZRwwN4cMBo21S3p","name":"Read","title":"Read AGENTS.md","input":{"file_path":"/Users/pedronauck/Dev/compozy/kb/AGENTS.md"},"raw_input":{"file_path":"/Users/pedronauck/Dev/compozy/kb/AGENTS.md"}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:20:53.558387Z","turn":1,"type":"session.update","update":{"kind":"tool_call_started","tool_call_id":"toolu_01PyQYDnws979C9wKYY3HgGR","tool_call_state":"pending","blocks":[{"type":"tool_use","id":"toolu_01PyQYDnws979C9wKYY3HgGR","name":"Read","title":"Read File","raw_input":{}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:20:53.565768Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01XEJ8eTbZRwwN4cMBo21S3p","usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:20:53.578105Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01XEJ8eTbZRwwN4cMBo21S3p","tool_call_state":"completed","blocks":[{"type":"tool_result","tool_use_id":"toolu_01XEJ8eTbZRwwN4cMBo21S3p","content":"```\n1\t# AGENTS.md\n2\t\n3\t## Project Overview\n4\t\n5\t`kb` is a single-binary Go CLI for building and maintaining topic-based knowledge bases in the Karpathy KB pattern. It handles the non-LLM workflow: topic scaffolding, multi-source ingestion, structural linting, codebase analysis, QMD indexing/search, and KB-oriented inspection commands.\n6\t\n7\t**Reference TypeScript source:** `~/dev/projects/kodebase`\n8\t\n9\t## Source of Truth\n10\t\n11\t- KB pivot tech spec: `.compozy/tasks/kb-pivot/_techspec.md`\n12\t- KB pivot task tracker: `.compozy/tasks/kb-pivot/_tasks.md`\n13\t- KB pivot workflow memory: `.compozy/tasks/kb-pivot/memory/`\n14\t\n15\t## Critical Rules\n16\t\n17\t- `make verify` is the non-negotiable completion gate: `fmt -\u003e lint -\u003e test -\u003e build -\u003e boundaries`.\n18\t- `make lint` must report zero findings.\n19\t- Use `go get` for dependency changes.\n20\t- Never use destructive git restore/reset/checkout/clean/rm commands without explicit approval.\n21\t- Prefer local repository inspection over web search for codebase questions.\n22\t\n23\t## Build Commands\n24\t\n25\t```bash\n26\tmake verify # fmt -\u003e lint -\u003e test -\u003e build -\u003e boundaries\n27\tmake fmt # gofmt over repository Go files\n28\tmake lint # golangci-lint v2\n29\tmake test # unit tests with -race via gotestsum\n30\tmake test-integration # unit + integration tests with -race and -tags integration\n31\tmake build # build ./... and bin/kb with ldflags\n32\tmake deps # go mod tidy\n33\tmake help # mage target list\n34\t```\n35\t\n36\t## Package Layout\n37\t\n38\t| Path | Responsibility |\n39\t| --- | --- |\n40\t| `cmd/kb` | Program entrypoint for the `kb` binary |\n41\t| `internal/cli` | Cobra root, subcommands, flag resolution, and command I/O |\n42\t| `internal/topic` | Topic scaffolding, listing, and topic metadata lookup |\n43\t| `internal/ingest` | Ingest orchestration, frontmatter assembly, raw writes, and log entries |\n44\t| `internal/convert` | Converter registry and format-specific file converters |\n45\t| `internal/firecrawl` | Firecrawl REST client for `kb ingest url` |\n46\t| `internal/youtube` | YouTube caption extraction through `yt-dlp` and STT providers |\n47\t| `internal/frontmatter` | Shared frontmatter parsing and generation helpers |\n48\t| `internal/lint` | KB structural lint engine and report rendering |\n49\t| `internal/generate` | Codebase-to-KB pipeline used by `kb ingest codebase` and the hidden legacy `generate` alias |\n50\t| `internal/scanner` | Source discovery and ignore handling for codebase ingest |\n51\t| `internal/adapter` | Tree-sitter parsing adapters |\n52\t| `internal/graph` | Graph normalization for codebase snapshots |\n53\t| `internal/metrics` | File, symbol, directory, and smell metrics |\n54\t| `internal/vault` | Topic path helpers, render/write logic, vault reads, and inspect snapshot loading |\n55\t| `internal/qmd` | QMD subprocess integration for index and search |\n56\t| `internal/output` | Table, JSON, and TSV formatting |\n57\t| `internal/models` | Shared domain models and interfaces |\n58\t| `internal/config` | TOML config plus env-backed runtime overrides |\n59\t| `internal/logger` | Slog logger setup |\n60\t| `internal/version` | Build metadata surfaced by `kb version` |\n61\t\n62\t## CLI Commands\n63\t\n64\t| Command | Purpose |\n65\t| --- | --- |\n66\t| `kb topic new \u003cslug\u003e \u003ctitle\u003e \u003cdomain\u003e` | Scaffold a new knowledge base topic |\n67\t| `kb topic list` | List scaffolded topics |\n68\t| `kb topic info \u003cslug\u003e` | Show metadata for one topic |\n69\t| `kb ingest url \u003curl\u003e --topic \u003cslug\u003e` | Scrape a web URL and ingest into a topic |\n70\t| `kb ingest file \u003cpath\u003e --topic \u003cslug\u003e` | Convert a local file and ingest into a topic |\n71\t| `kb ingest youtube \u003curl\u003e --topic \u003cslug\u003e` | Extract a YouTube transcript and ingest into a topic |\n72\t| `kb ingest codebase \u003cpath\u003e --topic \u003cslug\u003e` | Analyze a codebase and ingest artifacts into a topic |\n73\t| `kb ingest bookmarks \u003cpath\u003e --topic \u003cslug\u003e` | Ingest a bookmark-cluster markdown file into a topic |\n74\t| `kb lint [\u003cslug\u003e]` | Check one topic for structural KB issues |\n75\t| `kb inspect smells` | List smell signals for symbols and files |\n76\t| `kb inspect dead-code` | List dead exports and orphan files |\n77\t| `kb inspect complexity` | Rank functions by cyclomatic complexity |\n78\t| `kb inspect blast-radius` | Rank symbols by blast radius |\n79\t| `kb inspect coupling` | Rank files by instability |\n80\t| `kb inspect symbol \u003cname\u003e` | Find symbols by case-insensitive substring |\n81\t| `kb inspect file \u003cpath\u003e` | Resolve one source file by exact path |\n82\t| `kb inspect backlinks \u003cname-or-path\u003e` | Show inbound relations for a file or symbol |\n83\t| `kb inspect deps \u003cname-or-path\u003e` | Show outgoing relations for a file or symbol |\n84\t| `kb inspect circular-deps` | List detected circular dependency cycles |\n85\t| `kb search \u003cquery\u003e` | Query a vault through QMD hybrid, lexical, or vector modes |\n86\t| `kb index` | Create or update a QMD collection for a topic |\n87\t| `kb version` | Print build version metadata |\n88\t\n89\t### Command Notes\n90\t\n91\t- `topic` subcommands share the root `--vault` flag for vault path resolution.\n92\t- `ingest` subcommands require `--topic \u003cslug\u003e` to identify the target topic.\n93\t- `ingest codebase` accepts `--include`, `--exclude`, `--semantic`, `--progress`, and `--log-format`.\n94\t- `ingest youtube` accepts `--transcribe captions|auto|stt`; `yt-dlp` is required for YouTube metadata, captions, and audio extraction.\n95\t- `inspect` subcommands share `--vault`, `--topic`, and `--format` (`table`, `json`, `tsv`).\n96\t- `lint` accepts `--format`, `--save`, and optional positional `\u003cslug\u003e` or `--topic`.\n97\t- `search` supports `--lex`, `--vec`, `--limit`, `--min-score`, `--full`, `--all`, `--collection`, `--vault`, `--topic`, and `--format`.\n98\t- `index` supports `--vault`, `--topic`, `--name`, `--embed`, `--context`, and `--force-embed`.\n99\t- Hidden compatibility alias: `kb generate \u003cpath\u003e` (delegates to codebase pipeline).\n100\t\n101\t## Runtime Config\n102\t\n103\t- `config.example.toml` documents the TOML keys currently supported by `internal/config`.\n104\t- `APP_CONFIG` overrides the config file path.\n105\t- `.env` is loaded automatically when present.\n106\t- `FIRECRAWL_API_KEY` and `FIRECRAWL_API_URL` configure the Firecrawl client for `ingest url`.\n107\t- `OPENAI_API_KEY`, `OPENAI_API_URL`, `STT_PROVIDER`, and `STT_MODEL` configure the default OpenAI STT provider for `ingest youtube --transcribe auto|stt`.\n108\t- `OPENROUTER_API_KEY`, `OPENROUTER_API_URL`, and `openrouter.stt_model` (TOML-only) configure the optional OpenRouter STT provider when `stt.provider = \"openrouter\"`.\n109\t- Generation, inspect, search, and index behavior is configured by CLI flags rather than TOML keys.\n110\t\n111\t## Architecture Notes\n112\t\n113\t- `internal/generate` is the codebase pipeline orchestration layer. Keep Cobra commands thin and push behavior into internal packages.\n114\t- The codebase pipeline is: scan -\u003e adapter parse -\u003e graph normalize -\u003e metrics compute -\u003e vault render -\u003e vault write -\u003e inspect/search/index read paths.\n115\t- `kb ingest file` routes through a converter registry (`internal/convert`) that matches file extensions to format-specific converters (PDF, DOCX, XLSX, PPTX, EPUB, HTML, CSV, JSON, XML, text, images with OCR).\n116\t- `kb ingest url` uses `internal/firecrawl` for web scraping, then writes through `internal/ingest`.\n117\t- `kb ingest youtube` uses `internal/youtube` for `yt-dlp` caption/audio extraction, with OpenAI STT by default and OpenRouter as an optional provider.\n118\t- Raw KB documents must include frontmatter before being written; use `internal/frontmatter` helpers instead of hand-assembling YAML.\n119\t- `vault.RenderDocuments` returns markdown bodies that already include frontmatter. Base definitions are rendered separately and written as YAML `.base` files.\n120\t- `kb topic new` owns the topic skeleton under the vault root, including `raw/`, `wiki/`, `outputs/`, `bases/`, `CLAUDE.md`, `AGENTS.md`, and `log.md`.\n121\t- Codebase inspection commands operate on `raw/codebase/` beneath the resolved topic. Keep inspect behavior topic-aware rather than vault-global.\n122\t\n123\t## Testing Notes\n124\t\n125\t- Default to table-driven tests with focused helpers and `t.TempDir()` for filesystem isolation.\n126\t- Integration tests use the `integration` build tag and live next to the packages they exercise.\n127\t- CLI integration tests exercise real topic/ingest/lint/inspect flows instead of mocking Cobra wiring when the workflow itself is the behavior under test.\n128\t- QMD-related integration tests must isolate `HOME`, `XDG_CACHE_HOME`, and `XDG_CONFIG_HOME`.\n129\t- Treat failing tests as product bugs first. Fix production behavior instead of weakening assertions.\n130\t\n```"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:20:54.018138Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01PyQYDnws979C9wKYY3HgGR","blocks":[{"type":"tool_use","id":"toolu_01PyQYDnws979C9wKYY3HgGR","name":"Read","title":"Read CONTRIBUTING.md","input":{"file_path":"/Users/pedronauck/Dev/compozy/kb/CONTRIBUTING.md"},"raw_input":{"file_path":"/Users/pedronauck/Dev/compozy/kb/CONTRIBUTING.md"}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:20:54.02292Z","turn":1,"type":"session.update","update":{"kind":"tool_call_started","tool_call_id":"toolu_01CiEUBcMgyVzR3uVoYAqKZt","tool_call_state":"pending","blocks":[{"type":"tool_use","id":"toolu_01CiEUBcMgyVzR3uVoYAqKZt","name":"Read","title":"Read File","raw_input":{}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:20:54.025377Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01PyQYDnws979C9wKYY3HgGR","usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:20:54.035107Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01PyQYDnws979C9wKYY3HgGR","tool_call_state":"completed","blocks":[{"type":"tool_result","tool_use_id":"toolu_01PyQYDnws979C9wKYY3HgGR","content":"```\n1\t# Contributing to kb\n2\t\n3\tThanks for your interest in contributing. Whether it's a bug report, a new language adapter, a file converter, or a documentation fix, every contribution helps.\n4\t\n5\t---\n6\t\n7\t## Prerequisites\n8\t\n9\t- [Go](https://go.dev) \u003e= 1.24\n10\t- [Git](https://git-scm.com)\n11\t\n12\tOptional (auto-downloaded by the build system if missing):\n13\t\n14\t- [golangci-lint](https://golangci-lint.run) v2\n15\t- [gotestsum](https://github.com/gotestyourself/gotestsum)\n16\t\n17\t---\n18\t\n19\t## Getting Started\n20\t\n21\t```bash\n22\tgit clone https://github.com/pedronauck/kodebase-go.git\n23\tcd kodebase-go\n24\tmake verify # Verify everything passes before making changes\n25\t```\n26\t\n27\t### Project Structure\n28\t\n29\t```text\n30\tcmd/\n31\t kb/\n32\t main.go # Program entrypoint\n33\tinternal/\n34\t cli/ # Cobra command tree and command adapters\n35\t root.go\n36\t topic.go # kb topic {new,list,info}\n37\t ingest.go # kb ingest parent command + shared helpers\n38\t ingest_url.go # kb ingest url\n39\t ingest_file.go # kb ingest file\n40\t ingest_youtube.go # kb ingest youtube\n41\t ingest_codebase.go # kb ingest codebase\n42\t ingest_bookmarks.go # kb ingest bookmarks\n43\t lint.go # kb lint\n44\t generate.go # Hidden compatibility alias\n45\t inspect.go # Router for inspect subcommands\n46\t inspect_*.go # Inspect subcommand implementations\n47\t search.go\n48\t index.go\n49\t version.go\n50\t topic/ # Topic scaffolding and metadata\n51\t ingest/ # Ingest orchestration and vault writes\n52\t convert/ # Converter registry and format-specific converters\n53\t firecrawl/ # Firecrawl REST API client\n54\t youtube/ # YouTube transcript extraction + STT fallback\n55\t frontmatter/ # YAML frontmatter parsing and generation\n56\t lint/ # KB structural lint engine\n57\t generate/ # Codebase-to-vault pipeline orchestration\n58\t models/ # Domain types, snapshots, and interfaces\n59\t scanner/ # Workspace discovery and ignore filtering\n60\t adapter/ # Tree-sitter parsing adapters\n61\t go_adapter.go\n62\t ts_adapter.go\n63\t treesitter.go\n64\t graph/ # Graph normalization\n65\t metrics/ # File, symbol, and directory metrics\n66\t vault/ # Rendering, writing, reading, query helpers\n67\t qmd/ # QMD shell client integration\n68\t output/ # Table / JSON / TSV output rendering\n69\t config/ # TOML config and env-backed secrets\n70\t logger/ # Structured slog logger\n71\t version/ # Build metadata\n72\tmagefile.go # Mage build tasks (wrapped by Makefile)\n73\t```\n74\t\n75\t---\n76\t\n77\t## Development Workflow\n78\t\n79\t| Command | Description |\n80\t| ----------------------- | ----------------------------------------- |\n81\t| `make fmt` | Format all Go files with gofmt |\n82\t| `make lint` | Run golangci-lint v2 with zero tolerance |\n83\t| `make test` | Unit tests with race detector |\n84\t| `make test-integration` | Unit + integration tests |\n85\t| `make build` | Build binary to `bin/kb` with ldflags |\n86\t| `make verify` | fmt -\u003e lint -\u003e test -\u003e build -\u003e boundaries|\n87\t| `make deps` | Run `go mod tidy` |\n88\t\n89\t**`make verify` must pass before submitting a PR.** The CI pipeline runs the same command.\n90\t\n91\t---\n92\t\n93\t## Code Style\n94\t\n95\t- **File naming:** `snake_case.go` for all Go files\n96\t- **Exports:** Capitalize public symbols; keep internal logic unexported\n97\t- **Formatting:** `gofmt` (standard Go formatting, enforced by `make fmt`)\n98\t- **Linting:** golangci-lint v2 with zero warnings -- warnings are treated as errors\n99\t- **Imports:** Group in order: stdlib, third-party, internal\n100\t- **Dependencies:** Use `go get` for dependency changes, never hand-edit `go.mod`\n101\t- **CLI commands:** Use [Cobra](https://github.com/spf13/cobra). Keep commands thin -- delegate to packages like `internal/topic`, `internal/ingest`, `internal/lint`, `internal/generate`, `internal/vault`, and `internal/qmd`\n102\t\n103\t---\n104\t\n105\t## Commit Messages\n106\t\n107\tWe use [Conventional Commits](https://www.conventionalcommits.org) enforced by CI.\n108\t\n109\t```text\n110\tfeat(cli): add python language adapter\n111\tfix(cli): correct cyclomatic complexity for method receivers\n112\trefactor(cli): extract shared metric computation\n113\ttest(cli): add coverage for blast-radius edge cases\n114\tdocs(repo): update README with search command examples\n115\tchore(repo): update dependencies\n116\t```\n117\t\n118\t**Valid types:** `feat`, `fix`, `refactor`, `test`, `docs`, `chore`, `build`, `ci`\n119\t\n120\t**Valid scopes:** `cli`, `repo`, `docs`, `test`, `ci`\n121\t\n122\tPR titles must follow the same format (enforced by CI).\n123\t\n124\t---\n125\t\n126\t## Testing\n127\t\n128\t- **Framework:** Standard `testing` package with [gotestsum](https://github.com/gotestyourself/gotestsum) runner\n129\t- **Run tests:** `make test` or `make verify`\n130\t- **Test location:** Co-located with source (`*_test.go` next to the file under test)\n131\t- **File pattern:** `\u003cmodule\u003e_test.go`\n132\t\n133\t### Conventions\n134\t\n135\t- Table-driven tests with `t.Run` subtests\n136\t- `t.TempDir()` for filesystem isolation\n137\t- Focused test helpers with `t.Helper()`\n138\t- Integration tests use the `//go:build integration` build tag and run via `make test-integration`\n139\t- Race detector is always enabled (`-race`)\n140\t\n141\t---\n142\t\n143\t## Adding a New Language Adapter\n144\t\n145\tThis is one of the most impactful contributions you can make. `kb` uses a clean adapter interface that makes adding new languages straightforward.\n146\t\n147\t### Step 1: Create the Adapter File\n148\t\n149\t```text\n150\tinternal/adapter/\u003clanguage\u003e_adapter.go\n151\t```\n152\t\n153\t### Step 2: Implement the LanguageAdapter Interface\n154\t\n155\tThe interface is defined in `internal/models/models.go`:\n156\t\n157\t```go\n158\ttype LanguageAdapter interface {\n159\t Supports(lang SupportedLanguage) bool\n160\t ParseFiles(files []ScannedSourceFile, rootPath string) ([]ParsedFile, error)\n161\t}\n162\t```\n163\t\n164\tYour `ParseFiles` method must return, for each file:\n165\t\n166\t- **`File`** -- A `GraphFile` node with the file's path, language, and symbol IDs\n167\t- **`Symbols`** -- Slice of `SymbolNode` entries (functions, classes, interfaces, types, variables, methods)\n168\t- **`ExternalNodes`** -- External module references\n169\t- **`Relations`** -- Edges connecting files and symbols (`imports`, `exports`, `calls`, `references`, `declares`, `contains`)\n170\t- **`Diagnostics`** -- Any parse warnings or errors\n171\t\n172\t### Step 3: Register Language Extensions\n173\t\n174\tAdd your language's file extensions to the `SupportedLanguage` constants in `internal/models/models.go` and extend `SupportedLanguages()`.\n175\t\n176\t### Step 4: Add Tree-sitter Bindings\n177\t\n178\tAdd the tree-sitter grammar dependency and wire it in `internal/adapter/treesitter.go`.\n179\t\n180\t### Step 5: Wire the Adapter\n181\t\n182\tAdd adapter instantiation and file dispatch in `internal/generate/generate.go`, following the pattern of the existing Go and TypeScript adapters.\n183\t\n184\t### Step 6: Write Tests\n185\t\n186\tAdd `\u003clanguage\u003e_adapter_test.go` in `internal/adapter/` with fixture files in `internal/adapter/testdata/`.\n187\t\n188\t### Step 7: Update Documentation\n189\t\n190\tAdd the language to the \"Supported Languages\" table in `README.md`.\n191\t\n192\t### Reference Implementations\n193\t\n194\t- **`go_adapter.go`** -- Tree-sitter-based Go parser. Relation confidence: `syntactic`.\n195\t- **`ts_adapter.go`** -- Tree-sitter-based TypeScript/JavaScript parser. Relation confidence: `syntactic`.\n196\t\n197\t---\n198\t\n199\t## Adding a New File Converter\n200\t\n201\tThe converter registry (`internal/convert`) makes adding new file format support straightforward.\n202\t\n203\t### Step 1: Create the Converter File\n204\t\n205\t```text\n206\tinternal/convert/\u003cformat\u003e.go\n207\t```\n208\t\n209\t### Step 2: Implement the Converter Interface\n210\t\n211\tThe interface is defined in `internal/models/models.go`:\n212\t\n213\t```go\n214\ttype Converter interface {\n215\t Extensions() []string\n216\t Convert(ctx context.Context, input ConvertInput) (*ConvertResult, error)\n217\t}\n218\t```\n219\t\n220\t### Step 3: Register the Converter\n221\t\n222\tAdd your converter to `NewRegistry()` in `internal/convert/registry.go`.\n223\t\n224\t### Step 4: Write Tests\n225\t\n226\tAdd `\u003cformat\u003e_test.go` in `internal/convert/` with fixture files in `internal/convert/testdata/`.\n227\t\n228\t### Reference Implementations\n229\t\n230\t- **`pdf.go`** -- PDF text extraction via pdfcpu\n231\t- **`docx.go`** -- DOCX XML extraction\n232\t- **`html.go`** -- HTML-to-markdown conversion\n233\t\n234\t---\n235\t\n236\t## Pull Request Process\n237\t\n238\t1. Fork the repository and create a feature branch from `main`\n239\t2. Make your changes following the code style guidelines above\n240\t3. Run `make verify` and ensure it passes with zero warnings\n241\t4. Write a clear PR description explaining what changed and why\n242\t5. PR titles must follow Conventional Commits format with a scope\n243\t6. Keep PRs focused -- one feature or fix per PR\n244\t\n245\t---\n246\t\n247\t## Reporting Issues\n248\t\n249\t- **Bugs:** Use the [bug report template](https://github.com/pedronauck/kodebase-go/issues/new?template=bug-report.yml)\n250\t- **Features:** Use the [feature request template](https://github.com/pedronauck/kodebase-go/issues/new?template=feature-request.yml)\n251\t\n252\tInclude the command you ran, the output you got, and the output you expected. `kb version` output and Go version help us reproduce faster.\n253\t\n254\t---\n255\t\n256\t## Code of Conduct\n257\t\n258\tBe respectful and constructive. We follow the [Contributor Covenant](https://www.contributor-covenant.org/version/2/1/code_of_conduct/).\n259\t\n```"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:20:54.99197Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01CiEUBcMgyVzR3uVoYAqKZt","blocks":[{"type":"tool_use","id":"toolu_01CiEUBcMgyVzR3uVoYAqKZt","name":"Read","title":"Read .compozy/tasks/okf-dual-mode/qa/impl-review-diff-round1.patch","input":{"file_path":"/Users/pedronauck/Dev/compozy/kb/.compozy/tasks/okf-dual-mode/qa/impl-review-diff-round1.patch"},"raw_input":{"file_path":"/Users/pedronauck/Dev/compozy/kb/.compozy/tasks/okf-dual-mode/qa/impl-review-diff-round1.patch"}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:20:55.402207Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01CiEUBcMgyVzR3uVoYAqKZt","usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:20:55.425973Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01CiEUBcMgyVzR3uVoYAqKZt","tool_call_state":"completed","blocks":[{"type":"tool_result","tool_use_id":"toolu_01CiEUBcMgyVzR3uVoYAqKZt","content":"```\n\u003csystem-reminder\u003e[Truncated: PARTIAL view — showing lines 1-1226 of 2809 total (48686 tokens, cap 25000). Call Read with offset=1227 limit=1226 for the next page, or Grep to find a specific section. Do NOT answer from this page alone if the answer may be further in the file.]\u003c/system-reminder\u003e\n\n1\tdiff --git a/config.example.toml b/config.example.toml\n2\tindex a9ea428..0c91297 100644\n3\t--- a/config.example.toml\n4\t+++ b/config.example.toml\n5\t@@ -5,6 +5,7 @@\n6\t # - `[app]`\n7\t # - `[log]`\n8\t # - `[vault]`\n9\t+# - `[okf]`\n10\t # - `[firecrawl]`\n11\t # - `[openrouter]`\n12\t # - `[stt]`\n13\t@@ -48,6 +49,11 @@ level = \"info\"\n14\t root = \".\"\n15\t topic_globs = [\"*\"]\n16\t \n17\t+[okf]\n18\t+# Local OKF concept type vocabulary. Empty means `kb okf check` will not warn\n19\t+# about unknown types until the operator opts into a local standard.\n20\t+types = []\n21\t+\n22\t [firecrawl]\n23\t api_key = \"\"\n24\t api_url = \"https://api.firecrawl.dev\"\n25\tdiff --git a/internal/cli/root.go b/internal/cli/root.go\n26\tindex 2b123a6..22ab258 100644\n27\t--- a/internal/cli/root.go\n28\t+++ b/internal/cli/root.go\n29\t@@ -23,6 +23,8 @@ func newRootCommand() *cobra.Command {\n30\t \tbindRootPersistentFlags(command)\n31\t \tcommand.AddCommand(newTopicCommand())\n32\t \tcommand.AddCommand(newIngestCommand())\n33\t+\tcommand.AddCommand(newPromoteCommand())\n34\t+\tcommand.AddCommand(newOKFCommand())\n35\t \tcommand.AddCommand(newLintCommand())\n36\t \tcommand.AddCommand(newGenerateCommand())\n37\t \tcommand.AddCommand(newInspectCommand())\n38\tdiff --git a/internal/cli/topic.go b/internal/cli/topic.go\n39\tindex 3746632..d609335 100644\n40\t--- a/internal/cli/topic.go\n41\t+++ b/internal/cli/topic.go\n42\t@@ -4,6 +4,7 @@ import (\n43\t \t\"encoding/json\"\n44\t \t\"fmt\"\n45\t \t\"os\"\n46\t+\t\"strings\"\n47\t \n48\t \t\"github.com/spf13/cobra\"\n49\t \n50\t@@ -13,6 +14,7 @@ import (\n51\t )\n52\t \n53\t var runTopicNew = ktopic.New\n54\t+var runTopicNewWithMode = ktopic.NewWithMode\n55\t var runTopicList = ktopic.List\n56\t var runTopicInfo = ktopic.Info\n57\t var topicGetwd = os.Getwd\n58\t@@ -37,7 +39,8 @@ func newTopicCommand() *cobra.Command {\n59\t }\n60\t \n61\t func newTopicNewCommand() *cobra.Command {\n62\t-\treturn \u0026cobra.Command{\n63\t+\tvar mode string\n64\t+\tcommand := \u0026cobra.Command{\n65\t \t\tUse: \"new \u003cslug\u003e \u003ctitle\u003e \u003cdomain\u003e\",\n66\t \t\tShort: \"Scaffold a new knowledge base topic\",\n67\t \t\tArgs: cobra.ExactArgs(3),\n68\t@@ -47,7 +50,16 @@ func newTopicNewCommand() *cobra.Command {\n69\t \t\t\t\treturn err\n70\t \t\t\t}\n71\t \n72\t-\t\t\tinfo, err := runTopicNew(vaultPath, args[0], args[1], args[2])\n73\t+\t\t\ttopicMode, err := parseTopicMode(mode)\n74\t+\t\t\tif err != nil {\n75\t+\t\t\t\treturn err\n76\t+\t\t\t}\n77\t+\t\t\tvar info models.TopicInfo\n78\t+\t\t\tif topicMode == models.TopicModeWiki {\n79\t+\t\t\t\tinfo, err = runTopicNew(vaultPath, args[0], args[1], args[2])\n80\t+\t\t\t} else {\n81\t+\t\t\t\tinfo, err = runTopicNewWithMode(vaultPath, args[0], args[1], args[2], topicMode)\n82\t+\t\t\t}\n83\t \t\t\tif err != nil {\n84\t \t\t\t\treturn err\n85\t \t\t\t}\n86\t@@ -55,6 +67,8 @@ func newTopicNewCommand() *cobra.Command {\n87\t \t\t\treturn writeTopicInfoJSON(cmd, info)\n88\t \t\t},\n89\t \t}\n90\t+\tcommand.Flags().StringVar(\u0026mode, \"mode\", string(models.TopicModeWiki), \"Topic mode (wiki|okf)\")\n91\t+\treturn command\n92\t }\n93\t \n94\t func newTopicListCommand() *cobra.Command {\n95\t@@ -98,6 +112,17 @@ func newTopicListCommand() *cobra.Command {\n96\t \t}\n97\t }\n98\t \n99\t+func parseTopicMode(value string) (models.TopicMode, error) {\n100\t+\tswitch strings.TrimSpace(value) {\n101\t+\tcase \"\", string(models.TopicModeWiki):\n102\t+\t\treturn models.TopicModeWiki, nil\n103\t+\tcase string(models.TopicModeOKF):\n104\t+\t\treturn models.TopicModeOKF, nil\n105\t+\tdefault:\n106\t+\t\treturn \"\", fmt.Errorf(`invalid --mode %q: expected \"wiki\" or \"okf\"`, value)\n107\t+\t}\n108\t+}\n109\t+\n110\t func newTopicInfoCommand() *cobra.Command {\n111\t \treturn \u0026cobra.Command{\n112\t \t\tUse: \"info \u003cslug\u003e\",\n113\tdiff --git a/internal/cli/topic_test.go b/internal/cli/topic_test.go\n114\tindex 44d7b67..1f49e4d 100644\n115\t--- a/internal/cli/topic_test.go\n116\t+++ b/internal/cli/topic_test.go\n117\t@@ -79,6 +79,66 @@ func TestTopicNewCommandPassesArgsAndPrintsJSON(t *testing.T) {\n118\t \t}\n119\t }\n120\t \n121\t+func TestTopicNewCommandPassesOKFMode(t *testing.T) {\n122\t+\toriginalNewWithMode := runTopicNewWithMode\n123\t+\toriginalGetwd := topicGetwd\n124\t+\tt.Cleanup(func() {\n125\t+\t\trunTopicNewWithMode = originalNewWithMode\n126\t+\t\ttopicGetwd = originalGetwd\n127\t+\t})\n128\t+\n129\t+\tvar gotMode models.TopicMode\n130\t+\trunTopicNewWithMode = func(vaultPath, slug, title, domain string, mode models.TopicMode) (models.TopicInfo, error) {\n131\t+\t\tgotMode = mode\n132\t+\t\treturn models.TopicInfo{\n133\t+\t\t\tSlug: slug,\n134\t+\t\t\tTitle: title,\n135\t+\t\t\tDomain: domain,\n136\t+\t\t\tMode: mode,\n137\t+\t\t\tRootPath: filepath.Join(vaultPath, slug),\n138\t+\t\t}, nil\n139\t+\t}\n140\t+\ttopicGetwd = func() (string, error) {\n141\t+\t\treturn \"/workspace/repo\", nil\n142\t+\t}\n143\t+\n144\t+\tcommand := newRootCommand()\n145\t+\tvar stdout bytes.Buffer\n146\t+\tcommand.SetOut(\u0026stdout)\n147\t+\tcommand.SetErr(new(bytes.Buffer))\n148\t+\tcommand.SetArgs([]string{\"topic\", \"new\", \"ops-catalog\", \"Ops Catalog\", \"ops\", \"--mode\", \"okf\", \"--vault\", \"/tmp/vault\"})\n149\t+\n150\t+\tif err := command.ExecuteContext(context.Background()); err != nil {\n151\t+\t\tt.Fatalf(\"ExecuteContext returned error: %v\", err)\n152\t+\t}\n153\t+\tif gotMode != models.TopicModeOKF {\n154\t+\t\tt.Fatalf(\"mode = %q, want okf\", gotMode)\n155\t+\t}\n156\t+\n157\t+\tvar info models.TopicInfo\n158\t+\tif err := json.Unmarshal(stdout.Bytes(), \u0026info); err != nil {\n159\t+\t\tt.Fatalf(\"stdout did not contain JSON: %v\\n%s\", err, stdout.String())\n160\t+\t}\n161\t+\tif info.Mode != models.TopicModeOKF {\n162\t+\t\tt.Fatalf(\"payload mode = %q, want okf\", info.Mode)\n163\t+\t}\n164\t+}\n165\t+\n166\t+func TestTopicNewCommandRejectsInvalidMode(t *testing.T) {\n167\t+\tcommand := newRootCommand()\n168\t+\tcommand.SetOut(new(bytes.Buffer))\n169\t+\tcommand.SetErr(new(bytes.Buffer))\n170\t+\tcommand.SetArgs([]string{\"topic\", \"new\", \"ops\", \"Ops\", \"ops\", \"--mode\", \"catalog\", \"--vault\", \"/tmp/vault\"})\n171\t+\n172\t+\terr := command.ExecuteContext(context.Background())\n173\t+\tif err == nil {\n174\t+\t\tt.Fatal(\"expected invalid mode error\")\n175\t+\t}\n176\t+\tif !strings.Contains(err.Error(), \"invalid --mode\") {\n177\t+\t\tt.Fatalf(\"unexpected error: %v\", err)\n178\t+\t}\n179\t+}\n180\t+\n181\t func TestTopicNewCommandRequiresThreeArgs(t *testing.T) {\n182\t \tcommand := newRootCommand()\n183\t \tcommand.SetOut(new(bytes.Buffer))\n184\tdiff --git a/internal/config/config.go b/internal/config/config.go\n185\tindex 14d3417..5083362 100644\n186\t--- a/internal/config/config.go\n187\t+++ b/internal/config/config.go\n188\t@@ -47,6 +47,7 @@ type Config struct {\n189\t \tApp AppConfig `toml:\"app\"`\n190\t \tLog LogConfig `toml:\"log\"`\n191\t \tVault VaultConfig `toml:\"vault\"`\n192\t+\tOKF OKFConfig `toml:\"okf\"`\n193\t \tFirecrawl FirecrawlConfig `toml:\"firecrawl\"`\n194\t \tOpenRouter OpenRouterConfig `toml:\"openrouter\"`\n195\t \tSTT STTConfig `toml:\"stt\"`\n196\t@@ -71,6 +72,11 @@ type VaultConfig struct {\n197\t \tTopicGlobs []string `toml:\"topic_globs\"`\n198\t }\n199\t \n200\t+// OKFConfig controls local Open Knowledge Format producer standards.\n201\t+type OKFConfig struct {\n202\t+\tTypes []string `toml:\"types\"`\n203\t+}\n204\t+\n205\t // FirecrawlConfig controls URL scraping API access.\n206\t type FirecrawlConfig struct {\n207\t \tAPIKey string `toml:\"api_key\"`\n208\t@@ -211,6 +217,7 @@ func (c *Config) applyDefaults() {\n209\t \tif len(c.Vault.TopicGlobs) == 0 {\n210\t \t\tc.Vault.TopicGlobs = []string{defaultTopicGlob}\n211\t \t}\n212\t+\tc.OKF.Types = normalizeConfigStringList(c.OKF.Types)\n213\t \tif strings.TrimSpace(c.YouTube.YTDLPPath) == \"\" {\n214\t \t\tc.YouTube.YTDLPPath = defaultYouTubeYTDLPPath\n215\t \t}\n216\tdiff --git a/internal/config/config_test.go b/internal/config/config_test.go\n217\tindex 9edc0c1..e4c3c2e 100644\n218\t--- a/internal/config/config_test.go\n219\t+++ b/internal/config/config_test.go\n220\t@@ -59,6 +59,9 @@ func TestDefaultConfigHasValidDefaults(t *testing.T) {\n221\t \tif len(cfg.Vault.TopicGlobs) != 1 || cfg.Vault.TopicGlobs[0] != \"*\" {\n222\t \t\tt.Errorf(\"expected default vault.topic_globs [*], got %#v\", cfg.Vault.TopicGlobs)\n223\t \t}\n224\t+\tif len(cfg.OKF.Types) != 0 {\n225\t+\t\tt.Errorf(\"expected default okf.types to be empty, got %#v\", cfg.OKF.Types)\n226\t+\t}\n227\t \tif cfg.Firecrawl.APIURL != defaultFirecrawlAPIURL {\n228\t \t\tt.Errorf(\"expected default firecrawl.api_url %q, got %q\", defaultFirecrawlAPIURL, cfg.Firecrawl.APIURL)\n229\t \t}\n230\t@@ -118,6 +121,9 @@ level = \"debug\"\n231\t root = \".\"\n232\t topic_globs = [\"*\", \"harness/*\"]\n233\t \n234\t+[okf]\n235\t+types = [\"Playbook\", \" Reference \", \"\"]\n236\t+\n237\t [firecrawl]\n238\t api_key = \"firecrawl-key\"\n239\t api_url = \"https://firecrawl.internal\"\n240\t@@ -172,6 +178,9 @@ allow_translated_captions = true\n241\t \tif len(cfg.Vault.TopicGlobs) != 2 || cfg.Vault.TopicGlobs[1] != \"harness/*\" {\n242\t \t\tt.Errorf(\"expected vault.topic_globs to include harness/*, got %#v\", cfg.Vault.TopicGlobs)\n243\t \t}\n244\t+\tif !reflect.DeepEqual(cfg.OKF.Types, []string{\"Playbook\", \"Reference\"}) {\n245\t+\t\tt.Errorf(\"expected normalized okf.types, got %#v\", cfg.OKF.Types)\n246\t+\t}\n247\t \tif cfg.Firecrawl.APIKey != \"firecrawl-key\" {\n248\t \t\tt.Errorf(\"expected firecrawl.api_key 'firecrawl-key', got %q\", cfg.Firecrawl.APIKey)\n249\t \t}\n250\tdiff --git a/internal/generate/generate.go b/internal/generate/generate.go\n251\tindex be6a9a1..987e572 100644\n252\t--- a/internal/generate/generate.go\n253\t+++ b/internal/generate/generate.go\n254\t@@ -453,6 +453,7 @@ func (r runner) createTopicMetadata(target generationTarget, opts models.Generat\n255\t \t\tTitle: title,\n256\t \t\tSlug: target.TopicSlug,\n257\t \t\tDomain: vault.DeriveTopicDomain(domainSource),\n258\t+\t\tMode: models.TopicModeWiki,\n259\t \t\tToday: r.now().Format(\"2006-01-02\"),\n260\t \t\tVaultPath: target.VaultPath,\n261\t \t\tTopicPath: filepath.Join(target.VaultPath, target.TopicSlug),\n262\tdiff --git a/internal/models/kb_models.go b/internal/models/kb_models.go\n263\tindex 11d0ef2..2087683 100644\n264\t--- a/internal/models/kb_models.go\n265\t+++ b/internal/models/kb_models.go\n266\t@@ -114,11 +114,12 @@ type LintIssue struct {\n267\t \n268\t // TopicInfo captures topic metadata for list and info operations.\n269\t type TopicInfo struct {\n270\t-\tSlug string `json:\"slug\"`\n271\t-\tTitle string `json:\"title\"`\n272\t-\tDomain string `json:\"domain\"`\n273\t-\tRootPath string `json:\"rootPath\"`\n274\t-\tArticleCount int `json:\"articleCount\"`\n275\t-\tSourceCount int `json:\"sourceCount\"`\n276\t-\tLastLogEntry string `json:\"lastLogEntry,omitempty\"`\n277\t+\tSlug string `json:\"slug\"`\n278\t+\tTitle string `json:\"title\"`\n279\t+\tDomain string `json:\"domain\"`\n280\t+\tMode TopicMode `json:\"mode\"`\n281\t+\tRootPath string `json:\"rootPath\"`\n282\t+\tArticleCount int `json:\"articleCount\"`\n283\t+\tSourceCount int `json:\"sourceCount\"`\n284\t+\tLastLogEntry string `json:\"lastLogEntry,omitempty\"`\n285\t }\n286\tdiff --git a/internal/models/models.go b/internal/models/models.go\n287\tindex 3352045..061f0aa 100644\n288\t--- a/internal/models/models.go\n289\t+++ b/internal/models/models.go\n290\t@@ -102,6 +102,16 @@ const (\n291\t \tDocIndex DocumentKind = \"index\"\n292\t )\n293\t \n294\t+// TopicMode identifies the lifecycle and file contract for a topic.\n295\t+type TopicMode string\n296\t+\n297\t+const (\n298\t+\t// TopicModeWiki preserves the existing LLM-wiki topic lifecycle.\n299\t+\tTopicModeWiki TopicMode = \"wiki\"\n300\t+\t// TopicModeOKF uses the Open Knowledge Format bundle lifecycle.\n301\t+\tTopicModeOKF TopicMode = \"okf\"\n302\t+)\n303\t+\n304\t // ManagedArea identifies the managed subtree within a generated topic.\n305\t type ManagedArea string\n306\t \n307\t@@ -269,13 +279,14 @@ type RenderedDocument struct {\n308\t \n309\t // TopicMetadata captures the derived topic information for a vault render.\n310\t type TopicMetadata struct {\n311\t-\tRootPath string `json:\"rootPath\"`\n312\t-\tTitle string `json:\"title\"`\n313\t-\tSlug string `json:\"slug\"`\n314\t-\tDomain string `json:\"domain\"`\n315\t-\tToday string `json:\"today\"`\n316\t-\tVaultPath string `json:\"vaultPath\"`\n317\t-\tTopicPath string `json:\"topicPath\"`\n318\t+\tRootPath string `json:\"rootPath\"`\n319\t+\tTitle string `json:\"title\"`\n320\t+\tSlug string `json:\"slug\"`\n321\t+\tDomain string `json:\"domain\"`\n322\t+\tMode TopicMode `json:\"mode\"`\n323\t+\tToday string `json:\"today\"`\n324\t+\tVaultPath string `json:\"vaultPath\"`\n325\t+\tTopicPath string `json:\"topicPath\"`\n326\t }\n327\t \n328\t // GenerateOptions configures a full knowledge-base generation run.\n329\tdiff --git a/internal/topic/topic.go b/internal/topic/topic.go\n330\tindex 1097c4e..f74467e 100644\n331\t--- a/internal/topic/topic.go\n332\t+++ b/internal/topic/topic.go\n333\t@@ -23,6 +23,7 @@ const (\n334\t \tconceptIndexTemplatePath = \"assets/concept-index-template.md\"\n335\t \tdashboardTemplatePath = \"assets/dashboard-template.md\"\n336\t \tlogTemplatePath = \"assets/log-template.md\"\n337\t+\tokfClaudeTemplatePath = \"assets/okf-claude-template.md\"\n338\t \tsourceIndexTemplatePath = \"assets/source-index-template.md\"\n339\t \ttopicMarkerFile = \"CLAUDE.md\"\n340\t \ttopicMetadataFileName = \"topic.yaml\"\n341\t@@ -105,6 +106,7 @@ type topicMetadataFile struct {\n342\t \tSlug string `yaml:\"slug\"`\n343\t \tTitle string `yaml:\"title\"`\n344\t \tDomain string `yaml:\"domain\"`\n345\t+\tMode string `yaml:\"mode,omitempty\"`\n346\t \tCategory string `yaml:\"category,omitempty\"`\n347\t \tPath string `yaml:\"path,omitempty\"`\n348\t \tQMDCollection string `yaml:\"qmd_collection,omitempty\"`\n349\t@@ -112,7 +114,12 @@ type topicMetadataFile struct {\n350\t \n351\t // New scaffolds a new topic underneath the provided vault root.\n352\t func New(vaultPath, slug, title, domain string) (models.TopicInfo, error) {\n353\t-\treturn newWithDate(vaultPath, slug, title, domain, time.Now())\n354\t+\treturn NewWithMode(vaultPath, slug, title, domain, models.TopicModeWiki)\n355\t+}\n356\t+\n357\t+// NewWithMode scaffolds a new topic with an explicit lifecycle mode.\n358\t+func NewWithMode(vaultPath, slug, title, domain string, mode models.TopicMode) (models.TopicInfo, error) {\n359\t+\treturn newWithDateWithMode(vaultPath, slug, title, domain, mode, time.Now())\n360\t }\n361\t \n362\t // List returns the topics discovered under the provided vault root.\n363\t@@ -184,6 +191,10 @@ func Info(vaultPath, slug string) (models.TopicInfo, error) {\n364\t }\n365\t \n366\t func newWithDate(vaultPath, slug, title, domain string, now time.Time) (models.TopicInfo, error) {\n367\t+\treturn newWithDateWithMode(vaultPath, slug, title, domain, models.TopicModeWiki, now)\n368\t+}\n369\t+\n370\t+func newWithDateWithMode(vaultPath, slug, title, domain string, mode models.TopicMode, now time.Time) (models.TopicInfo, error) {\n371\t \tcleanVaultPath, err := normalizeVaultPath(vaultPath)\n372\t \tif err != nil {\n373\t \t\treturn models.TopicInfo{}, fmt.Errorf(\"new topic: %w\", err)\n374\t@@ -195,6 +206,10 @@ func newWithDate(vaultPath, slug, title, domain string, now time.Time) (models.T\n375\t \t}\n376\t \tcleanTitle := strings.TrimSpace(title)\n377\t \tcleanDomain := strings.TrimSpace(domain)\n378\t+\tcleanMode, err := normalizeTopicMode(mode)\n379\t+\tif err != nil {\n380\t+\t\treturn models.TopicInfo{}, fmt.Errorf(\"new topic: %w\", err)\n381\t+\t}\n382\t \n383\t \tswitch {\n384\t \tcase cleanTitle == \"\":\n385\t@@ -214,7 +229,7 @@ func newWithDate(vaultPath, slug, title, domain string, now time.Time) (models.T\n386\t \t\treturn models.TopicInfo{}, fmt.Errorf(\"new topic: inspect topic path %q: %w\", topicPath, err)\n387\t \t}\n388\t \n389\t-\tif err := createTopicSkeleton(topicPath); err != nil {\n390\t+\tif err := createTopicSkeleton(topicPath, cleanMode); err != nil {\n391\t \t\treturn models.TopicInfo{}, fmt.Errorf(\"new topic: create topic skeleton: %w\", err)\n392\t \t}\n393\t \n394\t@@ -225,19 +240,26 @@ func newWithDate(vaultPath, slug, title, domain string, now time.Time) (models.T\n395\t \t\tToday: now.Format(frontmatter.DateLayout),\n396\t \t}\n397\t \n398\t-\tif err := installTemplates(topicPath, context); err != nil {\n399\t+\tif err := installTemplatesWithMode(topicPath, context, cleanMode); err != nil {\n400\t \t\treturn models.TopicInfo{}, fmt.Errorf(\"new topic: install templates: %w\", err)\n401\t \t}\n402\t-\tif err := writeMetadataFile(topicPath, topicMetadataForRef(topicRef, cleanTitle, cleanDomain)); err != nil {\n403\t+\tif err := writeMetadataFile(topicPath, topicMetadataForRef(topicRef, cleanTitle, cleanDomain, cleanMode)); err != nil {\n404\t \t\treturn models.TopicInfo{}, fmt.Errorf(\"new topic: write topic metadata: %w\", err)\n405\t \t}\n406\t \tif err := ensureAgentsSymlink(topicPath); err != nil {\n407\t \t\treturn models.TopicInfo{}, fmt.Errorf(\"new topic: ensure AGENTS.md symlink: %w\", err)\n408\t \t}\n409\t-\tif err := ensureGitkeeps(topicPath); err != nil {\n410\t-\t\treturn models.TopicInfo{}, fmt.Errorf(\"new topic: ensure gitkeep files: %w\", err)\n411\t+\tif cleanMode == models.TopicModeWiki {\n412\t+\t\tif err := ensureGitkeeps(topicPath); err != nil {\n413\t+\t\t\treturn models.TopicInfo{}, fmt.Errorf(\"new topic: ensure gitkeep files: %w\", err)\n414\t+\t\t}\n415\t \t}\n416\t-\tif err := appendScaffoldEntry(filepath.Join(topicPath, \"log.md\"), context); err != nil {\n417\t+\tlogPath := filepath.Join(topicPath, \"log.md\")\n418\t+\tif cleanMode == models.TopicModeOKF {\n419\t+\t\tif err := appendOKFScaffoldEntry(logPath, context); err != nil {\n420\t+\t\t\treturn models.TopicInfo{}, fmt.Errorf(\"new topic: append scaffold log entry: %w\", err)\n421\t+\t\t}\n422\t+\t} else if err := appendScaffoldEntry(logPath, context); err != nil {\n423\t \t\treturn models.TopicInfo{}, fmt.Errorf(\"new topic: append scaffold log entry: %w\", err)\n424\t \t}\n425\t \n426\t@@ -412,7 +434,10 @@ func ensureDirectory(path string) error {\n427\t \treturn nil\n428\t }\n429\t \n430\t-func createTopicSkeleton(topicPath string) error {\n431\t+func createTopicSkeleton(topicPath string, mode models.TopicMode) error {\n432\t+\tif mode == models.TopicModeOKF {\n433\t+\t\treturn os.MkdirAll(topicPath, 0o755)\n434\t+\t}\n435\t \tfor _, relativePath := range currentTopicDirectories {\n436\t \t\tdirectoryPath := filepath.Join(topicPath, filepath.FromSlash(relativePath))\n437\t \t\tif err := os.MkdirAll(directoryPath, 0o755); err != nil {\n438\t@@ -430,7 +455,7 @@ func EnsureCurrentSkeleton(topicPath string) error {\n439\t \t\treturn fmt.Errorf(\"topic path is required\")\n440\t \t}\n441\t \n442\t-\tif err := createTopicSkeleton(topicPath); err != nil {\n443\t+\tif err := createTopicSkeleton(topicPath, models.TopicModeWiki); err != nil {\n444\t \t\treturn err\n445\t \t}\n446\t \tif err := ensureTopicLog(topicPath); err != nil {\n447\t@@ -485,7 +510,17 @@ func RenderSourceIndexTemplate(slug, title, domain, today string) (string, error\n448\t }\n449\t \n450\t func installTemplates(topicPath string, context templateContext) error {\n451\t-\tfor _, file := range topicTemplates {\n452\t+\treturn installTemplatesWithMode(topicPath, context, models.TopicModeWiki)\n453\t+}\n454\t+\n455\t+func installTemplatesWithMode(topicPath string, context templateContext, mode models.TopicMode) error {\n456\t+\ttemplates := topicTemplates\n457\t+\tif mode == models.TopicModeOKF {\n458\t+\t\ttemplates = []templateFile{\n459\t+\t\t\t{assetPath: okfClaudeTemplatePath, outputPath: \"CLAUDE.md\"},\n460\t+\t\t}\n461\t+\t}\n462\t+\tfor _, file := range templates {\n463\t \t\trendered, err := renderTemplate(file.assetPath, context)\n464\t \t\tif err != nil {\n465\t \t\t\treturn err\n466\t@@ -497,6 +532,19 @@ func installTemplates(topicPath string, context templateContext) error {\n467\t \t\t}\n468\t \t}\n469\t \n470\t+\tif mode == models.TopicModeOKF {\n471\t+\t\tindex, err := frontmatter.Generate(map[string]any{\"okf_version\": \"0.1\"}, \"# OKF Bundle Index\\n\")\n472\t+\t\tif err != nil {\n473\t+\t\t\treturn fmt.Errorf(\"generate OKF index: %w\", err)\n474\t+\t\t}\n475\t+\t\tif err := os.WriteFile(filepath.Join(topicPath, \"index.md\"), []byte(index), 0o644); err != nil {\n476\t+\t\t\treturn fmt.Errorf(\"write OKF index: %w\", err)\n477\t+\t\t}\n478\t+\t\tif err := os.WriteFile(filepath.Join(topicPath, \"log.md\"), []byte(\"# Directory Update Log\\n\"), 0o644); err != nil {\n479\t+\t\t\treturn fmt.Errorf(\"write OKF log: %w\", err)\n480\t+\t\t}\n481\t+\t}\n482\t+\n483\t \treturn nil\n484\t }\n485\t \n486\t@@ -506,14 +554,28 @@ func WriteMetadataFile(topicPath, slug, title, domain string) error {\n487\t \tif err != nil {\n488\t \t\treturn fmt.Errorf(\"validate topic metadata slug: %w\", err)\n489\t \t}\n490\t-\treturn writeMetadataFile(topicPath, topicMetadataForRef(topicRef, title, domain))\n491\t+\treturn writeMetadataFile(topicPath, topicMetadataForRef(topicRef, title, domain, \"\"))\n492\t+}\n493\t+\n494\t+// WriteMetadataFileWithMode writes structured topic metadata with an explicit mode.\n495\t+func WriteMetadataFileWithMode(topicPath, slug, title, domain string, mode models.TopicMode) error {\n496\t+\ttopicRef, err := ParseTopicRef(slug)\n497\t+\tif err != nil {\n498\t+\t\treturn fmt.Errorf(\"validate topic metadata slug: %w\", err)\n499\t+\t}\n500\t+\tcleanMode, err := normalizeTopicMode(mode)\n501\t+\tif err != nil {\n502\t+\t\treturn err\n503\t+\t}\n504\t+\treturn writeMetadataFile(topicPath, topicMetadataForRef(topicRef, title, domain, cleanMode))\n505\t }\n506\t \n507\t-func topicMetadataForRef(topicRef TopicRef, title, domain string) topicMetadataFile {\n508\t+func topicMetadataForRef(topicRef TopicRef, title, domain string, mode models.TopicMode) topicMetadataFile {\n509\t \tmetadata := topicMetadataFile{\n510\t \t\tSlug: topicRef.Leaf,\n511\t \t\tTitle: strings.TrimSpace(title),\n512\t \t\tDomain: strings.TrimSpace(domain),\n513\t+\t\tMode: string(mode),\n514\t \t}\n515\t \tif topicRef.Category != \"\" {\n516\t \t\tmetadata.Category = topicRef.Category\n517\t@@ -694,6 +756,32 @@ func appendScaffoldEntry(logPath string, context templateContext) error {\n518\t \treturn nil\n519\t }\n520\t \n521\t+func appendOKFScaffoldEntry(logPath string, context templateContext) error {\n522\t+\tcontent, err := os.ReadFile(logPath)\n523\t+\tif err != nil {\n524\t+\t\treturn fmt.Errorf(\"read %q: %w\", logPath, err)\n525\t+\t}\n526\t+\n527\t+\tentry := strings.Join([]string{\n528\t+\t\tfmt.Sprintf(\"## %s\", context.Today),\n529\t+\t\t\"\",\n530\t+\t\tfmt.Sprintf(\"* **Initialization**: Created OKF bundle `%s` for `%s`.\", context.Slug, context.Domain),\n531\t+\t\t\"\",\n532\t+\t}, \"\\n\")\n533\t+\n534\t+\ttrimmed := strings.TrimSpace(string(content))\n535\t+\tvar next string\n536\t+\tif trimmed == \"\" {\n537\t+\t\tnext = \"# Directory Update Log\\n\\n\" + entry\n538\t+\t} else {\n539\t+\t\tnext = trimmed + \"\\n\\n\" + entry\n540\t+\t}\n541\t+\tif err := os.WriteFile(logPath, []byte(next), 0o644); err != nil {\n542\t+\t\treturn fmt.Errorf(\"write %q: %w\", logPath, err)\n543\t+\t}\n544\t+\treturn nil\n545\t+}\n546\t+\n547\t func hasTopicSkeleton(topicPath string) (bool, error) {\n548\t \tinfo, err := os.Stat(topicPath)\n549\t \tif errors.Is(err, os.ErrNotExist) {\n550\t@@ -719,19 +807,14 @@ func hasTopicSkeleton(topicPath string) (bool, error) {\n551\t }\n552\t \n553\t func infoAtPath(topicPath, slug string) (models.TopicInfo, error) {\n554\t-\ttitle, domain, err := readTopicMetadata(topicPath, slug)\n555\t+\ttitle, domain, mode, err := readTopicMetadata(topicPath, slug)\n556\t \tif err != nil {\n557\t \t\treturn models.TopicInfo{}, fmt.Errorf(\"read topic metadata: %w\", err)\n558\t \t}\n559\t \n560\t-\tarticleCount, err := countMarkdownFiles(filepath.Join(topicPath, \"wiki\", \"concepts\"))\n561\t-\tif err != nil {\n562\t-\t\treturn models.TopicInfo{}, fmt.Errorf(\"count wiki articles: %w\", err)\n563\t-\t}\n564\t-\n565\t-\tsourceCount, err := countVisibleFiles(filepath.Join(topicPath, \"raw\"))\n566\t+\tarticleCount, sourceCount, err := topicCounts(topicPath, mode)\n567\t \tif err != nil {\n568\t-\t\treturn models.TopicInfo{}, fmt.Errorf(\"count raw sources: %w\", err)\n569\t+\t\treturn models.TopicInfo{}, fmt.Errorf(\"count topic files: %w\", err)\n570\t \t}\n571\t \n572\t \tlastLogEntry, err := readLastLogEntry(filepath.Join(topicPath, \"log.md\"))\n573\t@@ -743,6 +826,7 @@ func infoAtPath(topicPath, slug string) (models.TopicInfo, error) {\n574\t \t\tSlug: slug,\n575\t \t\tTitle: title,\n576\t \t\tDomain: domain,\n577\t+\t\tMode: mode,\n578\t \t\tRootPath: topicPath,\n579\t \t\tArticleCount: articleCount,\n580\t \t\tSourceCount: sourceCount,\n581\t@@ -750,21 +834,25 @@ func infoAtPath(topicPath, slug string) (models.TopicInfo, error) {\n582\t \t}, nil\n583\t }\n584\t \n585\t-func readTopicMetadata(claudePath, slug string) (string, string, error) {\n586\t+func readTopicMetadata(claudePath, slug string) (string, string, models.TopicMode, error) {\n587\t \tyamlMetadata, err := readTopicYAMLMetadata(filepath.Join(claudePath, topicMetadataFileName))\n588\t \tif err != nil {\n589\t-\t\treturn \"\", \"\", err\n590\t+\t\treturn \"\", \"\", \"\", err\n591\t \t}\n592\t \n593\t \tclaudeTitle, claudeDomain, err := readClaudeMetadata(filepath.Join(claudePath, topicMarkerFile), slug)\n594\t \tif err != nil {\n595\t-\t\treturn \"\", \"\", err\n596\t+\t\treturn \"\", \"\", \"\", err\n597\t \t}\n598\t \n599\t \ttitle := firstNonEmpty(yamlMetadata.Title, claudeTitle, humanizeSlug(slug))\n600\t \tdomain := firstNonEmpty(yamlMetadata.Domain, claudeDomain, slug)\n601\t+\tmode, err := normalizeTopicMode(models.TopicMode(yamlMetadata.Mode))\n602\t+\tif err != nil {\n603\t+\t\treturn \"\", \"\", \"\", err\n604\t+\t}\n605\t \n606\t-\treturn title, domain, nil\n607\t+\treturn title, domain, mode, nil\n608\t }\n609\t \n610\t func readTopicYAMLMetadata(metadataPath string) (topicMetadataFile, error) {\n611\t@@ -784,9 +872,52 @@ func readTopicYAMLMetadata(metadataPath string) (topicMetadataFile, error) {\n612\t \tmetadata.Slug = strings.TrimSpace(metadata.Slug)\n613\t \tmetadata.Title = strings.TrimSpace(metadata.Title)\n614\t \tmetadata.Domain = strings.TrimSpace(metadata.Domain)\n615\t+\tmetadata.Mode = strings.TrimSpace(metadata.Mode)\n616\t \treturn metadata, nil\n617\t }\n618\t \n619\t+func normalizeTopicMode(mode models.TopicMode) (models.TopicMode, error) {\n620\t+\tswitch strings.TrimSpace(string(mode)) {\n621\t+\tcase \"\", string(models.TopicModeWiki):\n622\t+\t\treturn models.TopicModeWiki, nil\n623\t+\tcase string(models.TopicModeOKF):\n624\t+\t\treturn models.TopicModeOKF, nil\n625\t+\tdefault:\n626\t+\t\treturn \"\", fmt.Errorf(\"topic mode must be wiki or okf: %q\", mode)\n627\t+\t}\n628\t+}\n629\t+\n630\t+func topicCounts(topicPath string, mode models.TopicMode) (int, int, error) {\n631\t+\tif mode == models.TopicModeOKF {\n632\t+\t\tconcepts, err := countOKFConceptFiles(topicPath)\n633\t+\t\treturn concepts, 0, err\n634\t+\t}\n635\t+\tarticleCount, err := countMarkdownFiles(filepath.Join(topicPath, \"wiki\", \"concepts\"))\n636\t+\tif err != nil {\n637\t+\t\treturn 0, 0, fmt.Errorf(\"count wiki articles: %w\", err)\n638\t+\t}\n639\t+\tsourceCount, err := countVisibleFiles(filepath.Join(topicPath, \"raw\"))\n640\t+\tif err != nil {\n641\t+\t\treturn 0, 0, fmt.Errorf(\"count raw sources: %w\", err)\n642\t+\t}\n643\t+\treturn articleCount, sourceCount, nil\n644\t+}\n645\t+\n646\t+func countOKFConceptFiles(root string) (int, error) {\n647\t+\treturn countFiles(root, func(entry fs.DirEntry) bool {\n648\t+\t\tname := entry.Name()\n649\t+\t\tif !strings.HasSuffix(strings.ToLower(name), \".md\") || strings.HasPrefix(name, \".\") {\n650\t+\t\t\treturn false\n651\t+\t\t}\n652\t+\t\tswitch strings.ToLower(name) {\n653\t+\t\tcase \"index.md\", \"log.md\", \"claude.md\", \"agents.md\", \"readme.md\", \"license.md\", \"notice.md\", \"attribution.md\":\n654\t+\t\t\treturn false\n655\t+\t\tdefault:\n656\t+\t\t\treturn true\n657\t+\t\t}\n658\t+\t})\n659\t+}\n660\t+\n661\t func readClaudeMetadata(claudePath, slug string) (string, string, error) {\n662\t \tcontent, err := os.ReadFile(claudePath)\n663\t \tif err != nil {\n664\t@@ -827,7 +958,7 @@ func readLastLogEntry(logPath string) (string, error) {\n665\t \tlast := \"\"\n666\t \tfor line := range strings.SplitSeq(string(content), \"\\n\") {\n667\t \t\ttrimmed := strings.TrimSpace(line)\n668\t-\t\tif strings.HasPrefix(trimmed, \"## [\") {\n669\t+\t\tif strings.HasPrefix(trimmed, \"## [\") || isOKFDateHeading(trimmed) {\n670\t \t\t\tlast = trimmed\n671\t \t\t}\n672\t \t}\n673\t@@ -835,6 +966,18 @@ func readLastLogEntry(logPath string) (string, error) {\n674\t \treturn last, nil\n675\t }\n676\t \n677\t+func isOKFDateHeading(line string) bool {\n678\t+\tif !strings.HasPrefix(line, \"## \") {\n679\t+\t\treturn false\n680\t+\t}\n681\t+\tvalue := strings.TrimSpace(strings.TrimPrefix(line, \"## \"))\n682\t+\tif len(value) != len(frontmatter.DateLayout) {\n683\t+\t\treturn false\n684\t+\t}\n685\t+\t_, err := time.Parse(frontmatter.DateLayout, value)\n686\t+\treturn err == nil\n687\t+}\n688\t+\n689\t func countMarkdownFiles(root string) (int, error) {\n690\t \treturn countFiles(root, func(entry fs.DirEntry) bool {\n691\t \t\tname := entry.Name()\n692\tdiff --git a/internal/topic/topic_test.go b/internal/topic/topic_test.go\n693\tindex 2730efa..46dbcee 100644\n694\t--- a/internal/topic/topic_test.go\n695\t+++ b/internal/topic/topic_test.go\n696\t@@ -8,6 +8,7 @@ import (\n697\t \t\"time\"\n698\t \n699\t \t\"github.com/compozy/kb/internal/frontmatter\"\n700\t+\t\"github.com/compozy/kb/internal/models\"\n701\t )\n702\t \n703\t func TestNewCreatesTopicSkeletonAndTemplates(t *testing.T) {\n704\t@@ -35,6 +36,9 @@ func TestNewCreatesTopicSkeletonAndTemplates(t *testing.T) {\n705\t \tif info.Domain != \"rust\" {\n706\t \t\tt.Fatalf(\"domain = %q, want rust\", info.Domain)\n707\t \t}\n708\t+\tif info.Mode != models.TopicModeWiki {\n709\t+\t\tt.Fatalf(\"mode = %q, want wiki\", info.Mode)\n710\t+\t}\n711\t \tif info.ArticleCount != 0 {\n712\t \t\tt.Fatalf(\"article count = %d, want 0\", info.ArticleCount)\n713\t \t}\n714\t@@ -156,6 +160,7 @@ func TestNewCreatesCategorizedTopic(t *testing.T) {\n715\t \t\t\"slug: asimov-academy\",\n716\t \t\t\"title: Asimov Academy\",\n717\t \t\t\"domain: youtube-channel\",\n718\t+\t\t\"mode: wiki\",\n719\t \t\t\"category: yt-channels\",\n720\t \t\t\"path: yt-channels/asimov-academy\",\n721\t \t\t\"qmd_collection: asimov-academy\",\n722\t@@ -207,6 +212,7 @@ func TestNewCreatesClaudeAndAgentsSymlink(t *testing.T) {\n723\t \t\t\"slug: distributed-systems\",\n724\t \t\t\"title: Distributed Systems\",\n725\t \t\t\"domain: distributed\",\n726\t+\t\t\"mode: wiki\",\n727\t \t} {\n728\t \t\tif !strings.Contains(metadataContent, fragment) {\n729\t \t\t\tt.Fatalf(\"topic.yaml missing %q:\\n%s\", fragment, metadataContent)\n730\t@@ -227,6 +233,83 @@ func TestNewCreatesClaudeAndAgentsSymlink(t *testing.T) {\n731\t \t}\n732\t }\n733\t \n734\t+func TestNewWithModeCreatesOKFTopicSkeleton(t *testing.T) {\n735\t+\tt.Parallel()\n736\t+\n737\t+\tvaultPath := t.TempDir()\n738\t+\n739\t+\tinfo, err := newWithDateWithMode(\n740\t+\t\tvaultPath,\n741\t+\t\t\"ops-catalog\",\n742\t+\t\t\"Operations Catalog\",\n743\t+\t\t\"operations\",\n744\t+\t\tmodels.TopicModeOKF,\n745\t+\t\ttime.Date(2026, 6, 27, 12, 0, 0, 0, time.UTC),\n746\t+\t)\n747\t+\tif err != nil {\n748\t+\t\tt.Fatalf(\"newWithDateWithMode returned error: %v\", err)\n749\t+\t}\n750\t+\tif info.Mode != models.TopicModeOKF {\n751\t+\t\tt.Fatalf(\"mode = %q, want okf\", info.Mode)\n752\t+\t}\n753\t+\tif info.ArticleCount != 0 || info.SourceCount != 0 {\n754\t+\t\tt.Fatalf(\"counts = articles %d sources %d, want 0/0\", info.ArticleCount, info.SourceCount)\n755\t+\t}\n756\t+\tif info.LastLogEntry != \"## 2026-06-27\" {\n757\t+\t\tt.Fatalf(\"last log entry = %q, want OKF date heading\", info.LastLogEntry)\n758\t+\t}\n759\t+\n760\t+\ttopicPath := filepath.Join(vaultPath, \"ops-catalog\")\n761\t+\tfor _, relativePath := range []string{\"CLAUDE.md\", \"AGENTS.md\", \"index.md\", \"log.md\", \"topic.yaml\"} {\n762\t+\t\tassertFileExists(t, filepath.Join(topicPath, filepath.FromSlash(relativePath)))\n763\t+\t}\n764\t+\tfor _, relativePath := range []string{\"raw\", \"wiki\", \"outputs\", \"bases\"} {\n765\t+\t\tif _, err := os.Stat(filepath.Join(topicPath, relativePath)); !os.IsNotExist(err) {\n766\t+\t\t\tt.Fatalf(\"OKF scaffold should not create %s\", relativePath)\n767\t+\t\t}\n768\t+\t}\n769\t+\n770\t+\tmetadataContent := readFile(t, filepath.Join(topicPath, \"topic.yaml\"))\n771\t+\tfor _, fragment := range []string{\n772\t+\t\t\"slug: ops-catalog\",\n773\t+\t\t\"title: Operations Catalog\",\n774\t+\t\t\"domain: operations\",\n775\t+\t\t\"mode: okf\",\n776\t+\t} {\n777\t+\t\tif !strings.Contains(metadataContent, fragment) {\n778\t+\t\t\tt.Fatalf(\"topic.yaml missing %q:\\n%s\", fragment, metadataContent)\n779\t+\t\t}\n780\t+\t}\n781\t+\n782\t+\tindexValues, indexBody := parseFrontmatterFile(t, filepath.Join(topicPath, \"index.md\"))\n783\t+\tif got := indexValues[\"okf_version\"]; got != \"0.1\" {\n784\t+\t\tt.Fatalf(\"index okf_version = %#v, want 0.1\", got)\n785\t+\t}\n786\t+\tif !strings.Contains(indexBody, \"# OKF Bundle Index\") {\n787\t+\t\tt.Fatalf(\"index body missing heading:\\n%s\", indexBody)\n788\t+\t}\n789\t+\tlogContent := readFile(t, filepath.Join(topicPath, \"log.md\"))\n790\t+\tif !strings.Contains(logContent, \"## 2026-06-27\") || strings.Contains(logContent, \"## [2026-06-27]\") {\n791\t+\t\tt.Fatalf(\"log.md does not use OKF date heading:\\n%s\", logContent)\n792\t+\t}\n793\t+}\n794\t+\n795\t+func TestReadTopicMetadataDefaultsMissingModeToWiki(t *testing.T) {\n796\t+\tt.Parallel()\n797\t+\n798\t+\ttopicPath := t.TempDir()\n799\t+\twriteFile(t, filepath.Join(topicPath, \"topic.yaml\"), \"slug: old-topic\\ntitle: Old Topic\\ndomain: legacy\\n\")\n800\t+\twriteFile(t, filepath.Join(topicPath, \"CLAUDE.md\"), \"# Old Topic\\n\\n**Domain:** `legacy`\\n\")\n801\t+\n802\t+\tinfo, err := infoAtPath(topicPath, \"old-topic\")\n803\t+\tif err != nil {\n804\t+\t\tt.Fatalf(\"infoAtPath returned error: %v\", err)\n805\t+\t}\n806\t+\tif info.Mode != models.TopicModeWiki {\n807\t+\t\tt.Fatalf(\"mode = %q, want wiki\", info.Mode)\n808\t+\t}\n809\t+}\n810\t+\n811\t func TestNewAppendsScaffoldEntryToLog(t *testing.T) {\n812\t \tt.Parallel()\n813\t \n814\tdiff --git a/internal/vault/pathutils.go b/internal/vault/pathutils.go\n815\tindex d356e3c..002b4df 100644\n816\t--- a/internal/vault/pathutils.go\n817\t+++ b/internal/vault/pathutils.go\n818\t@@ -4,6 +4,7 @@ package vault\n819\t import (\n820\t \t\"fmt\"\n821\t \t\"path\"\n822\t+\t\"path/filepath\"\n823\t \t\"regexp\"\n824\t \t\"strings\"\n825\t \n826\t@@ -251,6 +252,73 @@ func ToTopicWikiLink(topicSlug, documentPath, label string) string {\n827\t \treturn fmt.Sprintf(\"[[%s]]\", target)\n828\t }\n829\t \n830\t+// LinkFormatter renders a link from one topic-relative document to another.\n831\t+type LinkFormatter interface {\n832\t+\tLink(fromDir, targetPath, label string) string\n833\t+}\n834\t+\n835\t+// WikiLinkFormatter renders Obsidian topic-scoped wikilinks.\n836\t+type WikiLinkFormatter struct {\n837\t+\tSlug string\n838\t+}\n839\t+\n840\t+// Link renders a wikilink exactly like ToTopicWikiLink.\n841\t+func (f WikiLinkFormatter) Link(_, targetPath, label string) string {\n842\t+\treturn ToTopicWikiLink(f.Slug, targetPath, label)\n843\t+}\n844\t+\n845\t+// OKFLinkFormatter renders relative Markdown links for OKF bundles.\n846\t+type OKFLinkFormatter struct{}\n847\t+\n848\t+// Link renders a GitHub-safe relative Markdown link.\n849\t+func (OKFLinkFormatter) Link(fromDir, targetPath, label string) string {\n850\t+\tcleanTarget := strings.TrimPrefix(ToPosixPath(strings.TrimSpace(targetPath)), \"/\")\n851\t+\tdisplayLabel := strings.TrimSpace(label)\n852\t+\tif displayLabel == \"\" {\n853\t+\t\tdisplayLabel = HumanizeSlug(SlugifySegment(strings.TrimSuffix(path.Base(cleanTarget), \".md\")))\n854\t+\t}\n855\t+\n856\t+\tfragment := \"\"\n857\t+\tif hashIndex := strings.Index(cleanTarget, \"#\"); hashIndex \u003e= 0 {\n858\t+\t\tfragment = cleanTarget[hashIndex:]\n859\t+\t\tcleanTarget = cleanTarget[:hashIndex]\n860\t+\t}\n861\t+\n862\t+\tcleanFromDir := strings.TrimPrefix(ToPosixPath(strings.TrimSpace(fromDir)), \"/\")\n863\t+\tif cleanFromDir == \"\" {\n864\t+\t\tcleanFromDir = \".\"\n865\t+\t}\n866\t+\n867\t+\trelativePath, err := filepath.Rel(filepath.FromSlash(cleanFromDir), filepath.FromSlash(cleanTarget))\n868\t+\tif err != nil {\n869\t+\t\trelativePath = cleanTarget\n870\t+\t}\n871\t+\trelativePath = ToPosixPath(relativePath)\n872\t+\tif relativePath == \".\" || relativePath == \"\" {\n873\t+\t\trelativePath = path.Base(cleanTarget)\n874\t+\t}\n875\t+\trelativePath = strings.TrimPrefix(relativePath, \"./\")\n876\t+\trelativePath = markdownLinkTarget(relativePath + fragment)\n877\t+\treturn fmt.Sprintf(\"[%s](%s)\", displayLabel, relativePath)\n878\t+}\n879\t+\n880\t+// LinkFormatterFor selects the formatter from the topic mode.\n881\t+func LinkFormatterFor(topic models.TopicMetadata) LinkFormatter {\n882\t+\tif topic.Mode == models.TopicModeOKF {\n883\t+\t\treturn OKFLinkFormatter{}\n884\t+\t}\n885\t+\treturn WikiLinkFormatter{Slug: topic.Slug}\n886\t+}\n887\t+\n888\t+func markdownLinkTarget(target string) string {\n889\t+\treplacer := strings.NewReplacer(\n890\t+\t\t\" \", \"%20\",\n891\t+\t\t\"(\", \"%28\",\n892\t+\t\t\")\", \"%29\",\n893\t+\t)\n894\t+\treturn replacer.Replace(target)\n895\t+}\n896\t+\n897\t func stripWikiConceptFilePrefix(articleTitle string) string {\n898\t \tnormalizedTitle := strings.TrimSpace(articleTitle)\n899\t \treturn strings.TrimSpace(strings.TrimPrefix(normalizedTitle, legacyWikiConceptPrefix))\n900\tdiff --git a/internal/vault/pathutils_test.go b/internal/vault/pathutils_test.go\n901\tindex 927c7d4..6655604 100644\n902\t--- a/internal/vault/pathutils_test.go\n903\t+++ b/internal/vault/pathutils_test.go\n904\t@@ -196,6 +196,44 @@ func TestTopicWikiLinkHelpers(t *testing.T) {\n905\t \t}\n906\t }\n907\t \n908\t+func TestLinkFormatters(t *testing.T) {\n909\t+\tt.Parallel()\n910\t+\n911\t+\twiki := vault.WikiLinkFormatter{Slug: \"topic-slug\"}\n912\t+\tif got := wiki.Link(\"ignored\", \"wiki/codebase/concepts/Codebase Overview.md\", \"Overview\"); got != vault.ToTopicWikiLink(\"topic-slug\", \"wiki/codebase/concepts/Codebase Overview.md\", \"Overview\") {\n913\t+\t\tt.Fatalf(\"wiki formatter = %q\", got)\n914\t+\t}\n915\t+\n916\t+\tokf := vault.OKFLinkFormatter{}\n917\t+\ttestCases := []struct {\n918\t+\t\tname string\n919\t+\t\tfrom string\n920\t+\t\ttarget string\n921\t+\t\tlabel string\n922\t+\t\twant string\n923\t+\t}{\n924\t+\t\t{name: \"same directory\", from: \"\", target: \"orders.md\", label: \"Orders\", want: \"[Orders](orders.md)\"},\n925\t+\t\t{name: \"child\", from: \"\", target: \"tables/orders.md\", label: \"Orders\", want: \"[Orders](tables/orders.md)\"},\n926\t+\t\t{name: \"parent\", from: \"references\", target: \"tables/customer orders.md#schema\", label: \"Customers\", want: \"[Customers](../tables/customer%20orders.md#schema)\"},\n927\t+\t\t{name: \"sibling\", from: \"tables\", target: \"references/joins.md\", label: \"Joins\", want: \"[Joins](../references/joins.md)\"},\n928\t+\t}\n929\t+\tfor _, testCase := range testCases {\n930\t+\t\tt.Run(testCase.name, func(t *testing.T) {\n931\t+\t\t\tt.Parallel()\n932\t+\t\t\tif got := okf.Link(testCase.from, testCase.target, testCase.label); got != testCase.want {\n933\t+\t\t\t\tt.Fatalf(\"OKF link = %q, want %q\", got, testCase.want)\n934\t+\t\t\t}\n935\t+\t\t})\n936\t+\t}\n937\t+\n938\t+\tif got := vault.LinkFormatterFor(models.TopicMetadata{Slug: \"demo\", Mode: models.TopicModeWiki}).Link(\"\", \"x.md\", \"X\"); got != \"[[demo/x|X]]\" {\n939\t+\t\tt.Fatalf(\"wiki LinkFormatterFor = %q\", got)\n940\t+\t}\n941\t+\tif got := vault.LinkFormatterFor(models.TopicMetadata{Mode: models.TopicModeOKF}).Link(\"\", \"x.md\", \"X\"); got != \"[X](x.md)\" {\n942\t+\t\tt.Fatalf(\"OKF LinkFormatterFor = %q\", got)\n943\t+\t}\n944\t+}\n945\t+\n946\t func TestPathHelpersHandleEmptyInputs(t *testing.T) {\n947\t \tt.Parallel()\n948\t \n949\tdiff --git a/internal/vault/render.go b/internal/vault/render.go\n950\tindex 374133e..b646514 100644\n951\t--- a/internal/vault/render.go\n952\t+++ b/internal/vault/render.go\n953\t@@ -184,7 +184,11 @@ func renderMarkdownDocument(document models.RenderedDocument) string {\n954\t }\n955\t \n956\t func toSourceWikiLink(topic models.TopicMetadata, relativePath, label string) string {\n957\t-\treturn ToTopicWikiLink(topic.Slug, relativePath, label)\n958\t+\treturn linkFor(topic, \"\", relativePath, label)\n959\t+}\n960\t+\n961\t+func linkFor(topic models.TopicMetadata, fromDir, relativePath, label string) string {\n962\t+\treturn LinkFormatterFor(topic).Link(fromDir, relativePath, label)\n963\t }\n964\t \n965\t func createDocumentLookup(graph models.GraphSnapshot) map[string]string {\n966\t@@ -217,7 +221,7 @@ func linkForNode(\n967\t \tfallbackLabel string,\n968\t ) string {\n969\t \tif documentPath, exists := documentLookup[nodeID]; exists {\n970\t-\t\treturn toSourceWikiLink(topic, documentPath, fallbackLabel)\n971\t+\t\treturn linkFor(topic, \"\", documentPath, fallbackLabel)\n972\t \t}\n973\t \n974\t \tif externalNode, exists := externalNodes[nodeID]; exists {\n975\tdiff --git a/internal/vault/render_wiki.go b/internal/vault/render_wiki.go\n976\tindex 2c3978a..af371b9 100644\n977\t--- a/internal/vault/render_wiki.go\n978\t+++ b/internal/vault/render_wiki.go\n979\t@@ -105,7 +105,7 @@ func renderDashboard(\n980\t \tfor _, article := range articles {\n981\t \t\tlines = append(lines, fmt.Sprintf(\n982\t \t\t\t\"- %s - %s\",\n983\t-\t\t\tToTopicWikiLink(topic.Slug, GetWikiConceptPath(article.Title), article.Title),\n984\t+\t\t\tlinkFor(topic, \"\", GetWikiConceptPath(article.Title), article.Title),\n985\t \t\t\tarticle.Summary,\n986\t \t\t))\n987\t \t}\n988\t@@ -113,8 +113,8 @@ func renderDashboard(\n989\t \tlines = append(lines,\n990\t \t\t\"\",\n991\t \t\t\"## Navigation\",\n992\t-\t\t\"- \"+ToTopicWikiLink(topic.Slug, GetWikiIndexPath(CodebaseConceptIndexTitle), CodebaseConceptIndexTitle),\n993\t-\t\t\"- \"+ToTopicWikiLink(topic.Slug, GetWikiIndexPath(CodebaseSourceIndexTitle), CodebaseSourceIndexTitle),\n994\t+\t\t\"- \"+linkFor(topic, \"\", GetWikiIndexPath(CodebaseConceptIndexTitle), CodebaseConceptIndexTitle),\n995\t+\t\t\"- \"+linkFor(topic, \"\", GetWikiIndexPath(CodebaseSourceIndexTitle), CodebaseSourceIndexTitle),\n996\t \t)\n997\t \n998\t \treturn models.RenderedDocument{\n999\t@@ -144,7 +144,7 @@ func renderConceptIndex(\n1000\t \tfor _, article := range orderedArticles {\n1001\t \t\trows = append(rows, fmt.Sprintf(\n1002\t \t\t\t\"| %s | %s |\",\n1003\t-\t\t\tToTopicWikiLink(topic.Slug, GetWikiConceptPath(article.Title), article.Title),\n1004\t+\t\t\tlinkFor(topic, \"\", GetWikiConceptPath(article.Title), article.Title),\n1005\t \t\t\tarticle.Summary,\n1006\t \t\t))\n1007\t \t}\n1008\t@@ -191,7 +191,7 @@ func renderSourceIndex(topic models.TopicMetadata, articles []starterWikiArticle\n1009\t \n1010\t \t\tlinks := make([]string, 0, len(titles))\n1011\t \t\tfor _, title := range titles {\n1012\t-\t\t\tlinks = append(links, ToTopicWikiLink(topic.Slug, GetWikiConceptPath(title), title))\n1013\t+\t\t\tlinks = append(links, linkFor(topic, \"\", GetWikiConceptPath(title), title))\n1014\t \t\t}\n1015\t \n1016\t \t\trows = append(rows, fmt.Sprintf(\n1017\t@@ -311,9 +311,9 @@ func createCodebaseOverviewArticle(\n1018\t \t\t\"\",\n1019\t \t\tfmt.Sprintf(\n1020\t \t\t\t\"Start with %s for coupling, %s for function-level complexity, and %s for likely cleanup candidates.\",\n1021\t-\t\t\tToTopicWikiLink(topic.Slug, GetWikiConceptPath(\"Module Health\"), \"Module Health\"),\n1022\t-\t\t\tToTopicWikiLink(topic.Slug, GetWikiConceptPath(\"Complexity Hotspots\"), \"Complexity Hotspots\"),\n1023\t-\t\t\tToTopicWikiLink(topic.Slug, GetWikiConceptPath(\"Dead Code Report\"), \"Dead Code Report\"),\n1024\t+\t\t\tlinkFor(topic, \"\", GetWikiConceptPath(\"Module Health\"), \"Module Health\"),\n1025\t+\t\t\tlinkFor(topic, \"\", GetWikiConceptPath(\"Complexity Hotspots\"), \"Complexity Hotspots\"),\n1026\t+\t\t\tlinkFor(topic, \"\", GetWikiConceptPath(\"Dead Code Report\"), \"Dead Code Report\"),\n1027\t \t\t),\n1028\t \t\t\"\",\n1029\t \t\t\"## Language Coverage\",\n1030\t@@ -392,8 +392,8 @@ func createDirectoryMapArticle(\n1031\t \t\t\t\"\",\n1032\t \t\t\tfmt.Sprintf(\n1033\t \t\t\t\t\"Cross-check unstable directories against %s and hotspots against %s.\",\n1034\t-\t\t\t\tToTopicWikiLink(topic.Slug, GetWikiConceptPath(\"Module Health\"), \"Module Health\"),\n1035\t-\t\t\t\tToTopicWikiLink(topic.Slug, GetWikiConceptPath(\"Dependency Hotspots\"), \"Dependency Hotspots\"),\n1036\t+\t\t\t\tlinkFor(topic, \"\", GetWikiConceptPath(\"Module Health\"), \"Module Health\"),\n1037\t+\t\t\t\tlinkFor(topic, \"\", GetWikiConceptPath(\"Dependency Hotspots\"), \"Dependency Hotspots\"),\n1038\t \t\t\t),\n1039\t \t\t\t\"\",\n1040\t \t\t\t\"## Sources and Further Reading\",\n1041\t@@ -458,8 +458,8 @@ func createSymbolTaxonomyArticle(\n1042\t \t\t\t\"\",\n1043\t \t\t\tfmt.Sprintf(\n1044\t \t\t\t\t\"Use %s to find bottlenecks and %s to locate function-level smells.\",\n1045\t-\t\t\t\tToTopicWikiLink(topic.Slug, GetWikiConceptPath(\"High-Impact Symbols\"), \"High-Impact Symbols\"),\n1046\t-\t\t\t\tToTopicWikiLink(topic.Slug, GetWikiConceptPath(\"Code Smells\"), \"Code Smells\"),\n1047\t+\t\t\t\tlinkFor(topic, \"\", GetWikiConceptPath(\"High-Impact Symbols\"), \"High-Impact Symbols\"),\n1048\t+\t\t\t\tlinkFor(topic, \"\", GetWikiConceptPath(\"Code Smells\"), \"Code Smells\"),\n1049\t \t\t\t),\n1050\t \t\t\t\"\",\n1051\t \t\t\t\"## Sources and Further Reading\",\n1052\t@@ -535,7 +535,7 @@ func createDependencyHotspotsArticle(\n1053\t \t\t\t\"\",\n1054\t \t\t\tfmt.Sprintf(\n1055\t \t\t\t\t\"A high relation count usually indicates a coordination layer, a shared utility, or an entry point. Cross-check these files against %s to distinguish stable modules from unstable ones.\",\n1056\t-\t\t\t\tToTopicWikiLink(topic.Slug, GetWikiConceptPath(\"Module Health\"), \"Module Health\"),\n1057\t+\t\t\t\tlinkFor(topic, \"\", GetWikiConceptPath(\"Module Health\"), \"Module Health\"),\n1058\t \t\t\t),\n1059\t \t\t\t\"\",\n1060\t \t\t\t\"## Sources and Further Reading\",\n1061\t@@ -613,7 +613,7 @@ func createComplexityHotspotsArticle(\n1062\t \t\t\t\"\",\n1063\t \t\t\tfmt.Sprintf(\n1064\t \t\t\t\t\"Compare these hotspots against %s to distinguish locally complex functions from high-blast-radius functions.\",\n1065\t-\t\t\t\tToTopicWikiLink(topic.Slug, GetWikiConceptPath(\"High-Impact Symbols\"), \"High-Impact Symbols\"),\n1066\t+\t\t\t\tlinkFor(topic, \"\", GetWikiConceptPath(\"High-Impact Symbols\"), \"High-Impact Symbols\"),\n1067\t \t\t\t),\n1068\t \t\t}, \"\\n\"),\n1069\t \t}\n1070\tdiff --git a/internal/vault/writer.go b/internal/vault/writer.go\n1071\tindex 571c946..c85f5f3 100644\n1072\t--- a/internal/vault/writer.go\n1073\t+++ b/internal/vault/writer.go\n1074\t@@ -446,9 +446,9 @@ func buildTopicClaudeManagedBlock(\n1075\t \t\t\"\",\n1076\t \t\t\"### Generated codebase navigation\",\n1077\t \t\t\"\",\n1078\t-\t\t\"- \" + ToTopicWikiLink(topic.Slug, GetWikiIndexPath(CodebaseDashboardTitle), CodebaseDashboardTitle),\n1079\t-\t\t\"- \" + ToTopicWikiLink(topic.Slug, GetWikiIndexPath(CodebaseConceptIndexTitle), CodebaseConceptIndexTitle),\n1080\t-\t\t\"- \" + ToTopicWikiLink(topic.Slug, GetWikiIndexPath(CodebaseSourceIndexTitle), CodebaseSourceIndexTitle),\n1081\t+\t\t\"- \" + linkFor(topic, \"\", GetWikiIndexPath(CodebaseDashboardTitle), CodebaseDashboardTitle),\n1082\t+\t\t\"- \" + linkFor(topic, \"\", GetWikiIndexPath(CodebaseConceptIndexTitle), CodebaseConceptIndexTitle),\n1083\t+\t\t\"- \" + linkFor(topic, \"\", GetWikiIndexPath(CodebaseSourceIndexTitle), CodebaseSourceIndexTitle),\n1084\t \t\t\"\",\n1085\t \t\t\"### Generated codebase articles\",\n1086\t \t\t\"\",\n1087\t@@ -457,7 +457,7 @@ func buildTopicClaudeManagedBlock(\n1088\t \t\tlines = append(lines, \"_None generated._\")\n1089\t \t} else {\n1090\t \t\tfor _, articleTitle := range conceptDocuments {\n1091\t-\t\t\tlines = append(lines, \"- \"+ToTopicWikiLink(topic.Slug, GetWikiConceptPath(articleTitle), articleTitle))\n1092\t+\t\t\tlines = append(lines, \"- \"+linkFor(topic, \"\", GetWikiConceptPath(articleTitle), articleTitle))\n1093\t \t\t}\n1094\t \t}\n1095\t \tlines = append(lines, \"\", codebaseManagedBlockEnd)\n1096\t@@ -472,9 +472,9 @@ func buildTopicIndexBridgeManagedBlock(topic models.TopicMetadata) string {\n1097\t \t\t\"\",\n1098\t \t\t\"Latest `kb ingest codebase` output is published under `wiki/codebase/` so the top-level topic indexes can stay manually curated.\",\n1099\t \t\t\"\",\n1100\t-\t\t\"- \" + ToTopicWikiLink(topic.Slug, GetWikiIndexPath(CodebaseDashboardTitle), CodebaseDashboardTitle),\n1101\t-\t\t\"- \" + ToTopicWikiLink(topic.Slug, GetWikiIndexPath(CodebaseConceptIndexTitle), CodebaseConceptIndexTitle),\n1102\t-\t\t\"- \" + ToTopicWikiLink(topic.Slug, GetWikiIndexPath(CodebaseSourceIndexTitle), CodebaseSourceIndexTitle),\n1103\t+\t\t\"- \" + linkFor(topic, \"\", GetWikiIndexPath(CodebaseDashboardTitle), CodebaseDashboardTitle),\n1104\t+\t\t\"- \" + linkFor(topic, \"\", GetWikiIndexPath(CodebaseConceptIndexTitle), CodebaseConceptIndexTitle),\n1105\t+\t\t\"- \" + linkFor(topic, \"\", GetWikiIndexPath(CodebaseSourceIndexTitle), CodebaseSourceIndexTitle),\n1106\t \t\t\"\",\n1107\t \t\tcodebaseIndexBlockEnd,\n1108\t \t}\n1109\tdiff --git a/internal/topic/assets/okf-claude-template.md b/internal/topic/assets/okf-claude-template.md\n1110\tnew file mode 100644\n1111\tindex 0000000..3a18157\n1112\t--- /dev/null\n1113\t+++ b/internal/topic/assets/okf-claude-template.md\n1114\t@@ -0,0 +1,24 @@\n1115\t+# TOPIC_TITLE\n1116\t+\n1117\t+**Topic scope:** portable Open Knowledge Format bundle for `TOPIC_DOMAIN`.\n1118\t+\n1119\t+**Domain:** `TOPIC_DOMAIN`\n1120\t+\n1121\t+This topic is an OKF bundle, not an LLM-wiki research lab. Concepts live as typed markdown files at the bundle root for the MVP, with standard markdown links and YAML frontmatter.\n1122\t+\n1123\t+## Bundle contract\n1124\t+\n1125\t+- Every concept markdown file has `type`, `title`, `description`, and `timestamp` frontmatter.\n1126\t+- `index.md` is generated by `kb` and groups concepts by type.\n1127\t+- `log.md` records bundle updates using ISO date headings.\n1128\t+- Links should be relative markdown links so the bundle renders in GitHub and other markdown tools.\n1129\t+\n1130\t+## Operations\n1131\t+\n1132\t+- Promote a wiki concept into this bundle with `kb promote \u003cwiki-doc\u003e --to TOPIC_SLUG --type \u003cType\u003e`.\n1133\t+- Check conformance with `kb okf check TOPIC_SLUG`.\n1134\t+\n1135\t+## Catalog gaps\n1136\t+\n1137\t+- Gap 1\n1138\t+- Gap 2\n1139\tdiff --git a/internal/okf/okf.go b/internal/okf/okf.go\n1140\tnew file mode 100644\n1141\tindex 0000000..41977b6\n1142\t--- /dev/null\n1143\t+++ b/internal/okf/okf.go\n1144\t@@ -0,0 +1,760 @@\n1145\t+// Package okf implements Open Knowledge Format promotion and conformance checks.\n1146\t+package okf\n1147\t+\n1148\t+import (\n1149\t+\t\"context\"\n1150\t+\t\"errors\"\n1151\t+\t\"fmt\"\n1152\t+\t\"io/fs\"\n1153\t+\t\"os\"\n1154\t+\t\"path\"\n1155\t+\t\"path/filepath\"\n1156\t+\t\"regexp\"\n1157\t+\t\"sort\"\n1158\t+\t\"strings\"\n1159\t+\t\"time\"\n1160\t+\n1161\t+\t\"github.com/compozy/kb/internal/frontmatter\"\n1162\t+\t\"github.com/compozy/kb/internal/models\"\n1163\t+\t\"github.com/compozy/kb/internal/vault\"\n1164\t+)\n1165\t+\n1166\t+const (\n1167\t+\tOKFVersion = \"0.1\"\n1168\t+)\n1169\t+\n1170\t+var (\n1171\t+\twikilinkPattern = regexp.MustCompile(`\\[\\[([^\\[\\]]+)\\]\\]`)\n1172\t+\tnonSentenceSpace = regexp.MustCompile(`\\s+`)\n1173\t+\tmarkdownLinkPattern = regexp.MustCompile(`\\[[^\\]]+\\]\\([^)]+\\)`)\n1174\t+\tmarkdownSyntaxRunes = strings.NewReplacer(\"#\", \"\", \"*\", \"\", \"`\", \"\", \"_\", \"\", \"\u003e\", \"\", \"|\", \" \")\n1175\t+\terrTargetNotOKFTopic = errors.New(\"target topic must use mode okf\")\n1176\t+)\n1177\t+\n1178\t+// PromoteInput carries the data needed to promote one wiki document into an OKF bundle.\n1179\t+type PromoteInput struct {\n1180\t+\tSourceDocPath string\n1181\t+\tVaultPath string\n1182\t+\tTargetTopic models.TopicInfo\n1183\t+\tType string\n1184\t+\tDescription string\n1185\t+\tTypes []string\n1186\t+\tClock func() time.Time\n1187\t+}\n1188\t+\n1189\t+// ConceptResult reports the concept written by Promote.\n1190\t+type ConceptResult struct {\n1191\t+\tWrittenPath string `json:\"writtenPath\"`\n1192\t+\tType string `json:\"type\"`\n1193\t+\tLinksRewritten int `json:\"linksRewritten\"`\n1194\t+\tUnresolvedLinks []string `json:\"unresolvedLinks\"`\n1195\t+\tWarnings []string `json:\"warnings,omitempty\"`\n1196\t+}\n1197\t+\n1198\t+// CheckOptions configures OKF conformance checking.\n1199\t+type CheckOptions struct {\n1200\t+\tTypes []string\n1201\t+\tStrict bool\n1202\t+}\n1203\t+\n1204\t+// Promote performs a mechanical, non-destructive wiki-to-OKF conversion.\n1205\t+func Promote(ctx context.Context, input PromoteInput) (ConceptResult, error) {\n1206\t+\tif ctx == nil {\n1207\t+\t\tctx = context.Background()\n1208\t+\t}\n1209\t+\tif err := ctx.Err(); err != nil {\n1210\t+\t\treturn ConceptResult{}, fmt.Errorf(\"promote: %w\", err)\n1211\t+\t}\n1212\t+\tif input.TargetTopic.Mode != models.TopicModeOKF {\n1213\t+\t\treturn ConceptResult{}, fmt.Errorf(\"promote: %w: %s\", errTargetNotOKFTopic, input.TargetTopic.Slug)\n1214\t+\t}\n1215\t+\tconceptType := strings.TrimSpace(input.Type)\n1216\t+\tif conceptType == \"\" {\n1217\t+\t\treturn ConceptResult{}, fmt.Errorf(\"promote: --type is required\")\n1218\t+\t}\n1219\t+\tif strings.TrimSpace(input.TargetTopic.RootPath) == \"\" {\n1220\t+\t\treturn ConceptResult{}, fmt.Errorf(\"promote: target topic root path is required\")\n1221\t+\t}\n1222\t+\n1223\t+\tsourcePath, sourceTopicRoot, sourceTopicSlug, sourceRelativePath, err := resolveSourceDocument(input)\n1224\t+\tif err != nil {\n1225\t+\t\treturn ConceptResult{}, fmt.Errorf(\"promote: %w\", err)\n1226\t+\t}\n```"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:20:59.773841Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":""}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:20:59.774436Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":"The diff is large. Let me read the actual changed source files in full for complete context."}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:21:00.312149Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":" Starting with the new OKF package and CLI files."}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:21:00.313666Z","turn":1,"type":"session.update","update":{"kind":"tool_call_started","tool_call_id":"toolu_01RQtMxLSi7Jiw9jPCRCute8","tool_call_state":"pending","blocks":[{"type":"tool_use","id":"toolu_01RQtMxLSi7Jiw9jPCRCute8","name":"Read","title":"Read File","raw_input":{}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:21:01.172568Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01RQtMxLSi7Jiw9jPCRCute8","blocks":[{"type":"tool_use","id":"toolu_01RQtMxLSi7Jiw9jPCRCute8","name":"Read","title":"Read internal/okf/okf.go","input":{"file_path":"/Users/pedronauck/Dev/compozy/kb/internal/okf/okf.go"},"raw_input":{"file_path":"/Users/pedronauck/Dev/compozy/kb/internal/okf/okf.go"}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:21:01.175376Z","turn":1,"type":"session.update","update":{"kind":"tool_call_started","tool_call_id":"toolu_01QXz72fTN4nRhjgN6Wa6w6E","tool_call_state":"pending","blocks":[{"type":"tool_use","id":"toolu_01QXz72fTN4nRhjgN6Wa6w6E","name":"Read","title":"Read File","raw_input":{}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:21:01.180159Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01RQtMxLSi7Jiw9jPCRCute8","usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:21:01.190115Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01RQtMxLSi7Jiw9jPCRCute8","tool_call_state":"completed","blocks":[{"type":"tool_result","tool_use_id":"toolu_01RQtMxLSi7Jiw9jPCRCute8","content":"```\n1\t// Package okf implements Open Knowledge Format promotion and conformance checks.\n2\tpackage okf\n3\t\n4\timport (\n5\t\t\"context\"\n6\t\t\"errors\"\n7\t\t\"fmt\"\n8\t\t\"io/fs\"\n9\t\t\"os\"\n10\t\t\"path\"\n11\t\t\"path/filepath\"\n12\t\t\"regexp\"\n13\t\t\"sort\"\n14\t\t\"strings\"\n15\t\t\"time\"\n16\t\n17\t\t\"github.com/compozy/kb/internal/frontmatter\"\n18\t\t\"github.com/compozy/kb/internal/models\"\n19\t\t\"github.com/compozy/kb/internal/vault\"\n20\t)\n21\t\n22\tconst (\n23\t\tOKFVersion = \"0.1\"\n24\t)\n25\t\n26\tvar (\n27\t\twikilinkPattern = regexp.MustCompile(`\\[\\[([^\\[\\]]+)\\]\\]`)\n28\t\tnonSentenceSpace = regexp.MustCompile(`\\s+`)\n29\t\tmarkdownLinkPattern = regexp.MustCompile(`\\[[^\\]]+\\]\\([^)]+\\)`)\n30\t\tmarkdownSyntaxRunes = strings.NewReplacer(\"#\", \"\", \"*\", \"\", \"`\", \"\", \"_\", \"\", \"\u003e\", \"\", \"|\", \" \")\n31\t\terrTargetNotOKFTopic = errors.New(\"target topic must use mode okf\")\n32\t)\n33\t\n34\t// PromoteInput carries the data needed to promote one wiki document into an OKF bundle.\n35\ttype PromoteInput struct {\n36\t\tSourceDocPath string\n37\t\tVaultPath string\n38\t\tTargetTopic models.TopicInfo\n39\t\tType string\n40\t\tDescription string\n41\t\tTypes []string\n42\t\tClock func() time.Time\n43\t}\n44\t\n45\t// ConceptResult reports the concept written by Promote.\n46\ttype ConceptResult struct {\n47\t\tWrittenPath string `json:\"writtenPath\"`\n48\t\tType string `json:\"type\"`\n49\t\tLinksRewritten int `json:\"linksRewritten\"`\n50\t\tUnresolvedLinks []string `json:\"unresolvedLinks\"`\n51\t\tWarnings []string `json:\"warnings,omitempty\"`\n52\t}\n53\t\n54\t// CheckOptions configures OKF conformance checking.\n55\ttype CheckOptions struct {\n56\t\tTypes []string\n57\t\tStrict bool\n58\t}\n59\t\n60\t// Promote performs a mechanical, non-destructive wiki-to-OKF conversion.\n61\tfunc Promote(ctx context.Context, input PromoteInput) (ConceptResult, error) {\n62\t\tif ctx == nil {\n63\t\t\tctx = context.Background()\n64\t\t}\n65\t\tif err := ctx.Err(); err != nil {\n66\t\t\treturn ConceptResult{}, fmt.Errorf(\"promote: %w\", err)\n67\t\t}\n68\t\tif input.TargetTopic.Mode != models.TopicModeOKF {\n69\t\t\treturn ConceptResult{}, fmt.Errorf(\"promote: %w: %s\", errTargetNotOKFTopic, input.TargetTopic.Slug)\n70\t\t}\n71\t\tconceptType := strings.TrimSpace(input.Type)\n72\t\tif conceptType == \"\" {\n73\t\t\treturn ConceptResult{}, fmt.Errorf(\"promote: --type is required\")\n74\t\t}\n75\t\tif strings.TrimSpace(input.TargetTopic.RootPath) == \"\" {\n76\t\t\treturn ConceptResult{}, fmt.Errorf(\"promote: target topic root path is required\")\n77\t\t}\n78\t\n79\t\tsourcePath, sourceTopicRoot, sourceTopicSlug, sourceRelativePath, err := resolveSourceDocument(input)\n80\t\tif err != nil {\n81\t\t\treturn ConceptResult{}, fmt.Errorf(\"promote: %w\", err)\n82\t\t}\n83\t\t_ = sourceTopicRoot\n84\t\n85\t\tsourceBytes, err := os.ReadFile(sourcePath)\n86\t\tif err != nil {\n87\t\t\treturn ConceptResult{}, fmt.Errorf(\"promote: read source %q: %w\", sourcePath, err)\n88\t\t}\n89\t\tsourceValues, body, err := frontmatter.Parse(string(sourceBytes))\n90\t\tif err != nil {\n91\t\t\treturn ConceptResult{}, fmt.Errorf(\"promote: parse source frontmatter: %w\", err)\n92\t\t}\n93\t\n94\t\tkey := conceptKey(sourceRelativePath)\n95\t\twrittenPath, absoluteTargetPath, err := allocateConceptPath(input.TargetTopic.RootPath, key)\n96\t\tif err != nil {\n97\t\t\treturn ConceptResult{}, fmt.Errorf(\"promote: allocate concept path: %w\", err)\n98\t\t}\n99\t\n100\t\twarnings := typeWarnings(conceptType, input.Types)\n101\t\tdescription, descriptionWarnings := resolveDescription(input.Description, body)\n102\t\twarnings = append(warnings, descriptionWarnings...)\n103\t\ttitle := strings.TrimSpace(frontmatter.GetString(sourceValues, \"title\"))\n104\t\tif title == \"\" {\n105\t\t\ttitle = vault.HumanizeSlug(key)\n106\t\t}\n107\t\tclock := input.Clock\n108\t\tif clock == nil {\n109\t\t\tclock = func() time.Time { return time.Now().UTC() }\n110\t\t}\n111\t\tnow := clock().UTC()\n112\t\n113\t\ttransformedBody, rewriteCount, unresolvedLinks := transformWikiLinks(body, sourceTopicSlug, input.TargetTopic.RootPath, path.Dir(writtenPath), writtenPath)\n114\t\tvalues := map[string]any{\n115\t\t\t\"description\": description,\n116\t\t\t\"timestamp\": now.Format(time.RFC3339),\n117\t\t\t\"title\": title,\n118\t\t\t\"type\": conceptType,\n119\t\t}\n120\t\tif tags := frontmatter.GetStringSlice(sourceValues, \"tags\"); len(tags) \u003e 0 {\n121\t\t\tvalues[\"tags\"] = tags\n122\t\t}\n123\t\n124\t\tdocument, err := frontmatter.Generate(values, transformedBody)\n125\t\tif err != nil {\n126\t\t\treturn ConceptResult{}, fmt.Errorf(\"promote: generate concept frontmatter: %w\", err)\n127\t\t}\n128\t\tif err := os.WriteFile(absoluteTargetPath, []byte(document), 0o644); err != nil {\n129\t\t\treturn ConceptResult{}, fmt.Errorf(\"promote: write concept %q: %w\", absoluteTargetPath, err)\n130\t\t}\n131\t\n132\t\tif err := RegenerateIndex(input.TargetTopic.RootPath); err != nil {\n133\t\t\treturn ConceptResult{}, fmt.Errorf(\"promote: regenerate index: %w\", err)\n134\t\t}\n135\t\tif err := InsertLogEntry(input.TargetTopic.RootPath, now, title, writtenPath, sourceRelativePath); err != nil {\n136\t\t\treturn ConceptResult{}, fmt.Errorf(\"promote: update log: %w\", err)\n137\t\t}\n138\t\n139\t\treturn ConceptResult{\n140\t\t\tWrittenPath: writtenPath,\n141\t\t\tType: conceptType,\n142\t\t\tLinksRewritten: rewriteCount,\n143\t\t\tUnresolvedLinks: unresolvedLinks,\n144\t\t\tWarnings: warnings,\n145\t\t}, nil\n146\t}\n147\t\n148\t// Check validates an OKF bundle and returns diagnostics.\n149\tfunc Check(ctx context.Context, bundlePath string, options CheckOptions) ([]models.LintIssue, error) {\n150\t\tif ctx == nil {\n151\t\t\tctx = context.Background()\n152\t\t}\n153\t\tcleanBundlePath := strings.TrimSpace(bundlePath)\n154\t\tif cleanBundlePath == \"\" {\n155\t\t\treturn nil, fmt.Errorf(\"okf check: bundle path is required\")\n156\t\t}\n157\t\tinfo, err := os.Stat(cleanBundlePath)\n158\t\tif err != nil {\n159\t\t\treturn nil, fmt.Errorf(\"okf check: stat bundle path %q: %w\", cleanBundlePath, err)\n160\t\t}\n161\t\tif !info.IsDir() {\n162\t\t\treturn nil, fmt.Errorf(\"okf check: bundle path must be a directory: %s\", cleanBundlePath)\n163\t\t}\n164\t\n165\t\ttypeSet := normalizeTypeSet(options.Types)\n166\t\tissues := make([]models.LintIssue, 0)\n167\t\terr = filepath.WalkDir(cleanBundlePath, func(currentPath string, entry fs.DirEntry, walkErr error) error {\n168\t\t\tif walkErr != nil {\n169\t\t\t\treturn walkErr\n170\t\t\t}\n171\t\t\tif err := ctx.Err(); err != nil {\n172\t\t\t\treturn err\n173\t\t\t}\n174\t\t\tif currentPath == cleanBundlePath {\n175\t\t\t\treturn nil\n176\t\t\t}\n177\t\t\tif entry.Type()\u0026fs.ModeSymlink != 0 {\n178\t\t\t\tif entry.IsDir() {\n179\t\t\t\t\treturn filepath.SkipDir\n180\t\t\t\t}\n181\t\t\t\treturn nil\n182\t\t\t}\n183\t\t\tif entry.IsDir() {\n184\t\t\t\tif strings.HasPrefix(entry.Name(), \".\") {\n185\t\t\t\t\treturn filepath.SkipDir\n186\t\t\t\t}\n187\t\t\t\treturn nil\n188\t\t\t}\n189\t\t\tif !strings.EqualFold(filepath.Ext(entry.Name()), \".md\") {\n190\t\t\t\treturn nil\n191\t\t\t}\n192\t\n193\t\t\trelativePath, err := filepath.Rel(cleanBundlePath, currentPath)\n194\t\t\tif err != nil {\n195\t\t\t\treturn fmt.Errorf(\"derive relative path for %q: %w\", currentPath, err)\n196\t\t\t}\n197\t\t\trelativePath = vault.ToPosixPath(relativePath)\n198\t\t\tswitch {\n199\t\t\tcase isIndexFile(relativePath):\n200\t\t\t\tissues = append(issues, checkIndexFile(cleanBundlePath, currentPath, relativePath, options.Strict)...)\n201\t\t\tcase isLogFile(relativePath):\n202\t\t\t\tissues = append(issues, checkLogFile(currentPath, relativePath, options.Strict)...)\n203\t\t\tcase isExcludedMarkdown(entry.Name()):\n204\t\t\t\treturn nil\n205\t\t\tdefault:\n206\t\t\t\tissues = append(issues, checkConceptFile(currentPath, relativePath, typeSet, options.Strict)...)\n207\t\t\t}\n208\t\t\treturn nil\n209\t\t})\n210\t\tif err != nil {\n211\t\t\treturn nil, fmt.Errorf(\"okf check: %w\", err)\n212\t\t}\n213\t\n214\t\tsortIssues(issues)\n215\t\treturn issues, nil\n216\t}\n217\t\n218\t// Columns returns the stable output column order for OKF diagnostics.\n219\tfunc Columns() []string {\n220\t\treturn []string{\"severity\", \"kind\", \"filePath\", \"target\", \"message\"}\n221\t}\n222\t\n223\t// Rows converts issues into formatter rows.\n224\tfunc Rows(issues []models.LintIssue) []map[string]any {\n225\t\trows := make([]map[string]any, 0, len(issues))\n226\t\tfor _, issue := range issues {\n227\t\t\trows = append(rows, map[string]any{\n228\t\t\t\t\"severity\": issue.Severity,\n229\t\t\t\t\"kind\": issue.Kind,\n230\t\t\t\t\"filePath\": issue.FilePath,\n231\t\t\t\t\"target\": issue.Target,\n232\t\t\t\t\"message\": issue.Message,\n233\t\t\t})\n234\t\t}\n235\t\treturn rows\n236\t}\n237\t\n238\t// HasErrors reports whether any diagnostic should fail the command.\n239\tfunc HasErrors(issues []models.LintIssue) bool {\n240\t\tfor _, issue := range issues {\n241\t\t\tif issue.Severity == models.SeverityError {\n242\t\t\t\treturn true\n243\t\t\t}\n244\t\t}\n245\t\treturn false\n246\t}\n247\t\n248\t// RegenerateIndex rewrites the root OKF index from concept frontmatter.\n249\tfunc RegenerateIndex(bundlePath string) error {\n250\t\tconcepts, okfVersion, err := loadConcepts(bundlePath)\n251\t\tif err != nil {\n252\t\t\treturn err\n253\t\t}\n254\t\tsort.Slice(concepts, func(i, j int) bool {\n255\t\t\tif concepts[i].Type != concepts[j].Type {\n256\t\t\t\treturn concepts[i].Type \u003c concepts[j].Type\n257\t\t\t}\n258\t\t\tif concepts[i].Title != concepts[j].Title {\n259\t\t\t\treturn concepts[i].Title \u003c concepts[j].Title\n260\t\t\t}\n261\t\t\treturn concepts[i].RelativePath \u003c concepts[j].RelativePath\n262\t\t})\n263\t\n264\t\tvar body strings.Builder\n265\t\tbody.WriteString(\"# OKF Bundle Index\\n\")\n266\t\tcurrentType := \"\"\n267\t\tfor _, concept := range concepts {\n268\t\t\tif concept.Type != currentType {\n269\t\t\t\tcurrentType = concept.Type\n270\t\t\t\tbody.WriteString(\"\\n## \")\n271\t\t\t\tbody.WriteString(currentType)\n272\t\t\t\tbody.WriteString(\"\\n\\n\")\n273\t\t\t}\n274\t\t\tbody.WriteString(\"* \")\n275\t\t\tbody.WriteString(vault.OKFLinkFormatter{}.Link(\"\", concept.RelativePath, concept.Title))\n276\t\t\tif concept.Description != \"\" {\n277\t\t\t\tbody.WriteString(\" - \")\n278\t\t\t\tbody.WriteString(concept.Description)\n279\t\t\t}\n280\t\t\tbody.WriteString(\"\\n\")\n281\t\t}\n282\t\n283\t\tvalues := map[string]any{\"okf_version\": firstNonEmpty(okfVersion, OKFVersion)}\n284\t\tcontent, err := frontmatter.Generate(values, body.String())\n285\t\tif err != nil {\n286\t\t\treturn fmt.Errorf(\"generate index frontmatter: %w\", err)\n287\t\t}\n288\t\tif err := os.WriteFile(filepath.Join(bundlePath, \"index.md\"), []byte(content), 0o644); err != nil {\n289\t\t\treturn fmt.Errorf(\"write index.md: %w\", err)\n290\t\t}\n291\t\treturn nil\n292\t}\n293\t\n294\t// InsertLogEntry inserts a newest-first OKF log entry at the bundle root.\n295\tfunc InsertLogEntry(bundlePath string, when time.Time, title, conceptPath, sourcePath string) error {\n296\t\tlogPath := filepath.Join(bundlePath, \"log.md\")\n297\t\tcontent, err := os.ReadFile(logPath)\n298\t\tif errors.Is(err, os.ErrNotExist) {\n299\t\t\tcontent = []byte(\"# Directory Update Log\\n\")\n300\t\t} else if err != nil {\n301\t\t\treturn fmt.Errorf(\"read log.md: %w\", err)\n302\t\t}\n303\t\tentry := fmt.Sprintf(\n304\t\t\t\"* **Creation**: Promoted %s from `%s`.\",\n305\t\t\tvault.OKFLinkFormatter{}.Link(\"\", conceptPath, strings.TrimSpace(title)),\n306\t\t\tsourcePath,\n307\t\t)\n308\t\tnext := insertLogEntry(string(content), when.UTC().Format(frontmatter.DateLayout), entry)\n309\t\tif err := os.WriteFile(logPath, []byte(next), 0o644); err != nil {\n310\t\t\treturn fmt.Errorf(\"write log.md: %w\", err)\n311\t\t}\n312\t\treturn nil\n313\t}\n314\t\n315\ttype conceptInfo struct {\n316\t\tRelativePath string\n317\t\tType string\n318\t\tTitle string\n319\t\tDescription string\n320\t}\n321\t\n322\tfunc resolveSourceDocument(input PromoteInput) (absolutePath, topicRoot, topicSlug, topicRelativePath string, err error) {\n323\t\tcandidates := make([]string, 0, 3)\n324\t\tsource := strings.TrimSpace(input.SourceDocPath)\n325\t\tif source == \"\" {\n326\t\t\treturn \"\", \"\", \"\", \"\", fmt.Errorf(\"source document path is required\")\n327\t\t}\n328\t\tif filepath.IsAbs(source) {\n329\t\t\tcandidates = append(candidates, source)\n330\t\t} else {\n331\t\t\tcandidates = append(candidates, source)\n332\t\t\tif strings.TrimSpace(input.VaultPath) != \"\" {\n333\t\t\t\tcandidates = append(candidates, filepath.Join(input.VaultPath, filepath.FromSlash(source)))\n334\t\t\t}\n335\t\t}\n336\t\tfor _, candidate := range candidates {\n337\t\t\tinfo, statErr := os.Stat(candidate)\n338\t\t\tif statErr != nil {\n339\t\t\t\tcontinue\n340\t\t\t}\n341\t\t\tif info.IsDir() {\n342\t\t\t\treturn \"\", \"\", \"\", \"\", fmt.Errorf(\"source document %q must be a file\", candidate)\n343\t\t\t}\n344\t\t\tabsolutePath, err := filepath.Abs(candidate)\n345\t\t\tif err != nil {\n346\t\t\t\treturn \"\", \"\", \"\", \"\", fmt.Errorf(\"resolve source path %q: %w\", candidate, err)\n347\t\t\t}\n348\t\t\ttopicRoot, topicSlug = discoverTopicRoot(input.VaultPath, absolutePath)\n349\t\t\tif topicRoot != \"\" {\n350\t\t\t\trelative, relErr := filepath.Rel(topicRoot, absolutePath)\n351\t\t\t\tif relErr != nil {\n352\t\t\t\t\treturn \"\", \"\", \"\", \"\", fmt.Errorf(\"derive source topic path: %w\", relErr)\n353\t\t\t\t}\n354\t\t\t\treturn absolutePath, topicRoot, topicSlug, vault.ToPosixPath(relative), nil\n355\t\t\t}\n356\t\t\treturn absolutePath, \"\", \"\", vault.ToPosixPath(filepath.Base(absolutePath)), nil\n357\t\t}\n358\t\treturn \"\", \"\", \"\", \"\", fmt.Errorf(\"source document not found: %s\", source)\n359\t}\n360\t\n361\tfunc discoverTopicRoot(vaultPath, sourcePath string) (string, string) {\n362\t\tcleanVault := strings.TrimSpace(vaultPath)\n363\t\tcurrent := filepath.Dir(sourcePath)\n364\t\tfor {\n365\t\t\tif cleanVault != \"\" \u0026\u0026 !vault.IsPathInside(cleanVault, current) {\n366\t\t\t\treturn \"\", \"\"\n367\t\t\t}\n368\t\t\tif isRegularFile(filepath.Join(current, \"CLAUDE.md\")) {\n369\t\t\t\tif cleanVault == \"\" {\n370\t\t\t\t\treturn current, filepath.Base(current)\n371\t\t\t\t}\n372\t\t\t\trelative, err := filepath.Rel(cleanVault, current)\n373\t\t\t\tif err != nil {\n374\t\t\t\t\treturn current, filepath.Base(current)\n375\t\t\t\t}\n376\t\t\t\treturn current, vault.ToPosixPath(relative)\n377\t\t\t}\n378\t\t\tparent := filepath.Dir(current)\n379\t\t\tif parent == current {\n380\t\t\t\tbreak\n381\t\t\t}\n382\t\t\tcurrent = parent\n383\t\t}\n384\t\treturn \"\", \"\"\n385\t}\n386\t\n387\tfunc isRegularFile(filePath string) bool {\n388\t\tinfo, err := os.Stat(filePath)\n389\t\treturn err == nil \u0026\u0026 !info.IsDir()\n390\t}\n391\t\n392\tfunc allocateConceptPath(bundleRoot, key string) (string, string, error) {\n393\t\tfor suffix := 1; ; suffix++ {\n394\t\t\tname := key\n395\t\t\tif suffix \u003e 1 {\n396\t\t\t\tname = fmt.Sprintf(\"%s-%d\", key, suffix)\n397\t\t\t}\n398\t\t\trelativePath := name + \".md\"\n399\t\t\tabsolutePath := filepath.Join(bundleRoot, filepath.FromSlash(relativePath))\n400\t\t\tif _, err := os.Stat(absolutePath); errors.Is(err, os.ErrNotExist) {\n401\t\t\t\treturn relativePath, absolutePath, nil\n402\t\t\t} else if err != nil {\n403\t\t\t\treturn \"\", \"\", err\n404\t\t\t}\n405\t\t}\n406\t}\n407\t\n408\tfunc conceptKey(documentPath string) string {\n409\t\tbase := path.Base(vault.StripMarkdownExtension(vault.ToPosixPath(documentPath)))\n410\t\treturn vault.SlugifySegment(base)\n411\t}\n412\t\n413\tfunc typeWarnings(conceptType string, types []string) []string {\n414\t\ttypeSet := normalizeTypeSet(types)\n415\t\tif len(typeSet) == 0 {\n416\t\t\treturn nil\n417\t\t}\n418\t\tif _, ok := typeSet[conceptType]; ok {\n419\t\t\treturn nil\n420\t\t}\n421\t\treturn []string{fmt.Sprintf(\"type %q is outside the configured OKF vocabulary\", conceptType)}\n422\t}\n423\t\n424\tfunc normalizeTypeSet(types []string) map[string]struct{} {\n425\t\ttypeSet := make(map[string]struct{})\n426\t\tfor _, value := range types {\n427\t\t\tvalue = strings.TrimSpace(value)\n428\t\t\tif value == \"\" {\n429\t\t\t\tcontinue\n430\t\t\t}\n431\t\t\ttypeSet[value] = struct{}{}\n432\t\t}\n433\t\treturn typeSet\n434\t}\n435\t\n436\tfunc resolveDescription(flagValue, body string) (string, []string) {\n437\t\tif description := strings.TrimSpace(flagValue); description != \"\" {\n438\t\t\treturn description, nil\n439\t\t}\n440\t\tif sentence := firstBodySentence(body); sentence != \"\" {\n441\t\t\treturn sentence, nil\n442\t\t}\n443\t\treturn \"\", []string{\"description is empty because the source body has no sentence fallback\"}\n444\t}\n445\t\n446\tfunc firstBodySentence(body string) string {\n447\t\tnormalized := markdownSyntaxRunes.Replace(markdownLinkPattern.ReplaceAllString(body, \" \"))\n448\t\tnormalized = nonSentenceSpace.ReplaceAllString(strings.TrimSpace(normalized), \" \")\n449\t\tfor _, delimiter := range []string{\". \", \"! \", \"? \"} {\n450\t\t\tif index := strings.Index(normalized, delimiter); index \u003e= 0 {\n451\t\t\t\treturn strings.TrimSpace(normalized[:index+1])\n452\t\t\t}\n453\t\t}\n454\t\treturn strings.TrimSpace(normalized)\n455\t}\n456\t\n457\tfunc transformWikiLinks(body, sourceTopicSlug, bundleRoot, fromDir, currentPath string) (string, int, []string) {\n458\t\tcount := 0\n459\t\tunresolved := make([]string, 0)\n460\t\tformatter := vault.OKFLinkFormatter{}\n461\t\ttransformed := wikilinkPattern.ReplaceAllStringFunc(body, func(match string) string {\n462\t\t\tinner := strings.TrimSuffix(strings.TrimPrefix(match, \"[[\"), \"]]\")\n463\t\t\ttarget, label := splitWikiLink(inner)\n464\t\t\ttargetPath := mappedTargetPath(target, sourceTopicSlug)\n465\t\t\tif strings.TrimSpace(label) == \"\" {\n466\t\t\t\tlabel = fallbackWikiLabel(target)\n467\t\t\t}\n468\t\t\tcount++\n469\t\t\tif stripFragment(targetPath) != stripFragment(currentPath) \u0026\u0026 !isRegularFile(filepath.Join(bundleRoot, filepath.FromSlash(stripFragment(targetPath)))) {\n470\t\t\t\tunresolved = append(unresolved, targetPath)\n471\t\t\t}\n472\t\t\treturn formatter.Link(fromDir, targetPath, label)\n473\t\t})\n474\t\tsort.Strings(unresolved)\n475\t\treturn transformed, count, dedupeStrings(unresolved)\n476\t}\n477\t\n478\tfunc splitWikiLink(inner string) (string, string) {\n479\t\tparts := strings.SplitN(inner, \"|\", 2)\n480\t\ttarget := strings.TrimSpace(parts[0])\n481\t\tif len(parts) == 1 {\n482\t\t\treturn target, \"\"\n483\t\t}\n484\t\treturn target, strings.TrimSpace(parts[1])\n485\t}\n486\t\n487\tfunc mappedTargetPath(target, sourceTopicSlug string) string {\n488\t\ttarget = vault.ToPosixPath(strings.TrimSpace(target))\n489\t\tfragment := \"\"\n490\t\tif before, after, found := strings.Cut(target, \"#\"); found {\n491\t\t\ttarget = before\n492\t\t\tfragment = \"#\" + after\n493\t\t}\n494\t\ttarget = strings.TrimPrefix(target, \"/\")\n495\t\tif sourceTopicSlug != \"\" {\n496\t\t\tprefix := strings.Trim(sourceTopicSlug, \"/\") + \"/\"\n497\t\t\ttarget = strings.TrimPrefix(target, prefix)\n498\t\t}\n499\t\treturn conceptKey(target) + \".md\" + fragment\n500\t}\n501\t\n502\tfunc stripFragment(target string) string {\n503\t\tif before, _, found := strings.Cut(target, \"#\"); found {\n504\t\t\treturn before\n505\t\t}\n506\t\treturn target\n507\t}\n508\t\n509\tfunc fallbackWikiLabel(target string) string {\n510\t\ttarget = stripFragment(target)\n511\t\treturn vault.HumanizeSlug(conceptKey(target))\n512\t}\n513\t\n514\tfunc dedupeStrings(values []string) []string {\n515\t\tif len(values) == 0 {\n516\t\t\treturn nil\n517\t\t}\n518\t\tdeduped := values[:0]\n519\t\tvar previous string\n520\t\tfor index, value := range values {\n521\t\t\tif index \u003e 0 \u0026\u0026 value == previous {\n522\t\t\t\tcontinue\n523\t\t\t}\n524\t\t\tdeduped = append(deduped, value)\n525\t\t\tprevious = value\n526\t\t}\n527\t\treturn append([]string(nil), deduped...)\n528\t}\n529\t\n530\tfunc loadConcepts(bundlePath string) ([]conceptInfo, string, error) {\n531\t\tokfVersion := OKFVersion\n532\t\tindexPath := filepath.Join(bundlePath, \"index.md\")\n533\t\tif content, err := os.ReadFile(indexPath); err == nil {\n534\t\t\tvalues, _, parseErr := frontmatter.Parse(string(content))\n535\t\t\tif parseErr != nil {\n536\t\t\t\treturn nil, \"\", fmt.Errorf(\"parse index.md: %w\", parseErr)\n537\t\t\t}\n538\t\t\tif version := strings.TrimSpace(frontmatter.GetString(values, \"okf_version\")); version != \"\" {\n539\t\t\t\tokfVersion = version\n540\t\t\t}\n541\t\t} else if !errors.Is(err, os.ErrNotExist) {\n542\t\t\treturn nil, \"\", fmt.Errorf(\"read index.md: %w\", err)\n543\t\t}\n544\t\n545\t\tconcepts := make([]conceptInfo, 0)\n546\t\terr := filepath.WalkDir(bundlePath, func(currentPath string, entry fs.DirEntry, walkErr error) error {\n547\t\t\tif walkErr != nil {\n548\t\t\t\treturn walkErr\n549\t\t\t}\n550\t\t\tif currentPath == bundlePath {\n551\t\t\t\treturn nil\n552\t\t\t}\n553\t\t\tif entry.Type()\u0026fs.ModeSymlink != 0 {\n554\t\t\t\tif entry.IsDir() {\n555\t\t\t\t\treturn filepath.SkipDir\n556\t\t\t\t}\n557\t\t\t\treturn nil\n558\t\t\t}\n559\t\t\tif entry.IsDir() {\n560\t\t\t\tif strings.HasPrefix(entry.Name(), \".\") {\n561\t\t\t\t\treturn filepath.SkipDir\n562\t\t\t\t}\n563\t\t\t\treturn nil\n564\t\t\t}\n565\t\t\tif !strings.EqualFold(filepath.Ext(entry.Name()), \".md\") || isExcludedMarkdown(entry.Name()) {\n566\t\t\t\treturn nil\n567\t\t\t}\n568\t\t\trelativePath, err := filepath.Rel(bundlePath, currentPath)\n569\t\t\tif err != nil {\n570\t\t\t\treturn err\n571\t\t\t}\n572\t\t\trelativePath = vault.ToPosixPath(relativePath)\n573\t\t\tif isIndexFile(relativePath) || isLogFile(relativePath) {\n574\t\t\t\treturn nil\n575\t\t\t}\n576\t\t\tcontent, err := os.ReadFile(currentPath)\n577\t\t\tif err != nil {\n578\t\t\t\treturn err\n579\t\t\t}\n580\t\t\tvalues, _, err := frontmatter.Parse(string(content))\n581\t\t\tif err != nil || len(values) == 0 {\n582\t\t\t\treturn nil\n583\t\t\t}\n584\t\t\tconceptType := strings.TrimSpace(frontmatter.GetString(values, \"type\"))\n585\t\t\ttitle := strings.TrimSpace(frontmatter.GetString(values, \"title\"))\n586\t\t\tif title == \"\" {\n587\t\t\t\ttitle = vault.HumanizeSlug(conceptKey(relativePath))\n588\t\t\t}\n589\t\t\tconcepts = append(concepts, conceptInfo{\n590\t\t\t\tRelativePath: relativePath,\n591\t\t\t\tType: firstNonEmpty(conceptType, \"Concept\"),\n592\t\t\t\tTitle: title,\n593\t\t\t\tDescription: strings.TrimSpace(frontmatter.GetString(values, \"description\")),\n594\t\t\t})\n595\t\t\treturn nil\n596\t\t})\n597\t\tif err != nil {\n598\t\t\treturn nil, \"\", fmt.Errorf(\"scan concepts: %w\", err)\n599\t\t}\n600\t\treturn concepts, okfVersion, nil\n601\t}\n602\t\n603\tfunc checkConceptFile(filePath, relativePath string, typeSet map[string]struct{}, strict bool) []models.LintIssue {\n604\t\tcontent, err := os.ReadFile(filePath)\n605\t\tif err != nil {\n606\t\t\treturn []models.LintIssue{newIssue(models.SeverityError, relativePath, \"\", fmt.Sprintf(\"read concept: %v\", err))}\n607\t\t}\n608\t\tvalues, _, err := frontmatter.Parse(string(content))\n609\t\tif err != nil {\n610\t\t\treturn []models.LintIssue{newIssue(models.SeverityError, relativePath, \"\", fmt.Sprintf(\"frontmatter is invalid: %v\", err))}\n611\t\t}\n612\t\tif len(values) == 0 {\n613\t\t\treturn []models.LintIssue{newIssue(models.SeverityError, relativePath, \"type\", \"concept must start with YAML frontmatter\")}\n614\t\t}\n615\t\tissues := make([]models.LintIssue, 0)\n616\t\tconceptType := strings.TrimSpace(frontmatter.GetString(values, \"type\"))\n617\t\tif conceptType == \"\" {\n618\t\t\tissues = append(issues, newIssue(models.SeverityError, relativePath, \"type\", \"concept frontmatter must include non-empty type\"))\n619\t\t}\n620\t\tfor _, field := range []string{\"title\", \"description\", \"timestamp\"} {\n621\t\t\tif strings.TrimSpace(frontmatter.GetString(values, field)) == \"\" {\n622\t\t\t\tissues = append(issues, newIssue(warningSeverity(strict), relativePath, field, fmt.Sprintf(\"producer field %q is missing\", field)))\n623\t\t\t}\n624\t\t}\n625\t\tif len(typeSet) \u003e 0 \u0026\u0026 conceptType != \"\" {\n626\t\t\tif _, ok := typeSet[conceptType]; !ok {\n627\t\t\t\tissues = append(issues, newIssue(warningSeverity(strict), relativePath, \"type\", fmt.Sprintf(\"type %q is outside the configured OKF vocabulary\", conceptType)))\n628\t\t\t}\n629\t\t}\n630\t\treturn issues\n631\t}\n632\t\n633\tfunc checkIndexFile(bundlePath, filePath, relativePath string, strict bool) []models.LintIssue {\n634\t\tcontent, err := os.ReadFile(filePath)\n635\t\tif err != nil {\n636\t\t\treturn []models.LintIssue{newIssue(models.SeverityError, relativePath, \"\", fmt.Sprintf(\"read index: %v\", err))}\n637\t\t}\n638\t\tvalues, _, err := frontmatter.Parse(string(content))\n639\t\tif err != nil {\n640\t\t\treturn []models.LintIssue{newIssue(models.SeverityError, relativePath, \"\", fmt.Sprintf(\"index frontmatter is invalid: %v\", err))}\n641\t\t}\n642\t\tif len(values) == 0 {\n643\t\t\treturn nil\n644\t\t}\n645\t\tif relativePath != \"index.md\" {\n646\t\t\treturn []models.LintIssue{newIssue(models.SeverityError, relativePath, \"frontmatter\", \"only the bundle-root index.md may declare okf_version frontmatter\")}\n647\t\t}\n648\t\tfor key := range values {\n649\t\t\tif key != \"okf_version\" {\n650\t\t\t\treturn []models.LintIssue{newIssue(models.SeverityError, relativePath, key, \"root index.md frontmatter may only contain okf_version\")}\n651\t\t\t}\n652\t\t}\n653\t\t_ = bundlePath\n654\t\t_ = strict\n655\t\treturn nil\n656\t}\n657\t\n658\tfunc checkLogFile(filePath, relativePath string, strict bool) []models.LintIssue {\n659\t\tcontent, err := os.ReadFile(filePath)\n660\t\tif err != nil {\n661\t\t\treturn []models.LintIssue{newIssue(models.SeverityError, relativePath, \"\", fmt.Sprintf(\"read log: %v\", err))}\n662\t\t}\n663\t\tissues := make([]models.LintIssue, 0)\n664\t\tfor line := range strings.SplitSeq(string(content), \"\\n\") {\n665\t\t\ttrimmed := strings.TrimSpace(line)\n666\t\t\tif !strings.HasPrefix(trimmed, \"## \") {\n667\t\t\t\tcontinue\n668\t\t\t}\n669\t\t\tdateValue := strings.TrimSpace(strings.TrimPrefix(trimmed, \"## \"))\n670\t\t\tif _, err := time.Parse(frontmatter.DateLayout, dateValue); err != nil {\n671\t\t\t\tissues = append(issues, newIssue(models.SeverityError, relativePath, \"date\", fmt.Sprintf(\"log heading %q must use YYYY-MM-DD\", trimmed)))\n672\t\t\t}\n673\t\t}\n674\t\t_ = strict\n675\t\treturn issues\n676\t}\n677\t\n678\tfunc newIssue(severity models.DiagnosticSeverity, filePath, target, message string) models.LintIssue {\n679\t\treturn models.LintIssue{\n680\t\t\tKind: models.LintIssueKindFormat,\n681\t\t\tSeverity: severity,\n682\t\t\tFilePath: filePath,\n683\t\t\tTarget: target,\n684\t\t\tMessage: message,\n685\t\t}\n686\t}\n687\t\n688\tfunc warningSeverity(strict bool) models.DiagnosticSeverity {\n689\t\tif strict {\n690\t\t\treturn models.SeverityError\n691\t\t}\n692\t\treturn models.SeverityWarning\n693\t}\n694\t\n695\tfunc sortIssues(issues []models.LintIssue) {\n696\t\tsort.Slice(issues, func(i, j int) bool {\n697\t\t\tif issues[i].Severity != issues[j].Severity {\n698\t\t\t\treturn issues[i].Severity \u003c issues[j].Severity\n699\t\t\t}\n700\t\t\tif issues[i].FilePath != issues[j].FilePath {\n701\t\t\t\treturn issues[i].FilePath \u003c issues[j].FilePath\n702\t\t\t}\n703\t\t\tif issues[i].Target != issues[j].Target {\n704\t\t\t\treturn issues[i].Target \u003c issues[j].Target\n705\t\t\t}\n706\t\t\treturn issues[i].Message \u003c issues[j].Message\n707\t\t})\n708\t}\n709\t\n710\tfunc isIndexFile(relativePath string) bool {\n711\t\treturn strings.EqualFold(path.Base(vault.ToPosixPath(relativePath)), \"index.md\")\n712\t}\n713\t\n714\tfunc isLogFile(relativePath string) bool {\n715\t\treturn strings.EqualFold(path.Base(vault.ToPosixPath(relativePath)), \"log.md\")\n716\t}\n717\t\n718\tfunc isExcludedMarkdown(name string) bool {\n719\t\tlower := strings.ToLower(strings.TrimSpace(name))\n720\t\tswitch lower {\n721\t\tcase \"claude.md\", \"agents.md\", \"readme.md\", \"license.md\", \"notice.md\", \"attribution.md\":\n722\t\t\treturn true\n723\t\tdefault:\n724\t\t\treturn strings.HasPrefix(lower, \"license.\") || strings.HasPrefix(lower, \"notice.\") || strings.HasPrefix(lower, \"attribution.\")\n725\t\t}\n726\t}\n727\t\n728\tfunc insertLogEntry(content, dateHeading, entry string) string {\n729\t\tlines := strings.Split(strings.ReplaceAll(content, \"\\r\\n\", \"\\n\"), \"\\n\")\n730\t\tif len(lines) == 0 || strings.TrimSpace(lines[0]) == \"\" {\n731\t\t\tlines = []string{\"# Directory Update Log\"}\n732\t\t}\n733\t\theading := \"## \" + dateHeading\n734\t\tfor index, line := range lines {\n735\t\t\tif strings.TrimSpace(line) != heading {\n736\t\t\t\tcontinue\n737\t\t\t}\n738\t\t\tnext := append([]string{}, lines[:index+1]...)\n739\t\t\tnext = append(next, entry)\n740\t\t\tnext = append(next, lines[index+1:]...)\n741\t\t\treturn strings.TrimRight(strings.Join(next, \"\\n\"), \"\\n\") + \"\\n\"\n742\t\t}\n743\t\tnext := []string{strings.TrimRight(strings.Join(lines[:1], \"\\n\"), \"\\n\"), \"\", heading, \"\", entry}\n744\t\tif len(lines) \u003e 1 {\n745\t\t\trest := strings.TrimSpace(strings.Join(lines[1:], \"\\n\"))\n746\t\t\tif rest != \"\" {\n747\t\t\t\tnext = append(next, \"\", rest)\n748\t\t\t}\n749\t\t}\n750\t\treturn strings.TrimRight(strings.Join(next, \"\\n\"), \"\\n\") + \"\\n\"\n751\t}\n752\t\n753\tfunc firstNonEmpty(values ...string) string {\n754\t\tfor _, value := range values {\n755\t\t\tif trimmed := strings.TrimSpace(value); trimmed != \"\" {\n756\t\t\t\treturn trimmed\n757\t\t\t}\n758\t\t}\n759\t\treturn \"\"\n760\t}\n761\t\n```"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:21:01.786947Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01QXz72fTN4nRhjgN6Wa6w6E","blocks":[{"type":"tool_use","id":"toolu_01QXz72fTN4nRhjgN6Wa6w6E","name":"Read","title":"Read internal/cli/promote.go","input":{"file_path":"/Users/pedronauck/Dev/compozy/kb/internal/cli/promote.go"},"raw_input":{"file_path":"/Users/pedronauck/Dev/compozy/kb/internal/cli/promote.go"}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:21:01.789734Z","turn":1,"type":"session.update","update":{"kind":"tool_call_started","tool_call_id":"toolu_016rRekRNRtLtB1WSY7bSaL6","tool_call_state":"pending","blocks":[{"type":"tool_use","id":"toolu_016rRekRNRtLtB1WSY7bSaL6","name":"Read","title":"Read File","raw_input":{}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:21:01.793657Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01QXz72fTN4nRhjgN6Wa6w6E","usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:21:01.79981Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01QXz72fTN4nRhjgN6Wa6w6E","tool_call_state":"completed","blocks":[{"type":"tool_result","tool_use_id":"toolu_01QXz72fTN4nRhjgN6Wa6w6E","content":"```\n1\tpackage cli\n2\t\n3\timport (\n4\t\t\"fmt\"\n5\t\t\"os\"\n6\t\n7\t\t\"github.com/spf13/cobra\"\n8\t\n9\t\tkokf \"github.com/compozy/kb/internal/okf\"\n10\t\tktopic \"github.com/compozy/kb/internal/topic\"\n11\t)\n12\t\n13\tvar runPromote = kokf.Promote\n14\tvar runPromoteTopicInfo = ktopic.Info\n15\tvar promoteGetwd = os.Getwd\n16\t\n17\tfunc newPromoteCommand() *cobra.Command {\n18\t\tvar targetTopic string\n19\t\tvar conceptType string\n20\t\tvar description string\n21\t\n22\t\tcommand := \u0026cobra.Command{\n23\t\t\tUse: \"promote \u003cwiki-doc\u003e\",\n24\t\t\tShort: \"Promote a wiki document into an OKF topic\",\n25\t\t\tArgs: cobra.ExactArgs(1),\n26\t\t\tRunE: func(cmd *cobra.Command, args []string) error {\n27\t\t\t\tvaultPath, err := resolveCommandVaultPath(cmd, promoteGetwd, \"promote\")\n28\t\t\t\tif err != nil {\n29\t\t\t\t\treturn err\n30\t\t\t\t}\n31\t\t\t\tcfg, err := loadCLIConfig()\n32\t\t\t\tif err != nil {\n33\t\t\t\t\treturn fmt.Errorf(\"promote: %w\", err)\n34\t\t\t\t}\n35\t\t\t\ttarget, err := runPromoteTopicInfo(vaultPath, targetTopic)\n36\t\t\t\tif err != nil {\n37\t\t\t\t\treturn fmt.Errorf(\"promote: %w\", err)\n38\t\t\t\t}\n39\t\n40\t\t\t\tresult, err := runPromote(commandContext(cmd), kokf.PromoteInput{\n41\t\t\t\t\tSourceDocPath: args[0],\n42\t\t\t\t\tVaultPath: vaultPath,\n43\t\t\t\t\tTargetTopic: target,\n44\t\t\t\t\tType: conceptType,\n45\t\t\t\t\tDescription: description,\n46\t\t\t\t\tTypes: cfg.OKF.Types,\n47\t\t\t\t})\n48\t\t\t\tif err != nil {\n49\t\t\t\t\treturn err\n50\t\t\t\t}\n51\t\t\t\treturn writeJSON(cmd, result)\n52\t\t\t},\n53\t\t}\n54\t\n55\t\tcommand.Flags().StringVar(\u0026targetTopic, \"to\", \"\", \"Target OKF topic slug\")\n56\t\tcommand.Flags().StringVar(\u0026conceptType, \"type\", \"\", \"OKF concept type\")\n57\t\tcommand.Flags().StringVar(\u0026description, \"description\", \"\", \"OKF concept description\")\n58\t\t_ = command.MarkFlagRequired(\"to\")\n59\t\t_ = command.MarkFlagRequired(\"type\")\n60\t\treturn command\n61\t}\n62\t\n```"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:21:02.109408Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_016rRekRNRtLtB1WSY7bSaL6","blocks":[{"type":"tool_use","id":"toolu_016rRekRNRtLtB1WSY7bSaL6","name":"Read","title":"Read internal/cli/okf.go","input":{"file_path":"/Users/pedronauck/Dev/compozy/kb/internal/cli/okf.go"},"raw_input":{"file_path":"/Users/pedronauck/Dev/compozy/kb/internal/cli/okf.go"}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:21:02.111394Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_016rRekRNRtLtB1WSY7bSaL6","usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:21:02.114955Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_016rRekRNRtLtB1WSY7bSaL6","tool_call_state":"completed","blocks":[{"type":"tool_result","tool_use_id":"toolu_016rRekRNRtLtB1WSY7bSaL6","content":"```\n1\tpackage cli\n2\t\n3\timport (\n4\t\t\"fmt\"\n5\t\t\"os\"\n6\t\n7\t\t\"github.com/spf13/cobra\"\n8\t\n9\t\tkokf \"github.com/compozy/kb/internal/okf\"\n10\t\t\"github.com/compozy/kb/internal/output\"\n11\t\tktopic \"github.com/compozy/kb/internal/topic\"\n12\t)\n13\t\n14\ttype okfCheckOptions struct {\n15\t\tFormat string\n16\t\tStrict bool\n17\t}\n18\t\n19\tvar runOKFCheck = kokf.Check\n20\tvar runOKFTopicInfo = ktopic.Info\n21\tvar okfGetwd = os.Getwd\n22\t\n23\tfunc newOKFCommand() *cobra.Command {\n24\t\tcommand := \u0026cobra.Command{\n25\t\t\tUse: \"okf\",\n26\t\t\tShort: \"Work with Open Knowledge Format bundles\",\n27\t\t\tArgs: cobra.NoArgs,\n28\t\t\tRunE: func(cmd *cobra.Command, args []string) error {\n29\t\t\t\treturn cmd.Help()\n30\t\t\t},\n31\t\t}\n32\t\tcommand.AddCommand(newOKFCheckCommand())\n33\t\treturn command\n34\t}\n35\t\n36\tfunc newOKFCheckCommand() *cobra.Command {\n37\t\toptions := \u0026okfCheckOptions{\n38\t\t\tFormat: string(output.OutputFormatTable),\n39\t\t}\n40\t\tcommand := \u0026cobra.Command{\n41\t\t\tUse: \"check \u003ctopic\u003e\",\n42\t\t\tShort: \"Check an OKF topic for conformance\",\n43\t\t\tArgs: cobra.ExactArgs(1),\n44\t\t\tRunE: func(cmd *cobra.Command, args []string) error {\n45\t\t\t\treturn runOKFCheckCommand(cmd, options, args[0])\n46\t\t\t},\n47\t\t}\n48\t\tcommand.Flags().StringVar(\u0026options.Format, \"format\", string(output.OutputFormatTable), \"Output format (table|json|tsv)\")\n49\t\tcommand.Flags().BoolVar(\u0026options.Strict, \"strict\", false, \"Promote local-standard warnings to errors\")\n50\t\treturn command\n51\t}\n52\t\n53\tfunc runOKFCheckCommand(cmd *cobra.Command, options *okfCheckOptions, topicSlug string) error {\n54\t\tformat, err := parseInspectOutputFormat(options.Format)\n55\t\tif err != nil {\n56\t\t\treturn err\n57\t\t}\n58\t\tvaultPath, err := resolveCommandVaultPath(cmd, okfGetwd, \"okf check\")\n59\t\tif err != nil {\n60\t\t\treturn err\n61\t\t}\n62\t\tcfg, err := loadCLIConfig()\n63\t\tif err != nil {\n64\t\t\treturn fmt.Errorf(\"okf check: %w\", err)\n65\t\t}\n66\t\ttopicInfo, err := runOKFTopicInfo(vaultPath, topicSlug)\n67\t\tif err != nil {\n68\t\t\treturn fmt.Errorf(\"okf check: %w\", err)\n69\t\t}\n70\t\n71\t\tissues, err := runOKFCheck(commandContext(cmd), topicInfo.RootPath, kokf.CheckOptions{\n72\t\t\tTypes: cfg.OKF.Types,\n73\t\t\tStrict: options.Strict,\n74\t\t})\n75\t\tif err != nil {\n76\t\t\treturn err\n77\t\t}\n78\t\t_, writeErr := cmd.OutOrStdout().Write([]byte(output.FormatOutput(output.FormatOptions{\n79\t\t\tFormat: format,\n80\t\t\tColumns: kokf.Columns(),\n81\t\t\tData: kokf.Rows(issues),\n82\t\t})))\n83\t\tif writeErr != nil {\n84\t\t\treturn fmt.Errorf(\"okf check: write output: %w\", writeErr)\n85\t\t}\n86\t\tif kokf.HasErrors(issues) {\n87\t\t\treturn fmt.Errorf(\"okf check: found %d issue(s)\", len(issues))\n88\t\t}\n89\t\treturn nil\n90\t}\n91\t\n```"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:21:08.09383Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":""}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:21:08.094483Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":"Now the test files and key helper sources to"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:21:08.502181Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":" verify behavior claims."}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:21:08.504052Z","turn":1,"type":"session.update","update":{"kind":"tool_call_started","tool_call_id":"toolu_01LSQ1xEFaNUqEGjZojKgoN6","tool_call_state":"pending","blocks":[{"type":"tool_use","id":"toolu_01LSQ1xEFaNUqEGjZojKgoN6","name":"Read","title":"Read File","raw_input":{}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:21:09.414771Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01LSQ1xEFaNUqEGjZojKgoN6","blocks":[{"type":"tool_use","id":"toolu_01LSQ1xEFaNUqEGjZojKgoN6","name":"Read","title":"Read internal/okf/okf_test.go","input":{"file_path":"/Users/pedronauck/Dev/compozy/kb/internal/okf/okf_test.go"},"raw_input":{"file_path":"/Users/pedronauck/Dev/compozy/kb/internal/okf/okf_test.go"}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:21:09.420313Z","turn":1,"type":"session.update","update":{"kind":"tool_call_started","tool_call_id":"toolu_01Uu1irB94yD2XUwFBRvvepi","tool_call_state":"pending","blocks":[{"type":"tool_use","id":"toolu_01Uu1irB94yD2XUwFBRvvepi","name":"Read","title":"Read File","raw_input":{}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:21:09.423898Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01LSQ1xEFaNUqEGjZojKgoN6","usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:21:09.430307Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01LSQ1xEFaNUqEGjZojKgoN6","tool_call_state":"completed","blocks":[{"type":"tool_result","tool_use_id":"toolu_01LSQ1xEFaNUqEGjZojKgoN6","content":"```\n1\tpackage okf\n2\t\n3\timport (\n4\t\t\"context\"\n5\t\t\"os\"\n6\t\t\"path/filepath\"\n7\t\t\"strings\"\n8\t\t\"testing\"\n9\t\t\"time\"\n10\t\n11\t\t\"github.com/compozy/kb/internal/frontmatter\"\n12\t\t\"github.com/compozy/kb/internal/models\"\n13\t)\n14\t\n15\tfunc TestPromoteWritesConceptIndexAndLog(t *testing.T) {\n16\t\tt.Parallel()\n17\t\n18\t\tvaultPath := t.TempDir()\n19\t\tsourceTopic := filepath.Join(vaultPath, \"research\")\n20\t\ttargetTopic := filepath.Join(vaultPath, \"catalog\")\n21\t\tmkdirAll(t, filepath.Join(sourceTopic, \"wiki\", \"concepts\"))\n22\t\tmkdirAll(t, targetTopic)\n23\t\twriteFile(t, filepath.Join(sourceTopic, \"CLAUDE.md\"), \"# Research\\n\")\n24\t\twriteFile(t, filepath.Join(targetTopic, \"CLAUDE.md\"), \"# Catalog\\n\")\n25\t\twriteFile(t, filepath.Join(targetTopic, \"index.md\"), \"---\\nokf_version: \\\"0.1\\\"\\n---\\n# Old Index\\n\")\n26\t\twriteFile(t, filepath.Join(targetTopic, \"log.md\"), \"# Directory Update Log\\n\")\n27\t\tsourcePath := filepath.Join(sourceTopic, \"wiki\", \"concepts\", \"Alpha Note.md\")\n28\t\tsourceContent := strings.Join([]string{\n29\t\t\t\"---\",\n30\t\t\t\"title: Alpha Note\",\n31\t\t\t\"type: wiki\",\n32\t\t\t\"stage: compiled\",\n33\t\t\t\"tags: [systems, alpha]\",\n34\t\t\t\"---\",\n35\t\t\t\"Alpha note explains the operational flow. See [[research/wiki/concepts/Beta Note|Beta]] and [[research/wiki/concepts/Alpha Note#details|details]].\",\n36\t\t\t\"\",\n37\t\t\t\"## Details\",\n38\t\t}, \"\\n\")\n39\t\twriteFile(t, sourcePath, sourceContent)\n40\t\n41\t\tresult, err := Promote(context.Background(), PromoteInput{\n42\t\t\tSourceDocPath: sourcePath,\n43\t\t\tVaultPath: vaultPath,\n44\t\t\tTargetTopic: models.TopicInfo{\n45\t\t\t\tSlug: \"catalog\",\n46\t\t\t\tMode: models.TopicModeOKF,\n47\t\t\t\tRootPath: targetTopic,\n48\t\t\t},\n49\t\t\tType: \"Playbook\",\n50\t\t\tDescription: \"Operational alpha note.\",\n51\t\t\tTypes: []string{\"Playbook\"},\n52\t\t\tClock: func() time.Time {\n53\t\t\t\treturn time.Date(2026, 6, 27, 10, 11, 12, 0, time.UTC)\n54\t\t\t},\n55\t\t})\n56\t\tif err != nil {\n57\t\t\tt.Fatalf(\"Promote returned error: %v\", err)\n58\t\t}\n59\t\tif result.WrittenPath != \"alpha-note.md\" {\n60\t\t\tt.Fatalf(\"written path = %q, want alpha-note.md\", result.WrittenPath)\n61\t\t}\n62\t\tif result.LinksRewritten != 2 {\n63\t\t\tt.Fatalf(\"links rewritten = %d, want 2\", result.LinksRewritten)\n64\t\t}\n65\t\tif len(result.UnresolvedLinks) != 1 || result.UnresolvedLinks[0] != \"beta-note.md\" {\n66\t\t\tt.Fatalf(\"unresolved links = %#v, want beta-note.md\", result.UnresolvedLinks)\n67\t\t}\n68\t\tif got := readFile(t, sourcePath); got != sourceContent {\n69\t\t\tt.Fatalf(\"source document changed:\\n%s\", got)\n70\t\t}\n71\t\n72\t\tvalues, body := parseMarkdown(t, filepath.Join(targetTopic, \"alpha-note.md\"))\n73\t\tfor key, want := range map[string]any{\n74\t\t\t\"type\": \"Playbook\",\n75\t\t\t\"title\": \"Alpha Note\",\n76\t\t\t\"description\": \"Operational alpha note.\",\n77\t\t\t\"timestamp\": \"2026-06-27T10:11:12Z\",\n78\t\t} {\n79\t\t\tif got := values[key]; got != want {\n80\t\t\t\tt.Fatalf(\"frontmatter[%s] = %#v, want %#v\", key, got, want)\n81\t\t\t}\n82\t\t}\n83\t\tif _, ok := values[\"stage\"]; ok {\n84\t\t\tt.Fatalf(\"wiki stage leaked into OKF frontmatter: %#v\", values)\n85\t\t}\n86\t\tif !strings.Contains(body, \"[Beta](beta-note.md)\") || !strings.Contains(body, \"[details](alpha-note.md#details)\") {\n87\t\t\tt.Fatalf(\"body missing transformed links:\\n%s\", body)\n88\t\t}\n89\t\n90\t\tindex := readFile(t, filepath.Join(targetTopic, \"index.md\"))\n91\t\tfor _, fragment := range []string{\"okf_version: \\\"0.1\\\"\", \"## Playbook\", \"[Alpha Note](alpha-note.md)\", \"Operational alpha note.\"} {\n92\t\t\tif !strings.Contains(index, fragment) {\n93\t\t\t\tt.Fatalf(\"index.md missing %q:\\n%s\", fragment, index)\n94\t\t\t}\n95\t\t}\n96\t\tlog := readFile(t, filepath.Join(targetTopic, \"log.md\"))\n97\t\tfor _, fragment := range []string{\"## 2026-06-27\", \"**Creation**\", \"[Alpha Note](alpha-note.md)\", \"`wiki/concepts/Alpha Note.md`\"} {\n98\t\t\tif !strings.Contains(log, fragment) {\n99\t\t\t\tt.Fatalf(\"log.md missing %q:\\n%s\", fragment, log)\n100\t\t\t}\n101\t\t}\n102\t}\n103\t\n104\tfunc TestPromoteRejectsNonOKFTargetBeforeWriting(t *testing.T) {\n105\t\tt.Parallel()\n106\t\n107\t\ttargetTopic := t.TempDir()\n108\t\t_, err := Promote(context.Background(), PromoteInput{\n109\t\t\tSourceDocPath: \"missing.md\",\n110\t\t\tTargetTopic: models.TopicInfo{\n111\t\t\t\tSlug: \"wiki-topic\",\n112\t\t\t\tMode: models.TopicModeWiki,\n113\t\t\t\tRootPath: targetTopic,\n114\t\t\t},\n115\t\t\tType: \"Reference\",\n116\t\t})\n117\t\tif err == nil || !strings.Contains(err.Error(), \"target topic must use mode okf\") {\n118\t\t\tt.Fatalf(\"error = %v, want non-OKF target rejection\", err)\n119\t\t}\n120\t\tif entries, readErr := os.ReadDir(targetTopic); readErr != nil || len(entries) != 0 {\n121\t\t\tt.Fatalf(\"target changed before rejection: entries=%d err=%v\", len(entries), readErr)\n122\t\t}\n123\t}\n124\t\n125\tfunc TestCheckReportsConformanceAndStrictWarnings(t *testing.T) {\n126\t\tt.Parallel()\n127\t\n128\t\tbundle := t.TempDir()\n129\t\twriteFile(t, filepath.Join(bundle, \"CLAUDE.md\"), \"# Catalog\\n\")\n130\t\twriteFile(t, filepath.Join(bundle, \"index.md\"), \"---\\nokf_version: \\\"0.1\\\"\\n---\\n# Index\\n\")\n131\t\twriteFile(t, filepath.Join(bundle, \"log.md\"), \"# Directory Update Log\\n\\n## 2026-06-27\\n* **Creation**: Created bundle.\\n\")\n132\t\twriteFile(t, filepath.Join(bundle, \"good.md\"), \"---\\ntype: Playbook\\ntitle: Good\\ndescription: Good concept.\\ntimestamp: 2026-06-27T10:00:00Z\\n---\\nBody with [broken](missing.md).\\n\")\n133\t\twriteFile(t, filepath.Join(bundle, \"missing-type.md\"), \"---\\ntitle: Missing\\n---\\nBody.\\n\")\n134\t\twriteFile(t, filepath.Join(bundle, \"missing-fields.md\"), \"---\\ntype: Unknown\\n---\\nBody.\\n\")\n135\t\n136\t\tissues, err := Check(context.Background(), bundle, CheckOptions{\n137\t\t\tTypes: []string{\"Playbook\"},\n138\t\t})\n139\t\tif err != nil {\n140\t\t\tt.Fatalf(\"Check returned error: %v\", err)\n141\t\t}\n142\t\tassertIssue(t, issues, models.SeverityError, \"missing-type.md\", \"type\")\n143\t\tassertIssue(t, issues, models.SeverityWarning, \"missing-fields.md\", \"title\")\n144\t\tassertIssue(t, issues, models.SeverityWarning, \"missing-fields.md\", \"type\")\n145\t\tif !HasErrors(issues) {\n146\t\t\tt.Fatal(\"HasErrors = false, want true for missing type\")\n147\t\t}\n148\t\n149\t\tstrictIssues, err := Check(context.Background(), bundle, CheckOptions{\n150\t\t\tTypes: []string{\"Playbook\"},\n151\t\t\tStrict: true,\n152\t\t})\n153\t\tif err != nil {\n154\t\t\tt.Fatalf(\"strict Check returned error: %v\", err)\n155\t\t}\n156\t\tassertIssue(t, strictIssues, models.SeverityError, \"missing-fields.md\", \"title\")\n157\t}\n158\t\n159\tfunc TestCheckAllowsLenientExternalBundleTraits(t *testing.T) {\n160\t\tt.Parallel()\n161\t\n162\t\tbundle := t.TempDir()\n163\t\twriteFile(t, filepath.Join(bundle, \"index.md\"), \"# Index\\n\")\n164\t\twriteFile(t, filepath.Join(bundle, \"concept.md\"), \"---\\ntype: External Type\\n---\\nSee [missing](missing.md).\\n\")\n165\t\n166\t\tissues, err := Check(context.Background(), bundle, CheckOptions{})\n167\t\tif err != nil {\n168\t\t\tt.Fatalf(\"Check returned error: %v\", err)\n169\t\t}\n170\t\tif HasErrors(issues) {\n171\t\t\tt.Fatalf(\"lenient external bundle should not have errors: %#v\", issues)\n172\t\t}\n173\t}\n174\t\n175\tfunc assertIssue(t *testing.T, issues []models.LintIssue, severity models.DiagnosticSeverity, filePath, target string) {\n176\t\tt.Helper()\n177\t\tfor _, issue := range issues {\n178\t\t\tif issue.Severity == severity \u0026\u0026 issue.FilePath == filePath \u0026\u0026 issue.Target == target {\n179\t\t\t\treturn\n180\t\t\t}\n181\t\t}\n182\t\tt.Fatalf(\"missing issue severity=%s file=%s target=%s in %#v\", severity, filePath, target, issues)\n183\t}\n184\t\n185\tfunc parseMarkdown(t *testing.T, filePath string) (map[string]any, string) {\n186\t\tt.Helper()\n187\t\tvalues, body, err := frontmatter.Parse(readFile(t, filePath))\n188\t\tif err != nil {\n189\t\t\tt.Fatalf(\"parse %s: %v\", filePath, err)\n190\t\t}\n191\t\treturn values, body\n192\t}\n193\t\n194\tfunc mkdirAll(t *testing.T, dir string) {\n195\t\tt.Helper()\n196\t\tif err := os.MkdirAll(dir, 0o755); err != nil {\n197\t\t\tt.Fatalf(\"mkdir %s: %v\", dir, err)\n198\t\t}\n199\t}\n200\t\n201\tfunc writeFile(t *testing.T, filePath string, content string) {\n202\t\tt.Helper()\n203\t\tif err := os.MkdirAll(filepath.Dir(filePath), 0o755); err != nil {\n204\t\t\tt.Fatalf(\"mkdir parent for %s: %v\", filePath, err)\n205\t\t}\n206\t\tif err := os.WriteFile(filePath, []byte(content), 0o644); err != nil {\n207\t\t\tt.Fatalf(\"write %s: %v\", filePath, err)\n208\t\t}\n209\t}\n210\t\n211\tfunc readFile(t *testing.T, filePath string) string {\n212\t\tt.Helper()\n213\t\tcontent, err := os.ReadFile(filePath)\n214\t\tif err != nil {\n215\t\t\tt.Fatalf(\"read %s: %v\", filePath, err)\n216\t\t}\n217\t\treturn string(content)\n218\t}\n219\t\n```"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:21:09.93409Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01Uu1irB94yD2XUwFBRvvepi","blocks":[{"type":"tool_use","id":"toolu_01Uu1irB94yD2XUwFBRvvepi","name":"Read","title":"Read internal/okf/official_integration_test.go","input":{"file_path":"/Users/pedronauck/Dev/compozy/kb/internal/okf/official_integration_test.go"},"raw_input":{"file_path":"/Users/pedronauck/Dev/compozy/kb/internal/okf/official_integration_test.go"}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:21:09.934587Z","turn":1,"type":"session.update","update":{"kind":"tool_call_started","tool_call_id":"toolu_01AsLfitjtL2fChdRLY7vZUW","tool_call_state":"pending","blocks":[{"type":"tool_use","id":"toolu_01AsLfitjtL2fChdRLY7vZUW","name":"Read","title":"Read File","raw_input":{}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:21:09.944359Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01Uu1irB94yD2XUwFBRvvepi","usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:21:09.946326Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01Uu1irB94yD2XUwFBRvvepi","tool_call_state":"completed","blocks":[{"type":"tool_result","tool_use_id":"toolu_01Uu1irB94yD2XUwFBRvvepi","content":"```\n1\t//go:build integration\n2\t\n3\tpackage okf\n4\t\n5\timport (\n6\t\t\"context\"\n7\t\t\"path/filepath\"\n8\t\t\"testing\"\n9\t)\n10\t\n11\tfunc TestOfficialBundlesPassLenientConformance(t *testing.T) {\n12\t\tt.Parallel()\n13\t\n14\t\tfor _, bundle := range []string{\"ga4\", \"stackoverflow\", \"crypto_bitcoin\"} {\n15\t\t\tbundle := bundle\n16\t\t\tt.Run(bundle, func(t *testing.T) {\n17\t\t\t\tt.Parallel()\n18\t\t\t\tissues, err := Check(context.Background(), filepath.Join(\"testdata\", \"official\", bundle), CheckOptions{})\n19\t\t\t\tif err != nil {\n20\t\t\t\t\tt.Fatalf(\"Check returned error: %v\", err)\n21\t\t\t\t}\n22\t\t\t\tif HasErrors(issues) {\n23\t\t\t\t\tt.Fatalf(\"official bundle %s has error issues: %#v\", bundle, issues)\n24\t\t\t\t}\n25\t\t\t})\n26\t\t}\n27\t}\n28\t\n```"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:21:10.823188Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01AsLfitjtL2fChdRLY7vZUW","blocks":[{"type":"tool_use","id":"toolu_01AsLfitjtL2fChdRLY7vZUW","name":"Read","title":"Read internal/cli/okf_test.go","input":{"file_path":"/Users/pedronauck/Dev/compozy/kb/internal/cli/okf_test.go"},"raw_input":{"file_path":"/Users/pedronauck/Dev/compozy/kb/internal/cli/okf_test.go"}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:21:10.825719Z","turn":1,"type":"session.update","update":{"kind":"tool_call_started","tool_call_id":"toolu_01H6ikkQWdy2W696rKFEPksg","tool_call_state":"pending","blocks":[{"type":"tool_use","id":"toolu_01H6ikkQWdy2W696rKFEPksg","name":"Read","title":"Read File","raw_input":{}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:21:10.827001Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01AsLfitjtL2fChdRLY7vZUW","usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:21:10.833151Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01AsLfitjtL2fChdRLY7vZUW","tool_call_state":"completed","blocks":[{"type":"tool_result","tool_use_id":"toolu_01AsLfitjtL2fChdRLY7vZUW","content":"```\n1\tpackage cli\n2\t\n3\timport (\n4\t\t\"bytes\"\n5\t\t\"context\"\n6\t\t\"encoding/json\"\n7\t\t\"fmt\"\n8\t\t\"os\"\n9\t\t\"path/filepath\"\n10\t\t\"strings\"\n11\t\t\"testing\"\n12\t\n13\t\tkconfig \"github.com/compozy/kb/internal/config\"\n14\t\t\"github.com/compozy/kb/internal/models\"\n15\t\tkokf \"github.com/compozy/kb/internal/okf\"\n16\t)\n17\t\n18\tfunc TestPromoteCommandResolvesTargetAndPrintsJSON(t *testing.T) {\n19\t\toriginalPromote := runPromote\n20\t\toriginalTopicInfo := runPromoteTopicInfo\n21\t\tt.Cleanup(func() {\n22\t\t\trunPromote = originalPromote\n23\t\t\trunPromoteTopicInfo = originalTopicInfo\n24\t\t})\n25\t\tt.Setenv(kconfig.EnvConfigPath, writeCLIConfig(t, \"[okf]\\ntypes = [\\\"Playbook\\\"]\\n\"))\n26\t\n27\t\tvar gotInput kokf.PromoteInput\n28\t\trunPromoteTopicInfo = func(vaultPath, slug string) (models.TopicInfo, error) {\n29\t\t\treturn models.TopicInfo{\n30\t\t\t\tSlug: slug,\n31\t\t\t\tMode: models.TopicModeOKF,\n32\t\t\t\tRootPath: filepath.Join(vaultPath, slug),\n33\t\t\t}, nil\n34\t\t}\n35\t\trunPromote = func(ctx context.Context, input kokf.PromoteInput) (kokf.ConceptResult, error) {\n36\t\t\tgotInput = input\n37\t\t\treturn kokf.ConceptResult{\n38\t\t\t\tWrittenPath: \"alpha.md\",\n39\t\t\t\tType: input.Type,\n40\t\t\t\tLinksRewritten: 1,\n41\t\t\t}, nil\n42\t\t}\n43\t\n44\t\tcommand := newRootCommand()\n45\t\tvar stdout bytes.Buffer\n46\t\tcommand.SetOut(\u0026stdout)\n47\t\tcommand.SetErr(new(bytes.Buffer))\n48\t\tcommand.SetArgs([]string{\n49\t\t\t\"promote\", \"research/wiki/concepts/Alpha.md\",\n50\t\t\t\"--to\", \"catalog\",\n51\t\t\t\"--type\", \"Playbook\",\n52\t\t\t\"--description\", \"Alpha description.\",\n53\t\t\t\"--vault\", \"/tmp/vault\",\n54\t\t})\n55\t\n56\t\tif err := command.ExecuteContext(context.Background()); err != nil {\n57\t\t\tt.Fatalf(\"ExecuteContext returned error: %v\", err)\n58\t\t}\n59\t\tif gotInput.VaultPath != \"/tmp/vault\" || gotInput.TargetTopic.Slug != \"catalog\" || gotInput.Type != \"Playbook\" {\n60\t\t\tt.Fatalf(\"unexpected promote input: %#v\", gotInput)\n61\t\t}\n62\t\tif gotInput.Description != \"Alpha description.\" {\n63\t\t\tt.Fatalf(\"description = %q\", gotInput.Description)\n64\t\t}\n65\t\tif len(gotInput.Types) != 1 || gotInput.Types[0] != \"Playbook\" {\n66\t\t\tt.Fatalf(\"types = %#v, want Playbook\", gotInput.Types)\n67\t\t}\n68\t\n69\t\tvar result kokf.ConceptResult\n70\t\tif err := json.Unmarshal(stdout.Bytes(), \u0026result); err != nil {\n71\t\t\tt.Fatalf(\"stdout did not contain JSON: %v\\n%s\", err, stdout.String())\n72\t\t}\n73\t\tif result.WrittenPath != \"alpha.md\" || result.Type != \"Playbook\" {\n74\t\t\tt.Fatalf(\"unexpected result: %#v\", result)\n75\t\t}\n76\t}\n77\t\n78\tfunc TestOKFCheckCommandRendersIssuesAndFailsOnErrors(t *testing.T) {\n79\t\toriginalCheck := runOKFCheck\n80\t\toriginalTopicInfo := runOKFTopicInfo\n81\t\tt.Cleanup(func() {\n82\t\t\trunOKFCheck = originalCheck\n83\t\t\trunOKFTopicInfo = originalTopicInfo\n84\t\t})\n85\t\tt.Setenv(kconfig.EnvConfigPath, writeCLIConfig(t, \"[okf]\\ntypes = [\\\"Playbook\\\"]\\n\"))\n86\t\n87\t\trunOKFTopicInfo = func(vaultPath, slug string) (models.TopicInfo, error) {\n88\t\t\treturn models.TopicInfo{\n89\t\t\t\tSlug: slug,\n90\t\t\t\tMode: models.TopicModeOKF,\n91\t\t\t\tRootPath: filepath.Join(vaultPath, slug),\n92\t\t\t}, nil\n93\t\t}\n94\t\trunOKFCheck = func(ctx context.Context, bundlePath string, options kokf.CheckOptions) ([]models.LintIssue, error) {\n95\t\t\tif bundlePath != \"/tmp/vault/catalog\" {\n96\t\t\t\treturn nil, fmt.Errorf(\"bundle path = %q\", bundlePath)\n97\t\t\t}\n98\t\t\tif !options.Strict || len(options.Types) != 1 || options.Types[0] != \"Playbook\" {\n99\t\t\t\treturn nil, fmt.Errorf(\"unexpected options: %#v\", options)\n100\t\t\t}\n101\t\t\treturn []models.LintIssue{{\n102\t\t\t\tKind: models.LintIssueKindFormat,\n103\t\t\t\tSeverity: models.SeverityError,\n104\t\t\t\tFilePath: \"bad.md\",\n105\t\t\t\tTarget: \"type\",\n106\t\t\t\tMessage: \"missing type\",\n107\t\t\t}}, nil\n108\t\t}\n109\t\n110\t\tcommand := newRootCommand()\n111\t\tvar stdout bytes.Buffer\n112\t\tcommand.SetOut(\u0026stdout)\n113\t\tcommand.SetErr(new(bytes.Buffer))\n114\t\tcommand.SetArgs([]string{\"okf\", \"check\", \"catalog\", \"--strict\", \"--format\", \"json\", \"--vault\", \"/tmp/vault\"})\n115\t\n116\t\terr := command.ExecuteContext(context.Background())\n117\t\tif err == nil {\n118\t\t\tt.Fatal(\"expected okf check to fail on error issues\")\n119\t\t}\n120\t\tif !strings.Contains(err.Error(), \"found 1 issue\") {\n121\t\t\tt.Fatalf(\"unexpected error: %v\", err)\n122\t\t}\n123\t\tif !strings.Contains(stdout.String(), `\"filePath\": \"bad.md\"`) {\n124\t\t\tt.Fatalf(\"stdout missing issue JSON:\\n%s\", stdout.String())\n125\t\t}\n126\t}\n127\t\n128\tfunc writeCLIConfig(t *testing.T, content string) string {\n129\t\tt.Helper()\n130\t\tpath := filepath.Join(t.TempDir(), \"kb.toml\")\n131\t\tif err := os.WriteFile(path, []byte(content), 0o644); err != nil {\n132\t\t\tt.Fatalf(\"write config: %v\", err)\n133\t\t}\n134\t\treturn path\n135\t}\n136\t\n```"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:21:10.993988Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01H6ikkQWdy2W696rKFEPksg","blocks":[{"type":"tool_use","id":"toolu_01H6ikkQWdy2W696rKFEPksg","name":"Read","title":"Read internal/cli/okf_integration_test.go","input":{"file_path":"/Users/pedronauck/Dev/compozy/kb/internal/cli/okf_integration_test.go"},"raw_input":{"file_path":"/Users/pedronauck/Dev/compozy/kb/internal/cli/okf_integration_test.go"}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:21:10.997584Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01H6ikkQWdy2W696rKFEPksg","usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:21:11.003428Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01H6ikkQWdy2W696rKFEPksg","tool_call_state":"completed","blocks":[{"type":"tool_result","tool_use_id":"toolu_01H6ikkQWdy2W696rKFEPksg","content":"```\n1\t//go:build integration\n2\t\n3\tpackage cli\n4\t\n5\timport (\n6\t\t\"encoding/json\"\n7\t\t\"os\"\n8\t\t\"path/filepath\"\n9\t\t\"strings\"\n10\t\t\"testing\"\n11\t\n12\t\t\"github.com/compozy/kb/internal/frontmatter\"\n13\t\t\"github.com/compozy/kb/internal/models\"\n14\t\tkokf \"github.com/compozy/kb/internal/okf\"\n15\t)\n16\t\n17\tfunc TestCLIIntegrationOKFPromoteAndCheck(t *testing.T) {\n18\t\tvaultRoot := t.TempDir()\n19\t\twikiTopic := scaffoldTopicForIntegration(t, vaultRoot, \"research\", \"Research\", \"ops\")\n20\t\tokfTopic := runCLIJSON[models.TopicInfo](t,\n21\t\t\t\"topic\", \"new\", \"catalog\", \"Catalog\", \"ops\",\n22\t\t\t\"--mode\", \"okf\",\n23\t\t\t\"--vault\", vaultRoot,\n24\t\t)\n25\t\tif okfTopic.Mode != models.TopicModeOKF {\n26\t\t\tt.Fatalf(\"OKF topic mode = %q, want okf\", okfTopic.Mode)\n27\t\t}\n28\t\n29\t\tsourcePath := filepath.Join(wikiTopic.RootPath, \"wiki\", \"concepts\", \"Alpha Note.md\")\n30\t\twriteFile(t, sourcePath, strings.Join([]string{\n31\t\t\t\"---\",\n32\t\t\t\"title: Alpha Note\",\n33\t\t\t\"type: wiki\",\n34\t\t\t\"stage: compiled\",\n35\t\t\t\"tags: [ops]\",\n36\t\t\t\"---\",\n37\t\t\t\"Alpha note explains the operations flow. See [[research/wiki/concepts/Beta Note|Beta]].\",\n38\t\t}, \"\\n\"))\n39\t\n40\t\tpromoteOutput := runCLI(t,\n41\t\t\t\"promote\", sourcePath,\n42\t\t\t\"--to\", okfTopic.Slug,\n43\t\t\t\"--type\", \"Playbook\",\n44\t\t\t\"--description\", \"Operational flow.\",\n45\t\t\t\"--vault\", vaultRoot,\n46\t\t)\n47\t\tvar result kokf.ConceptResult\n48\t\tif err := json.Unmarshal([]byte(promoteOutput), \u0026result); err != nil {\n49\t\t\tt.Fatalf(\"promote output is not JSON: %v\\n%s\", err, promoteOutput)\n50\t\t}\n51\t\tif result.WrittenPath != \"alpha-note.md\" {\n52\t\t\tt.Fatalf(\"written path = %q, want alpha-note.md\", result.WrittenPath)\n53\t\t}\n54\t\n55\t\tsourceAfter := readFile(t, sourcePath)\n56\t\tif !strings.Contains(sourceAfter, \"stage: compiled\") {\n57\t\t\tt.Fatalf(\"source was unexpectedly changed:\\n%s\", sourceAfter)\n58\t\t}\n59\t\tconceptPath := filepath.Join(okfTopic.RootPath, \"alpha-note.md\")\n60\t\tvalues, body := readMarkdownDocument(t, conceptPath)\n61\t\tif values[\"type\"] != \"Playbook\" || values[\"description\"] != \"Operational flow.\" {\n62\t\t\tt.Fatalf(\"unexpected concept frontmatter: %#v\", values)\n63\t\t}\n64\t\tif !strings.Contains(body, \"[Beta](beta-note.md)\") {\n65\t\t\tt.Fatalf(\"body missing relative OKF link:\\n%s\", body)\n66\t\t}\n67\t\tif _, ok := values[\"stage\"]; ok {\n68\t\t\tt.Fatalf(\"wiki stage leaked into OKF concept: %#v\", values)\n69\t\t}\n70\t\n71\t\tcheckOutput := runCLI(t, \"okf\", \"check\", okfTopic.Slug, \"--format\", \"json\", \"--vault\", vaultRoot)\n72\t\tvar issues []models.LintIssue\n73\t\tif err := json.Unmarshal([]byte(checkOutput), \u0026issues); err != nil {\n74\t\t\tt.Fatalf(\"check output is not JSON: %v\\n%s\", err, checkOutput)\n75\t\t}\n76\t\tif len(issues) != 0 {\n77\t\t\tt.Fatalf(\"freshly promoted bundle has issues: %#v\", issues)\n78\t\t}\n79\t\n80\t\tindex := readFile(t, filepath.Join(okfTopic.RootPath, \"index.md\"))\n81\t\tif !strings.Contains(index, \"## Playbook\") || !strings.Contains(index, \"[Alpha Note](alpha-note.md)\") {\n82\t\t\tt.Fatalf(\"index.md missing promoted concept:\\n%s\", index)\n83\t\t}\n84\t\tlogContent := readFile(t, filepath.Join(okfTopic.RootPath, \"log.md\"))\n85\t\tif !strings.Contains(logContent, \"## \"+frontmatter.DateLayout[:4]) \u0026\u0026 !strings.Contains(logContent, \"**Creation**\") {\n86\t\t\tt.Fatalf(\"log.md missing promotion entry:\\n%s\", logContent)\n87\t\t}\n88\t}\n89\t\n90\tfunc TestCLIIntegrationOKFCheckFailsStrictWarnings(t *testing.T) {\n91\t\tvaultRoot := t.TempDir()\n92\t\tokfTopic := runCLIJSON[models.TopicInfo](t,\n93\t\t\t\"topic\", \"new\", \"catalog\", \"Catalog\", \"ops\",\n94\t\t\t\"--mode\", \"okf\",\n95\t\t\t\"--vault\", vaultRoot,\n96\t\t)\n97\t\twriteFile(t, filepath.Join(okfTopic.RootPath, \"concept.md\"), \"---\\ntype: Unknown\\n---\\nBody.\\n\")\n98\t\n99\t\terrText := runCLIError(t, \"okf\", \"check\", okfTopic.Slug, \"--strict\", \"--vault\", vaultRoot)\n100\t\tif !strings.Contains(errText, \"found\") {\n101\t\t\tt.Fatalf(\"strict check error = %q\", errText)\n102\t\t}\n103\t}\n104\t\n105\tfunc readFile(t *testing.T, filePath string) string {\n106\t\tt.Helper()\n107\t\tcontent, err := os.ReadFile(filePath)\n108\t\tif err != nil {\n109\t\t\tt.Fatalf(\"read %s: %v\", filePath, err)\n110\t\t}\n111\t\treturn string(content)\n112\t}\n113\t\n```"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:21:25.401246Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":""}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:21:25.402824Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":"Let me verify the helper functions these depend on actually exist with"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:21:25.90942Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":" the signatures used."}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:21:25.91002Z","turn":1,"type":"session.update","update":{"kind":"tool_call_started","tool_call_id":"toolu_0176VgV8jLoxLKgCCBm2Yr45","tool_call_state":"pending","blocks":[{"type":"tool_use","id":"toolu_0176VgV8jLoxLKgCCBm2Yr45","name":"Read","title":"Read File","raw_input":{}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:21:27.227737Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_0176VgV8jLoxLKgCCBm2Yr45","blocks":[{"type":"tool_use","id":"toolu_0176VgV8jLoxLKgCCBm2Yr45","name":"Read","title":"Read internal/frontmatter/frontmatter.go","input":{"file_path":"/Users/pedronauck/Dev/compozy/kb/internal/frontmatter/frontmatter.go"},"raw_input":{"file_path":"/Users/pedronauck/Dev/compozy/kb/internal/frontmatter/frontmatter.go"}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:21:27.234038Z","turn":1,"type":"session.update","update":{"kind":"tool_call_started","tool_call_id":"toolu_01HeoMFEpUeH2uZttWYrHMHs","tool_call_state":"pending","blocks":[{"type":"tool_use","id":"toolu_01HeoMFEpUeH2uZttWYrHMHs","name":"Read","title":"Read File","raw_input":{}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:21:27.239676Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_0176VgV8jLoxLKgCCBm2Yr45","usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:21:27.253955Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_0176VgV8jLoxLKgCCBm2Yr45","tool_call_state":"completed","blocks":[{"type":"tool_result","tool_use_id":"toolu_0176VgV8jLoxLKgCCBm2Yr45","content":"```\n1\t// Package frontmatter provides helpers for parsing and generating YAML frontmatter in markdown documents.\n2\tpackage frontmatter\n3\t\n4\timport (\n5\t\t\"fmt\"\n6\t\t\"reflect\"\n7\t\t\"regexp\"\n8\t\t\"sort\"\n9\t\t\"strconv\"\n10\t\t\"strings\"\n11\t\t\"time\"\n12\t\n13\t\t\"gopkg.in/yaml.v3\"\n14\t)\n15\t\n16\tconst (\n17\t\t// DateLayout matches the KB schema date fields.\n18\t\tDateLayout = \"2006-01-02\"\n19\t)\n20\t\n21\t// ErrorKind identifies the failure class for frontmatter operations.\n22\ttype ErrorKind string\n23\t\n24\tconst (\n25\t\tErrorKindMissingClosingDelimiter ErrorKind = \"missing_closing_delimiter\"\n26\t\tErrorKindInvalidYAML ErrorKind = \"invalid_yaml\"\n27\t\tErrorKindUnsupportedValue ErrorKind = \"unsupported_value\"\n28\t)\n29\t\n30\tvar yamlLinePattern = regexp.MustCompile(`line (\\d+)`)\n31\t\n32\t// Error reports structured parse and generation failures.\n33\ttype Error struct {\n34\t\tKind ErrorKind\n35\t\tKey string\n36\t\tLine int\n37\t\tErr error\n38\t}\n39\t\n40\tfunc (e *Error) Error() string {\n41\t\tif e == nil {\n42\t\t\treturn \"\u003cnil\u003e\"\n43\t\t}\n44\t\n45\t\tparts := []string{string(e.Kind)}\n46\t\tif e.Key != \"\" {\n47\t\t\tparts = append(parts, \"key=\"+strconv.Quote(e.Key))\n48\t\t}\n49\t\tif e.Line \u003e 0 {\n50\t\t\tparts = append(parts, fmt.Sprintf(\"line=%d\", e.Line))\n51\t\t}\n52\t\tif e.Err != nil {\n53\t\t\tparts = append(parts, e.Err.Error())\n54\t\t}\n55\t\n56\t\treturn \"frontmatter: \" + strings.Join(parts, \": \")\n57\t}\n58\t\n59\t// Unwrap returns the wrapped error.\n60\tfunc (e *Error) Unwrap() error {\n61\t\tif e == nil {\n62\t\t\treturn nil\n63\t\t}\n64\t\n65\t\treturn e.Err\n66\t}\n67\t\n68\t// Parse extracts YAML frontmatter from markdown content and returns the decoded\n69\t// metadata plus the remaining markdown body. When the markdown has no leading\n70\t// frontmatter block, Parse returns an empty map and the original body.\n71\tfunc Parse(markdown string) (map[string]any, string, error) {\n72\t\tif !hasOpeningDelimiter(markdown) {\n73\t\t\treturn map[string]any{}, markdown, nil\n74\t\t}\n75\t\n76\t\tsource, body, found := splitFrontmatter(markdown)\n77\t\tif !found {\n78\t\t\treturn nil, \"\", \u0026Error{\n79\t\t\t\tKind: ErrorKindMissingClosingDelimiter,\n80\t\t\t\tErr: fmt.Errorf(\"missing closing frontmatter delimiter\"),\n81\t\t\t}\n82\t\t}\n83\t\n84\t\tif strings.TrimSpace(source) == \"\" {\n85\t\t\treturn map[string]any{}, body, nil\n86\t\t}\n87\t\n88\t\tvar values map[string]any\n89\t\tif err := yaml.Unmarshal([]byte(source), \u0026values); err != nil {\n90\t\t\treturn nil, \"\", \u0026Error{\n91\t\t\t\tKind: ErrorKindInvalidYAML,\n92\t\t\t\tLine: extractYAMLLine(err),\n93\t\t\t\tErr: err,\n94\t\t\t}\n95\t\t}\n96\t\n97\t\treturn normalizeMap(values), body, nil\n98\t}\n99\t\n100\t// Generate encodes frontmatter and prepends it to body. When values is empty,\n101\t// Generate returns body unchanged.\n102\tfunc Generate(values map[string]any, body string) (string, error) {\n103\t\tif len(values) == 0 {\n104\t\t\treturn body, nil\n105\t\t}\n106\t\n107\t\tnode, err := buildNode(values, \"\")\n108\t\tif err != nil {\n109\t\t\treturn \"\", err\n110\t\t}\n111\t\n112\t\tencoded, err := yaml.Marshal(node)\n113\t\tif err != nil {\n114\t\t\treturn \"\", fmt.Errorf(\"marshal frontmatter: %w\", err)\n115\t\t}\n116\t\n117\t\treturn \"---\\n\" + strings.TrimRight(string(encoded), \"\\n\") + \"\\n---\\n\" + body, nil\n118\t}\n119\t\n120\t// GetString returns the string value for key, or the zero value when missing.\n121\tfunc GetString(values map[string]any, key string) string {\n122\t\tvalue, exists := values[key]\n123\t\tif !exists {\n124\t\t\treturn \"\"\n125\t\t}\n126\t\n127\t\tswitch typed := value.(type) {\n128\t\tcase string:\n129\t\t\treturn typed\n130\t\tcase fmt.Stringer:\n131\t\t\treturn typed.String()\n132\t\tdefault:\n133\t\t\treturn fmt.Sprint(typed)\n134\t\t}\n135\t}\n136\t\n137\t// GetStringSlice returns the string slice value for key, or nil when missing.\n138\tfunc GetStringSlice(values map[string]any, key string) []string {\n139\t\tvalue, exists := values[key]\n140\t\tif !exists {\n141\t\t\treturn nil\n142\t\t}\n143\t\n144\t\tswitch typed := value.(type) {\n145\t\tcase []string:\n146\t\t\treturn append([]string(nil), typed...)\n147\t\tcase []any:\n148\t\t\titems := make([]string, 0, len(typed))\n149\t\t\tfor _, item := range typed {\n150\t\t\t\ttext, ok := item.(string)\n151\t\t\t\tif !ok {\n152\t\t\t\t\treturn nil\n153\t\t\t\t}\n154\t\t\t\titems = append(items, text)\n155\t\t\t}\n156\t\t\treturn items\n157\t\tdefault:\n158\t\t\treturn nil\n159\t\t}\n160\t}\n161\t\n162\t// GetTime returns the time value for key, or the zero time when missing.\n163\tfunc GetTime(values map[string]any, key string) time.Time {\n164\t\tvalue, exists := values[key]\n165\t\tif !exists {\n166\t\t\treturn time.Time{}\n167\t\t}\n168\t\n169\t\tswitch typed := value.(type) {\n170\t\tcase time.Time:\n171\t\t\treturn typed\n172\t\tcase string:\n173\t\t\tfor _, layout := range []string{DateLayout, time.RFC3339, time.RFC3339Nano} {\n174\t\t\t\tparsed, err := time.Parse(layout, typed)\n175\t\t\t\tif err == nil {\n176\t\t\t\t\treturn parsed\n177\t\t\t\t}\n178\t\t\t}\n179\t\t\treturn time.Time{}\n180\t\tdefault:\n181\t\t\treturn time.Time{}\n182\t\t}\n183\t}\n184\t\n185\t// GetBool returns the bool value for key, or false when missing.\n186\tfunc GetBool(values map[string]any, key string) bool {\n187\t\tvalue, exists := values[key]\n188\t\tif !exists {\n189\t\t\treturn false\n190\t\t}\n191\t\n192\t\tswitch typed := value.(type) {\n193\t\tcase bool:\n194\t\t\treturn typed\n195\t\tcase string:\n196\t\t\tparsed, err := strconv.ParseBool(typed)\n197\t\t\tif err == nil {\n198\t\t\t\treturn parsed\n199\t\t\t}\n200\t\t\treturn false\n201\t\tdefault:\n202\t\t\treturn false\n203\t\t}\n204\t}\n205\t\n206\tfunc hasOpeningDelimiter(markdown string) bool {\n207\t\treturn strings.HasPrefix(markdown, \"---\\n\") || strings.HasPrefix(markdown, \"---\\r\\n\") || markdown == \"---\"\n208\t}\n209\t\n210\tfunc splitFrontmatter(markdown string) (string, string, bool) {\n211\t\tstart := firstLineEnd(markdown)\n212\t\tif start \u003c 0 {\n213\t\t\treturn \"\", \"\", false\n214\t\t}\n215\t\n216\t\tlineStart := start\n217\t\tfor lineStart \u003c= len(markdown) {\n218\t\t\tlineEnd, next := lineBounds(markdown, lineStart)\n219\t\t\tif strings.TrimSuffix(markdown[lineStart:lineEnd], \"\\r\") == \"---\" {\n220\t\t\t\treturn markdown[start:lineStart], markdown[next:], true\n221\t\t\t}\n222\t\t\tif next == len(markdown) {\n223\t\t\t\tbreak\n224\t\t\t}\n225\t\t\tlineStart = next\n226\t\t}\n227\t\n228\t\treturn \"\", \"\", false\n229\t}\n230\t\n231\tfunc firstLineEnd(markdown string) int {\n232\t\tif strings.HasPrefix(markdown, \"---\\r\\n\") {\n233\t\t\treturn len(\"---\\r\\n\")\n234\t\t}\n235\t\tif strings.HasPrefix(markdown, \"---\\n\") {\n236\t\t\treturn len(\"---\\n\")\n237\t\t}\n238\t\treturn -1\n239\t}\n240\t\n241\tfunc lineBounds(markdown string, start int) (int, int) {\n242\t\tif start \u003e= len(markdown) {\n243\t\t\treturn len(markdown), len(markdown)\n244\t\t}\n245\t\n246\t\tindex := strings.IndexByte(markdown[start:], '\\n')\n247\t\tif index \u003c 0 {\n248\t\t\treturn len(markdown), len(markdown)\n249\t\t}\n250\t\n251\t\tlineEnd := start + index\n252\t\treturn lineEnd, lineEnd + 1\n253\t}\n254\t\n255\tfunc normalizeMap(values map[string]any) map[string]any {\n256\t\tif len(values) == 0 {\n257\t\t\treturn map[string]any{}\n258\t\t}\n259\t\n260\t\tnormalized := make(map[string]any, len(values))\n261\t\tfor key, value := range values {\n262\t\t\tnormalized[key] = normalizeValue(value)\n263\t\t}\n264\t\n265\t\treturn normalized\n266\t}\n267\t\n268\tfunc normalizeValue(value any) any {\n269\t\tswitch typed := value.(type) {\n270\t\tcase map[string]any:\n271\t\t\treturn normalizeMap(typed)\n272\t\tcase map[any]any:\n273\t\t\tnormalized := make(map[string]any, len(typed))\n274\t\t\tfor key, nested := range typed {\n275\t\t\t\tnormalized[fmt.Sprint(key)] = normalizeValue(nested)\n276\t\t\t}\n277\t\t\treturn normalized\n278\t\tcase []any:\n279\t\t\tvalues := make([]any, len(typed))\n280\t\t\ttexts := make([]string, 0, len(typed))\n281\t\t\tallStrings := true\n282\t\n283\t\t\tfor index, item := range typed {\n284\t\t\t\tnormalized := normalizeValue(item)\n285\t\t\t\tvalues[index] = normalized\n286\t\n287\t\t\t\ttext, ok := normalized.(string)\n288\t\t\t\tif !ok {\n289\t\t\t\t\tallStrings = false\n290\t\t\t\t\tcontinue\n291\t\t\t\t}\n292\t\t\t\ttexts = append(texts, text)\n293\t\t\t}\n294\t\n295\t\t\tif allStrings {\n296\t\t\t\treturn texts\n297\t\t\t}\n298\t\n299\t\t\treturn values\n300\t\tdefault:\n301\t\t\treturn typed\n302\t\t}\n303\t}\n304\t\n305\tfunc buildNode(value any, key string) (*yaml.Node, error) {\n306\t\tif value == nil {\n307\t\t\treturn \u0026yaml.Node{Kind: yaml.ScalarNode, Tag: \"!!null\", Value: \"null\"}, nil\n308\t\t}\n309\t\n310\t\tswitch typed := value.(type) {\n311\t\tcase string:\n312\t\t\treturn \u0026yaml.Node{Kind: yaml.ScalarNode, Tag: \"!!str\", Value: typed}, nil\n313\t\tcase bool:\n314\t\t\treturn \u0026yaml.Node{Kind: yaml.ScalarNode, Tag: \"!!bool\", Value: strconv.FormatBool(typed)}, nil\n315\t\tcase time.Time:\n316\t\t\treturn \u0026yaml.Node{Kind: yaml.ScalarNode, Tag: \"!!timestamp\", Value: formatTime(typed)}, nil\n317\t\tcase map[string]any:\n318\t\t\treturn buildMappingNode(typed)\n319\t\tcase []string:\n320\t\t\tnode := \u0026yaml.Node{Kind: yaml.SequenceNode, Tag: \"!!seq\"}\n321\t\t\tfor _, item := range typed {\n322\t\t\t\tchild, err := buildNode(item, key)\n323\t\t\t\tif err != nil {\n324\t\t\t\t\treturn nil, err\n325\t\t\t\t}\n326\t\t\t\tnode.Content = append(node.Content, child)\n327\t\t\t}\n328\t\t\treturn node, nil\n329\t\tcase []any:\n330\t\t\tnode := \u0026yaml.Node{Kind: yaml.SequenceNode, Tag: \"!!seq\"}\n331\t\t\tfor _, item := range typed {\n332\t\t\t\tchild, err := buildNode(item, key)\n333\t\t\t\tif err != nil {\n334\t\t\t\t\treturn nil, err\n335\t\t\t\t}\n336\t\t\t\tnode.Content = append(node.Content, child)\n337\t\t\t}\n338\t\t\treturn node, nil\n339\t\t}\n340\t\n341\t\trv := reflect.ValueOf(value)\n342\t\tif !rv.IsValid() {\n343\t\t\treturn \u0026yaml.Node{Kind: yaml.ScalarNode, Tag: \"!!null\", Value: \"null\"}, nil\n344\t\t}\n345\t\n346\t\tswitch rv.Kind() {\n347\t\tcase reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:\n348\t\t\treturn \u0026yaml.Node{Kind: yaml.ScalarNode, Tag: \"!!int\", Value: strconv.FormatInt(rv.Int(), 10)}, nil\n349\t\tcase reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr:\n350\t\t\treturn \u0026yaml.Node{Kind: yaml.ScalarNode, Tag: \"!!int\", Value: strconv.FormatUint(rv.Uint(), 10)}, nil\n351\t\tcase reflect.Float32, reflect.Float64:\n352\t\t\treturn \u0026yaml.Node{\n353\t\t\t\tKind: yaml.ScalarNode,\n354\t\t\t\tTag: \"!!float\",\n355\t\t\t\tValue: strconv.FormatFloat(rv.Float(), 'f', -1, 64),\n356\t\t\t}, nil\n357\t\tcase reflect.Map:\n358\t\t\tif rv.Type().Key().Kind() != reflect.String {\n359\t\t\t\treturn nil, \u0026Error{\n360\t\t\t\t\tKind: ErrorKindUnsupportedValue,\n361\t\t\t\t\tKey: key,\n362\t\t\t\t\tErr: fmt.Errorf(\"map keys must be strings, got %s\", rv.Type().Key()),\n363\t\t\t\t}\n364\t\t\t}\n365\t\n366\t\t\tvalues := make(map[string]any, rv.Len())\n367\t\t\tfor _, mapKey := range rv.MapKeys() {\n368\t\t\t\tvalues[mapKey.String()] = rv.MapIndex(mapKey).Interface()\n369\t\t\t}\n370\t\t\treturn buildMappingNode(values)\n371\t\tcase reflect.Slice, reflect.Array:\n372\t\t\tnode := \u0026yaml.Node{Kind: yaml.SequenceNode, Tag: \"!!seq\"}\n373\t\t\tfor index := 0; index \u003c rv.Len(); index++ {\n374\t\t\t\tchild, err := buildNode(rv.Index(index).Interface(), key)\n375\t\t\t\tif err != nil {\n376\t\t\t\t\treturn nil, err\n377\t\t\t\t}\n378\t\t\t\tnode.Content = append(node.Content, child)\n379\t\t\t}\n380\t\t\treturn node, nil\n381\t\tdefault:\n382\t\t\treturn nil, \u0026Error{\n383\t\t\t\tKind: ErrorKindUnsupportedValue,\n384\t\t\t\tKey: key,\n385\t\t\t\tErr: fmt.Errorf(\"unsupported value type %T\", value),\n386\t\t\t}\n387\t\t}\n388\t}\n389\t\n390\tfunc buildMappingNode(values map[string]any) (*yaml.Node, error) {\n391\t\tnode := \u0026yaml.Node{Kind: yaml.MappingNode, Tag: \"!!map\"}\n392\t\tkeys := make([]string, 0, len(values))\n393\t\tfor key := range values {\n394\t\t\tkeys = append(keys, key)\n395\t\t}\n396\t\tsort.Strings(keys)\n397\t\n398\t\tfor _, key := range keys {\n399\t\t\tvalueNode, err := buildNode(values[key], key)\n400\t\t\tif err != nil {\n401\t\t\t\treturn nil, err\n402\t\t\t}\n403\t\t\tnode.Content = append(node.Content,\n404\t\t\t\t\u0026yaml.Node{Kind: yaml.ScalarNode, Tag: \"!!str\", Value: key},\n405\t\t\t\tvalueNode,\n406\t\t\t)\n407\t\t}\n408\t\n409\t\treturn node, nil\n410\t}\n411\t\n412\tfunc formatTime(value time.Time) string {\n413\t\tif value.Hour() == 0 \u0026\u0026 value.Minute() == 0 \u0026\u0026 value.Second() == 0 \u0026\u0026 value.Nanosecond() == 0 {\n414\t\t\treturn value.Format(DateLayout)\n415\t\t}\n416\t\n417\t\treturn value.Format(time.RFC3339Nano)\n418\t}\n419\t\n420\tfunc extractYAMLLine(err error) int {\n421\t\tmatches := yamlLinePattern.FindStringSubmatch(err.Error())\n422\t\tif len(matches) != 2 {\n423\t\t\treturn 0\n424\t\t}\n425\t\n426\t\tline, convErr := strconv.Atoi(matches[1])\n427\t\tif convErr != nil {\n428\t\t\treturn 0\n429\t\t}\n430\t\n431\t\treturn line\n432\t}\n433\t\n```"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:21:27.340011Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01HeoMFEpUeH2uZttWYrHMHs","blocks":[{"type":"tool_use","id":"toolu_01HeoMFEpUeH2uZttWYrHMHs","name":"Read","title":"Read internal/vault/pathutils.go","input":{"file_path":"/Users/pedronauck/Dev/compozy/kb/internal/vault/pathutils.go"},"raw_input":{"file_path":"/Users/pedronauck/Dev/compozy/kb/internal/vault/pathutils.go"}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:21:27.348912Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01HeoMFEpUeH2uZttWYrHMHs","usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:21:27.355368Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01HeoMFEpUeH2uZttWYrHMHs","tool_call_state":"completed","blocks":[{"type":"tool_result","tool_use_id":"toolu_01HeoMFEpUeH2uZttWYrHMHs","content":"```\n1\t// Package vault provides path resolution, document rendering, reading, writing, and query helpers for Obsidian-compatible knowledge vaults.\n2\tpackage vault\n3\t\n4\timport (\n5\t\t\"fmt\"\n6\t\t\"path\"\n7\t\t\"path/filepath\"\n8\t\t\"regexp\"\n9\t\t\"strings\"\n10\t\n11\t\t\"github.com/compozy/kb/internal/models\"\n12\t)\n13\t\n14\tvar windowsDriveRootPattern = regexp.MustCompile(`^[A-Za-z]:/?$`)\n15\t\n16\tconst (\n17\t\tCodebaseDashboardTitle = \"Codebase Dashboard\"\n18\t\tCodebaseConceptIndexTitle = \"Codebase Concept Index\"\n19\t\tCodebaseSourceIndexTitle = \"Codebase Source Index\"\n20\t\tTopicDashboardTitle = \"Dashboard\"\n21\t\tTopicConceptIndexTitle = \"Concept Index\"\n22\t\tTopicSourceIndexTitle = \"Source Index\"\n23\t\tlegacyWikiConceptPrefix = \"Kodebase - \"\n24\t)\n25\t\n26\t// ToPosixPath normalizes path separators to forward slashes and trims trailing slashes.\n27\tfunc ToPosixPath(value string) string {\n28\t\tif value == \"\" {\n29\t\t\treturn \"\"\n30\t\t}\n31\t\n32\t\tnormalized := strings.ReplaceAll(value, `\\`, `/`)\n33\t\tif windowsDriveRootPattern.MatchString(normalized) {\n34\t\t\treturn normalized[:2] + \"/\"\n35\t\t}\n36\t\n37\t\tfor len(normalized) \u003e 1 \u0026\u0026 strings.HasSuffix(normalized, \"/\") {\n38\t\t\tnormalized = strings.TrimSuffix(normalized, \"/\")\n39\t\t}\n40\t\n41\t\tif normalized == \"\" {\n42\t\t\treturn \"/\"\n43\t\t}\n44\t\n45\t\treturn normalized\n46\t}\n47\t\n48\t// IsPathInside reports whether targetPath is the same as or nested under parentPath.\n49\tfunc IsPathInside(parentPath, targetPath string) bool {\n50\t\tparent := cleanComparablePath(parentPath)\n51\t\ttarget := cleanComparablePath(targetPath)\n52\t\n53\t\tswitch {\n54\t\tcase parent == \"\":\n55\t\t\treturn target == \"\"\n56\t\tcase target == \"\":\n57\t\t\treturn false\n58\t\t}\n59\t\n60\t\tif parent == target {\n61\t\t\treturn true\n62\t\t}\n63\t\n64\t\tparentDrive, parentParts, parentAbsolute := splitComparablePath(parent)\n65\t\ttargetDrive, targetParts, targetAbsolute := splitComparablePath(target)\n66\t\n67\t\tif parentAbsolute != targetAbsolute {\n68\t\t\treturn false\n69\t\t}\n70\t\n71\t\tif !strings.EqualFold(parentDrive, targetDrive) {\n72\t\t\treturn false\n73\t\t}\n74\t\n75\t\tif len(parentParts) \u003e len(targetParts) {\n76\t\t\treturn false\n77\t\t}\n78\t\n79\t\tfor index := range parentParts {\n80\t\t\tif parentParts[index] != targetParts[index] {\n81\t\t\t\treturn false\n82\t\t\t}\n83\t\t}\n84\t\n85\t\treturn true\n86\t}\n87\t\n88\t// CreateFileID creates a stable file node identifier from a file path.\n89\tfunc CreateFileID(filePath string) string {\n90\t\treturn \"file:\" + ToPosixPath(filePath)\n91\t}\n92\t\n93\t// CreateExternalID creates a stable external node identifier from a module source.\n94\tfunc CreateExternalID(source string) string {\n95\t\treturn \"external:\" + source\n96\t}\n97\t\n98\t// SlugifySegment converts a free-form segment into a filesystem-friendly slug.\n99\tfunc SlugifySegment(value string) string {\n100\t\ttrimmed := strings.TrimSpace(strings.ToLower(value))\n101\t\tif trimmed == \"\" {\n102\t\t\treturn \"item\"\n103\t\t}\n104\t\n105\t\tvar builder strings.Builder\n106\t\tlastWasDash := false\n107\t\n108\t\tfor _, r := range trimmed {\n109\t\t\tif (r \u003e= 'a' \u0026\u0026 r \u003c= 'z') || (r \u003e= '0' \u0026\u0026 r \u003c= '9') {\n110\t\t\t\tbuilder.WriteRune(r)\n111\t\t\t\tlastWasDash = false\n112\t\t\t\tcontinue\n113\t\t\t}\n114\t\n115\t\t\tif lastWasDash {\n116\t\t\t\tcontinue\n117\t\t\t}\n118\t\n119\t\t\tbuilder.WriteByte('-')\n120\t\t\tlastWasDash = true\n121\t\t}\n122\t\n123\t\tslug := strings.Trim(builder.String(), \"-\")\n124\t\tif slug == \"\" {\n125\t\t\treturn \"item\"\n126\t\t}\n127\t\n128\t\treturn slug\n129\t}\n130\t\n131\t// HumanizeSlug converts a hyphenated slug into a title-cased label.\n132\tfunc HumanizeSlug(value string) string {\n133\t\tif value == \"\" {\n134\t\t\treturn \"\"\n135\t\t}\n136\t\n137\t\tsegments := strings.Split(value, \"-\")\n138\t\thumanized := make([]string, 0, len(segments))\n139\t\n140\t\tfor _, segment := range segments {\n141\t\t\tif segment == \"\" {\n142\t\t\t\tcontinue\n143\t\t\t}\n144\t\n145\t\t\thumanized = append(humanized, strings.ToUpper(segment[:1])+segment[1:])\n146\t\t}\n147\t\n148\t\treturn strings.Join(humanized, \" \")\n149\t}\n150\t\n151\t// DeriveTopicSlug derives the topic slug from a root path.\n152\tfunc DeriveTopicSlug(rootPath string) string {\n153\t\tnormalized := ToPosixPath(rootPath)\n154\t\tif normalized == \"\" {\n155\t\t\treturn SlugifySegment(\"\")\n156\t\t}\n157\t\n158\t\tbase := strings.TrimRight(normalized, \"/\")\n159\t\tif base == \"\" {\n160\t\t\tbase = normalized\n161\t\t}\n162\t\n163\t\treturn SlugifySegment(path.Base(base))\n164\t}\n165\t\n166\t// DeriveTopicTitle converts a topic slug into a human-readable title.\n167\tfunc DeriveTopicTitle(topicSlug string) string {\n168\t\tif title := HumanizeSlug(topicSlug); title != \"\" {\n169\t\t\treturn title\n170\t\t}\n171\t\n172\t\treturn \"Knowledge Base\"\n173\t}\n174\t\n175\t// DeriveTopicDomain returns the topic domain identifier for a topic slug.\n176\tfunc DeriveTopicDomain(topicSlug string) string {\n177\t\treturn topicSlug\n178\t}\n179\t\n180\t// CreateSymbolID creates a stable symbol identifier from a symbol node.\n181\tfunc CreateSymbolID(symbol models.SymbolNode) string {\n182\t\treturn strings.Join([]string{\n183\t\t\t\"symbol\",\n184\t\t\tToPosixPath(symbol.FilePath),\n185\t\t\tSlugifySegment(symbol.Name),\n186\t\t\tSlugifySegment(symbol.SymbolKind),\n187\t\t\tfmt.Sprintf(\"%d\", symbol.StartLine),\n188\t\t\tfmt.Sprintf(\"%d\", symbol.EndLine),\n189\t\t}, \":\")\n190\t}\n191\t\n192\t// GetRawFileDocumentPath derives the vault document path for a raw file snapshot.\n193\tfunc GetRawFileDocumentPath(filePath string) string {\n194\t\treturn fmt.Sprintf(\"raw/codebase/files/%s.md\", normalizeDocumentPathSegment(filePath))\n195\t}\n196\t\n197\t// GetRawSymbolDocumentPath derives the vault document path for a raw symbol snapshot.\n198\tfunc GetRawSymbolDocumentPath(symbol models.SymbolNode) string {\n199\t\tfileSlug := SlugifySegment(strings.ReplaceAll(ToPosixPath(symbol.FilePath), \".\", \"-\"))\n200\t\tsymbolSlug := fmt.Sprintf(\"%s--%s-l%d\", SlugifySegment(symbol.Name), fileSlug, symbol.StartLine)\n201\t\treturn fmt.Sprintf(\"raw/codebase/symbols/%s.md\", symbolSlug)\n202\t}\n203\t\n204\t// GetRawDirectoryIndexPath derives the vault document path for a raw directory index.\n205\tfunc GetRawDirectoryIndexPath(directoryPath string) string {\n206\t\tnormalized := normalizeDocumentPathSegment(directoryPath)\n207\t\tif normalized == \"\" || normalized == \".\" {\n208\t\t\treturn \"raw/codebase/indexes/directories/root.md\"\n209\t\t}\n210\t\n211\t\treturn fmt.Sprintf(\"raw/codebase/indexes/directories/%s.md\", normalized)\n212\t}\n213\t\n214\t// GetRawLanguageIndexPath derives the vault document path for a raw language index.\n215\tfunc GetRawLanguageIndexPath(language string) string {\n216\t\treturn fmt.Sprintf(\"raw/codebase/indexes/languages/%s.md\", strings.TrimSpace(language))\n217\t}\n218\t\n219\t// GetWikiConceptPath derives the vault document path for a generated wiki concept article.\n220\tfunc GetWikiConceptPath(articleTitle string) string {\n221\t\tnormalizedTitle := strings.TrimSpace(articleTitle)\n222\t\treturn fmt.Sprintf(\"wiki/codebase/concepts/%s.md\", normalizedTitle)\n223\t}\n224\t\n225\t// GetWikiIndexPath derives the vault document path for a generated wiki index page.\n226\tfunc GetWikiIndexPath(indexTitle string) string {\n227\t\treturn fmt.Sprintf(\"wiki/codebase/index/%s.md\", indexTitle)\n228\t}\n229\t\n230\t// GetTopicIndexPath derives the vault document path for a top-level topic index page.\n231\tfunc GetTopicIndexPath(indexTitle string) string {\n232\t\treturn fmt.Sprintf(\"wiki/index/%s.md\", indexTitle)\n233\t}\n234\t\n235\t// GetBaseFilePath derives the vault document path for an Obsidian Base definition.\n236\tfunc GetBaseFilePath(baseName string) string {\n237\t\treturn fmt.Sprintf(\"bases/%s.base\", baseName)\n238\t}\n239\t\n240\t// StripMarkdownExtension removes a trailing .md extension from a document path.\n241\tfunc StripMarkdownExtension(documentPath string) string {\n242\t\treturn strings.TrimSuffix(documentPath, \".md\")\n243\t}\n244\t\n245\t// ToTopicWikiLink formats a topic-scoped Obsidian wiki-link.\n246\tfunc ToTopicWikiLink(topicSlug, documentPath, label string) string {\n247\t\ttarget := fmt.Sprintf(\"%s/%s\", topicSlug, StripMarkdownExtension(documentPath))\n248\t\tif label != \"\" {\n249\t\t\treturn fmt.Sprintf(\"[[%s|%s]]\", target, label)\n250\t\t}\n251\t\n252\t\treturn fmt.Sprintf(\"[[%s]]\", target)\n253\t}\n254\t\n255\t// LinkFormatter renders a link from one topic-relative document to another.\n256\ttype LinkFormatter interface {\n257\t\tLink(fromDir, targetPath, label string) string\n258\t}\n259\t\n260\t// WikiLinkFormatter renders Obsidian topic-scoped wikilinks.\n261\ttype WikiLinkFormatter struct {\n262\t\tSlug string\n263\t}\n264\t\n265\t// Link renders a wikilink exactly like ToTopicWikiLink.\n266\tfunc (f WikiLinkFormatter) Link(_, targetPath, label string) string {\n267\t\treturn ToTopicWikiLink(f.Slug, targetPath, label)\n268\t}\n269\t\n270\t// OKFLinkFormatter renders relative Markdown links for OKF bundles.\n271\ttype OKFLinkFormatter struct{}\n272\t\n273\t// Link renders a GitHub-safe relative Markdown link.\n274\tfunc (OKFLinkFormatter) Link(fromDir, targetPath, label string) string {\n275\t\tcleanTarget := strings.TrimPrefix(ToPosixPath(strings.TrimSpace(targetPath)), \"/\")\n276\t\tdisplayLabel := strings.TrimSpace(label)\n277\t\tif displayLabel == \"\" {\n278\t\t\tdisplayLabel = HumanizeSlug(SlugifySegment(strings.TrimSuffix(path.Base(cleanTarget), \".md\")))\n279\t\t}\n280\t\n281\t\tfragment := \"\"\n282\t\tif hashIndex := strings.Index(cleanTarget, \"#\"); hashIndex \u003e= 0 {\n283\t\t\tfragment = cleanTarget[hashIndex:]\n284\t\t\tcleanTarget = cleanTarget[:hashIndex]\n285\t\t}\n286\t\n287\t\tcleanFromDir := strings.TrimPrefix(ToPosixPath(strings.TrimSpace(fromDir)), \"/\")\n288\t\tif cleanFromDir == \"\" {\n289\t\t\tcleanFromDir = \".\"\n290\t\t}\n291\t\n292\t\trelativePath, err := filepath.Rel(filepath.FromSlash(cleanFromDir), filepath.FromSlash(cleanTarget))\n293\t\tif err != nil {\n294\t\t\trelativePath = cleanTarget\n295\t\t}\n296\t\trelativePath = ToPosixPath(relativePath)\n297\t\tif relativePath == \".\" || relativePath == \"\" {\n298\t\t\trelativePath = path.Base(cleanTarget)\n299\t\t}\n300\t\trelativePath = strings.TrimPrefix(relativePath, \"./\")\n301\t\trelativePath = markdownLinkTarget(relativePath + fragment)\n302\t\treturn fmt.Sprintf(\"[%s](%s)\", displayLabel, relativePath)\n303\t}\n304\t\n305\t// LinkFormatterFor selects the formatter from the topic mode.\n306\tfunc LinkFormatterFor(topic models.TopicMetadata) LinkFormatter {\n307\t\tif topic.Mode == models.TopicModeOKF {\n308\t\t\treturn OKFLinkFormatter{}\n309\t\t}\n310\t\treturn WikiLinkFormatter{Slug: topic.Slug}\n311\t}\n312\t\n313\tfunc markdownLinkTarget(target string) string {\n314\t\treplacer := strings.NewReplacer(\n315\t\t\t\" \", \"%20\",\n316\t\t\t\"(\", \"%28\",\n317\t\t\t\")\", \"%29\",\n318\t\t)\n319\t\treturn replacer.Replace(target)\n320\t}\n321\t\n322\tfunc stripWikiConceptFilePrefix(articleTitle string) string {\n323\t\tnormalizedTitle := strings.TrimSpace(articleTitle)\n324\t\treturn strings.TrimSpace(strings.TrimPrefix(normalizedTitle, legacyWikiConceptPrefix))\n325\t}\n326\t\n327\tfunc cleanComparablePath(value string) string {\n328\t\tnormalized := ToPosixPath(value)\n329\t\tif normalized == \"\" || normalized == \"/\" || windowsDriveRootPattern.MatchString(normalized) {\n330\t\t\treturn normalized\n331\t\t}\n332\t\n333\t\treturn ToPosixPath(path.Clean(normalized))\n334\t}\n335\t\n336\tfunc hasWindowsDrivePrefix(value string) bool {\n337\t\treturn len(value) \u003e= 2 \u0026\u0026 ((value[0] \u003e= 'A' \u0026\u0026 value[0] \u003c= 'Z') || (value[0] \u003e= 'a' \u0026\u0026 value[0] \u003c= 'z')) \u0026\u0026 value[1] == ':'\n338\t}\n339\t\n340\tfunc normalizeDocumentPathSegment(value string) string {\n341\t\treturn strings.TrimLeft(ToPosixPath(value), \"/\")\n342\t}\n343\t\n344\tfunc splitComparablePath(value string) (drive string, parts []string, absolute bool) {\n345\t\tnormalized := value\n346\t\tif hasWindowsDrivePrefix(normalized) {\n347\t\t\tdrive = normalized[:2]\n348\t\t\tnormalized = strings.TrimPrefix(normalized[2:], \"/\")\n349\t\t\tabsolute = true\n350\t\t} else {\n351\t\t\tabsolute = strings.HasPrefix(normalized, \"/\")\n352\t\t\tnormalized = strings.TrimPrefix(normalized, \"/\")\n353\t\t}\n354\t\n355\t\tif normalized == \"\" || normalized == \".\" {\n356\t\t\treturn drive, nil, absolute\n357\t\t}\n358\t\n359\t\trawParts := strings.Split(normalized, \"/\")\n360\t\tparts = make([]string, 0, len(rawParts))\n361\t\tfor _, part := range rawParts {\n362\t\t\tif part == \"\" || part == \".\" {\n363\t\t\t\tcontinue\n364\t\t\t}\n365\t\t\tparts = append(parts, part)\n366\t\t}\n367\t\n368\t\treturn drive, parts, absolute\n369\t}\n370\t\n```"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:21:32.846019Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":""}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:21:32.84778Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":"Let me verify the models (LintIss"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:21:33.39079Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":"ue/severities), CLI helpers, config normalize, and the official fixture corpus presence."}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:21:33.396131Z","turn":1,"type":"session.update","update":{"kind":"tool_call_started","tool_call_id":"toolu_01MxBnz2xPQzEwDUyCGJPhCt","tool_call_state":"pending","blocks":[{"type":"tool_use","id":"toolu_01MxBnz2xPQzEwDUyCGJPhCt","name":"Read","title":"Read File","raw_input":{}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:21:34.866368Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01MxBnz2xPQzEwDUyCGJPhCt","blocks":[{"type":"tool_use","id":"toolu_01MxBnz2xPQzEwDUyCGJPhCt","name":"Read","title":"Read internal/models/kb_models.go","input":{"file_path":"/Users/pedronauck/Dev/compozy/kb/internal/models/kb_models.go"},"raw_input":{"file_path":"/Users/pedronauck/Dev/compozy/kb/internal/models/kb_models.go"}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:21:34.872193Z","turn":1,"type":"session.update","update":{"kind":"tool_call_started","tool_call_id":"toolu_01PR8QW55t2EZKFNsFGoCpyz","tool_call_state":"pending","blocks":[{"type":"tool_use","id":"toolu_01PR8QW55t2EZKFNsFGoCpyz","name":"Bash","title":"Terminal","raw_input":{}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:21:34.880072Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01MxBnz2xPQzEwDUyCGJPhCt","usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:21:34.886528Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01MxBnz2xPQzEwDUyCGJPhCt","tool_call_state":"completed","blocks":[{"type":"tool_result","tool_use_id":"toolu_01MxBnz2xPQzEwDUyCGJPhCt","content":"```\n1\tpackage models\n2\t\n3\timport (\n4\t\t\"context\"\n5\t\t\"io\"\n6\t)\n7\t\n8\t// Converter transforms a source into Markdown content.\n9\ttype Converter interface {\n10\t\t// Accepts reports whether the converter supports the given file extension\n11\t\t// and/or MIME type.\n12\t\tAccepts(ext string, mimeType string) bool\n13\t\t// Convert reads from the source and produces Markdown content plus metadata.\n14\t\tConvert(ctx context.Context, input ConvertInput) (*ConvertResult, error)\n15\t}\n16\t\n17\t// ConvertInput carries the source content and metadata needed for conversion.\n18\ttype ConvertInput struct {\n19\t\tReader io.ReadSeeker `json:\"-\"`\n20\t\tFilePath string `json:\"filePath,omitempty\"`\n21\t\tURL string `json:\"url,omitempty\"`\n22\t\tOptions map[string]any `json:\"options,omitempty\"`\n23\t}\n24\t\n25\t// ConvertResult contains the Markdown output and metadata from a conversion.\n26\ttype ConvertResult struct {\n27\t\tMarkdown string `json:\"markdown,omitempty\"`\n28\t\tTitle string `json:\"title,omitempty\"`\n29\t\tMetadata map[string]any `json:\"metadata,omitempty\"`\n30\t}\n31\t\n32\t// SourceKind identifies the source category for ingested KB content.\n33\ttype SourceKind string\n34\t\n35\tconst (\n36\t\t// SourceKindArticle marks a general article source.\n37\t\tSourceKindArticle SourceKind = \"article\"\n38\t\t// SourceKindGitHubREADME marks an ingested GitHub README.\n39\t\tSourceKindGitHubREADME SourceKind = \"github-readme\"\n40\t\t// SourceKindYouTubeTranscript marks an ingested YouTube transcript.\n41\t\tSourceKindYouTubeTranscript SourceKind = \"youtube-transcript\"\n42\t\t// SourceKindCodebaseFile marks a codebase file snapshot.\n43\t\tSourceKindCodebaseFile SourceKind = \"codebase-file\"\n44\t\t// SourceKindCodebaseSymbol marks a codebase symbol snapshot.\n45\t\tSourceKindCodebaseSymbol SourceKind = \"codebase-symbol\"\n46\t\t// SourceKindBookmarkCluster marks an ingested bookmark cluster.\n47\t\tSourceKindBookmarkCluster SourceKind = \"bookmark-cluster\"\n48\t\t// SourceKindDocument marks a general uploaded document.\n49\t\tSourceKindDocument SourceKind = \"document\"\n50\t\t// SourceKindInstagramVideo marks an ingested Instagram reel/video transcript.\n51\t\tSourceKindInstagramVideo SourceKind = \"instagram-video\"\n52\t)\n53\t\n54\t// SourceKinds returns every source kind in stable order.\n55\tfunc SourceKinds() []SourceKind {\n56\t\treturn []SourceKind{\n57\t\t\tSourceKindArticle,\n58\t\t\tSourceKindGitHubREADME,\n59\t\t\tSourceKindYouTubeTranscript,\n60\t\t\tSourceKindCodebaseFile,\n61\t\t\tSourceKindCodebaseSymbol,\n62\t\t\tSourceKindBookmarkCluster,\n63\t\t\tSourceKindDocument,\n64\t\t\tSourceKindInstagramVideo,\n65\t\t}\n66\t}\n67\t\n68\t// IngestResult represents a successfully ingested source.\n69\ttype IngestResult struct {\n70\t\tTopic string `json:\"topic\"`\n71\t\tSourceType SourceKind `json:\"sourceType\"`\n72\t\tFilePath string `json:\"filePath\"`\n73\t\tTitle string `json:\"title\"`\n74\t}\n75\t\n76\t// LintIssueKind identifies the structural lint issue category.\n77\ttype LintIssueKind string\n78\t\n79\tconst (\n80\t\t// LintIssueKindDeadLink marks a dead wikilink or reference.\n81\t\tLintIssueKindDeadLink LintIssueKind = \"dead-link\"\n82\t\t// LintIssueKindOrphan marks content with no inbound references.\n83\t\tLintIssueKindOrphan LintIssueKind = \"orphan\"\n84\t\t// LintIssueKindMissingSource marks missing referenced source material.\n85\t\tLintIssueKindMissingSource LintIssueKind = \"missing-source\"\n86\t\t// LintIssueKindStale marks content that is older than its source material.\n87\t\tLintIssueKindStale LintIssueKind = \"stale\"\n88\t\t// LintIssueKindFormat marks frontmatter or structural format violations.\n89\t\tLintIssueKindFormat LintIssueKind = \"format\"\n90\t\t// LintIssueKindJavaDiagnosticGovernance marks Java diagnostic governance threshold outcomes.\n91\t\tLintIssueKindJavaDiagnosticGovernance LintIssueKind = \"java-diagnostic-governance\"\n92\t)\n93\t\n94\t// LintIssueKinds returns every lint issue kind in stable order.\n95\tfunc LintIssueKinds() []LintIssueKind {\n96\t\treturn []LintIssueKind{\n97\t\t\tLintIssueKindDeadLink,\n98\t\t\tLintIssueKindOrphan,\n99\t\t\tLintIssueKindMissingSource,\n100\t\t\tLintIssueKindStale,\n101\t\t\tLintIssueKindFormat,\n102\t\t\tLintIssueKindJavaDiagnosticGovernance,\n103\t\t}\n104\t}\n105\t\n106\t// LintIssue represents a single structural problem found in the vault.\n107\ttype LintIssue struct {\n108\t\tKind LintIssueKind `json:\"kind\"`\n109\t\tSeverity DiagnosticSeverity `json:\"severity\"`\n110\t\tFilePath string `json:\"filePath,omitempty\"`\n111\t\tMessage string `json:\"message\"`\n112\t\tTarget string `json:\"target,omitempty\"`\n113\t}\n114\t\n115\t// TopicInfo captures topic metadata for list and info operations.\n116\ttype TopicInfo struct {\n117\t\tSlug string `json:\"slug\"`\n118\t\tTitle string `json:\"title\"`\n119\t\tDomain string `json:\"domain\"`\n120\t\tMode TopicMode `json:\"mode\"`\n121\t\tRootPath string `json:\"rootPath\"`\n122\t\tArticleCount int `json:\"articleCount\"`\n123\t\tSourceCount int `json:\"sourceCount\"`\n124\t\tLastLogEntry string `json:\"lastLogEntry,omitempty\"`\n125\t}\n126\t\n```"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:21:36.754105Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01PR8QW55t2EZKFNsFGoCpyz","blocks":[{"type":"tool_use","id":"toolu_01PR8QW55t2EZKFNsFGoCpyz","name":"Bash","title":"echo \"=== official dir tree (top 2 levels) ===\" \u0026\u0026 find internal/okf/testdata/official -maxdepth 2 | head -80 \u0026\u0026 echo \"=== count md files per bundle ===\" \u0026\u0026 for b in ga4 stackoverflow crypto_bitcoin; do echo -n \"$b: \"; find \"internal/okf/testdata/official/$b\" -name '*.md' 2\u003e/dev/null | wc -l; done","input":{"command":"echo \"=== official dir tree (top 2 levels) ===\" \u0026\u0026 find internal/okf/testdata/official -maxdepth 2 | head -80 \u0026\u0026 echo \"=== count md files per bundle ===\" \u0026\u0026 for b in ga4 stackoverflow crypto_bitcoin; do echo -n \"$b: \"; find \"internal/okf/testdata/official/$b\" -name '*.md' 2\u003e/dev/null | wc -l; done"},"raw_input":{"command":"echo \"=== official dir tree (top 2 levels) ===\" \u0026\u0026 find internal/okf/testdata/official -maxdepth 2 | head -80 \u0026\u0026 echo \"=== count md files per bundle ===\" \u0026\u0026 for b in ga4 stackoverflow crypto_bitcoin; do echo -n \"$b: \"; find \"internal/okf/testdata/official/$b\" -name '*.md' 2\u003e/dev/null | wc -l; done"}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:21:36.762002Z","turn":1,"type":"session.update","update":{"kind":"tool_call_started","tool_call_id":"toolu_01FxdJbEVtzxUprGabTPW9Ly","tool_call_state":"pending","blocks":[{"type":"tool_use","id":"toolu_01FxdJbEVtzxUprGabTPW9Ly","name":"Bash","title":"Terminal","raw_input":{}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:21:36.867085Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01PR8QW55t2EZKFNsFGoCpyz","usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:21:36.86842Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01PR8QW55t2EZKFNsFGoCpyz","tool_call_state":"completed","blocks":[{"type":"tool_result","tool_use_id":"toolu_01PR8QW55t2EZKFNsFGoCpyz","content":"```console\n=== official dir tree (top 2 levels) ===\ninternal/okf/testdata/official\ninternal/okf/testdata/official/LICENSE.md\ninternal/okf/testdata/official/crypto_bitcoin\ninternal/okf/testdata/official/README.md\ninternal/okf/testdata/official/ga4\ninternal/okf/testdata/official/stackoverflow\ninternal/okf/testdata/official/crypto_bitcoin/tables\ninternal/okf/testdata/official/crypto_bitcoin/viz.html\ninternal/okf/testdata/official/crypto_bitcoin/datasets\ninternal/okf/testdata/official/crypto_bitcoin/index.md\ninternal/okf/testdata/official/ga4/tables\ninternal/okf/testdata/official/ga4/viz.html\ninternal/okf/testdata/official/ga4/references\ninternal/okf/testdata/official/ga4/datasets\ninternal/okf/testdata/official/ga4/index.md\ninternal/okf/testdata/official/stackoverflow/tables\ninternal/okf/testdata/official/stackoverflow/viz.html\ninternal/okf/testdata/official/stackoverflow/references\ninternal/okf/testdata/official/stackoverflow/datasets\ninternal/okf/testdata/official/stackoverflow/index.md\n=== count md files per bundle ===\nga4: 17\nstackoverflow: 53\ncrypto_bitcoin: 8\n```"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:21:38.492801Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01FxdJbEVtzxUprGabTPW9Ly","blocks":[{"type":"tool_use","id":"toolu_01FxdJbEVtzxUprGabTPW9Ly","name":"Bash","title":"echo \"=== normalizeConfigStringList ===\" \u0026\u0026 grep -n \"func normalizeConfigStringList\" -A 20 internal/config/config.go; echo \"=== CLI helpers ===\" \u0026\u0026 grep -rn \"func commandContext\\|func writeJSON\\|func loadCLIConfig\\|func parseInspectOutputFormat\\|func resolveCommandVaultPath\\|func writeTopicInfoJSON\" internal/cli/","input":{"command":"echo \"=== normalizeConfigStringList ===\" \u0026\u0026 grep -n \"func normalizeConfigStringList\" -A 20 internal/config/config.go; echo \"=== CLI helpers ===\" \u0026\u0026 grep -rn \"func commandContext\\|func writeJSON\\|func loadCLIConfig\\|func parseInspectOutputFormat\\|func resolveCommandVaultPath\\|func writeTopicInfoJSON\" internal/cli/"},"raw_input":{"command":"echo \"=== normalizeConfigStringList ===\" \u0026\u0026 grep -n \"func normalizeConfigStringList\" -A 20 internal/config/config.go; echo \"=== CLI helpers ===\" \u0026\u0026 grep -rn \"func commandContext\\|func writeJSON\\|func loadCLIConfig\\|func parseInspectOutputFormat\\|func resolveCommandVaultPath\\|func writeTopicInfoJSON\" internal/cli/"}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:21:38.594381Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01FxdJbEVtzxUprGabTPW9Ly","usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:21:38.595944Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01FxdJbEVtzxUprGabTPW9Ly","tool_call_state":"completed","blocks":[{"type":"tool_result","tool_use_id":"toolu_01FxdJbEVtzxUprGabTPW9Ly","content":"```console\n=== normalizeConfigStringList ===\n482:func normalizeConfigStringList(values []string) []string {\n483-\tnormalized := make([]string, 0, len(values))\n484-\tfor _, value := range values {\n485-\t\tvalue = strings.TrimSpace(value)\n486-\t\tif value == \"\" {\n487-\t\t\tcontinue\n488-\t\t}\n489-\t\tnormalized = append(normalized, value)\n490-\t}\n491-\treturn normalized\n492-}\n493-\n494-// BulkThrottleDuration parses the configured inter-request throttle for bulk\n495-// channel ingestion.\n496-func (c YouTubeConfig) BulkThrottleDuration() (time.Duration, error) {\n497-\tvalue := strings.TrimSpace(c.BulkThrottle)\n498-\tif value == \"\" {\n499-\t\tvalue = defaultYouTubeBulkThrottle\n500-\t}\n501-\tduration, err := time.ParseDuration(value)\n502-\tif err != nil {\n=== CLI helpers ===\ninternal/cli/ingest.go:122:func commandContext(cmd *cobra.Command) context.Context {\ninternal/cli/ingest.go:131:func loadCLIConfig() (kconfig.Config, error) {\ninternal/cli/ingest.go:156:func writeJSON(cmd *cobra.Command, payload any) error {\ninternal/cli/topic.go:147:func writeTopicInfoJSON(cmd *cobra.Command, info models.TopicInfo) error {\ninternal/cli/inspect.go:180:func parseInspectOutputFormat(value string) (output.OutputFormat, error) {\ninternal/cli/vault_flag.go:37:func resolveCommandVaultPath(cmd *cobra.Command, getwd func() (string, error), action string) (string, error) {\n```"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:23:25.521295Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":""}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:23:25.522085Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":"Let me check the official bundle internals for c"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:23:25.791587Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":"ases that could break the lenient conformance integration test (nested index.md with frontmatter, or concept files lacking frontmatter/"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:23:26.328386Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":"type)."}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:23:26.330983Z","turn":1,"type":"session.update","update":{"kind":"tool_call_started","tool_call_id":"toolu_013i7bDDgUvS8N573N9P5sim","tool_call_state":"pending","blocks":[{"type":"tool_use","id":"toolu_013i7bDDgUvS8N573N9P5sim","name":"Bash","title":"Terminal","raw_input":{}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:23:29.705174Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_013i7bDDgUvS8N573N9P5sim","blocks":[{"type":"tool_use","id":"toolu_013i7bDDgUvS8N573N9P5sim","name":"Bash","title":"cd internal/okf/testdata/official\necho \"=== nested index.md files (basename index.md not at bundle root) ===\"\nfind . -name 'index.md'\necho\necho \"=== .md files whose first line is NOT '---' (would be treated as missing frontmatter) ===\"\nfor f in $(find ga4 stackoverflow crypto_bitcoin -name '*.md'); do\n first=$(head -1 \"$f\")\n base=$(basename \"$f\" | tr '[:upper:]' '[:lower:]')\n case \"$base\" in\n index.md|log.md|readme.md|license.md|notice.md|attribution.md) continue;;\n esac\n if [ \"$first\" != \"---\" ]; then echo \"NO-FM: $f (first line: $first)\"; fi\ndone\necho \"(end no-fm scan)\"","input":{"command":"cd internal/okf/testdata/official\necho \"=== nested index.md files (basename index.md not at bundle root) ===\"\nfind . -name 'index.md'\necho\necho \"=== .md files whose first line is NOT '---' (would be treated as missing frontmatter) ===\"\nfor f in $(find ga4 stackoverflow crypto_bitcoin -name '*.md'); do\n first=$(head -1 \"$f\")\n base=$(basename \"$f\" | tr '[:upper:]' '[:lower:]')\n case \"$base\" in\n index.md|log.md|readme.md|license.md|notice.md|attribution.md) continue;;\n esac\n if [ \"$first\" != \"---\" ]; then echo \"NO-FM: $f (first line: $first)\"; fi\ndone\necho \"(end no-fm scan)\""},"raw_input":{"command":"cd internal/okf/testdata/official\necho \"=== nested index.md files (basename index.md not at bundle root) ===\"\nfind . -name 'index.md'\necho\necho \"=== .md files whose first line is NOT '---' (would be treated as missing frontmatter) ===\"\nfor f in $(find ga4 stackoverflow crypto_bitcoin -name '*.md'); do\n first=$(head -1 \"$f\")\n base=$(basename \"$f\" | tr '[:upper:]' '[:lower:]')\n case \"$base\" in\n index.md|log.md|readme.md|license.md|notice.md|attribution.md) continue;;\n esac\n if [ \"$first\" != \"---\" ]; then echo \"NO-FM: $f (first line: $first)\"; fi\ndone\necho \"(end no-fm scan)\""}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:23:29.713782Z","turn":1,"type":"session.update","update":{"kind":"tool_call_started","tool_call_id":"toolu_01FBwUWRLigB9DtKAmK8fNZP","tool_call_state":"pending","blocks":[{"type":"tool_use","id":"toolu_01FBwUWRLigB9DtKAmK8fNZP","name":"Bash","title":"Terminal","raw_input":{}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:23:30.046773Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_013i7bDDgUvS8N573N9P5sim","usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:23:30.048455Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_013i7bDDgUvS8N573N9P5sim","tool_call_state":"completed","blocks":[{"type":"tool_result","tool_use_id":"toolu_013i7bDDgUvS8N573N9P5sim","content":"```console\n=== nested index.md files (basename index.md not at bundle root) ===\n./crypto_bitcoin/index.md\n./ga4/index.md\n./stackoverflow/index.md\n./crypto_bitcoin/tables/index.md\n./crypto_bitcoin/datasets/index.md\n./ga4/tables/index.md\n./ga4/references/index.md\n./ga4/datasets/index.md\n./stackoverflow/tables/index.md\n./stackoverflow/references/index.md\n./stackoverflow/datasets/index.md\n./ga4/references/metrics/index.md\n./ga4/references/joins/index.md\n\n=== .md files whose first line is NOT '---' (would be treated as missing frontmatter) ===\n(end no-fm scan)\n```"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:23:32.779204Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01FBwUWRLigB9DtKAmK8fNZP","blocks":[{"type":"tool_use","id":"toolu_01FBwUWRLigB9DtKAmK8fNZP","name":"Bash","title":"cd internal/okf/testdata/official\necho \"=== concept .md files missing a 'type:' key in first frontmatter block ===\"\nfor f in $(find ga4 stackoverflow crypto_bitcoin -name '*.md'); do\n base=$(basename \"$f\" | tr '[:upper:]' '[:lower:]')\n case \"$base\" in\n index.md|log.md|readme.md|license.md|notice.md|attribution.md) continue;;\n esac\n # extract frontmatter block between first two --- lines\n fm=$(awk 'NR==1\u0026\u0026$0!=\"---\"{exit} NR==1{next} /^---/{exit} {print}' \"$f\")\n if ! echo \"$fm\" | grep -q '^type:'; then echo \"NO-TYPE: $f\"; fi\ndone\necho \"(end no-type scan)\"\necho\necho \"=== sample ga4 root index.md head ===\"\nhead -15 ga4/index.md","input":{"command":"cd internal/okf/testdata/official\necho \"=== concept .md files missing a 'type:' key in first frontmatter block ===\"\nfor f in $(find ga4 stackoverflow crypto_bitcoin -name '*.md'); do\n base=$(basename \"$f\" | tr '[:upper:]' '[:lower:]')\n case \"$base\" in\n index.md|log.md|readme.md|license.md|notice.md|attribution.md) continue;;\n esac\n # extract frontmatter block between first two --- lines\n fm=$(awk 'NR==1\u0026\u0026$0!=\"---\"{exit} NR==1{next} /^---/{exit} {print}' \"$f\")\n if ! echo \"$fm\" | grep -q '^type:'; then echo \"NO-TYPE: $f\"; fi\ndone\necho \"(end no-type scan)\"\necho\necho \"=== sample ga4 root index.md head ===\"\nhead -15 ga4/index.md"},"raw_input":{"command":"cd internal/okf/testdata/official\necho \"=== concept .md files missing a 'type:' key in first frontmatter block ===\"\nfor f in $(find ga4 stackoverflow crypto_bitcoin -name '*.md'); do\n base=$(basename \"$f\" | tr '[:upper:]' '[:lower:]')\n case \"$base\" in\n index.md|log.md|readme.md|license.md|notice.md|attribution.md) continue;;\n esac\n # extract frontmatter block between first two --- lines\n fm=$(awk 'NR==1\u0026\u0026$0!=\"---\"{exit} NR==1{next} /^---/{exit} {print}' \"$f\")\n if ! echo \"$fm\" | grep -q '^type:'; then echo \"NO-TYPE: $f\"; fi\ndone\necho \"(end no-type scan)\"\necho\necho \"=== sample ga4 root index.md head ===\"\nhead -15 ga4/index.md"}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:23:33.343513Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01FBwUWRLigB9DtKAmK8fNZP","usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:23:33.345072Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01FBwUWRLigB9DtKAmK8fNZP","tool_call_state":"completed","blocks":[{"type":"tool_result","tool_use_id":"toolu_01FBwUWRLigB9DtKAmK8fNZP","content":"```console\n(eval):cd:1: no such file or directory: internal/okf/testdata/official\n=== concept .md files missing a 'type:' key in first frontmatter block ===\n(end no-type scan)\n\n=== sample ga4 root index.md head ===\n# Subdirectories\n\n* [datasets](datasets/index.md) - A sample of obfuscated Google Analytics BigQuery event export data for three months from the Google Merchandise Store is available as a public dataset in BigQuery.\n* [references](references/index.md) - This directory contains specifications for data joins and definitions for user activity and purchase metrics.\n* [tables](tables/index.md) - Contains Google Analytics event export data from the `ga4_obfuscated_sample_ecommerce` dataset.\n```"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:23:42.556812Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":""}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:23:42.558288Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":"Nested `index.md` files exist in the official bundles. The"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:23:43.020121Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":" checker treats every `index.md` as a reserved index file and **errors if any non-root `index.md` has frontmatter**. Let me check whether the"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:23:43.487822Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":" nested ones carry frontmatter — this directly determines whether the official-bundle conformance test (a P"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:23:43.968334Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":"RD success metric) passes."}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:23:43.970222Z","turn":1,"type":"session.update","update":{"kind":"tool_call_started","tool_call_id":"toolu_014PeRtqH9rNs89vzNRhsS3c","tool_call_state":"pending","blocks":[{"type":"tool_use","id":"toolu_014PeRtqH9rNs89vzNRhsS3c","name":"Bash","title":"Terminal","raw_input":{}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:23:45.961071Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_014PeRtqH9rNs89vzNRhsS3c","blocks":[{"type":"tool_use","id":"toolu_014PeRtqH9rNs89vzNRhsS3c","name":"Bash","title":"cd /Users/pedronauck/Dev/compozy/kb/internal/okf/testdata/official\nfor f in ga4/tables/index.md ga4/references/index.md ga4/datasets/index.md ga4/references/metrics/index.md ga4/references/joins/index.md stackoverflow/tables/index.md crypto_bitcoin/tables/index.md; do\n echo \"===== $f (first line: $(head -1 \"$f\")) =====\"\n head -8 \"$f\"\n echo\ndone","input":{"command":"cd /Users/pedronauck/Dev/compozy/kb/internal/okf/testdata/official\nfor f in ga4/tables/index.md ga4/references/index.md ga4/datasets/index.md ga4/references/metrics/index.md ga4/references/joins/index.md stackoverflow/tables/index.md crypto_bitcoin/tables/index.md; do\n echo \"===== $f (first line: $(head -1 \"$f\")) =====\"\n head -8 \"$f\"\n echo\ndone"},"raw_input":{"command":"cd /Users/pedronauck/Dev/compozy/kb/internal/okf/testdata/official\nfor f in ga4/tables/index.md ga4/references/index.md ga4/datasets/index.md ga4/references/metrics/index.md ga4/references/joins/index.md stackoverflow/tables/index.md crypto_bitcoin/tables/index.md; do\n echo \"===== $f (first line: $(head -1 \"$f\")) =====\"\n head -8 \"$f\"\n echo\ndone"}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:23:46.026639Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_014PeRtqH9rNs89vzNRhsS3c","usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:23:46.028681Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_014PeRtqH9rNs89vzNRhsS3c","tool_call_state":"completed","blocks":[{"type":"tool_result","tool_use_id":"toolu_014PeRtqH9rNs89vzNRhsS3c","content":"```console\n===== ga4/tables/index.md (first line: # BigQuery Table) =====\n# BigQuery Table\n\n* [Events table (Google Analytics BigQuery Export)](events_.md) - Contains Google Analytics event export data from the `ga4_obfuscated_sample_ecommerce` dataset.\n\n===== ga4/references/index.md (first line: # Subdirectories) =====\n# Subdirectories\n\n* [joins](joins/index.md) - Join Google Analytics event data with Google Ads click data.\n* [metrics](metrics/index.md) - This directory contains definitions for metrics related to user activity, events, and purchase behavior.\n\n===== ga4/datasets/index.md (first line: # BigQuery Dataset) =====\n# BigQuery Dataset\n\n* [BigQuery sample dataset for Google Analytics ecommerce web implementation](ga4_obfuscated_sample_ecommerce.md) - A sample of obfuscated Google Analytics BigQuery event export data for three months from the Google Merchandise Store is available as a public dataset in BigQuery.\n\n===== ga4/references/metrics/index.md (first line: # Reference) =====\n# Reference\n\n* [Average Pageviews](avg_pageviews.md) - The average number of pageviews per user.\n* [Average Spend Per Purchase Session By User](avg_spend_per_purchase_session_by_user.md) - The average amount of money spent per purchase session for each individual user.\n* [Average Transactions Per Purchaser](avg_transactions_per_purchaser.md) - The average number of transactions made by purchasers.\n* [Day Count](day_count.md) - Total number of unique days.\n* [Event Count](event_count.md) - Total number of events.\n* [New User Count](new_user_count.md) - The number of unique users who triggered a first_visit or first_open event.\n\n===== ga4/references/joins/index.md (first line: # Reference) =====\n# Reference\n\n* [Join Google Analytics Events to Google Ads Clicks](events___ads_clickstats.md) - Join Google Analytics event data with Google Ads click data.\n\n===== stackoverflow/tables/index.md (first line: # BigQuery Table) =====\n# BigQuery Table\n\n* [Badges](badges.md) - This table contains information about badges awarded to users on the Stack Overflow platform, including details about the badge name, class, and whether it is tag-based.\n* [Comments](comments.md) - This table contains comments made by users on posts within the Stack Overflow platform, including details about the comment text, score, and author, along with licensing information.\n* [Orphaned Tag Wiki Posts](posts_orphaned_tag_wiki.md) - This table contains Tag Wiki posts that are no longer associated with an active tag on Stack Overflow.\n* [Post History](post_history.md) - Tracks the revision history of posts on Stack Overflow, including details about event types, associated users, and content changes.\n* [Post Links](post_links.md) - Contains information about links between posts on Stack Overflow, including the type of link and the posts involved.\n* [Posts Answers](posts_answers.md) - Contains information about answers to questions posted on Stack Overflow, including detailed schema fields, their descriptions, and licensing information.\n\n===== crypto_bitcoin/tables/index.md (first line: # BigQuery Table) =====\n# BigQuery Table\n\n* [Bitcoin Blocks Table](blocks.md) - Details about the Bitcoin Blocks BigQuery table, including its schema.\n* [Bitcoin Transaction Inputs](inputs.md) - Details about transaction inputs on the Bitcoin blockchain.\n* [Bitcoin Transactions](transactions.md) - A comprehensive table detailing all transactions on the Bitcoin blockchain.\n* [Outputs](outputs.md) - Outputs from all transactions in the Bitcoin blockchain.\n```"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:23:55.931447Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":""}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:23:55.93346Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":"All nested `index.md` files l"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:23:56.433053Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":"ack frontmatter and all concept files carry `type:`, so the official-bundle test should pass. Now let me check the zero-regression golden coverage and the N-001 "}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:23:56.943874Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":"OKF-render-smoke test that ADR-004 explicitly requires."}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:23:56.945771Z","turn":1,"type":"session.update","update":{"kind":"tool_call_started","tool_call_id":"toolu_01Qti3NrQNJGGXrekTWYVmjD","tool_call_state":"pending","blocks":[{"type":"tool_use","id":"toolu_01Qti3NrQNJGGXrekTWYVmjD","name":"Bash","title":"Terminal","raw_input":{}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:24:01.099531Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01Qti3NrQNJGGXrekTWYVmjD","blocks":[{"type":"tool_use","id":"toolu_01Qti3NrQNJGGXrekTWYVmjD","name":"Bash","title":"cd /Users/pedronauck/Dev/compozy/kb\necho \"=== vault test files ===\" \u0026\u0026 ls internal/vault/*_test.go\necho\necho \"=== render/render_wiki golden-ish tests referencing TopicMetadata mode or ToTopicWikiLink output ===\"\ngrep -rln \"RenderDocuments\\|renderDashboard\\|renderConceptIndex\\|createCodebaseOverviewArticle\\|TopicModeOKF\\|\\[\\[\" internal/vault/*_test.go\necho\necho \"=== any test that constructs TopicMetadata{...Mode: okf...} and calls a render fn ===\"\ngrep -rn \"TopicModeOKF\" internal/vault/*_test.go || echo \"none\"\necho\necho \"=== WriteMetadataFile callers (the mode-less exported variant) ===\"\ngrep -rn \"WriteMetadataFile\\b\\|WriteMetadataFileWithMode\" internal/ --include=*.go | grep -v _test.go","input":{"command":"cd /Users/pedronauck/Dev/compozy/kb\necho \"=== vault test files ===\" \u0026\u0026 ls internal/vault/*_test.go\necho\necho \"=== render/render_wiki golden-ish tests referencing TopicMetadata mode or ToTopicWikiLink output ===\"\ngrep -rln \"RenderDocuments\\|renderDashboard\\|renderConceptIndex\\|createCodebaseOverviewArticle\\|TopicModeOKF\\|\\[\\[\" internal/vault/*_test.go\necho\necho \"=== any test that constructs TopicMetadata{...Mode: okf...} and calls a render fn ===\"\ngrep -rn \"TopicModeOKF\" internal/vault/*_test.go || echo \"none\"\necho\necho \"=== WriteMetadataFile callers (the mode-less exported variant) ===\"\ngrep -rn \"WriteMetadataFile\\b\\|WriteMetadataFileWithMode\" internal/ --include=*.go | grep -v _test.go"},"raw_input":{"command":"cd /Users/pedronauck/Dev/compozy/kb\necho \"=== vault test files ===\" \u0026\u0026 ls internal/vault/*_test.go\necho\necho \"=== render/render_wiki golden-ish tests referencing TopicMetadata mode or ToTopicWikiLink output ===\"\ngrep -rln \"RenderDocuments\\|renderDashboard\\|renderConceptIndex\\|createCodebaseOverviewArticle\\|TopicModeOKF\\|\\[\\[\" internal/vault/*_test.go\necho\necho \"=== any test that constructs TopicMetadata{...Mode: okf...} and calls a render fn ===\"\ngrep -rn \"TopicModeOKF\" internal/vault/*_test.go || echo \"none\"\necho\necho \"=== WriteMetadataFile callers (the mode-less exported variant) ===\"\ngrep -rn \"WriteMetadataFile\\b\\|WriteMetadataFileWithMode\" internal/ --include=*.go | grep -v _test.go"}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:24:01.187248Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01Qti3NrQNJGGXrekTWYVmjD","usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:24:01.18896Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01Qti3NrQNJGGXrekTWYVmjD","tool_call_state":"completed","blocks":[{"type":"tool_result","tool_use_id":"toolu_01Qti3NrQNJGGXrekTWYVmjD","content":"```console\n=== vault test files ===\ninternal/vault/pathutils_test.go 8.1K\ninternal/vault/query_test.go 8.2K\ninternal/vault/reader_integration_test.go 2.1K\ninternal/vault/reader_test.go 8.1K\ninternal/vault/render_integration_test.go 1.8K\ninternal/vault/render_test.go 14.3K\ninternal/vault/textutils_test.go 2.0K\ninternal/vault/writer_integration_test.go 1.3K\ninternal/vault/writer_test.go 25.2K\n\n=== render/render_wiki golden-ish tests referencing TopicMetadata mode or ToTopicWikiLink output ===\ninternal/vault/reader_test.go\ninternal/vault/pathutils_test.go\ninternal/vault/render_test.go\ninternal/vault/render_integration_test.go\ninternal/vault/writer_test.go\n\n=== any test that constructs TopicMetadata{...Mode: okf...} and calls a render fn ===\ninternal/vault/pathutils_test.go:232:\tif got := vault.LinkFormatterFor(models.TopicMetadata{Mode: models.TopicModeOKF}).Link(\"\", \"x.md\", \"X\"); got != \"[X](x.md)\" {\n\n=== WriteMetadataFile callers (the mode-less exported variant) ===\n(eval):11: no matches found: --include=*.go\n```"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:24:04.376677Z","turn":1,"type":"session.update","update":{"kind":"tool_call_started","tool_call_id":"toolu_012BzpK3XU4eYqcQkqU4Pzw7","tool_call_state":"pending","blocks":[{"type":"tool_use","id":"toolu_012BzpK3XU4eYqcQkqU4Pzw7","name":"Bash","title":"Terminal","raw_input":{}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:24:06.550755Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_012BzpK3XU4eYqcQkqU4Pzw7","blocks":[{"type":"tool_use","id":"toolu_012BzpK3XU4eYqcQkqU4Pzw7","name":"Bash","title":"cd /Users/pedronauck/Dev/compozy/kb\necho \"=== WriteMetadataFile / WriteMetadataFileWithMode callers (non-test) ===\"\ngrep -rn \"WriteMetadataFile\" internal/ cmd/ | grep -v \"_test.go\"\necho\necho \"=== EnsureCurrentSkeleton callers ===\"\ngrep -rn \"EnsureCurrentSkeleton\" internal/ cmd/ | grep -v \"_test.go\"\necho\necho \"=== render_test.go: does it assert wikilink output strings? ===\"\ngrep -n \"\\[\\[\\|ToTopicWikiLink\\|linkFor\\|RenderDocuments\\|Mode\" internal/vault/render_test.go | head -40","input":{"command":"cd /Users/pedronauck/Dev/compozy/kb\necho \"=== WriteMetadataFile / WriteMetadataFileWithMode callers (non-test) ===\"\ngrep -rn \"WriteMetadataFile\" internal/ cmd/ | grep -v \"_test.go\"\necho\necho \"=== EnsureCurrentSkeleton callers ===\"\ngrep -rn \"EnsureCurrentSkeleton\" internal/ cmd/ | grep -v \"_test.go\"\necho\necho \"=== render_test.go: does it assert wikilink output strings? ===\"\ngrep -n \"\\[\\[\\|ToTopicWikiLink\\|linkFor\\|RenderDocuments\\|Mode\" internal/vault/render_test.go | head -40"},"raw_input":{"command":"cd /Users/pedronauck/Dev/compozy/kb\necho \"=== WriteMetadataFile / WriteMetadataFileWithMode callers (non-test) ===\"\ngrep -rn \"WriteMetadataFile\" internal/ cmd/ | grep -v \"_test.go\"\necho\necho \"=== EnsureCurrentSkeleton callers ===\"\ngrep -rn \"EnsureCurrentSkeleton\" internal/ cmd/ | grep -v \"_test.go\"\necho\necho \"=== render_test.go: does it assert wikilink output strings? ===\"\ngrep -n \"\\[\\[\\|ToTopicWikiLink\\|linkFor\\|RenderDocuments\\|Mode\" internal/vault/render_test.go | head -40"}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:24:06.636437Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_012BzpK3XU4eYqcQkqU4Pzw7","usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:24:06.63875Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_012BzpK3XU4eYqcQkqU4Pzw7","tool_call_state":"completed","blocks":[{"type":"tool_result","tool_use_id":"toolu_012BzpK3XU4eYqcQkqU4Pzw7","content":"```console\n=== WriteMetadataFile / WriteMetadataFileWithMode callers (non-test) ===\ninternal/topic/topic.go:551:// WriteMetadataFile writes the structured topic.yaml metadata file.\ninternal/topic/topic.go:552:func WriteMetadataFile(topicPath, slug, title, domain string) error {\ninternal/topic/topic.go:560:// WriteMetadataFileWithMode writes structured topic metadata with an explicit mode.\ninternal/topic/topic.go:561:func WriteMetadataFileWithMode(topicPath, slug, title, domain string, mode models.TopicMode) error {\ninternal/vault/writer.go:106:\tif err := topic.WriteMetadataFile(options.Topic.TopicPath, options.Topic.Slug, options.Topic.Title, options.Topic.Domain); err != nil {\n\n=== EnsureCurrentSkeleton callers ===\ninternal/ingest/ingest.go:56:\tif err := topic.EnsureCurrentSkeleton(topicInfo.RootPath); err != nil {\ninternal/topic/topic.go:451:// EnsureCurrentSkeleton creates the current topic directory structure without\ninternal/topic/topic.go:453:func EnsureCurrentSkeleton(topicPath string) error {\ninternal/migrate/transcripts.go:31:\tif err := topic.EnsureCurrentSkeleton(topicInfo.RootPath); err != nil {\ninternal/vault/writer.go:147:\treturn topic.EnsureCurrentSkeleton(topicPath)\ninternal/vault/writer.go:168:\treturn topic.EnsureCurrentSkeleton(topicPath)\n\n=== render_test.go: does it assert wikilink output strings? ===\n13:func TestRenderDocumentsProducesRawWikiAndBaseSurfaces(t *testing.T) {\n19:\tdocuments := vault.RenderDocuments(graph, metricResult, testTopicFixture())\n43:func TestRenderDocumentsRawFileFrontmatterAndBody(t *testing.T) {\n65:\tif !strings.Contains(document.Body, \"[[demo-repo/raw/codebase/symbols/alpha--src-alpha-ts-l10|Alpha (function)]]\") {\n70:func TestRenderDocumentsRawSymbolFrontmatterAndSignature(t *testing.T) {\n97:func TestRenderDocumentsDirectoryIndexUsesWikiLinks(t *testing.T) {\n104:\t\t\"[[demo-repo/raw/codebase/files/src/alpha.ts|src/alpha.ts]]\",\n105:\t\t\"[[demo-repo/raw/codebase/files/src/beta.ts|src/beta.ts]]\",\n106:\t\t\"[[demo-repo/raw/codebase/files/src/orphan.ts|src/orphan.ts]]\",\n116:func TestRenderDocumentsCodebaseOverviewContainsSummary(t *testing.T) {\n126:\tif !strings.Contains(document.Body, vault.ToTopicWikiLink(\"demo-repo\", vault.GetWikiConceptPath(\"Module Health\"), \"Module Health\")) {\n131:func TestRenderDocumentsDependencyHotspotsListsTopFiles(t *testing.T) {\n137:\tif !strings.Contains(document.Body, \"[[demo-repo/raw/codebase/files/src/alpha.ts|src/alpha.ts]]\") {\n142:func TestRenderDocumentsCircularDependenciesListsGroups(t *testing.T) {\n148:\tif !strings.Contains(document.Body, \"[[demo-repo/raw/codebase/files/src/alpha.ts|src/alpha.ts]] · [[demo-repo/raw/codebase/files/src/beta.ts|src/beta.ts]]\") {\n153:func TestRenderDocumentsRawSourcesIncludeScrapedFrontmatter(t *testing.T) {\n172:func TestRenderDocumentsCircularDependenciesKeepSourceEvidenceWithoutCycles(t *testing.T) {\n187:func TestRenderDocumentsDashboardLinksToAllConceptArticles(t *testing.T) {\n205:\t\tlink := vault.ToTopicWikiLink(\"demo-repo\", vault.GetWikiConceptPath(title), title)\n230:func TestRenderDocumentsBodiesHaveValidFrontmatterAndKinds(t *testing.T) {\n255:func TestRenderDocumentsUseTopicWikiLinkSyntax(t *testing.T) {\n261:\tif !strings.Contains(document.Body, \"[[demo-repo/raw/codebase/symbols/main--commands-run-ts-l1|main (function)]]\") {\n271:\treturn vault.RenderDocuments(graph, metricResult, testTopicFixture())\n```"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:25:24.307201Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":""}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:25:24.308942Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":"Let me check whether the official bundles contain `log.md` files (the checker err"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:25:24.75194Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":"ors on any non-date `## ` heading in a log, even in lenient mode — could fail the success-metric test), and scan all"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:25:25.156491Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":" `## ` headings in any such logs."}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:25:25.163164Z","turn":1,"type":"session.update","update":{"kind":"tool_call_started","tool_call_id":"toolu_016MoJc42b4vsDjJ1p7ieqv2","tool_call_state":"pending","blocks":[{"type":"tool_use","id":"toolu_016MoJc42b4vsDjJ1p7ieqv2","name":"Bash","title":"Terminal","raw_input":{}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:25:28.931689Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_016MoJc42b4vsDjJ1p7ieqv2","blocks":[{"type":"tool_use","id":"toolu_016MoJc42b4vsDjJ1p7ieqv2","name":"Bash","title":"cd /Users/pedronauck/Dev/compozy/kb/internal/okf/testdata/official\necho \"=== any log.md in official bundles? ===\"\nfind . -iname 'log.md'\necho \"(end)\"\necho\necho \"=== README/LICENSE inside individual bundles (would they be walked \u0026 flagged)? ===\"\nfind ga4 stackoverflow crypto_bitcoin -iname 'readme*' -o -iname 'license*' -o -iname 'notice*' -o -iname 'attribution*' 2\u003e/dev/null\necho \"(end)\"\necho\necho \"=== non-.md, non-html files that exist (ignored by checker) ===\"\nfind ga4 stackoverflow crypto_bitcoin -type f ! -name '*.md' | sort | head","input":{"command":"cd /Users/pedronauck/Dev/compozy/kb/internal/okf/testdata/official\necho \"=== any log.md in official bundles? ===\"\nfind . -iname 'log.md'\necho \"(end)\"\necho\necho \"=== README/LICENSE inside individual bundles (would they be walked \u0026 flagged)? ===\"\nfind ga4 stackoverflow crypto_bitcoin -iname 'readme*' -o -iname 'license*' -o -iname 'notice*' -o -iname 'attribution*' 2\u003e/dev/null\necho \"(end)\"\necho\necho \"=== non-.md, non-html files that exist (ignored by checker) ===\"\nfind ga4 stackoverflow crypto_bitcoin -type f ! -name '*.md' | sort | head"},"raw_input":{"command":"cd /Users/pedronauck/Dev/compozy/kb/internal/okf/testdata/official\necho \"=== any log.md in official bundles? ===\"\nfind . -iname 'log.md'\necho \"(end)\"\necho\necho \"=== README/LICENSE inside individual bundles (would they be walked \u0026 flagged)? ===\"\nfind ga4 stackoverflow crypto_bitcoin -iname 'readme*' -o -iname 'license*' -o -iname 'notice*' -o -iname 'attribution*' 2\u003e/dev/null\necho \"(end)\"\necho\necho \"=== non-.md, non-html files that exist (ignored by checker) ===\"\nfind ga4 stackoverflow crypto_bitcoin -type f ! -name '*.md' | sort | head"}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:25:28.989124Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_016MoJc42b4vsDjJ1p7ieqv2","usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:25:28.991255Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_016MoJc42b4vsDjJ1p7ieqv2","tool_call_state":"completed","blocks":[{"type":"tool_result","tool_use_id":"toolu_016MoJc42b4vsDjJ1p7ieqv2","content":"```console\n=== any log.md in official bundles? ===\n(end)\n\n=== README/LICENSE inside individual bundles (would they be walked \u0026 flagged)? ===\n(end)\n\n=== non-.md, non-html files that exist (ignored by checker) ===\ncrypto_bitcoin/viz.html\nga4/viz.html\nstackoverflow/viz.html\n```"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:25:34.597171Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":""}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:25:34.598176Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":"No `log.md`"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:25:34.921101Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":", README, or LICENSE inside individual bundles — the official corpus will pass cleanly. Now"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:25:35.448298Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":" let me compile and run the affected unit tests to gather real evidence."}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:25:35.449048Z","turn":1,"type":"session.update","update":{"kind":"tool_call_started","tool_call_id":"toolu_014w8ExV3HWKJV2xQKjrjPPo","tool_call_state":"pending","blocks":[{"type":"tool_use","id":"toolu_014w8ExV3HWKJV2xQKjrjPPo","name":"Bash","title":"Terminal","raw_input":{}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:25:36.305331Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_014w8ExV3HWKJV2xQKjrjPPo","blocks":[{"type":"tool_use","id":"toolu_014w8ExV3HWKJV2xQKjrjPPo","name":"Bash","title":"cd /Users/pedronauck/Dev/compozy/kb\necho \"=== go build ./... ===\" \u0026\u0026 go build ./... 2\u003e\u00261 | head -30 \u0026\u0026 echo \"BUILD_EXIT=$?\"","input":{"command":"cd /Users/pedronauck/Dev/compozy/kb\necho \"=== go build ./... ===\" \u0026\u0026 go build ./... 2\u003e\u00261 | head -30 \u0026\u0026 echo \"BUILD_EXIT=$?\""},"raw_input":{"command":"cd /Users/pedronauck/Dev/compozy/kb\necho \"=== go build ./... ===\" \u0026\u0026 go build ./... 2\u003e\u00261 | head -30 \u0026\u0026 echo \"BUILD_EXIT=$?\""}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:25:37.761664Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_014w8ExV3HWKJV2xQKjrjPPo","usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:25:37.7633Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_014w8ExV3HWKJV2xQKjrjPPo","tool_call_state":"completed","blocks":[{"type":"tool_result","tool_use_id":"toolu_014w8ExV3HWKJV2xQKjrjPPo","content":"```console\n=== go build ./... ===\nGo build: Success\nBUILD_EXIT=0\n```"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:25:41.21788Z","turn":1,"type":"session.update","update":{"kind":"tool_call_started","tool_call_id":"toolu_01MLCPQ6nPmxh9zeGiKBsPhN","tool_call_state":"pending","blocks":[{"type":"tool_use","id":"toolu_01MLCPQ6nPmxh9zeGiKBsPhN","name":"Bash","title":"Terminal","raw_input":{}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:25:41.223054Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01MLCPQ6nPmxh9zeGiKBsPhN","blocks":[{"type":"tool_use","id":"toolu_01MLCPQ6nPmxh9zeGiKBsPhN","name":"Bash","title":"cd /Users/pedronauck/Dev/compozy/kb\necho \"=== unit tests (okf, cli, topic, vault, config) ===\" \ngo test ./internal/okf/... ./internal/cli/... ./internal/topic/... ./internal/vault/... ./internal/config/... 2\u003e\u00261 | tail -40\necho \"EXIT=${PIPESTATUS[0]}\"","input":{"command":"cd /Users/pedronauck/Dev/compozy/kb\necho \"=== unit tests (okf, cli, topic, vault, config) ===\" \ngo test ./internal/okf/... ./internal/cli/... ./internal/topic/... ./internal/vault/... ./internal/config/... 2\u003e\u00261 | tail -40\necho \"EXIT=${PIPESTATUS[0]}\""},"raw_input":{"command":"cd /Users/pedronauck/Dev/compozy/kb\necho \"=== unit tests (okf, cli, topic, vault, config) ===\" \ngo test ./internal/okf/... ./internal/cli/... ./internal/topic/... ./internal/vault/... ./internal/config/... 2\u003e\u00261 | tail -40\necho \"EXIT=${PIPESTATUS[0]}\""}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:25:43.38183Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01MLCPQ6nPmxh9zeGiKBsPhN","usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:25:43.383447Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01MLCPQ6nPmxh9zeGiKBsPhN","tool_call_state":"completed","blocks":[{"type":"tool_result","tool_use_id":"toolu_01MLCPQ6nPmxh9zeGiKBsPhN","content":"```console\n=== unit tests (okf, cli, topic, vault, config) ===\nok \tgithub.com/compozy/kb/internal/okf\t0.349s\nok \tgithub.com/compozy/kb/internal/cli\t1.406s\nok \tgithub.com/compozy/kb/internal/topic\t0.766s\nok \tgithub.com/compozy/kb/internal/vault\t1.140s\nok \tgithub.com/compozy/kb/internal/config\t0.299s\nEXIT=\n```"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:25:45.805235Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":""}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:25:45.805853Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":"Unit tests pass. Let me"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:25:46.27131Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":" run the integration tests (official bundles + end-to-end promote) since those are PRD"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:25:46.747421Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":" success metrics."}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:25:46.748198Z","turn":1,"type":"session.update","update":{"kind":"tool_call_started","tool_call_id":"toolu_019yEahD1C9up2W7WomUbxn5","tool_call_state":"pending","blocks":[{"type":"tool_use","id":"toolu_019yEahD1C9up2W7WomUbxn5","name":"Bash","title":"Terminal","raw_input":{}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:25:47.570023Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_019yEahD1C9up2W7WomUbxn5","blocks":[{"type":"tool_use","id":"toolu_019yEahD1C9up2W7WomUbxn5","name":"Bash","title":"cd /Users/pedronauck/Dev/compozy/kb\necho \"=== integration tests for okf + cli ===\" \ngo test -tags integration ./internal/okf/... ./internal/cli/... 2\u003e\u00261 | tail -40\necho \"EXIT=${PIPESTATUS[0]}\"","input":{"command":"cd /Users/pedronauck/Dev/compozy/kb\necho \"=== integration tests for okf + cli ===\" \ngo test -tags integration ./internal/okf/... ./internal/cli/... 2\u003e\u00261 | tail -40\necho \"EXIT=${PIPESTATUS[0]}\""},"raw_input":{"command":"cd /Users/pedronauck/Dev/compozy/kb\necho \"=== integration tests for okf + cli ===\" \ngo test -tags integration ./internal/okf/... ./internal/cli/... 2\u003e\u00261 | tail -40\necho \"EXIT=${PIPESTATUS[0]}\""}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:25:51.028369Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_019yEahD1C9up2W7WomUbxn5","usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:25:51.029881Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_019yEahD1C9up2W7WomUbxn5","tool_call_state":"completed","blocks":[{"type":"tool_result","tool_use_id":"toolu_019yEahD1C9up2W7WomUbxn5","content":"```console\n=== integration tests for okf + cli ===\nok \tgithub.com/compozy/kb/internal/okf\t0.306s\nok \tgithub.com/compozy/kb/internal/cli\t2.697s\nEXIT=\n```"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:25:59.04424Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":""}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:25:59.045462Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":"Both integration suites pass (official bundles + e2e promote). The project m"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:25:59.446904Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":"andates `make lint` reports **zero** findings. Let me run the linter on the changed packages, since several functions have discarded params"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:25:59.920596Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":" (`_ = bundlePath`, `_ = strict`, `_ = sourceTopicRoot`) that `unparam`/`revive` may flag."}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:26:00.39757Z","turn":1,"type":"session.update","update":{"kind":"tool_call_started","tool_call_id":"toolu_01NNZTdbNaWipqdkLPpuuo64","tool_call_state":"pending","blocks":[{"type":"tool_use","id":"toolu_01NNZTdbNaWipqdkLPpuuo64","name":"Bash","title":"Terminal","raw_input":{}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:26:01.974984Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01NNZTdbNaWipqdkLPpuuo64","blocks":[{"type":"tool_use","id":"toolu_01NNZTdbNaWipqdkLPpuuo64","name":"Bash","title":"cd /Users/pedronauck/Dev/compozy/kb\nwhich golangci-lint \u003e/dev/null 2\u003e\u00261 \u0026\u0026 echo \"golangci-lint: $(golangci-lint version 2\u003e\u00261 | head -1)\" || echo \"golangci-lint NOT on PATH\"\nls bin/ 2\u003e/dev/null | head\necho \"--- mise/tool shims ---\"\nls .bin 2\u003e/dev/null; command -v mise \u003e/dev/null 2\u003e\u00261 \u0026\u0026 echo \"mise present\"","input":{"command":"cd /Users/pedronauck/Dev/compozy/kb\nwhich golangci-lint \u003e/dev/null 2\u003e\u00261 \u0026\u0026 echo \"golangci-lint: $(golangci-lint version 2\u003e\u00261 | head -1)\" || echo \"golangci-lint NOT on PATH\"\nls bin/ 2\u003e/dev/null | head\necho \"--- mise/tool shims ---\"\nls .bin 2\u003e/dev/null; command -v mise \u003e/dev/null 2\u003e\u00261 \u0026\u0026 echo \"mise present\""},"raw_input":{"command":"cd /Users/pedronauck/Dev/compozy/kb\nwhich golangci-lint \u003e/dev/null 2\u003e\u00261 \u0026\u0026 echo \"golangci-lint: $(golangci-lint version 2\u003e\u00261 | head -1)\" || echo \"golangci-lint NOT on PATH\"\nls bin/ 2\u003e/dev/null | head\necho \"--- mise/tool shims ---\"\nls .bin 2\u003e/dev/null; command -v mise \u003e/dev/null 2\u003e\u00261 \u0026\u0026 echo \"mise present\""}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:26:02.062042Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01NNZTdbNaWipqdkLPpuuo64","usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:26:02.064722Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01NNZTdbNaWipqdkLPpuuo64","tool_call_state":"completed","blocks":[{"type":"tool_result","tool_use_id":"toolu_01NNZTdbNaWipqdkLPpuuo64","content":"```console\ngolangci-lint: golangci-lint has version 2.11.4 built with go1.26.1 from 8f3b0c7e on 2026-03-22T17:35:14Z\nkb\n--- mise/tool shims ---\nmise present\n```"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:26:05.332205Z","turn":1,"type":"session.update","update":{"kind":"tool_call_started","tool_call_id":"toolu_01WC4f6nnrYGEQvcRFarfhnF","tool_call_state":"pending","blocks":[{"type":"tool_use","id":"toolu_01WC4f6nnrYGEQvcRFarfhnF","name":"Bash","title":"Terminal","raw_input":{}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:26:05.410777Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01WC4f6nnrYGEQvcRFarfhnF","blocks":[{"type":"tool_use","id":"toolu_01WC4f6nnrYGEQvcRFarfhnF","name":"Bash","title":"cd /Users/pedronauck/Dev/compozy/kb\necho \"=== golangci-lint on changed packages (timeout 300s) ===\"\ngolangci-lint run ./internal/okf/... ./internal/cli/... ./internal/topic/... ./internal/vault/... ./internal/config/... ./internal/models/... ./internal/generate/... 2\u003e\u00261 | tail -60\necho \"EXIT=${PIPESTATUS[0]}\"","input":{"command":"cd /Users/pedronauck/Dev/compozy/kb\necho \"=== golangci-lint on changed packages (timeout 300s) ===\"\ngolangci-lint run ./internal/okf/... ./internal/cli/... ./internal/topic/... ./internal/vault/... ./internal/config/... ./internal/models/... ./internal/generate/... 2\u003e\u00261 | tail -60\necho \"EXIT=${PIPESTATUS[0]}\""},"raw_input":{"command":"cd /Users/pedronauck/Dev/compozy/kb\necho \"=== golangci-lint on changed packages (timeout 300s) ===\"\ngolangci-lint run ./internal/okf/... ./internal/cli/... ./internal/topic/... ./internal/vault/... ./internal/config/... ./internal/models/... ./internal/generate/... 2\u003e\u00261 | tail -60\necho \"EXIT=${PIPESTATUS[0]}\""}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:26:05.857551Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01WC4f6nnrYGEQvcRFarfhnF","usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:26:05.859242Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01WC4f6nnrYGEQvcRFarfhnF","tool_call_state":"completed","blocks":[{"type":"tool_result","tool_use_id":"toolu_01WC4f6nnrYGEQvcRFarfhnF","content":"```console\n=== golangci-lint on changed packages (timeout 300s) ===\n0 issues.\nEXIT=\n```"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:27:05.998421Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":""}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:27:06.162416Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":"Build"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:27:06.429296Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":", lint, unit, and integration all green. Let me check for dead wrapper functions left"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:27:06.993456Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":" behind by the refactor (a compat-shim concern the review rubric flags)."}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:27:07.378475Z","turn":1,"type":"session.update","update":{"kind":"tool_call_started","tool_call_id":"toolu_01HQJx7TPBrpgowNBk9fHuHC","tool_call_state":"pending","blocks":[{"type":"tool_use","id":"toolu_01HQJx7TPBrpgowNBk9fHuHC","name":"Bash","title":"Terminal","raw_input":{}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:27:09.307423Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01HQJx7TPBrpgowNBk9fHuHC","blocks":[{"type":"tool_use","id":"toolu_01HQJx7TPBrpgowNBk9fHuHC","name":"Bash","title":"cd /Users/pedronauck/Dev/compozy/kb\nfor fn in \"installTemplates\" \"newWithDate\" \"toSourceWikiLink\" \"WriteMetadataFileWithMode\" \"runTopicNewWithMode\"; do\n echo \"=== $fn references (all) ===\"\n grep -rn \"\\b$fn\\b\" internal/ --include=*.go | grep -v \"func $fn\" | grep -v \"func.*$fn(\" \n echo\ndone","input":{"command":"cd /Users/pedronauck/Dev/compozy/kb\nfor fn in \"installTemplates\" \"newWithDate\" \"toSourceWikiLink\" \"WriteMetadataFileWithMode\" \"runTopicNewWithMode\"; do\n echo \"=== $fn references (all) ===\"\n grep -rn \"\\b$fn\\b\" internal/ --include=*.go | grep -v \"func $fn\" | grep -v \"func.*$fn(\" \n echo\ndone"},"raw_input":{"command":"cd /Users/pedronauck/Dev/compozy/kb\nfor fn in \"installTemplates\" \"newWithDate\" \"toSourceWikiLink\" \"WriteMetadataFileWithMode\" \"runTopicNewWithMode\"; do\n echo \"=== $fn references (all) ===\"\n grep -rn \"\\b$fn\\b\" internal/ --include=*.go | grep -v \"func $fn\" | grep -v \"func.*$fn(\" \n echo\ndone"}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:27:09.368024Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01HQJx7TPBrpgowNBk9fHuHC","usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:27:09.36971Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01HQJx7TPBrpgowNBk9fHuHC","tool_call_state":"completed","blocks":[{"type":"tool_result","tool_use_id":"toolu_01HQJx7TPBrpgowNBk9fHuHC","content":"```console\n=== installTemplates references (all) ===\n(eval):4: no matches found: --include=*.go\n\n=== newWithDate references (all) ===\n(eval):4: no matches found: --include=*.go\n\n=== toSourceWikiLink references (all) ===\n(eval):4: no matches found: --include=*.go\n\n=== WriteMetadataFileWithMode references (all) ===\n(eval):4: no matches found: --include=*.go\n\n=== runTopicNewWithMode references (all) ===\n(eval):4: no matches found: --include=*.go\n```"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:27:11.298032Z","turn":1,"type":"session.update","update":{"kind":"tool_call_started","tool_call_id":"toolu_012MMY35nxPZVwFw1gQq73Mx","tool_call_state":"pending","blocks":[{"type":"tool_use","id":"toolu_012MMY35nxPZVwFw1gQq73Mx","name":"Bash","title":"Terminal","raw_input":{}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:27:13.124377Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_012MMY35nxPZVwFw1gQq73Mx","blocks":[{"type":"tool_use","id":"toolu_012MMY35nxPZVwFw1gQq73Mx","name":"Bash","title":"cd /Users/pedronauck/Dev/compozy/kb\nfor fn in installTemplates newWithDate toSourceWikiLink WriteMetadataFileWithMode; do\n echo \"=== $fn (call sites, excluding its own definition) ===\"\n grep -rn \"$fn\" internal --include=\"*.go\" | grep -v \"func $fn\"\n echo\ndone","input":{"command":"cd /Users/pedronauck/Dev/compozy/kb\nfor fn in installTemplates newWithDate toSourceWikiLink WriteMetadataFileWithMode; do\n echo \"=== $fn (call sites, excluding its own definition) ===\"\n grep -rn \"$fn\" internal --include=\"*.go\" | grep -v \"func $fn\"\n echo\ndone"},"raw_input":{"command":"cd /Users/pedronauck/Dev/compozy/kb\nfor fn in installTemplates newWithDate toSourceWikiLink WriteMetadataFileWithMode; do\n echo \"=== $fn (call sites, excluding its own definition) ===\"\n grep -rn \"$fn\" internal --include=\"*.go\" | grep -v \"func $fn\"\n echo\ndone"}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:27:13.181376Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_012MMY35nxPZVwFw1gQq73Mx","usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:27:13.184168Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_012MMY35nxPZVwFw1gQq73Mx","tool_call_state":"completed","blocks":[{"type":"tool_result","tool_use_id":"toolu_012MMY35nxPZVwFw1gQq73Mx","content":"```console\n=== installTemplates (call sites, excluding its own definition) ===\ninternal/topic/topic.go:243:\tif err := installTemplatesWithMode(topicPath, context, cleanMode); err != nil {\ninternal/topic/topic.go:513:\treturn installTemplatesWithMode(topicPath, context, models.TopicModeWiki)\ninternal/topic/topic_test.go:964:\terr := installTemplates(t.TempDir(), templateContext{\ninternal/topic/topic_test.go:971:\t\tt.Fatalf(\"installTemplates error = %v, want write error\", err)\n\n=== newWithDate (call sites, excluding its own definition) ===\ninternal/topic/topic.go:122:\treturn newWithDateWithMode(vaultPath, slug, title, domain, mode, time.Now())\ninternal/topic/topic.go:194:\treturn newWithDateWithMode(vaultPath, slug, title, domain, models.TopicModeWiki, now)\ninternal/topic/topic_test.go:19:\tinfo, err := newWithDate(\ninternal/topic/topic_test.go:27:\t\tt.Fatalf(\"newWithDate returned error: %v\", err)\ninternal/topic/topic_test.go:138:\tinfo, err := newWithDate(\ninternal/topic/topic_test.go:146:\t\tt.Fatalf(\"newWithDate returned error: %v\", err)\ninternal/topic/topic_test.go:187:\t_, err := newWithDate(\ninternal/topic/topic_test.go:195:\t\tt.Fatalf(\"newWithDate returned error: %v\", err)\ninternal/topic/topic_test.go:241:\tinfo, err := newWithDateWithMode(\ninternal/topic/topic_test.go:250:\t\tt.Fatalf(\"newWithDateWithMode returned error: %v\", err)\ninternal/topic/topic_test.go:318:\t_, err := newWithDate(\ninternal/topic/topic_test.go:326:\t\tt.Fatalf(\"newWithDate returned error: %v\", err)\ninternal/topic/topic_test.go:351:\t_, err := newWithDate(\ninternal/topic/topic_test.go:359:\t\tt.Fatalf(\"newWithDate error = %v, want already exists\", err)\ninternal/topic/topic_test.go:511:\t\t\t_, err := newWithDate(\ninternal/topic/topic_test.go:519:\t\t\t\tt.Fatalf(\"newWithDate error = %v, want substring %q\", err, tt.contains)\ninternal/topic/topic_test.go:550:\t\tif _, err := newWithDate(\ninternal/topic/topic_test.go:589:\tif _, err := newWithDate(\ninternal/topic/topic_test.go:652:\tif _, err := newWithDate(\ninternal/topic/topic_test.go:728:\tif _, err := newWithDate(\ninternal/topic/topic_test.go:765:\tif _, err := newWithDate(\ninternal/topic/topic_test.go:882:\tif _, err := newWithDate(\n\n=== toSourceWikiLink (call sites, excluding its own definition) ===\ninternal/vault/render.go:343:\t\t\tsymbolLink := toSourceWikiLink(\ninternal/vault/render.go:477:\tfileLink := toSourceWikiLink(topic, GetRawFileDocumentPath(symbol.FilePath), symbol.FilePath)\ninternal/vault/render.go:559:\t\t\tfileLinks = append(fileLinks, \"- \"+toSourceWikiLink(topic, GetRawFileDocumentPath(file.FilePath), file.FilePath))\ninternal/vault/render.go:570:\t\t\tsymbolLinks = append(symbolLinks, \"- \"+toSourceWikiLink(\ninternal/vault/render.go:636:\t\t\tfileLinks = append(fileLinks, \"- \"+toSourceWikiLink(topic, GetRawFileDocumentPath(file.FilePath), file.FilePath))\ninternal/vault/render.go:647:\t\t\tsymbolLinks = append(symbolLinks, \"- \"+toSourceWikiLink(\ninternal/vault/render_wiki.go:49:\t\tsources = append(sources, toSourceWikiLink(topic, source, \"\"))\ninternal/vault/render_wiki.go:199:\t\t\ttoSourceWikiLink(topic, source, \"\"),\ninternal/vault/render_wiki.go:250:\t\t\ttoSourceWikiLink(topic, GetRawLanguageIndexPath(language), language),\ninternal/vault/render_wiki.go:270:\t\t\ttoSourceWikiLink(topic, GetRawDirectoryIndexPath(directoryPath), directoryPath),\ninternal/vault/render_wiki.go:280:\t\t\thotspotLines = append(hotspotLines, \"- \"+toSourceWikiLink(topic, GetRawFileDocumentPath(file.FilePath), file.FilePath))\ninternal/vault/render_wiki.go:298:\t\tsourceLines = append(sourceLines, \"- \"+toSourceWikiLink(topic, source, \"\"))\ninternal/vault/render_wiki.go:364:\t\t\ttoSourceWikiLink(topic, GetRawDirectoryIndexPath(directoryPath), directoryPath),\ninternal/vault/render_wiki.go:418:\t\t\texampleLink = toSourceWikiLink(topic, GetRawSymbolDocumentPath(symbols[0]), symbols[0].Name)\ninternal/vault/render_wiki.go:513:\t\t\ttoSourceWikiLink(topic, GetRawFileDocumentPath(entry.File.FilePath), entry.File.FilePath),\ninternal/vault/render_wiki.go:589:\t\t\ttoSourceWikiLink(topic, GetRawSymbolDocumentPath(symbol), symbol.Name),\ninternal/vault/render_wiki.go:592:\t\t\ttoSourceWikiLink(topic, GetRawFileDocumentPath(symbol.FilePath), symbol.FilePath),\ninternal/vault/render_wiki.go:638:\t\t\t\"- \"+toSourceWikiLink(topic, GetRawSymbolDocumentPath(symbol), symbol.Name),\ninternal/vault/render_wiki.go:650:\t\t\t\"- \"+toSourceWikiLink(topic, GetRawFileDocumentPath(file.FilePath), file.FilePath),\ninternal/vault/render_wiki.go:727:\t\t\ttoSourceWikiLink(topic, GetRawFileDocumentPath(entry.File.FilePath), entry.File.FilePath),\ninternal/vault/render_wiki.go:742:\t\t\ttoSourceWikiLink(topic, GetRawDirectoryIndexPath(directoryPath), directoryPath),\ninternal/vault/render_wiki.go:835:\t\t\t\ttoSourceWikiLink(topic, entry.RelativePath, entry.Label),\ninternal/vault/render_wiki.go:885:\t\t\t\tlinks = append(links, toSourceWikiLink(topic, GetRawFileDocumentPath(filePath), filePath))\ninternal/vault/render_wiki.go:945:\t\t\ttoSourceWikiLink(topic, GetRawSymbolDocumentPath(entry.Symbol), entry.Symbol.Name),\ninternal/vault/render_wiki.go:948:\t\t\ttoSourceWikiLink(topic, GetRawFileDocumentPath(entry.Symbol.FilePath), entry.Symbol.FilePath),\ninternal/vault/render_wiki.go:992:\t\tlines = append(lines, \"- \"+toSourceWikiLink(topic, source, \"\"))\n\n=== WriteMetadataFileWithMode (call sites, excluding its own definition) ===\ninternal/topic/topic.go:560:// WriteMetadataFileWithMode writes structured topic metadata with an explicit mode.\n```"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:28:12.732271Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":""}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:28:12.733402Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":"All gates pass ("}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:28:13.134053Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":"build, lint, unit, integration incl. official-bundle conformance + e2e promote). Found dead exported code ("}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:28:13.604516Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":"`WriteMetadataFileWithMode`, zero callers) and a missing spec-mandated test (N-001). Let me get an ISO timestamp and confirm"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:28:14.165516Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":" the `okf check` topic-resolution constraint and `firstBodySentence` line, then write findings."}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:28:14.577546Z","turn":1,"type":"session.update","update":{"kind":"tool_call_started","tool_call_id":"toolu_01QMLAQYCtuCN2nidp8C3m2D","tool_call_state":"pending","blocks":[{"type":"tool_use","id":"toolu_01QMLAQYCtuCN2nidp8C3m2D","name":"Bash","title":"Terminal","raw_input":{}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:28:15.020857Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01QMLAQYCtuCN2nidp8C3m2D","blocks":[{"type":"tool_use","id":"toolu_01QMLAQYCtuCN2nidp8C3m2D","name":"Bash","title":"date -u +\"%Y-%m-%dT%H:%M:%SZ\"","input":{"command":"date -u +\"%Y-%m-%dT%H:%M:%SZ\""},"raw_input":{"command":"date -u +\"%Y-%m-%dT%H:%M:%SZ\""}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:28:15.024338Z","turn":1,"type":"session.update","update":{"kind":"tool_call_started","tool_call_id":"toolu_01UErmoz9LBwtudpLCyh8tdp","tool_call_state":"pending","blocks":[{"type":"tool_use","id":"toolu_01UErmoz9LBwtudpLCyh8tdp","name":"Bash","title":"Terminal","raw_input":{}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:28:15.056229Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01QMLAQYCtuCN2nidp8C3m2D","usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:28:15.058802Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01QMLAQYCtuCN2nidp8C3m2D","tool_call_state":"completed","blocks":[{"type":"tool_result","tool_use_id":"toolu_01QMLAQYCtuCN2nidp8C3m2D","content":"```console\n2026-06-28T01:28:15Z\n```"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:28:17.969974Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01UErmoz9LBwtudpLCyh8tdp","blocks":[{"type":"tool_use","id":"toolu_01UErmoz9LBwtudpLCyh8tdp","name":"Bash","title":"cd /Users/pedronauck/Dev/compozy/kb\necho \"=== okf package imports logger? ===\"\ngrep -rn \"internal/logger\\|slog\" internal/okf/*.go internal/cli/promote.go internal/cli/okf.go || echo \"NO logger import in okf/promote/okf-cli\"\necho\necho \"=== CLAUDE.md / AGENTS.md mention of promote or 'kb okf'? ===\"\ngrep -rin \"kb promote\\|kb okf\\|--mode\" CLAUDE.md AGENTS.md README.md 2\u003e/dev/null || echo \"NO mentions in CLAUDE.md/AGENTS.md/README.md\"","input":{"command":"cd /Users/pedronauck/Dev/compozy/kb\necho \"=== okf package imports logger? ===\"\ngrep -rn \"internal/logger\\|slog\" internal/okf/*.go internal/cli/promote.go internal/cli/okf.go || echo \"NO logger import in okf/promote/okf-cli\"\necho\necho \"=== CLAUDE.md / AGENTS.md mention of promote or 'kb okf'? ===\"\ngrep -rin \"kb promote\\|kb okf\\|--mode\" CLAUDE.md AGENTS.md README.md 2\u003e/dev/null || echo \"NO mentions in CLAUDE.md/AGENTS.md/README.md\""},"raw_input":{"command":"cd /Users/pedronauck/Dev/compozy/kb\necho \"=== okf package imports logger? ===\"\ngrep -rn \"internal/logger\\|slog\" internal/okf/*.go internal/cli/promote.go internal/cli/okf.go || echo \"NO logger import in okf/promote/okf-cli\"\necho\necho \"=== CLAUDE.md / AGENTS.md mention of promote or 'kb okf'? ===\"\ngrep -rin \"kb promote\\|kb okf\\|--mode\" CLAUDE.md AGENTS.md README.md 2\u003e/dev/null || echo \"NO mentions in CLAUDE.md/AGENTS.md/README.md\""}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:28:18.000297Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01UErmoz9LBwtudpLCyh8tdp","usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:28:18.002127Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01UErmoz9LBwtudpLCyh8tdp","tool_call_state":"completed","blocks":[{"type":"tool_result","tool_use_id":"toolu_01UErmoz9LBwtudpLCyh8tdp","content":"```console\n=== okf package imports logger? ===\nNO logger import in okf/promote/okf-cli\n\n=== CLAUDE.md / AGENTS.md mention of promote or 'kb okf'? ===\nNO mentions in CLAUDE.md/AGENTS.md/README.md\n```"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:28:22.443137Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":""}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:28:22.444211Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":"All find"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:28:22.915264Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":"ings confirmed. Writing the scoped findings file."}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:28:22.916308Z","turn":1,"type":"session.update","update":{"kind":"tool_call_started","tool_call_id":"toolu_018Pg8ZpPPjsFWU2P9pAzs91","tool_call_state":"pending","blocks":[{"type":"tool_use","id":"toolu_018Pg8ZpPPjsFWU2P9pAzs91","name":"Edit","title":"Write","raw_input":{}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:29:12.065043Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_018Pg8ZpPPjsFWU2P9pAzs91","blocks":[{"type":"tool_use","id":"toolu_018Pg8ZpPPjsFWU2P9pAzs91","name":"Edit","title":"Write .compozy/tasks/okf-dual-mode/qa/impl-review-findings-round1.md","input":{"content":"---\nschema_version: 1\nreview_kind: implementation\nround: 1\nverdict: FIX_BEFORE_SHIP\nreviewer_runtime: claude\nreviewer_model: opus\ngenerated_at: 2026-06-28T01:28:15Z\n---\n\n# Summary\n\nThe OKF dual-mode MVP is implemented coherently and matches the accepted ADRs: the `mode` field, OKF scaffold, `[okf].types` config, mode-aware `LinkFormatter` with the full ~43-site migration, the mechanical/non-destructive `promote`, the lenient `kb okf check`, both documented deviations (four producer fields + relative links), and the vendored official fixtures all build, lint clean, and pass unit + integration tests (including the three official-bundle conformance tests and the end-to-end promote — the PRD's explicit success metrics). The one blocker is the omission of the ADR-004/TechSpec-mandated N-001 \"OKF render smoke\" test, which was the accepted condition for migrating the dormant render-pipeline OKF branch now rather than in Phase 3, leaving that branch shipped without any test exercising it through a render function.\n\n# Blockers\n\n## B-001 — N-001 OKF render-smoke test mandated by ADR-004 is missing\n\n- File: internal/vault/render.go\n- Line: 191\n- Issue: ADR-004 (Risks → \"Dormant OKF render branch ships as dead code in the MVP (N-001)\") and the TechSpec Testing Approach (\"OKF render smoke (N-001) — render one document for a `mode: okf` topic and assert the OKF link branch and its `fromDir` plumbing execute and emit a relative markdown link, so the wired render branch is live, not shipped dead\") make this smoke test the explicit, accepted justification for migrating all ~43 link call sites now. The migration was done (every site routes through `linkFor(topic, \"\", target, label)`), but no test ever invokes a render function (`RenderDocuments`/`renderDashboard`/`renderConceptIndex`/etc.) with `TopicMetadata{Mode: TopicModeOKF}`. The only OKF-mode coverage is `internal/vault/pathutils_test.go:232`, which calls `vault.LinkFormatterFor(...).Link(...)` directly — it exercises the formatter selection but not the render-site `linkFor` plumbing with `fromDir`. The render-site OKF branch therefore ships effectively untested, which is precisely the dead-code outcome N-001 was added to prevent. The codex plan's Test Plan silently dropped N-001 from the TechSpec.\n- Rationale: This is an architectural decision recorded in an accepted ADR that is \"implemented differently than specified\" — the migration landed but its required guard test did not. Project rules (CLAUDE.md / AGENTS.md \"Treat test failures as behavior bugs first\"; the global \"no partial deliverables\" rule) and the review's dead-code / test-shape criteria make shipping a wired-but-unexercised branch without the spec-mandated smoke test a fix-before-ship gap. Remediation is local and the change shape is correct.\n- Suggested fix: Add one table-friendly test in `internal/vault` that builds a minimal graph/metrics fixture (reuse `testTopicFixture()`), sets `topic.Mode = models.TopicModeOKF`, calls `vault.RenderDocuments(...)` (or a single render helper), and asserts at least one emitted body contains a relative markdown link (`[Label](...md)`) and no `[[wikilink]]`, confirming the OKF branch and `fromDir` plumbing execute through the render path.\n\n# Risks\n\n## R-001 — No structured logging despite the TechSpec observability contract\n\n- File: internal/okf/okf.go\n- Line: 1\n- Issue: The TechSpec \"Monitoring and Observability\" section enumerates required `internal/logger` (slog) events for both verbs — `promote` source/target/type, link-transform count, broken-link count, `promote rejected: target not okf` (N-202), `description fallback used`; and `okf check` files-scanned, `reserved file skipped` (N-205), errors/warnings/strict flag. The `internal/okf` package and the new `internal/cli/promote.go` / `internal/cli/okf.go` import no logger and emit none of these. Operators get only the final JSON/table output and have no debug trail for unresolved links, description fallbacks, or skipped files.\n- Suggested fix: Thread `internal/logger` into `Promote`/`Check` (or log at the CLI layer from `ConceptResult`/issues) for the events listed in the TechSpec, at debug/info level.\n\n## R-002 — New user-facing commands are not documented in the project CLI surface\n\n- File: CLAUDE.md\n- Line: null\n- Issue: `kb promote`, the `kb okf` group / `kb okf check`, and `topic new --mode` are absent from the CLI command tables in CLAUDE.md (\"CLI Surface\"), AGENTS.md (\"CLI Commands\"), and README. `config.example.toml` was correctly updated with `[okf]`, but the command docs now drift from the shipped surface, and these files are the canonical agent/operator references for what `kb` exposes. A hard-cut change should touch code, config, and docs together.\n- Suggested fix: Add `kb promote`, `kb okf {check}`, and the `topic new --mode wiki|okf` flag to the CLI tables/notes in CLAUDE.md and AGENTS.md (and README if it lists commands), in the same change.\n\n## R-003 — `kb okf check` cannot validate arbitrary/external bundles the checker supports\n\n- File: internal/cli/okf.go\n- Line: 66\n- Issue: ADR-005 states the checker \"takes the resolved bundle (topic) path; it does not require `mode: okf` so it can validate arbitrary directories and external bundles.\" The underlying `okf.Check` honors this (the integration test runs it on raw `testdata/official/...` paths). The CLI, however, resolves the argument through `ktopic.Info(vaultPath, topicSlug)`, which requires a valid kb topic (e.g. a readable `CLAUDE.md`/`topic.yaml`), so `kb okf check \u003csome-external-dir\u003e` cannot be run from the CLI. This is acceptable for the MVP (the operator's own bundle is a kb topic and the success metric is met via the direct `Check` integration test), but it narrows the documented capability and will need revisiting for Phase-2 ingest.\n- Suggested fix: Note the MVP limitation, or allow `okf check` to fall back to treating the argument as a direct bundle path when topic resolution fails, so it matches the `Check` contract.\n\n# Nits\n\n## N-001 — Dead exported function `WriteMetadataFileWithMode`\n\n- File: internal/topic/topic.go\n- Line: 561\n- Issue: `WriteMetadataFileWithMode` is exported but has zero callers (production or test); `internal/vault/writer.go` still uses the mode-less `WriteMetadataFile`. It is speculative dead API surface (the linter won't catch it because it is exported).\n- Suggested fix: Delete `WriteMetadataFileWithMode`, or route the one real caller (`writer.go:106`) through it with an explicit wiki mode if a written `mode:` line is desired there.\n\n## N-002 — `firstBodySentence` returns the entire body when no sentence delimiter exists\n\n- File: internal/okf/okf.go\n- Line: 446\n- Issue: The TechSpec description fallback is \"first non-empty body sentence (markdown-stripped) → else empty + warning.\" When the body has text but no `. `/`! `/`? ` delimiter, `firstBodySentence` returns the whole normalized body, producing a potentially very long `description` with no warning instead of a single sentence.\n- Suggested fix: Cap the no-delimiter fallback (e.g. first line, or first N chars) or emit the empty-description warning when no sentence boundary is found.\n\n## N-003 — Unreachable symlink-directory branch in the conformance walk\n\n- File: internal/okf/okf.go\n- Line: 177\n- Issue: In `Check` (and identically in `loadConcepts`), `if entry.Type()\u0026fs.ModeSymlink != 0 { if entry.IsDir() { return filepath.SkipDir } ... }` — for a symlink, `fs.DirEntry.IsDir()` is always false (it reflects `Type().IsDir()`), and `filepath.WalkDir` never descends into symlinked directories anyway, so the inner `SkipDir` branch is dead.\n- Suggested fix: Drop the `entry.IsDir()` sub-branch for symlinks (always `return nil`), or add a comment noting it is defensive.\n\n# Evidence\n\n- Read in full: PRD (`_prd.md`), TechSpec (`_techspec.md`), ADR-001..006, codex plan `20260627-220335-okf-dual-mode.md`; project rules CLAUDE.md, AGENTS.md, CONTRIBUTING.md.\n- Read in full: `internal/okf/okf.go`, `internal/okf/okf_test.go`, `internal/okf/official_integration_test.go`, `internal/cli/promote.go`, `internal/cli/okf.go`, `internal/cli/okf_test.go`, `internal/cli/okf_integration_test.go`, `internal/cli/topic.go` (diff), `internal/topic/topic.go` (diff + on-disk), `internal/vault/pathutils.go`, `internal/frontmatter/frontmatter.go`, `internal/models/kb_models.go`; reviewed the raw patch hunks for `models.go`, `config.go`, `config_test.go`, `generate.go`, `render.go`, `render_wiki.go`, `writer.go`, `topic_test.go`, `pathutils_test.go`, and the `okf-claude-template.md` asset.\n- `go build ./...` — Success.\n- `go test ./internal/okf/... ./internal/cli/... ./internal/topic/... ./internal/vault/... ./internal/config/...` — all `ok`.\n- `go test -tags integration ./internal/okf/... ./internal/cli/...` — all `ok` (official ga4/stackoverflow/crypto_bitcoin lenient conformance + end-to-end promote/check pass).\n- `golangci-lint run` (v2.11.4) over the changed packages — `0 issues.`\n- Inspected `internal/okf/testdata/official`: ga4 (17 md), stackoverflow (53 md), crypto_bitcoin (8 md); root and nested `index.md` files carry no frontmatter; every concept `.md` has a `type:`; no `log.md`/README/LICENSE inside the individual bundles; only `viz.html` non-md files — confirming the lenient checker classifies the corpus correctly.\n- Verified zero-regression coverage: existing `internal/vault/render_test.go` asserts exact `[[demo-repo/...|...]]` wikilink output through `RenderDocuments`, so the wiki branch of the migration is byte-verified by the existing suite (ADR-004 mitigation satisfied for the wiki side only).\n- Confirmed no logger usage in `internal/okf` / new CLI files, and no mention of the new commands in CLAUDE.md/AGENTS.md/README.\n- Limitation: did not run the full `make verify` (mage/mise gating noted in the plan); evidence above used direct `go`/`golangci-lint` invocations on the affected packages.\n\n# Deferred Or Follow-Up\n\n- `--to \u003ctopic\u003e/\u003csubdir\u003e` promotion is intentionally deferred (codex plan Assumptions); flat-root concepts only for MVP — no action needed now, but track for Phase 2.\n- Phase 2/3 surfaces (`kb okf export`, `kb okf ingest`, codebase→OKF) are out of scope and correctly absent.\n- Consider consolidating the duplicated bundle-walk logic shared by `Check` and `loadConcepts` (entry filtering, exclusion set, posix-rel derivation) into one helper before Phase 2 grows the package.","file_path":"/Users/pedronauck/Dev/compozy/kb/.compozy/tasks/okf-dual-mode/qa/impl-review-findings-round1.md"},"raw_input":{"content":"---\nschema_version: 1\nreview_kind: implementation\nround: 1\nverdict: FIX_BEFORE_SHIP\nreviewer_runtime: claude\nreviewer_model: opus\ngenerated_at: 2026-06-28T01:28:15Z\n---\n\n# Summary\n\nThe OKF dual-mode MVP is implemented coherently and matches the accepted ADRs: the `mode` field, OKF scaffold, `[okf].types` config, mode-aware `LinkFormatter` with the full ~43-site migration, the mechanical/non-destructive `promote`, the lenient `kb okf check`, both documented deviations (four producer fields + relative links), and the vendored official fixtures all build, lint clean, and pass unit + integration tests (including the three official-bundle conformance tests and the end-to-end promote — the PRD's explicit success metrics). The one blocker is the omission of the ADR-004/TechSpec-mandated N-001 \"OKF render smoke\" test, which was the accepted condition for migrating the dormant render-pipeline OKF branch now rather than in Phase 3, leaving that branch shipped without any test exercising it through a render function.\n\n# Blockers\n\n## B-001 — N-001 OKF render-smoke test mandated by ADR-004 is missing\n\n- File: internal/vault/render.go\n- Line: 191\n- Issue: ADR-004 (Risks → \"Dormant OKF render branch ships as dead code in the MVP (N-001)\") and the TechSpec Testing Approach (\"OKF render smoke (N-001) — render one document for a `mode: okf` topic and assert the OKF link branch and its `fromDir` plumbing execute and emit a relative markdown link, so the wired render branch is live, not shipped dead\") make this smoke test the explicit, accepted justification for migrating all ~43 link call sites now. The migration was done (every site routes through `linkFor(topic, \"\", target, label)`), but no test ever invokes a render function (`RenderDocuments`/`renderDashboard`/`renderConceptIndex`/etc.) with `TopicMetadata{Mode: TopicModeOKF}`. The only OKF-mode coverage is `internal/vault/pathutils_test.go:232`, which calls `vault.LinkFormatterFor(...).Link(...)` directly — it exercises the formatter selection but not the render-site `linkFor` plumbing with `fromDir`. The render-site OKF branch therefore ships effectively untested, which is precisely the dead-code outcome N-001 was added to prevent. The codex plan's Test Plan silently dropped N-001 from the TechSpec.\n- Rationale: This is an architectural decision recorded in an accepted ADR that is \"implemented differently than specified\" — the migration landed but its required guard test did not. Project rules (CLAUDE.md / AGENTS.md \"Treat test failures as behavior bugs first\"; the global \"no partial deliverables\" rule) and the review's dead-code / test-shape criteria make shipping a wired-but-unexercised branch without the spec-mandated smoke test a fix-before-ship gap. Remediation is local and the change shape is correct.\n- Suggested fix: Add one table-friendly test in `internal/vault` that builds a minimal graph/metrics fixture (reuse `testTopicFixture()`), sets `topic.Mode = models.TopicModeOKF`, calls `vault.RenderDocuments(...)` (or a single render helper), and asserts at least one emitted body contains a relative markdown link (`[Label](...md)`) and no `[[wikilink]]`, confirming the OKF branch and `fromDir` plumbing execute through the render path.\n\n# Risks\n\n## R-001 — No structured logging despite the TechSpec observability contract\n\n- File: internal/okf/okf.go\n- Line: 1\n- Issue: The TechSpec \"Monitoring and Observability\" section enumerates required `internal/logger` (slog) events for both verbs — `promote` source/target/type, link-transform count, broken-link count, `promote rejected: target not okf` (N-202), `description fallback used`; and `okf check` files-scanned, `reserved file skipped` (N-205), errors/warnings/strict flag. The `internal/okf` package and the new `internal/cli/promote.go` / `internal/cli/okf.go` import no logger and emit none of these. Operators get only the final JSON/table output and have no debug trail for unresolved links, description fallbacks, or skipped files.\n- Suggested fix: Thread `internal/logger` into `Promote`/`Check` (or log at the CLI layer from `ConceptResult`/issues) for the events listed in the TechSpec, at debug/info level.\n\n## R-002 — New user-facing commands are not documented in the project CLI surface\n\n- File: CLAUDE.md\n- Line: null\n- Issue: `kb promote`, the `kb okf` group / `kb okf check`, and `topic new --mode` are absent from the CLI command tables in CLAUDE.md (\"CLI Surface\"), AGENTS.md (\"CLI Commands\"), and README. `config.example.toml` was correctly updated with `[okf]`, but the command docs now drift from the shipped surface, and these files are the canonical agent/operator references for what `kb` exposes. A hard-cut change should touch code, config, and docs together.\n- Suggested fix: Add `kb promote`, `kb okf {check}`, and the `topic new --mode wiki|okf` flag to the CLI tables/notes in CLAUDE.md and AGENTS.md (and README if it lists commands), in the same change.\n\n## R-003 — `kb okf check` cannot validate arbitrary/external bundles the checker supports\n\n- File: internal/cli/okf.go\n- Line: 66\n- Issue: ADR-005 states the checker \"takes the resolved bundle (topic) path; it does not require `mode: okf` so it can validate arbitrary directories and external bundles.\" The underlying `okf.Check` honors this (the integration test runs it on raw `testdata/official/...` paths). The CLI, however, resolves the argument through `ktopic.Info(vaultPath, topicSlug)`, which requires a valid kb topic (e.g. a readable `CLAUDE.md`/`topic.yaml`), so `kb okf check \u003csome-external-dir\u003e` cannot be run from the CLI. This is acceptable for the MVP (the operator's own bundle is a kb topic and the success metric is met via the direct `Check` integration test), but it narrows the documented capability and will need revisiting for Phase-2 ingest.\n- Suggested fix: Note the MVP limitation, or allow `okf check` to fall back to treating the argument as a direct bundle path when topic resolution fails, so it matches the `Check` contract.\n\n# Nits\n\n## N-001 — Dead exported function `WriteMetadataFileWithMode`\n\n- File: internal/topic/topic.go\n- Line: 561\n- Issue: `WriteMetadataFileWithMode` is exported but has zero callers (production or test); `internal/vault/writer.go` still uses the mode-less `WriteMetadataFile`. It is speculative dead API surface (the linter won't catch it because it is exported).\n- Suggested fix: Delete `WriteMetadataFileWithMode`, or route the one real caller (`writer.go:106`) through it with an explicit wiki mode if a written `mode:` line is desired there.\n\n## N-002 — `firstBodySentence` returns the entire body when no sentence delimiter exists\n\n- File: internal/okf/okf.go\n- Line: 446\n- Issue: The TechSpec description fallback is \"first non-empty body sentence (markdown-stripped) → else empty + warning.\" When the body has text but no `. `/`! `/`? ` delimiter, `firstBodySentence` returns the whole normalized body, producing a potentially very long `description` with no warning instead of a single sentence.\n- Suggested fix: Cap the no-delimiter fallback (e.g. first line, or first N chars) or emit the empty-description warning when no sentence boundary is found.\n\n## N-003 — Unreachable symlink-directory branch in the conformance walk\n\n- File: internal/okf/okf.go\n- Line: 177\n- Issue: In `Check` (and identically in `loadConcepts`), `if entry.Type()\u0026fs.ModeSymlink != 0 { if entry.IsDir() { return filepath.SkipDir } ... }` — for a symlink, `fs.DirEntry.IsDir()` is always false (it reflects `Type().IsDir()`), and `filepath.WalkDir` never descends into symlinked directories anyway, so the inner `SkipDir` branch is dead.\n- Suggested fix: Drop the `entry.IsDir()` sub-branch for symlinks (always `return nil`), or add a comment noting it is defensive.\n\n# Evidence\n\n- Read in full: PRD (`_prd.md`), TechSpec (`_techspec.md`), ADR-001..006, codex plan `20260627-220335-okf-dual-mode.md`; project rules CLAUDE.md, AGENTS.md, CONTRIBUTING.md.\n- Read in full: `internal/okf/okf.go`, `internal/okf/okf_test.go`, `internal/okf/official_integration_test.go`, `internal/cli/promote.go`, `internal/cli/okf.go`, `internal/cli/okf_test.go`, `internal/cli/okf_integration_test.go`, `internal/cli/topic.go` (diff), `internal/topic/topic.go` (diff + on-disk), `internal/vault/pathutils.go`, `internal/frontmatter/frontmatter.go`, `internal/models/kb_models.go`; reviewed the raw patch hunks for `models.go`, `config.go`, `config_test.go`, `generate.go`, `render.go`, `render_wiki.go`, `writer.go`, `topic_test.go`, `pathutils_test.go`, and the `okf-claude-template.md` asset.\n- `go build ./...` — Success.\n- `go test ./internal/okf/... ./internal/cli/... ./internal/topic/... ./internal/vault/... ./internal/config/...` — all `ok`.\n- `go test -tags integration ./internal/okf/... ./internal/cli/...` — all `ok` (official ga4/stackoverflow/crypto_bitcoin lenient conformance + end-to-end promote/check pass).\n- `golangci-lint run` (v2.11.4) over the changed packages — `0 issues.`\n- Inspected `internal/okf/testdata/official`: ga4 (17 md), stackoverflow (53 md), crypto_bitcoin (8 md); root and nested `index.md` files carry no frontmatter; every concept `.md` has a `type:`; no `log.md`/README/LICENSE inside the individual bundles; only `viz.html` non-md files — confirming the lenient checker classifies the corpus correctly.\n- Verified zero-regression coverage: existing `internal/vault/render_test.go` asserts exact `[[demo-repo/...|...]]` wikilink output through `RenderDocuments`, so the wiki branch of the migration is byte-verified by the existing suite (ADR-004 mitigation satisfied for the wiki side only).\n- Confirmed no logger usage in `internal/okf` / new CLI files, and no mention of the new commands in CLAUDE.md/AGENTS.md/README.\n- Limitation: did not run the full `make verify` (mage/mise gating noted in the plan); evidence above used direct `go`/`golangci-lint` invocations on the affected packages.\n\n# Deferred Or Follow-Up\n\n- `--to \u003ctopic\u003e/\u003csubdir\u003e` promotion is intentionally deferred (codex plan Assumptions); flat-root concepts only for MVP — no action needed now, but track for Phase 2.\n- Phase 2/3 surfaces (`kb okf export`, `kb okf ingest`, codebase→OKF) are out of scope and correctly absent.\n- Consider consolidating the duplicated bundle-walk logic shared by `Check` and `loadConcepts` (entry filtering, exclusion set, posix-rel derivation) into one helper before Phase 2 grows the package.\n","file_path":"/Users/pedronauck/Dev/compozy/kb/.compozy/tasks/okf-dual-mode/qa/impl-review-findings-round1.md"}},{"type":"diff","file_path":"/Users/pedronauck/Dev/compozy/kb/.compozy/tasks/okf-dual-mode/qa/impl-review-findings-round1.md","diff":"+++ /Users/pedronauck/Dev/compozy/kb/.compozy/tasks/okf-dual-mode/qa/impl-review-findings-round1.md\n---\nschema_version: 1\nreview_kind: implementation\nround: 1\nverdict: FIX_BEFORE_SHIP\nreviewer_runtime: claude\nreviewer_model: opus\ngenerated_at: 2026-06-28T01:28:15Z\n---\n\n# Summary\n\nThe OKF dual-mode MVP is implemented coherently and matches the accepted ADRs: the `mode` field, OKF scaffold, `[okf].types` config, mode-aware `LinkFormatter` with the full ~43-site migration, the mechanical/non-destructive `promote`, the lenient `kb okf check`, both documented deviations (four producer fields + relative links), and the vendored official fixtures all build, lint clean, and pass unit + integration tests (including the three official-bundle conformance tests and the end-to-end promote — the PRD's explicit success metrics). The one blocker is the omission of the ADR-004/TechSpec-mandated N-001 \"OKF render smoke\" test, which was the accepted condition for migrating the dormant render-pipeline OKF branch now rather than in Phase 3, leaving that branch shipped without any test exercising it through a render function.\n\n# Blockers\n\n## B-001 — N-001 OKF render-smoke test mandated by ADR-004 is missing\n\n- File: internal/vault/render.go\n- Line: 191\n- Issue: ADR-004 (Risks → \"Dormant OKF render branch ships as dead code in the MVP (N-001)\") and the TechSpec Testing Approach (\"OKF render smoke (N-001) — render one document for a `mode: okf` topic and assert the OKF link branch and its `fromDir` plumbing execute and emit a relative markdown link, so the wired render branch is live, not shipped dead\") make this smoke test the explicit, accepted justification for migrating all ~43 link call sites now. The migration was done (every site routes through `linkFor(topic, \"\", target, label)`), but no test ever invokes a render function (`RenderDocuments`/`renderDashboard`/`renderConceptIndex`/etc.) with `TopicMetadata{Mode: TopicModeOKF}`. The only OKF-mode coverage is `internal/vault/pathutils_test.go:232`, which calls `vault.LinkFormatterFor(...).Link(...)` directly — it exercises the formatter selection but not the render-site `linkFor` plumbing with `fromDir`. The render-site OKF branch therefore ships effectively untested, which is precisely the dead-code outcome N-001 was added to prevent. The codex plan's Test Plan silently dropped N-001 from the TechSpec.\n- Rationale: This is an architectural decision recorded in an accepted ADR that is \"implemented differently than specified\" — the migration landed but its required guard test did not. Project rules (CLAUDE.md / AGENTS.md \"Treat test failures as behavior bugs first\"; the global \"no partial deliverables\" rule) and the review's dead-code / test-shape criteria make shipping a wired-but-unexercised branch without the spec-mandated smoke test a fix-before-ship gap. Remediation is local and the change shape is correct.\n- Suggested fix: Add one table-friendly test in `internal/vault` that builds a minimal graph/metrics fixture (reuse `testTopicFixture()`), sets `topic.Mode = models.TopicModeOKF`, calls `vault.RenderDocuments(...)` (or a single render helper), and asserts at least one emitted body contains a relative markdown link (`[Label](...md)`) and no `[[wikilink]]`, confirming the OKF branch and `fromDir` plumbing execute through the render path.\n\n# Risks\n\n## R-001 — No structured logging despite the TechSpec observability contract\n\n- File: internal/okf/okf.go\n- Line: 1\n- Issue: The TechSpec \"Monitoring and Observability\" section enumerates required `internal/logger` (slog) events for both verbs — `promote` source/target/type, link-transform count, broken-link count, `promote rejected: target not okf` (N-202), `description fallback used`; and `okf check` files-scanned, `reserved file skipped` (N-205), errors/warnings/strict flag. The `internal/okf` package and the new `internal/cli/promote.go` / `internal/cli/okf.go` import no logger and emit none of these. Operators get only the final JSON/table output and have no debug trail for unresolved links, description fallbacks, or skipped files.\n- Suggested fix: Thread `internal/logger` into `Promote`/`Check` (or log at the CLI layer from `ConceptResult`/issues) for the events listed in the TechSpec, at debug/info level.\n\n## R-002 — New user-facing commands are not documented in the project CLI surface\n\n- File: CLAUDE.md\n- Line: null\n- Issue: `kb promote`, the `kb okf` group / `kb okf check`, and `topic new --mode` are absent from the CLI command tables in CLAUDE.md (\"CLI Surface\"), AGENTS.md (\"CLI Commands\"), and README. `config.example.toml` was correctly updated with `[okf]`, but the command docs now drift from the shipped surface, and these files are the canonical agent/operator references for what `kb` exposes. A hard-cut change should touch code, config, and docs together.\n- Suggested fix: Add `kb promote`, `kb okf {check}`, and the `topic new --mode wiki|okf` flag to the CLI tables/notes in CLAUDE.md and AGENTS.md (and README if it lists commands), in the same change.\n\n## R-003 — `kb okf check` cannot validate arbitrary/external bundles the checker supports\n\n- File: internal/cli/okf.go\n- Line: 66\n- Issue: ADR-005 states the checker \"takes the resolved bundle (topic) path; it does not require `mode: okf` so it can validate arbitrary directories and external bundles.\" The underlying `okf.Check` honors this (the integration test runs it on raw `testdata/official/...` paths). The CLI, however, resolves the argument through `ktopic.Info(vaultPath, topicSlug)`, which requires a valid kb topic (e.g. a readable `CLAUDE.md`/`topic.yaml`), so `kb okf check \u003csome-external-dir\u003e` cannot be run from the CLI. This is acceptable for the MVP (the operator's own bundle is a kb topic and the success metric is met via the direct `Check` integration test), but it narrows the documented capability and will need revisiting for Phase-2 ingest.\n- Suggested fix: Note the MVP limitation, or allow `okf check` to fall back to treating the argument as a direct bundle path when topic resolution fails, so it matches the `Check` contract.\n\n# Nits\n\n## N-001 — Dead exported function `WriteMetadataFileWithMode`\n\n- File: internal/topic/topic.go\n- Line: 561\n- Issue: `WriteMetadataFileWithMode` is exported but has zero callers (production or test); `internal/vault/writer.go` still uses the mode-less `WriteMetadataFile`. It is speculative dead API surface (the linter won't catch it because it is exported).\n- Suggested fix: Delete `WriteMetadataFileWithMode`, or route the one real caller (`writer.go:106`) through it with an explicit wiki mode if a written `mode:` line is desired there.\n\n## N-002 — `firstBodySentence` returns the entire body when no sentence delimiter exists\n\n- File: internal/okf/okf.go\n- Line: 446\n- Issue: The TechSpec description fallback is \"first non-empty body sentence (markdown-stripped) → else empty + warning.\" When the body has text but no `. `/`! `/`? ` delimiter, `firstBodySentence` returns the whole normalized body, producing a potentially very long `description` with no warning instead of a single sentence.\n- Suggested fix: Cap the no-delimiter fallback (e.g. first line, or first N chars) or emit the empty-description warning when no sentence boundary is found.\n\n## N-003 — Unreachable symlink-directory branch in the conformance walk\n\n- File: internal/okf/okf.go\n- Line: 177\n- Issue: In `Check` (and identically in `loadConcepts`), `if entry.Type()\u0026fs.ModeSymlink != 0 { if entry.IsDir() { return filepath.SkipDir } ... }` — for a symlink, `fs.DirEntry.IsDir()` is always false (it reflects `Type().IsDir()`), and `filepath.WalkDir` never descends into symlinked directories anyway, so the inner `SkipDir` branch is dead.\n- Suggested fix: Drop the `entry.IsDir()` sub-branch for symlinks (always `return nil`), or add a comment noting it is defensive.\n\n# Evidence\n\n- Read in full: PRD (`_prd.md`), TechSpec (`_techspec.md`), ADR-001..006, codex plan `20260627-220335-okf-dual-mode.md`; project rules CLAUDE.md, AGENTS.md, CONTRIBUTING.md.\n- Read in full: `internal/okf/okf.go`, `internal/okf/okf_test.go`, `internal/okf/official_integration_test.go`, `internal/cli/promote.go`, `internal/cli/okf.go`, `internal/cli/okf_test.go`, `internal/cli/okf_integration_test.go`, `internal/cli/topic.go` (diff), `internal/topic/topic.go` (diff + on-disk), `internal/vault/pathutils.go`, `internal/frontmatter/frontmatter.go`, `internal/models/kb_models.go`; reviewed the raw patch hunks for `models.go`, `config.go`, `config_test.go`, `generate.go`, `render.go`, `render_wiki.go`, `writer.go`, `topic_test.go`, `pathutils_test.go`, and the `okf-claude-template.md` asset.\n- `go build ./...` — Success.\n- `go test ./internal/okf/... ./internal/cli/... ./internal/topic/... ./internal/vault/... ./internal/config/...` — all `ok`.\n- `go test -tags integration ./internal/okf/... ./internal/cli/...` — all `ok` (official ga4/stackoverflow/crypto_bitcoin lenient conformance + end-to-end promote/check pass).\n- `golangci-lint run` (v2.11.4) over the changed packages — `0 issues.`\n- Inspected `internal/okf/testdata/official`: ga4 (17 md), stackoverflow (53 md), crypto_bitcoin (8 md); root and nested `index.md` files carry no frontmatter; every concept `.md` has a `type:`; no `log.md`/README/LICENSE inside the individual bundles; only `viz.html` non-md files — confirming the lenient checker classifies the corpus correctly.\n- Verified zero-regression coverage: existing `internal/vault/render_test.go` asserts exact `[[demo-repo/...|...]]` wikilink output through `RenderDocuments`, so the wiki branch of the migration is byte-verified by the existing suite (ADR-004 mitigation satisfied for the wiki side only).\n- Confirmed no logger usage in `internal/okf` / new CLI files, and no mention of the new commands in CLAUDE.md/AGENTS.md/README.\n- Limitation: did not run the full `make verify` (mage/mise gating noted in the plan); evidence above used direct `go`/`golangci-lint` invocations on the affected packages.\n\n# Deferred Or Follow-Up\n\n- `--to \u003ctopic\u003e/\u003csubdir\u003e` promotion is intentionally deferred (codex plan Assumptions); flat-root concepts only for MVP — no action needed now, but track for Phase 2.\n- Phase 2/3 surfaces (`kb okf export`, `kb okf ingest`, codebase→OKF) are out of scope and correctly absent.\n- Consider consolidating the duplicated bundle-walk logic shared by `Check` and `loadConcepts` (entry filtering, exclusion set, posix-rel derivation) into one helper before Phase 2 grows the package.\n","new_text":"---\nschema_version: 1\nreview_kind: implementation\nround: 1\nverdict: FIX_BEFORE_SHIP\nreviewer_runtime: claude\nreviewer_model: opus\ngenerated_at: 2026-06-28T01:28:15Z\n---\n\n# Summary\n\nThe OKF dual-mode MVP is implemented coherently and matches the accepted ADRs: the `mode` field, OKF scaffold, `[okf].types` config, mode-aware `LinkFormatter` with the full ~43-site migration, the mechanical/non-destructive `promote`, the lenient `kb okf check`, both documented deviations (four producer fields + relative links), and the vendored official fixtures all build, lint clean, and pass unit + integration tests (including the three official-bundle conformance tests and the end-to-end promote — the PRD's explicit success metrics). The one blocker is the omission of the ADR-004/TechSpec-mandated N-001 \"OKF render smoke\" test, which was the accepted condition for migrating the dormant render-pipeline OKF branch now rather than in Phase 3, leaving that branch shipped without any test exercising it through a render function.\n\n# Blockers\n\n## B-001 — N-001 OKF render-smoke test mandated by ADR-004 is missing\n\n- File: internal/vault/render.go\n- Line: 191\n- Issue: ADR-004 (Risks → \"Dormant OKF render branch ships as dead code in the MVP (N-001)\") and the TechSpec Testing Approach (\"OKF render smoke (N-001) — render one document for a `mode: okf` topic and assert the OKF link branch and its `fromDir` plumbing execute and emit a relative markdown link, so the wired render branch is live, not shipped dead\") make this smoke test the explicit, accepted justification for migrating all ~43 link call sites now. The migration was done (every site routes through `linkFor(topic, \"\", target, label)`), but no test ever invokes a render function (`RenderDocuments`/`renderDashboard`/`renderConceptIndex`/etc.) with `TopicMetadata{Mode: TopicModeOKF}`. The only OKF-mode coverage is `internal/vault/pathutils_test.go:232`, which calls `vault.LinkFormatterFor(...).Link(...)` directly — it exercises the formatter selection but not the render-site `linkFor` plumbing with `fromDir`. The render-site OKF branch therefore ships effectively untested, which is precisely the dead-code outcome N-001 was added to prevent. The codex plan's Test Plan silently dropped N-001 from the TechSpec.\n- Rationale: This is an architectural decision recorded in an accepted ADR that is \"implemented differently than specified\" — the migration landed but its required guard test did not. Project rules (CLAUDE.md / AGENTS.md \"Treat test failures as behavior bugs first\"; the global \"no partial deliverables\" rule) and the review's dead-code / test-shape criteria make shipping a wired-but-unexercised branch without the spec-mandated smoke test a fix-before-ship gap. Remediation is local and the change shape is correct.\n- Suggested fix: Add one table-friendly test in `internal/vault` that builds a minimal graph/metrics fixture (reuse `testTopicFixture()`), sets `topic.Mode = models.TopicModeOKF`, calls `vault.RenderDocuments(...)` (or a single render helper), and asserts at least one emitted body contains a relative markdown link (`[Label](...md)`) and no `[[wikilink]]`, confirming the OKF branch and `fromDir` plumbing execute through the render path.\n\n# Risks\n\n## R-001 — No structured logging despite the TechSpec observability contract\n\n- File: internal/okf/okf.go\n- Line: 1\n- Issue: The TechSpec \"Monitoring and Observability\" section enumerates required `internal/logger` (slog) events for both verbs — `promote` source/target/type, link-transform count, broken-link count, `promote rejected: target not okf` (N-202), `description fallback used`; and `okf check` files-scanned, `reserved file skipped` (N-205), errors/warnings/strict flag. The `internal/okf` package and the new `internal/cli/promote.go` / `internal/cli/okf.go` import no logger and emit none of these. Operators get only the final JSON/table output and have no debug trail for unresolved links, description fallbacks, or skipped files.\n- Suggested fix: Thread `internal/logger` into `Promote`/`Check` (or log at the CLI layer from `ConceptResult`/issues) for the events listed in the TechSpec, at debug/info level.\n\n## R-002 — New user-facing commands are not documented in the project CLI surface\n\n- File: CLAUDE.md\n- Line: null\n- Issue: `kb promote`, the `kb okf` group / `kb okf check`, and `topic new --mode` are absent from the CLI command tables in CLAUDE.md (\"CLI Surface\"), AGENTS.md (\"CLI Commands\"), and README. `config.example.toml` was correctly updated with `[okf]`, but the command docs now drift from the shipped surface, and these files are the canonical agent/operator references for what `kb` exposes. A hard-cut change should touch code, config, and docs together.\n- Suggested fix: Add `kb promote`, `kb okf {check}`, and the `topic new --mode wiki|okf` flag to the CLI tables/notes in CLAUDE.md and AGENTS.md (and README if it lists commands), in the same change.\n\n## R-003 — `kb okf check` cannot validate arbitrary/external bundles the checker supports\n\n- File: internal/cli/okf.go\n- Line: 66\n- Issue: ADR-005 states the checker \"takes the resolved bundle (topic) path; it does not require `mode: okf` so it can validate arbitrary directories and external bundles.\" The underlying `okf.Check` honors this (the integration test runs it on raw `testdata/official/...` paths). The CLI, however, resolves the argument through `ktopic.Info(vaultPath, topicSlug)`, which requires a valid kb topic (e.g. a readable `CLAUDE.md`/`topic.yaml`), so `kb okf check \u003csome-external-dir\u003e` cannot be run from the CLI. This is acceptable for the MVP (the operator's own bundle is a kb topic and the success metric is met via the direct `Check` integration test), but it narrows the documented capability and will need revisiting for Phase-2 ingest.\n- Suggested fix: Note the MVP limitation, or allow `okf check` to fall back to treating the argument as a direct bundle path when topic resolution fails, so it matches the `Check` contract.\n\n# Nits\n\n## N-001 — Dead exported function `WriteMetadataFileWithMode`\n\n- File: internal/topic/topic.go\n- Line: 561\n- Issue: `WriteMetadataFileWithMode` is exported but has zero callers (production or test); `internal/vault/writer.go` still uses the mode-less `WriteMetadataFile`. It is speculative dead API surface (the linter won't catch it because it is exported).\n- Suggested fix: Delete `WriteMetadataFileWithMode`, or route the one real caller (`writer.go:106`) through it with an explicit wiki mode if a written `mode:` line is desired there.\n\n## N-002 — `firstBodySentence` returns the entire body when no sentence delimiter exists\n\n- File: internal/okf/okf.go\n- Line: 446\n- Issue: The TechSpec description fallback is \"first non-empty body sentence (markdown-stripped) → else empty + warning.\" When the body has text but no `. `/`! `/`? ` delimiter, `firstBodySentence` returns the whole normalized body, producing a potentially very long `description` with no warning instead of a single sentence.\n- Suggested fix: Cap the no-delimiter fallback (e.g. first line, or first N chars) or emit the empty-description warning when no sentence boundary is found.\n\n## N-003 — Unreachable symlink-directory branch in the conformance walk\n\n- File: internal/okf/okf.go\n- Line: 177\n- Issue: In `Check` (and identically in `loadConcepts`), `if entry.Type()\u0026fs.ModeSymlink != 0 { if entry.IsDir() { return filepath.SkipDir } ... }` — for a symlink, `fs.DirEntry.IsDir()` is always false (it reflects `Type().IsDir()`), and `filepath.WalkDir` never descends into symlinked directories anyway, so the inner `SkipDir` branch is dead.\n- Suggested fix: Drop the `entry.IsDir()` sub-branch for symlinks (always `return nil`), or add a comment noting it is defensive.\n\n# Evidence\n\n- Read in full: PRD (`_prd.md`), TechSpec (`_techspec.md`), ADR-001..006, codex plan `20260627-220335-okf-dual-mode.md`; project rules CLAUDE.md, AGENTS.md, CONTRIBUTING.md.\n- Read in full: `internal/okf/okf.go`, `internal/okf/okf_test.go`, `internal/okf/official_integration_test.go`, `internal/cli/promote.go`, `internal/cli/okf.go`, `internal/cli/okf_test.go`, `internal/cli/okf_integration_test.go`, `internal/cli/topic.go` (diff), `internal/topic/topic.go` (diff + on-disk), `internal/vault/pathutils.go`, `internal/frontmatter/frontmatter.go`, `internal/models/kb_models.go`; reviewed the raw patch hunks for `models.go`, `config.go`, `config_test.go`, `generate.go`, `render.go`, `render_wiki.go`, `writer.go`, `topic_test.go`, `pathutils_test.go`, and the `okf-claude-template.md` asset.\n- `go build ./...` — Success.\n- `go test ./internal/okf/... ./internal/cli/... ./internal/topic/... ./internal/vault/... ./internal/config/...` — all `ok`.\n- `go test -tags integration ./internal/okf/... ./internal/cli/...` — all `ok` (official ga4/stackoverflow/crypto_bitcoin lenient conformance + end-to-end promote/check pass).\n- `golangci-lint run` (v2.11.4) over the changed packages — `0 issues.`\n- Inspected `internal/okf/testdata/official`: ga4 (17 md), stackoverflow (53 md), crypto_bitcoin (8 md); root and nested `index.md` files carry no frontmatter; every concept `.md` has a `type:`; no `log.md`/README/LICENSE inside the individual bundles; only `viz.html` non-md files — confirming the lenient checker classifies the corpus correctly.\n- Verified zero-regression coverage: existing `internal/vault/render_test.go` asserts exact `[[demo-repo/...|...]]` wikilink output through `RenderDocuments`, so the wiki branch of the migration is byte-verified by the existing suite (ADR-004 mitigation satisfied for the wiki side only).\n- Confirmed no logger usage in `internal/okf` / new CLI files, and no mention of the new commands in CLAUDE.md/AGENTS.md/README.\n- Limitation: did not run the full `make verify` (mage/mise gating noted in the plan); evidence above used direct `go`/`golangci-lint` invocations on the affected packages.\n\n# Deferred Or Follow-Up\n\n- `--to \u003ctopic\u003e/\u003csubdir\u003e` promotion is intentionally deferred (codex plan Assumptions); flat-root concepts only for MVP — no action needed now, but track for Phase 2.\n- Phase 2/3 surfaces (`kb okf export`, `kb okf ingest`, codebase→OKF) are out of scope and correctly absent.\n- Consider consolidating the duplicated bundle-walk logic shared by `Check` and `loadConcepts` (entry filtering, exclusion set, posix-rel derivation) into one helper before Phase 2 grows the package.\n"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:29:12.070128Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_018Pg8ZpPPjsFWU2P9pAzs91","usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:29:12.073951Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_018Pg8ZpPPjsFWU2P9pAzs91","tool_call_state":"completed","blocks":[{"type":"tool_result","tool_use_id":"toolu_018Pg8ZpPPjsFWU2P9pAzs91","content":"File created successfully at: /Users/pedronauck/Dev/compozy/kb/.compozy/tasks/okf-dual-mode/qa/impl-review-findings-round1.md (file state is current in your context — no need to Read it back)"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:29:14.362081Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":""}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:29:14.365616Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":"W"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:29:14.368462Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":"rote /Users/pedronauck/Dev/compozy/kb/.compozy/tasks/okf-dual-mode/qa/impl-review"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:29:14.458704Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":"-findings-round1.md"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","time":"2026-06-28T01:29:14.540743Z","turn":1,"type":"session.update","update":{"usage":{},"status":"completed"},"usage":{}} +{"output":"I'll start by reading all the context files and project rules, then the changed files. Let me batch the initial reads.\n\nNow the ADRs and project rule files.\n\nNow the project rule files and the diff patch.\n\nThe diff is large. Let me read the actual changed source files in full for complete context. Starting with the new OKF package and CLI files.\n\nNow the test files and key helper sources to verify behavior claims.\n\nLet me verify the helper functions these depend on actually exist with the signatures used.\n\nLet me verify the models (LintIssue/severities), CLI helpers, config normalize, and the official fixture corpus presence.\n\nLet me check the official bundle internals for cases that could break the lenient conformance integration test (nested index.md with frontmatter, or concept files lacking frontmatter/type).\n\nNested `index.md` files exist in the official bundles. The checker treats every `index.md` as a reserved index file and **errors if any non-root `index.md` has frontmatter**. Let me check whether the nested ones carry frontmatter — this directly determines whether the official-bundle conformance test (a PRD success metric) passes.\n\nAll nested `index.md` files lack frontmatter and all concept files carry `type:`, so the official-bundle test should pass. Now let me check the zero-regression golden coverage and the N-001 OKF-render-smoke test that ADR-004 explicitly requires.\n\nLet me check whether the official bundles contain `log.md` files (the checker errors on any non-date `## ` heading in a log, even in lenient mode — could fail the success-metric test), and scan all `## ` headings in any such logs.\n\nNo `log.md`, README, or LICENSE inside individual bundles — the official corpus will pass cleanly. Now let me compile and run the affected unit tests to gather real evidence.\n\nUnit tests pass. Let me run the integration tests (official bundles + end-to-end promote) since those are PRD success metrics.\n\nBoth integration suites pass (official bundles + e2e promote). The project mandates `make lint` reports **zero** findings. Let me run the linter on the changed packages, since several functions have discarded params (`_ = bundlePath`, `_ = strict`, `_ = sourceTopicRoot`) that `unparam`/`revive` may flag.\n\nBuild, lint, unit, and integration all green. Let me check for dead wrapper functions left behind by the refactor (a compat-shim concern the review rubric flags).\n\nAll gates pass (build, lint, unit, integration incl. official-bundle conformance + e2e promote). Found dead exported code (`WriteMetadataFileWithMode`, zero callers) and a missing spec-mandated test (N-001). Let me get an ISO timestamp and confirm the `okf check` topic-resolution constraint and `firstBodySentence` line, then write findings.\n\nAll findings confirmed. Writing the scoped findings file.\n\nWrote /Users/pedronauck/Dev/compozy/kb/.compozy/tasks/okf-dual-mode/qa/impl-review-findings-round1.md","run_id":"exec-20260628-012033-655151000-cc998569a8b4de34","status":"succeeded","time":"2026-06-28T01:29:14.563658Z","type":"run.succeeded"} diff --git a/.compozy/tasks/okf-dual-mode/qa/impl-review-events-round2.jsonl b/.compozy/tasks/okf-dual-mode/qa/impl-review-events-round2.jsonl new file mode 100644 index 0000000..68f0a67 --- /dev/null +++ b/.compozy/tasks/okf-dual-mode/qa/impl-review-events-round2.jsonl @@ -0,0 +1,110 @@ +{"dry_run":false,"run_id":"exec-20260628-013457-686051000-25106f92d0f1a262","status":"running","time":"2026-06-28T01:34:59.261785Z","type":"run.started"} +{"run_id":"exec-20260628-013457-686051000-25106f92d0f1a262","session":{"acp_session_id":"4b250e08-6852-4b83-9342-d3f23365a26e","agent_session_id":"","resumed":false},"time":"2026-06-28T01:35:00.095834Z","turn":1,"type":"session.attached"} +{"run_id":"exec-20260628-013457-686051000-25106f92d0f1a262","time":"2026-06-28T01:35:03.25133Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":""}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-013457-686051000-25106f92d0f1a262","time":"2026-06-28T01:35:03.251926Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":"I'll start by reading all"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-013457-686051000-25106f92d0f1a262","time":"2026-06-28T01:35:03.585722Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":" the context files, project rules, and the review diff in full."}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-013457-686051000-25106f92d0f1a262","time":"2026-06-28T01:35:03.591828Z","turn":1,"type":"session.update","update":{"kind":"tool_call_started","tool_call_id":"toolu_014a2gNhuFNqn8emWuWmPSjL","tool_call_state":"pending","blocks":[{"type":"tool_use","id":"toolu_014a2gNhuFNqn8emWuWmPSjL","name":"Read","title":"Read File","raw_input":{}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-013457-686051000-25106f92d0f1a262","time":"2026-06-28T01:35:05.01566Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_014a2gNhuFNqn8emWuWmPSjL","blocks":[{"type":"tool_use","id":"toolu_014a2gNhuFNqn8emWuWmPSjL","name":"Read","title":"Read .compozy/tasks/okf-dual-mode/_prd.md","input":{"file_path":"/Users/pedronauck/Dev/compozy/kb/.compozy/tasks/okf-dual-mode/_prd.md"},"raw_input":{"file_path":"/Users/pedronauck/Dev/compozy/kb/.compozy/tasks/okf-dual-mode/_prd.md"}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-013457-686051000-25106f92d0f1a262","time":"2026-06-28T01:35:05.024129Z","turn":1,"type":"session.update","update":{"kind":"tool_call_started","tool_call_id":"toolu_01AyNFZ1cfB2ZfdoHCiecRvY","tool_call_state":"pending","blocks":[{"type":"tool_use","id":"toolu_01AyNFZ1cfB2ZfdoHCiecRvY","name":"Read","title":"Read File","raw_input":{}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-013457-686051000-25106f92d0f1a262","time":"2026-06-28T01:35:05.037954Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_014a2gNhuFNqn8emWuWmPSjL","usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-013457-686051000-25106f92d0f1a262","time":"2026-06-28T01:35:05.044329Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_014a2gNhuFNqn8emWuWmPSjL","tool_call_state":"completed","blocks":[{"type":"tool_result","tool_use_id":"toolu_014a2gNhuFNqn8emWuWmPSjL","content":"```\n1\t# PRD: OKF Dual-Mode for `kb`\n2\t\n3\t## Overview\n4\t\n5\t`kb` today implements one knowledge lifecycle: the Karpathy **LLM-Wiki** — a\n6\tpersonal research *lab* where raw sources are ingested, compiled into a wiki,\n7\tqueried, and linted, linked with Obsidian `[[wikilinks]]`.\n8\t\n9\tThe **Open Knowledge Format (OKF)** v0.1 (Google Cloud, 2026-06-12) standardizes\n10\tthe same markdown + YAML-frontmatter substrate for a different purpose: a\n11\tportable, vendor-neutral **catalog** that other people's agents and tools consume.\n12\tIt requires exactly one thing of every concept — a `type` field — uses plain\n13\tmarkdown links, and is meant to be *declared and shared*, not *explored*.\n14\t\n15\tThese are two lifecycles, not one derived from the other:\n16\t\n17\t- **LLM-Wiki** = a lab: `ingest → compile → query → lint`; for the operator to\n18\t research.\n19\t- **OKF** = a catalog/API: `declare → consume`; for others (humans and agents) to\n20\t consume; portable.\n21\t\n22\tThis duality already exists by hand in the operator's workspace: a `research/`\n23\tarea (LLM-wiki, where they research) and a `second-brain/` area (OKF, where they\n24\tdeclare \"what is mine\" for operations). The daily, highest-leverage move is\n25\t**distilling research into the operational catalog** — done manually today.\n26\t\n27\tThis feature makes both lifecycles first-class in `kb` via a per-topic\n28\t**`mode: wiki | okf`** (default `wiki`, so nothing regresses), and ships the\n29\tdistill loop as a one-command **`promote`** plus an OKF **conformance check**. It\n30\tis valuable because it turns a hand-maintained workflow into a tool-enforced one,\n31\tand positions `kb` as an OKF-conformant producer in a brand-new, vendor-neutral\n32\tecosystem.\n33\t\n34\t## Goals\n35\t\n36\t- Let an operator maintain LLM-Wiki research and OKF catalogs in the same tool,\n37\t with the research → catalog distill loop tool-enforced.\n38\t- Ship OKF as a first-class authoring mode (not an export-only projection).\n39\t- Produce bundles that are conformant with OKF v0.1 and aligned with Google's real\n40\t sample bundles and tooling.\n41\t- Preserve a consistent, drift-free local standard for concept types.\n42\t- Guarantee zero regression for existing wiki topics.\n43\t- Milestone: MVP delivers `mode` + OKF scaffold + `promote` + conformance check;\n44\t Phase 2 adds publish/consume interop; Phase 3 adds codebase → OKF.\n45\t\n46\t## User Stories\n47\t\n48\t**Primary persona — the Knowledge Operator** (researches in wiki mode, declares\n49\t\"what's mine\" in OKF mode, distills daily):\n50\t\n51\t- As an operator, I want to mark a topic as an OKF bundle so `kb` scaffolds and\n52\t lints it as a portable catalog instead of a research lab.\n53\t- As an operator, I want to promote a finished wiki concept into my OKF bundle\n54\t with one command so my research compounds into my operational catalog without\n55\t manual reformatting or re-linking.\n56\t- As an operator, I want my source research to stay untouched when I promote, so\n57\t the lab remains the immutable record and the catalog is the distilled view.\n58\t- As an operator, I want `kb` to validate my bundle against OKF conformance so my\n59\t catalog stays portable and consumable by agents.\n60\t- As an operator, I want a canonical list of concept types so my bundle stays\n61\t consistent and avoids type drift over time.\n62\t- As an operator, I want my existing wiki topics to keep working exactly as before\n63\t when OKF mode lands.\n64\t\n65\t**Secondary personas** (later phases):\n66\t\n67\t- As a knowledge publisher, I want to export a wiki topic as a portable OKF bundle\n68\t so others can consume it (Phase 2).\n69\t- As an AI agent / consuming tool, I want to read a `kb`-produced bundle as\n70\t standard OKF so I can use it without a custom integration (enabled throughout).\n71\t\n72\t## Core Features\n73\t\n74\tGrouped by priority. MVP features first.\n75\t\n76\t### 1. OKF mode for topics (MVP)\n77\t\n78\t- A per-topic **`mode: wiki | okf`** setting, defaulting to `wiki`.\n79\t- `kb topic new --mode okf` scaffolds an **OKF bundle** (flat typed concepts,\n80\t auto-maintained `index.md` and `log.md`) instead of the wiki pyramid\n81\t (`raw/` + `wiki/` + `outputs/`).\n82\t- An OKF bundle is a `kb` topic; it reuses the topic, vault, lint, and index\n83\t machinery. `mode` selects scaffold shape, link format, frontmatter contract, and\n84\t lint ruleset.\n85\t\n86\t### 2. `kb promote` — wiki → OKF distill (MVP)\n87\t\n88\t- `kb promote \u003cwiki-doc\u003e --to \u003cokf-topic\u003e --type \u003cConceptType\u003e` creates a new\n89\t typed OKF concept in the target bundle from a compiled wiki document.\n90\t- **Mechanical and non-LLM**: converts `[[wikilinks]]` to relative markdown links,\n91\t remaps wiki frontmatter to the OKF contract (`type`, `title`, `description`,\n92\t `timestamp`), and appends a dated entry to the bundle's `log.md`.\n93\t- **Non-destructive**: the source wiki document stays in research; a new concept is\n94\t born in the catalog.\n95\t- Any intelligent rewriting/condensing is left to the operator or an external\n96\t agent — `kb` does the structural work only.\n97\t\n98\t### 3. OKF conformance check (MVP)\n99\t\n100\t- A conformance command for an OKF bundle that validates OKF v0.1:\n101\t - every non-reserved `.md` has parseable frontmatter with a non-empty `type`;\n102\t - `index.md` / `log.md` follow their required shapes when present.\n103\t- **Lenient by default** (per spec §9): tolerates broken cross-links, unknown\n104\t `type` values, and missing optional fields (so externally produced bundles pass).\n105\t- **Local standards as warnings** on the operator's own bundles: all four producer\n106\t fields present, and `type` within the configured vocabulary.\n107\t- A `--strict` option promotes warnings to errors for CI gates.\n108\t\n109\t### 4. Concept-type governance (MVP)\n110\t\n111\t- A canonical **type vocabulary** in `kb.toml` (e.g. `[okf].types`).\n112\t- `promote` and authoring take `type` explicitly; a value outside the vocabulary\n113\t produces a lint warning. The vocabulary is extended by editing `kb.toml`.\n114\t- Prevents type drift (`Voice Profile` vs `voice-profile`) and keeps a consistent\n115\t local standard, while staying compatible with OKF's \"no global registry\" model.\n116\t\n117\t### 5. OKF-native emission (MVP, underpins 1–4)\n118\t\n119\t- Relative, GitHub-safe markdown links (not `[[wikilinks]]`, not absolute paths).\n120\t- The four producer fields on every concept; auto-maintained `index.md`\n121\t (type-grouped bullet lists) and `log.md` (ISO-date entries, newest first).\n122\t\n123\t### Later phases (not MVP)\n124\t\n125\t- **`kb export okf`** — publish an existing wiki topic as a portable bundle\n126\t (Phase 2).\n127\t- **`kb ingest okf`** — consume an external OKF bundle into research (Phase 2).\n128\t- **Codebase → OKF** — emit the typed symbol/relation graph as a bundle\n129\t (Phase 3, differentiator demo).\n130\t\n131\t## User Experience\n132\t\n133\t**Operator journey (MVP):**\n134\t\n135\t1. **Create a catalog.** `kb topic new \u003cname\u003e --mode okf` scaffolds an OKF bundle.\n136\t Existing topics are unaffected (they remain `mode: wiki`).\n137\t2. **Research as usual.** Ingest and compile in wiki-mode topics — unchanged.\n138\t3. **Distill.** When a wiki concept is ready, `kb promote \u003cwiki-doc\u003e --to \u003cbundle\u003e\n139\t --type \u003cConceptType\u003e`. The concept appears in the catalog with relative links,\n140\t the four producer fields, and a `log.md` entry; the source stays put.\n141\t4. **Verify.** Run the conformance check on the bundle; fix any warnings (missing\n142\t fields, off-vocabulary types). Use `--strict` in CI.\n143\t5. **Consume / share.** The bundle is plain markdown + frontmatter — readable in\n144\t Obsidian/GitHub and parseable by any OKF-aware agent.\n145\t\n146\t**UX considerations:**\n147\t\n148\t- `mode` is invisible to existing users; OKF behavior is opt-in per topic.\n149\t- `promote` is one command with a predictable, mechanical result (no surprises\n150\t from an LLM in the loop).\n151\t- Conformance output is actionable (what failed, where, how to fix) and\n152\t distinguishes hard errors from local-standard warnings.\n153\t- Discoverability: OKF options surface under existing `kb topic` / `kb lint`-style\n154\t help; `kb.toml` documents the type vocabulary.\n155\t\n156\t## High-Level Technical Constraints\n157\t\n158\tProduct boundaries (not implementation):\n159\t\n160\t- **Conform to OKF v0.1**, and remain aligned with Google's reference samples\n161\t (GA4, Stack Overflow, Bitcoin) and producer behavior.\n162\t- **Two deliberate, documented deviations** from the written spec, matching real\n163\t tooling: emit the four producer fields (not just `type`), and emit relative\n164\t links (not the spec-recommended absolute links, which break GitHub rendering).\n165\t- **Stay non-LLM.** The core workflow, including `promote`, performs no model\n166\t inference; `kb` remains \"the non-LLM workflow.\"\n167\t- **Zero regression.** Default `mode: wiki`; existing topics scaffold, lint, and\n168\t build identically; no migration required.\n169\t- **Portable output.** Bundles are usable without `kb`: plain files renderable in\n170\t Obsidian/GitHub and parseable by any OKF consumer.\n171\t- OKF is a v0.1 draft; conformance rules must be pinned to v0.1 and isolated so the\n172\t spec can evolve without disrupting the rest of the tool.\n173\t\n174\t## Non-Goals (Out of Scope)\n175\t\n176\t- **Export and ingest interop** (`kb export okf`, `kb ingest okf`) — Phase 2.\n177\t- **Codebase → OKF** emission — Phase 3.\n178\t- **LLM-assisted distillation** inside `promote` (no `--distill`) — possibly a\n179\t later flag, not now.\n180\t- **A global/shared type registry** — only a local per-vault vocabulary.\n181\t- **Whole-vault or cross-topic OKF operations** — OKF is per-topic/bundle.\n182\t- **An HTML/graph visualizer** for bundles.\n183\t- **Migrating existing wiki topics** to OKF automatically.\n184\t- **Changing the LLM-Wiki lifecycle** — wiki mode behavior is unchanged.\n185\t\n186\t## Phased Rollout Plan\n187\t\n188\t### MVP (Phase 1) — the distill loop\n189\t\n190\t- OKF mode (`mode: wiki | okf`) + `kb topic new --mode okf` scaffold.\n191\t- `kb promote` (mechanical, non-destructive, non-LLM).\n192\t- OKF conformance check (lenient + local warnings + `--strict`).\n193\t- Concept-type vocabulary in `kb.toml`.\n194\t- OKF-native emission (relative links, four producer fields, auto `index.md` /\n195\t `log.md`).\n196\t- **Success criteria to proceed:** the conformance check passes on all three\n197\t official sample bundles; the operator's catalog passes the check; a real wiki\n198\t concept promotes end-to-end and the result conforms; existing wiki topics show\n199\t zero behavior change.\n200\t\n201\t### Phase 2 — publish \u0026 consume interop\n202\t\n203\t- `kb export okf` (wiki topic → portable bundle).\n204\t- `kb ingest okf` (external bundle → research), accepting real-world bundles under\n205\t lenient conformance.\n206\t- **Success criteria to proceed:** a wiki topic round-trips through export and back\n207\t through ingest without loss of conceptual content; third-party sample bundles\n208\t ingest cleanly.\n209\t\n210\t### Phase 3 — codebase → OKF\n211\t\n212\t- Emit the existing typed symbol/relation graph as an OKF bundle (concepts =\n213\t files/symbols, links = relations).\n214\t- **Long-term success:** a real codebase produces a conformant, navigable OKF\n215\t bundle usable as agent context.\n216\t\n217\t## Success Metrics\n218\t\n219\t- **Spec correctness:** the conformance check passes on all three official OKF\n220\t sample bundles (GA4, Stack Overflow, Bitcoin).\n221\t- **Loop works end-to-end:** a real wiki concept promotes into a bundle and the\n222\t result conforms, with links resolved and the four producer fields present.\n223\t- **Operator catalog conformant:** the existing `second-brain/` catalog passes the\n224\t conformance check (with local standards) once adopted.\n225\t- **Zero regression:** existing wiki topics scaffold, lint, and build identically\n226\t to before (verified by the existing suite).\n227\t- **No type drift:** every concept type in the operator's bundle is within the\n228\t `kb.toml` vocabulary.\n229\t- **Adoption:** the operator stops distilling by hand — `promote` replaces the\n230\t manual copy/reformat step.\n231\t\n232\t## Risks and Mitigations\n233\t\n234\t- **Adoption risk** — if `promote` is not clearly lower-friction than copy/paste,\n235\t it won't be used. *Mitigation:* one command, mechanical, predictable output;\n236\t non-destructive so it's safe to run freely.\n237\t- **Spec-churn risk** — OKF is a v0.1 draft and may change. *Mitigation:* pin to\n238\t v0.1; isolate conformance rules; track the spec repo.\n239\t- **Scope risk** — the MVP already touches several surfaces. *Mitigation:* strict\n240\t phasing; export/ingest/codebase are explicit non-goals for the MVP.\n241\t- **Standard-drift risk** — free-text types fragment the catalog. *Mitigation:*\n242\t `kb.toml` vocabulary + lint warning + `--strict` in CI.\n243\t- **Identity-drift risk** — pressure to add LLM \"magic\" to `promote`.\n244\t *Mitigation:* keep the core non-LLM by decision (ADR-002); defer any\n245\t `--distill` flag.\n246\t- **Interop-expectation risk** — bundles must be consumable outside `kb`.\n247\t *Mitigation:* validate against official samples; emit what real tooling expects\n248\t (four fields, relative links).\n249\t\n250\t## Architecture Decision Records\n251\t\n252\t- [ADR-001: OKF as a first-class authoring mode, not an export-only projection](adrs/adr-001.md)\n253\t — OKF is a first-class per-topic `mode`, an OKF bundle is a `kb` topic, and the\n254\t MVP optimizes the research → operations distill loop with phased delivery.\n255\t- [ADR-002: promote is a mechanical non-LLM transform; type governance and a spec-compatible conformance posture](adrs/adr-002.md)\n256\t — `promote` is mechanical, non-destructive, and non-LLM; `type` is explicit and\n257\t governed by a local `kb.toml` vocabulary; conformance emits four fields + relative\n258\t links and validates lenient with local warnings and `--strict`.\n259\t\n260\t## Open Questions\n261\t\n262\t- **Conformance command surface** — `kb okf check \u003cbundle\u003e` vs `kb lint --okf`\n263\t (naming/placement). Defer to TechSpec.\n264\t- **Catalog granularity** — within the operator's `second-brain/`, do sub-areas\n265\t (`identidade/`, `estudos/`, `conteudo/`, `vendas/`) map to one OKF topic with\n266\t nested concept directories, or to multiple OKF topics? Needs confirmation; both\n267\t are valid OKF.\n268\t- **index.md / log.md ownership** — assumed auto-maintained by `kb`\n269\t (generated/updated on scaffold + promote). Confirm whether operators ever\n270\t hand-edit them.\n271\t- **Off-vocabulary type handling** — assumed \"warn + allow + extend via\n272\t `kb.toml`\". Confirm whether `promote` should suggest the closest existing type.\n273\t- **Broken-link reporting on promote** — promoting a concept whose wikilinks point\n274\t to not-yet-promoted concepts yields tolerated broken links (per §9). Confirm\n275\t whether the operator wants a warning listing them.\n276\t\n```"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-013457-686051000-25106f92d0f1a262","time":"2026-06-28T01:35:05.504635Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01AyNFZ1cfB2ZfdoHCiecRvY","blocks":[{"type":"tool_use","id":"toolu_01AyNFZ1cfB2ZfdoHCiecRvY","name":"Read","title":"Read .compozy/tasks/okf-dual-mode/_techspec.md","input":{"file_path":"/Users/pedronauck/Dev/compozy/kb/.compozy/tasks/okf-dual-mode/_techspec.md"},"raw_input":{"file_path":"/Users/pedronauck/Dev/compozy/kb/.compozy/tasks/okf-dual-mode/_techspec.md"}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-013457-686051000-25106f92d0f1a262","time":"2026-06-28T01:35:05.508396Z","turn":1,"type":"session.update","update":{"kind":"tool_call_started","tool_call_id":"toolu_01KtmL2DuCcPsDMrLojMThze","tool_call_state":"pending","blocks":[{"type":"tool_use","id":"toolu_01KtmL2DuCcPsDMrLojMThze","name":"Read","title":"Read File","raw_input":{}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-013457-686051000-25106f92d0f1a262","time":"2026-06-28T01:35:05.513126Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01AyNFZ1cfB2ZfdoHCiecRvY","usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-013457-686051000-25106f92d0f1a262","time":"2026-06-28T01:35:05.524122Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01AyNFZ1cfB2ZfdoHCiecRvY","tool_call_state":"completed","blocks":[{"type":"tool_result","tool_use_id":"toolu_01AyNFZ1cfB2ZfdoHCiecRvY","content":"```\n1\t# TechSpec: OKF Dual-Mode for `kb`\n2\t\n3\t\u003e Implements `_prd.md`. Business context (WHAT/WHY/WHO) lives there; this document\n4\t\u003e covers HOW. Phasing follows the PRD: **MVP** = `mode` + OKF scaffold +\n5\t\u003e `kb promote` + `kb okf check`; Phase 2 = export/ingest; Phase 3 = codebase→OKF.\n6\t\n7\t## Executive Summary\n8\t\n9\tAdd OKF as a first-class topic mode to `kb` by (1) introducing a `mode: wiki|okf`\n10\tfield on the topic model, (2) abstracting link emission behind a mode-aware\n11\t`LinkFormatter`, (3) housing all OKF behavior in a new cohesive `internal/okf`\n12\tpackage (frontmatter contract, wikilink→markdown transform, `index.md`/`log.md`\n13\tgeneration, conformance checker, and the `promote` orchestration), and (4) exposing\n14\ta top-level `kb promote` plus a `kb okf` command group. OKF emission stays\n15\t**mechanical and non-LLM**, matching `kb`'s identity; conformance is **lenient per\n16\tOKF §9** with local-standard **warnings** and a `--strict` gate, emitting the four\n17\tproducer fields and relative, GitHub-safe links to match Google's real tooling.\n18\t\n19\t**Primary trade-off:** per the user's decision (ADR-004), the MVP introduces the\n20\tfull `LinkFormatter` abstraction and migrates **all ~43 link call sites** in the\n21\tcodebase→wiki render pipeline now, even though that pipeline produces wiki-mode\n22\ttopics and its OKF branch stays **dormant until Phase 3**. We accept a large\n23\tmechanical refactor with no MVP-exercised OKF render path in exchange for a single,\n24\tfinished mode-aware link seam — guarded against regression by byte-identical golden\n25\ttests of wiki output. The MVP's *active* OKF link consumer is `promote` (body\n26\ttransform) and `index.md` generation.\n27\t\n28\t## System Architecture\n29\t\n30\t### Component Overview\n31\t\n32\t| Component | Home | Responsibility |\n33\t| --- | --- | --- |\n34\t| `mode` field | `internal/models`, `internal/topic` | Mark a topic as `wiki` (default) or `okf`; normalize empty→`wiki`. |\n35\t| `LinkFormatter` | `internal/vault` | Mode-aware link rendering; `WikiLinkFormatter` + `OKFLinkFormatter`. |\n36\t| OKF core | `internal/okf` (new) | Frontmatter contract, link transform, `index.md`/`log.md` gen, conformance checker, `promote`. |\n37\t| Type vocabulary | `internal/config` | `[okf].types` canonical list for local-standard validation. |\n38\t| OKF scaffold | `internal/topic` | `--mode okf` builds a minimal bundle (no wiki pyramid). |\n39\t| CLI | `internal/cli` | `kb promote`, `kb okf check`, `kb topic new --mode`. |\n40\t\n41\t### Data Flow\n42\t\n43\t- **Scaffold:** `kb topic new --mode okf` → `internal/topic` writes `topic.yaml`\n44\t (`mode: okf`), a root `index.md` (`okf_version: \"0.1\"`), `log.md`, an OKF-flavored\n45\t `CLAUDE.md`, and the `AGENTS.md` symlink. No `raw/`/`wiki/`/`outputs/`.\n46\t- **Promote:** `kb promote \u003cwiki-doc\u003e --to \u003ctopic\u003e --type \u003cT\u003e` → `internal/okf`\n47\t reads the source via `vault` + `frontmatter.Parse`, remaps frontmatter to the OKF\n48\t contract, transforms `[[wikilinks]]`→relative markdown links, writes the new\n49\t concept to `\u003cbundle-root\u003e/\u003ckey(base(SourceDocPath))\u003e.md` (see **Concept Path\n50\t Model**) via `frontmatter.Generate` + `vault`, appends to `log.md`, and regenerates\n51\t `index.md` (preserving the root `okf_version`). Source doc is untouched.\n52\t- **Check:** `kb okf check \u003ctopic\u003e` → `internal/okf` walks the bundle, **skipping the\n53\t reserved/excluded set** (`index.md`, `log.md`, `CLAUDE.md`, `AGENTS.md`, symlinks,\n54\t `README`/license files), validates §9 leniently over the remaining concept files,\n55\t emits `[]models.LintIssue` rendered by `internal/output`.\n56\t\n57\tDependency direction is one-way: `internal/okf` → {`frontmatter`, `vault`,\n58\t`config`, `models`}; nothing imports `okf` back (ADR-003).\n59\t\n60\t## Implementation Design\n61\t\n62\t### Core Interfaces\n63\t\n64\tMode-aware link rendering, defined in `internal/vault` (ADR-004):\n65\t\n66\t```go\n67\t// LinkFormatter renders a link from the document being written (fromDir,\n68\t// bundle-relative, forward-slash) to a target document, with an optional label.\n69\ttype LinkFormatter interface {\n70\t Link(fromDir, targetPath, label string) string\n71\t}\n72\t\n73\t// LinkFormatterFor selects the formatter from the topic's mode.\n74\tfunc LinkFormatterFor(topic models.TopicMetadata) LinkFormatter {\n75\t if topic.Mode == models.TopicModeOKF {\n76\t return OKFLinkFormatter{}\n77\t }\n78\t return WikiLinkFormatter{Slug: topic.Slug}\n79\t}\n80\t```\n81\t\n82\tOKF promotion and conformance, in `internal/okf`:\n83\t\n84\t```go\n85\ttype PromoteInput struct {\n86\t SourceDocPath string // wiki doc to read (vault-relative)\n87\t TargetTopic models.TopicInfo // an OKF-mode topic (the bundle)\n88\t Type string // OKF concept type (validated vs config.OKF.Types)\n89\t}\n90\t\n91\t// ConceptResult reports what promote wrote; UnresolvedLinks tolerated (§9).\n92\ttype ConceptResult struct {\n93\t WrittenPath string // bundle-relative path of the new concept\n94\t Type string\n95\t LinksRewritten int // [[wikilink]] occurrences converted\n96\t UnresolvedLinks []string // targets with no promoted counterpart yet\n97\t}\n98\t\n99\ttype Promoter interface {\n100\t Promote(ctx context.Context, in PromoteInput) (ConceptResult, error)\n101\t}\n102\t\n103\ttype Checker interface {\n104\t // Check walks an OKF bundle; strict promotes warnings to errors.\n105\t Check(ctx context.Context, bundlePath string, strict bool) ([]models.LintIssue, error)\n106\t}\n107\t```\n108\t\n109\t### Data Models\n110\t\n111\tAdditions only; existing structs are unchanged except for one field each.\n112\t\n113\t```go\n114\t// internal/models\n115\ttype TopicMode string // defined type (not an alias) per ADR-003\n116\tconst (\n117\t TopicModeWiki TopicMode = \"wiki\"\n118\t TopicModeOKF TopicMode = \"okf\"\n119\t)\n120\t// TopicMetadata and TopicInfo each gain: Mode TopicMode `json:\"mode\"`\n121\t\n122\t// internal/topic topicMetadataFile gains:\n123\t// Mode string `yaml:\"mode,omitempty\"` // empty normalized to \"wiki\" on read\n124\t\n125\t// internal/config\n126\ttype OKFConfig struct {\n127\t Types []string `toml:\"types\"` // local vocabulary; ships EMPTY (no false warnings)\n128\t}\n129\t// Config gains: OKF OKFConfig `toml:\"okf\"`\n130\t// Empty Types ⇒ the type-vocabulary check is a no-op until the operator opts in.\n131\t```\n132\t\n133\t**OKF frontmatter contract — source→OKF remap (B-201).** `promote` and authoring\n134\twrite the four producer fields via this deterministic map; wiki stage markers\n135\t(`stage`, wiki `type`) are dropped:\n136\t\n137\t| OKF field | Source | Fallback |\n138\t| --- | --- | --- |\n139\t| `type` | `--type` (validated vs `config.OKF.Types`) | none — required; error if absent |\n140\t| `title` | source `title` | humanized `base(SourceDocPath)` |\n141\t| `description` | `--description` flag | first non-empty body sentence (markdown-stripped) → else empty + warning |\n142\t| `timestamp` | promote-time clock, RFC3339 / UTC | — (promotion *is* the \"last meaningful change\"; source `scraped`/`created` are date-only `2006-01-02`, so they are not the timestamp source) |\n143\t| `tags` | source `tags` when present | omitted |\n144\t\n145\tThe clock is injected as a seam (a `Clock` function) so golden/integration tests\n146\tassert a fixed `timestamp` instead of freezing a wall-clock literal.\n147\t\n148\t**Field order:** `frontmatter.Generate` sorts keys alphabetically\n149\t(`internal/frontmatter/frontmatter.go:396`), so concepts emit `description, tags,\n150\ttimestamp, title, type`. This is conformant (YAML maps are unordered); tests MUST\n151\tNOT assert a `type`-first producer order. If byte-matching the official samples is\n152\tever required, that needs a dedicated ordered encoder (out of scope).\n153\t\n154\t### Concept Path Model (resolves B-001 / B-002 / B-203)\n155\t\n156\tFor the MVP an OKF bundle is **one topic**, and promoted concepts land **flat at the\n157\tbundle root** (`type`-grouped or nested subdirectories are deferred). A **single\n158\tcanonical key** drives both the filename and every inbound link, so they can never\n159\tdiverge (B-203):\n160\t\n161\t- **Canonical key:** `vault.SlugifySegment(base(path))` — the existing slug primitive\n162\t (`internal/vault/pathutils.go:98`; lowercase `[a-z0-9-]`, `\"item\"` empty fallback,\n163\t drops accents so `Conteúdo`→`conte-do`). `internal/okf` reuses it (N-201) rather\n164\t than re-implementing a slug.\n165\t- **Filename:** a concept from `SourceDocPath` is written to\n166\t `\u003ctopic-root\u003e/\u003ckey(base(SourceDocPath))\u003e.md`. Collisions get a numeric suffix\n167\t (`-2`, `-3`, …).\n168\t- **Wikilink target mapping:** a body link `[[srcSlug/docPath|label]]` strips the\n169\t source slug and maps to `\u003ctopic-root\u003e/\u003ckey(base(docPath))\u003e.md` — the **same** key\n170\t the target's own filename uses, so a link to an already-promoted concept always\n171\t resolves to its real file. `OKFLinkFormatter.Link(fromDir, target, label)` renders\n172\t it; with a flat root `fromDir == targetDir`, the link is `[label](\u003ckey\u003e.md)`. A\n173\t `#anchor` fragment is carried through: `[label](\u003ckey\u003e.md#anchor)` (N-203).\n174\t- **Unresolved targets:** when no promoted counterpart exists yet, the link is still\n175\t emitted to the would-be path (tolerated per §9) and recorded in\n176\t `ConceptResult.UnresolvedLinks` (logged; a warning under `--strict`).\n177\t- **`--to`** names the target OKF topic and **must resolve to a `mode: okf` topic** —\n178\t `promote` errors otherwise (N-202), so wiki-formatter output never leaks into a\n179\t bundle. `--to \u003ctopic\u003e/\u003csubdir\u003e` writes under that subdir (`fromDir` updates\n180\t accordingly); the default is the bundle root.\n181\t\n182\t\u003e Filename trade-off: keying off the source path (not `title`) guarantees\n183\t\u003e symmetry/correctness at the cost of less pretty names for ingested docs (a slug of\n184\t\u003e a source path/URL). Title-named files with an index/manifest-based link resolver is\n185\t\u003e a deliberate Phase-2 option, not MVP.\n186\t\n187\t\u003e Resolves the PRD open question \"catalog granularity\": for the MVP a bundle is one\n188\t\u003e OKF topic with concepts at its root. Splitting `second-brain/` sub-areas\n189\t\u003e (`identidade`, `estudos`, …) into separate OKF topics, or introducing nested\n190\t\u003e concept directories, is a deliberate Phase-2 follow-up, not MVP behavior.\n191\t\n192\t### Command Surface (CLI)\n193\t\n194\t| Command | Args / Flags | Behavior |\n195\t| --- | --- | --- |\n196\t| `kb topic new \u003cslug\u003e \u003ctitle\u003e \u003cdomain\u003e` | `--mode wiki\\|okf` (default `wiki`) | Wiki scaffold (unchanged) or minimal OKF scaffold. |\n197\t| `kb promote \u003cwiki-doc\u003e` | `--to \u003ctopic\u003e`, `--type \u003cT\u003e`, `--description \u003ctext\u003e` | Mechanical, non-destructive wiki→OKF concept; `--to` must be a `mode: okf` topic. |\n198\t| `kb okf check \u003ctopic\u003e` | `--strict`, `--format table\\|json\\|tsv` | OKF v0.1 conformance + local-standard warnings. |\n199\t\n200\tPhase 2 grows the group: `kb okf export`, `kb okf ingest` (out of MVP scope).\n201\t\n202\t## Integration Points\n203\t\n204\tNo external services. The only \"integration\" is the **OKF v0.1 file format**\n205\t(`GoogleCloudPlatform/knowledge-catalog` → `okf/SPEC.md`). Two deliberate,\n206\tdocumented deviations from the written spec, matching Google's reference tooling\n207\t(ADR-002): emit the four producer fields (spec mandates only `type`) and emit\n208\trelative links (spec recommends absolute `/path.md`, which breaks GitHub\n209\trendering). The official sample bundles are vendored as test fixtures (ADR-005),\n210\tnot a runtime dependency.\n211\t\n212\t## Impact Analysis\n213\t\n214\t| Component | Impact Type | Description and Risk | Required Action |\n215\t| --- | --- | --- | --- |\n216\t| `internal/okf` | new | Cohesive OKF package; isolated, low blast radius. | Build per ADR-003/004/005. |\n217\t| `internal/vault` (pathutils, render, render_wiki) | modified | `LinkFormatter` + route ~43 call sites; **regression risk** in wiki output. | Golden byte-identical wiki tests; keep suite green. |\n218\t| `internal/models` | modified | Add `Mode` to `TopicMetadata`/`TopicInfo` + mode consts; low risk. | Add fields/consts. |\n219\t| `internal/topic` | modified | `Mode` on `topicMetadataFile`; mode-conditional scaffold + OKF templates; medium risk (scaffold branch). | Normalize empty→wiki; add OKF assets. |\n220\t| `internal/config` | modified | Add `[okf].types`; low risk (additive). | Add struct + example + defaults. |\n221\t| `internal/cli` | modified | `kb promote`, `kb okf` group, `topic new --mode`; low risk. | Wire commands per existing patterns. |\n222\t| `internal/lint` | unchanged | OKF uses a separate checker (ADR-005); wiki lint untouched. | None. |\n223\t| existing wiki topics | unchanged | Default mode wiki ⇒ zero behavior change. | Verify via existing tests. |\n224\t\n225\t## Testing Approach\n226\t\n227\t### Unit Tests\n228\t\n229\t- **`OKFLinkFormatter.Link`** — table-driven relative-path cases: sibling, child,\n230\t parent (`../`), `fromDir == targetDir`, bundle root; never a leading `/`.\n231\t- **`WikiLinkFormatter.Link`** — asserts byte-identical output to today's\n232\t `ToTopicWikiLink` (with and without label).\n233\t- **Wikilink→markdown body transform** — `[[a]]`, `[[a|label]]`, `[[a#anchor]]`,\n234\t links to not-yet-promoted concepts (tolerated/flagged).\n235\t- **Frontmatter remap (B-201)** — source→OKF per the remap table; `description`\n236\t fallback chain (flag → first body sentence → empty + warning); `timestamp` from the\n237\t injected clock, asserted as a fixed RFC3339 value; stage markers dropped.\n238\t- **Reserved/excluded files (B-202)** — `CLAUDE.md`, `AGENTS.md` (and the symlink),\n239\t `index.md`, `log.md`, `README`/license are NOT flagged for a missing `type`; a\n240\t freshly scaffolded-then-promoted bundle passes `kb okf check`.\n241\t- **Reject non-okf target (N-202)** — `promote --to \u003cwiki-topic\u003e` errors before writing.\n242\t- **`--type` vocabulary** — in-vocabulary (ok), out-of-vocabulary (warning),\n243\t empty (error).\n244\t- **mode normalization** — empty/absent `mode` → `wiki`; invalid explicit value\n245\t rejected at scaffold.\n246\t- **Conformance rules** — missing `type` (error), reserved files excluded,\n247\t `index.md`/`log.md` shape, `--strict` promotion.\n248\t- **Cross-concept wikilink mapping** — `[[srcSlug/Foo Bar|Foo]]` → `[Foo](foo-bar.md)`;\n249\t assert the exact emitted relative path; filename collision → `-2` suffix;\n250\t unresolved target recorded in `ConceptResult.UnresolvedLinks`.\n251\t- **OKF render smoke (N-001)** — render one document for a `mode: okf` topic and\n252\t assert the OKF link branch and its `fromDir` plumbing execute and emit a relative\n253\t markdown link, so the wired render branch is live, not shipped dead.\n254\t\n255\t### Integration Tests (`//go:build integration`)\n256\t\n257\t- **Conformance against vendored official bundles** (GA4, Stack Overflow,\n258\t crypto_bitcoin) — all pass leniently (ADR-005).\n259\t- **Negative fixtures** — missing `type`, unterminated frontmatter, list-not-\n260\t mapping frontmatter → expected errors.\n261\t- **Must-tolerate fixtures** — broken link, unknown `type`, frontmatter-less\n262\t `index.md` → no error under lenient mode.\n263\t- **End-to-end promote** — scaffold an OKF topic, ingest+compile a small wiki\n264\t topic, `kb promote` a concept, assert: source untouched, concept written with\n265\t four fields + relative links, `log.md` appended, `index.md` updated, and\n266\t `kb okf check` passes.\n267\t- **Zero-regression** — an existing wiki topic renders byte-identically and\n268\t `kb lint` output is unchanged.\n269\t\n270\tUse `t.TempDir()` for filesystem isolation; co-locate tests with their package.\n271\t\n272\t## Development Sequencing\n273\t\n274\t### Build Order\n275\t\n276\t1. **`mode` data model** — add `TopicMode` consts + `Mode` on\n277\t `models.TopicMetadata`/`TopicInfo`; add `Mode` to `topicMetadataFile` with\n278\t empty→wiki normalization in `readTopicYAMLMetadata`. *No dependencies.*\n279\t2. **`internal/config` `[okf].types`** — add `OKFConfig`, defaults, env/TOML load,\n280\t `config.example.toml`. *No dependencies.*\n281\t3. **`LinkFormatter` in `internal/vault`** — interface + `WikiLinkFormatter`\n282\t (wraps current behavior) + `OKFLinkFormatter` + `LinkFormatterFor`. *Depends on\n283\t step 1 (reads `topic.Mode`).*\n284\t4. **Migrate the ~43 call sites** through `linkFor(topic, fromDir, target, label)`;\n285\t add golden wiki-output tests. *Depends on step 3.*\n286\t5. **OKF scaffold** — `internal/topic` mode-conditional dirs + OKF `CLAUDE.md`/\n287\t `index.md`(`okf_version`)/`log.md` templates. Extend `topic.New`/`newWithDate`\n288\t with a `mode` parameter (or a `NewWithMode` variant) and write an explicit `mode`\n289\t value into `topic.yaml`. *Depends on step 1.*\n290\t6. **`internal/okf` emission primitives** — frontmatter contract, wikilink→markdown\n291\t transform (reusing step 3's `OKFLinkFormatter`), `index.md`/`log.md` generation.\n292\t *Depends on steps 2, 3.*\n293\t7. **`promote` orchestration** — read source, remap, transform, write concept,\n294\t append log, regenerate index. *Depends on steps 5, 6.*\n295\t8. **Conformance checker** — recursive walk, §9 lenient + local warnings +\n296\t `--strict`, emitting `models.LintIssue`. *Depends on step 2 (vocabulary).*\n297\t9. **CLI wiring** — `topic new --mode`, `kb promote`, `kb okf check`. *Depends on\n298\t steps 5, 7, 8.*\n299\t10. **Fixtures + integration tests** — vendor official bundles, negatives,\n300\t must-tolerate, end-to-end promote, zero-regression. *Depends on steps 7, 8, 9.*\n301\t\n302\t### Technical Dependencies\n303\t\n304\t- None external. Vendoring the official bundles requires keeping their Apache-2.0\n305\t license/attribution under `testdata/`.\n306\t\n307\t## Monitoring and Observability\n308\t\n309\tCLI tool — observability is structured logging via `internal/logger` (slog) and\n310\tcommand output:\n311\t\n312\t- **Log events** (debug/info): `promote` source/target/type, link-transform count,\n313\t broken-link count, `promote rejected: target not okf` (N-202), `description fallback\n314\t used`; `okf check` files-scanned, `reserved file skipped` (N-205), errors, warnings,\n315\t strict flag.\n316\t- **Exit codes**: `okf check` returns non-zero on errors (and on warnings under\n317\t `--strict`) for CI gating.\n318\t- **Report output**: `okf check` renders via `internal/output` (table/json/tsv),\n319\t consistent with `kb lint`.\n320\t\n321\t## Technical Considerations\n322\t\n323\t### Key Decisions\n324\t\n325\t- **Cohesive `internal/okf` package** (ADR-003). *Rationale:* one testable home,\n326\t clean phase growth. *Trade-off:* a new package vs maximal reuse.\n327\t- **Full `LinkFormatter` migration in the MVP** (ADR-004). *Rationale:* one\n328\t finished mode-aware seam. *Trade-off:* large refactor whose OKF render branch is\n329\t dormant until Phase 3; mitigated by golden tests.\n330\t- **Dedicated conformance checker, lenient + `--strict`** (ADR-002/005).\n331\t *Rationale:* OKF is structurally unlike the wiki pyramid and is permissive by\n332\t spec. *Trade-off:* two checking engines coexist.\n333\t- **Non-LLM mechanical `promote`** (ADR-002). *Rationale:* preserves `kb`'s\n334\t identity and determinism. *Trade-off:* distillation stays a human/agent job.\n335\t- **`kb promote` top-level + `kb okf` group** (ADR-006). *Rationale:* prominence\n336\t for the daily verb, cohesive namespace for the ecosystem.\n337\t\n338\t### Known Risks\n339\t\n340\t- **Wiki render regression** during the 43-site migration (medium likelihood,\n341\t high impact) → byte-identical golden tests + keep the `vault` suite green;\n342\t default formatter is wiki when `mode != okf`.\n343\t- **OKF relative-path correctness** (medium) → exhaustive table-driven tests\n344\t including parent/sibling/root.\n345\t- **OKF v0.1 is a draft** (medium) → isolate the ruleset behind a versioned module;\n346\t track the spec repo.\n347\t- **Vocabulary ignored in practice** (low) → `--strict` in CI enforces it where it\n348\t matters.\n349\t- **Vendored-fixture drift / license** (low) → pin a commit, retain attribution.\n350\t\n351\t## Architecture Decision Records\n352\t\n353\t- [ADR-001: OKF as a first-class authoring mode, not an export-only projection](adrs/adr-001.md) — OKF is a first-class per-topic mode with phased delivery (product).\n354\t- [ADR-002: promote is a mechanical non-LLM transform; type governance and a spec-compatible conformance posture](adrs/adr-002.md) — mechanical/non-destructive promote, `--type` + `kb.toml` vocabulary, emit-four + relative links, lenient validation (product).\n355\t- [ADR-003: A cohesive `internal/okf` package and a `mode` field on the topic model](adrs/adr-003.md) — one-way-dependency OKF package + `mode` on the topic model (default wiki).\n356\t- [ADR-004: A `LinkFormatter` abstraction and full migration of the link call sites](adrs/adr-004.md) — mode-aware `LinkFormatter` in `vault`; migrate all ~43 sites in the MVP; OKF render branch dormant until Phase 3.\n357\t- [ADR-005: A dedicated OKF conformance checker with vendored official fixtures](adrs/adr-005.md) — separate checker in `internal/okf`, lenient §9 + local warnings + `--strict`, vendored official bundles as fixtures.\n358\t- [ADR-006: CLI surface — `kb promote` top-level and a `kb okf` command group](adrs/adr-006.md) — top-level `kb promote`, `kb okf check` group, `topic new --mode`.\n359\t\n```"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-013457-686051000-25106f92d0f1a262","time":"2026-06-28T01:35:05.930583Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01KtmL2DuCcPsDMrLojMThze","blocks":[{"type":"tool_use","id":"toolu_01KtmL2DuCcPsDMrLojMThze","name":"Read","title":"Read .compozy/tasks/okf-dual-mode/qa/impl-review-diff-round2.patch","input":{"file_path":"/Users/pedronauck/Dev/compozy/kb/.compozy/tasks/okf-dual-mode/qa/impl-review-diff-round2.patch"},"raw_input":{"file_path":"/Users/pedronauck/Dev/compozy/kb/.compozy/tasks/okf-dual-mode/qa/impl-review-diff-round2.patch"}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-013457-686051000-25106f92d0f1a262","time":"2026-06-28T01:35:06.441167Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01KtmL2DuCcPsDMrLojMThze","usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-013457-686051000-25106f92d0f1a262","time":"2026-06-28T01:35:06.463285Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01KtmL2DuCcPsDMrLojMThze","tool_call_state":"completed","blocks":[{"type":"tool_result","tool_use_id":"toolu_01KtmL2DuCcPsDMrLojMThze","content":"```\n\u003csystem-reminder\u003e[Truncated: PARTIAL view — showing lines 1-1197 of 2997 total (53196 tokens, cap 25000). Call Read with offset=1198 limit=1197 for the next page, or Grep to find a specific section. Do NOT answer from this page alone if the answer may be further in the file.]\u003c/system-reminder\u003e\n\n1\tdiff --git a/AGENTS.md b/AGENTS.md\n2\tindex ead6156..d1f2170 100644\n3\t--- a/AGENTS.md\n4\t+++ b/AGENTS.md\n5\t@@ -63,9 +63,11 @@ make help # mage target list\n6\t \n7\t | Command | Purpose |\n8\t | --- | --- |\n9\t-| `kb topic new \u003cslug\u003e \u003ctitle\u003e \u003cdomain\u003e` | Scaffold a new knowledge base topic |\n10\t+| `kb topic new \u003cslug\u003e \u003ctitle\u003e \u003cdomain\u003e` | Scaffold a new knowledge base topic (`--mode wiki\\|okf`) |\n11\t | `kb topic list` | List scaffolded topics |\n12\t | `kb topic info \u003cslug\u003e` | Show metadata for one topic |\n13\t+| `kb promote \u003cwiki-doc\u003e --to \u003cokf-topic\u003e --type \u003ctype\u003e` | Promote a compiled wiki document into an OKF topic |\n14\t+| `kb okf check \u003ctopic\u003e` | Check an OKF topic for bundle conformance |\n15\t | `kb ingest url \u003curl\u003e --topic \u003cslug\u003e` | Scrape a web URL and ingest into a topic |\n16\t | `kb ingest file \u003cpath\u003e --topic \u003cslug\u003e` | Convert a local file and ingest into a topic |\n17\t | `kb ingest youtube \u003curl\u003e --topic \u003cslug\u003e` | Extract a YouTube transcript and ingest into a topic |\n18\t@@ -88,7 +90,9 @@ make help # mage target list\n19\t \n20\t ### Command Notes\n21\t \n22\t-- `topic` subcommands share the root `--vault` flag for vault path resolution.\n23\t+- `topic` subcommands share the root `--vault` flag for vault path resolution; `topic new --mode okf` creates a root-level OKF bundle instead of the wiki/raw scaffold.\n24\t+- `promote` requires `--to \u003cokf-topic\u003e` and `--type \u003ctype\u003e`; `--description` overrides the generated concept description.\n25\t+- `okf check` accepts `--strict` and `--format table|json|tsv`.\n26\t - `ingest` subcommands require `--topic \u003cslug\u003e` to identify the target topic.\n27\t - `ingest codebase` accepts `--include`, `--exclude`, `--semantic`, `--progress`, and `--log-format`.\n28\t - `ingest youtube` accepts `--transcribe captions|auto|stt`; `yt-dlp` is required for YouTube metadata, captions, and audio extraction.\n29\t@@ -104,6 +108,7 @@ make help # mage target list\n30\t - `APP_CONFIG` overrides the config file path.\n31\t - `.env` is loaded automatically when present.\n32\t - `FIRECRAWL_API_KEY` and `FIRECRAWL_API_URL` configure the Firecrawl client for `ingest url`.\n33\t+- `[okf].types` configures the optional OKF type vocabulary used by `promote` and `okf check`.\n34\t - `OPENAI_API_KEY`, `OPENAI_API_URL`, `STT_PROVIDER`, and `STT_MODEL` configure the default OpenAI STT provider for `ingest youtube --transcribe auto|stt`.\n35\t - `OPENROUTER_API_KEY`, `OPENROUTER_API_URL`, and `openrouter.stt_model` (TOML-only) configure the optional OpenRouter STT provider when `stt.provider = \"openrouter\"`.\n36\t - Generation, inspect, search, and index behavior is configured by CLI flags rather than TOML keys.\n37\tdiff --git a/CLAUDE.md b/CLAUDE.md\n38\tindex d0ab51d..dfeaf44 100644\n39\t--- a/CLAUDE.md\n40\t+++ b/CLAUDE.md\n41\t@@ -78,7 +78,9 @@ make help\n42\t \n43\t ## CLI Surface\n44\t \n45\t-- `kb topic {new,list,info}`\n46\t+- `kb topic {new,list,info}` (`topic new --mode wiki|okf`)\n47\t+- `kb promote \u003cwiki-doc\u003e --to \u003cokf-topic\u003e --type \u003ctype\u003e`\n48\t+- `kb okf check \u003ctopic\u003e`\n49\t - `kb ingest {url,file,youtube,channel,instagram,codebase,bookmarks}`\n50\t - `kb lint [\u003cslug\u003e]`\n51\t - `kb inspect {smells|dead-code|complexity|blast-radius|coupling|symbol|file|backlinks|deps|circular-deps}`\n52\t@@ -89,7 +91,7 @@ make help\n53\t \n54\t ## Runtime Config Notes\n55\t \n56\t-- `config.example.toml` documents every TOML section currently accepted by `internal/config`: `[app]`, `[log]`, `[vault]`, `[firecrawl]`, `[openrouter]`, `[stt]`, `[youtube]`, and `[instagram]`.\n57\t+- `config.example.toml` documents every TOML section currently accepted by `internal/config`: `[app]`, `[log]`, `[vault]`, `[firecrawl]`, `[openrouter]`, `[stt]`, `[youtube]`, `[instagram]`, and `[okf]`.\n58\t - `APP_CONFIG` selects the TOML file path.\n59\t - `kb ingest youtube` requires `yt-dlp` for metadata, captions, and audio extraction. `[youtube].yt_dlp_path`, `[youtube].proxy`, `[youtube].cookies_file`, `[youtube].user_agent`, `[youtube].retry_attempts`, `[youtube].retry_backoff`, and `[youtube].transcription` configure that path.\n60\t - YouTube transcription policy is `captions|auto|stt`: `captions` uses YouTube captions only, `auto` uses manual captions when present and STT when only automatic captions or no captions are available, and `stt` forces audio transcription.\n61\t@@ -97,6 +99,7 @@ make help\n62\t - OpenAI is the default STT provider through `/v1/audio/transcriptions`. Configure it with `[stt]` plus `OPENAI_API_KEY`, `OPENAI_API_URL`, `STT_PROVIDER`, and `STT_MODEL`.\n63\t - OpenRouter is an optional STT provider when `stt.provider = \"openrouter\"`; configure it with `OPENROUTER_API_KEY`, `OPENROUTER_API_URL`, and `openrouter.stt_model`.\n64\t - Long STT audio is segmented with `ffmpeg`; keep `[stt].ffmpeg_path`, `[stt].chunk_duration`, `[stt].max_chunk_bytes`, and `[stt].concurrency` aligned with provider upload limits.\n65\t+- `[okf].types` configures the optional OKF type vocabulary used by `kb promote` and `kb okf check`.\n66\t \n67\t ## Testing Conventions\n68\t \n69\tdiff --git a/README.md b/README.md\n70\tindex 29464f9..70e85cb 100644\n71\t--- a/README.md\n72\t+++ b/README.md\n73\t@@ -87,6 +87,9 @@ $ kb topic new rust-lang \"Rust Language\" programming\n74\t \"domain\": \"programming\"\n75\t }\n76\t \n77\t+# scaffold an OKF bundle topic\n78\t+$ kb topic new rust-catalog \"Rust Catalog\" programming --mode okf\n79\t+\n80\t # ingest a web article\n81\t $ kb ingest url https://doc.rust-lang.org/book/ch04-01-what-is-ownership.html --topic rust-lang\n82\t \n83\t@@ -102,6 +105,12 @@ $ kb ingest codebase ./my-rust-project --topic rust-lang --progress never\n84\t \n85\t # lint the topic for structural issues\n86\t $ kb lint rust-lang\n87\t+\n88\t+# promote a compiled wiki document into the OKF bundle\n89\t+$ kb promote .kb/vault/rust-lang/wiki/concepts/Ownership.md --to rust-catalog --type Concept\n90\t+\n91\t+# check the OKF bundle\n92\t+$ kb okf check rust-catalog --strict\n93\t ```\n94\t \n95\t Analyze codebase snapshots from the terminal:\n96\t@@ -181,11 +190,29 @@ The key difference: `kb` outputs compound. A SonarQube scan from last month is s\n97\t Scaffold and manage knowledge base topics.\n98\t \n99\t ```bash\n100\t-kb topic new \u003cslug\u003e \u003ctitle\u003e \u003cdomain\u003e # Create a new topic\n101\t+kb topic new \u003cslug\u003e \u003ctitle\u003e \u003cdomain\u003e [--mode wiki|okf] # Create a new topic\n102\t kb topic list # List all topics in the vault\n103\t kb topic info \u003cslug\u003e # Show metadata for a topic\n104\t ```\n105\t \n106\t+`--mode wiki` is the default scaffold. `--mode okf` creates a root-level Open Knowledge File bundle with `index.md`, `log.md`, and OKF authoring guidance.\n107\t+\n108\t+### `kb promote`\n109\t+\n110\t+Promote a compiled wiki document into an OKF topic without modifying the source document.\n111\t+\n112\t+```bash\n113\t+kb promote \u003cwiki-doc\u003e --to \u003cokf-topic\u003e --type \u003ctype\u003e [--description \u003ctext\u003e]\n114\t+```\n115\t+\n116\t+### `kb okf`\n117\t+\n118\t+Check an OKF topic for bundle conformance.\n119\t+\n120\t+```bash\n121\t+kb okf check \u003ctopic\u003e [--strict] [--format table|json|tsv]\n122\t+```\n123\t+\n124\t ### `kb ingest`\n125\t \n126\t Ingest source material into a topic. `url`, `file`, `youtube`, and `bookmarks` require an existing topic; `codebase` can bootstrap one on first run.\n127\tdiff --git a/config.example.toml b/config.example.toml\n128\tindex a9ea428..0c91297 100644\n129\t--- a/config.example.toml\n130\t+++ b/config.example.toml\n131\t@@ -5,6 +5,7 @@\n132\t # - `[app]`\n133\t # - `[log]`\n134\t # - `[vault]`\n135\t+# - `[okf]`\n136\t # - `[firecrawl]`\n137\t # - `[openrouter]`\n138\t # - `[stt]`\n139\t@@ -48,6 +49,11 @@ level = \"info\"\n140\t root = \".\"\n141\t topic_globs = [\"*\"]\n142\t \n143\t+[okf]\n144\t+# Local OKF concept type vocabulary. Empty means `kb okf check` will not warn\n145\t+# about unknown types until the operator opts into a local standard.\n146\t+types = []\n147\t+\n148\t [firecrawl]\n149\t api_key = \"\"\n150\t api_url = \"https://api.firecrawl.dev\"\n151\tdiff --git a/internal/cli/root.go b/internal/cli/root.go\n152\tindex 2b123a6..22ab258 100644\n153\t--- a/internal/cli/root.go\n154\t+++ b/internal/cli/root.go\n155\t@@ -23,6 +23,8 @@ func newRootCommand() *cobra.Command {\n156\t \tbindRootPersistentFlags(command)\n157\t \tcommand.AddCommand(newTopicCommand())\n158\t \tcommand.AddCommand(newIngestCommand())\n159\t+\tcommand.AddCommand(newPromoteCommand())\n160\t+\tcommand.AddCommand(newOKFCommand())\n161\t \tcommand.AddCommand(newLintCommand())\n162\t \tcommand.AddCommand(newGenerateCommand())\n163\t \tcommand.AddCommand(newInspectCommand())\n164\tdiff --git a/internal/cli/topic.go b/internal/cli/topic.go\n165\tindex 3746632..d609335 100644\n166\t--- a/internal/cli/topic.go\n167\t+++ b/internal/cli/topic.go\n168\t@@ -4,6 +4,7 @@ import (\n169\t \t\"encoding/json\"\n170\t \t\"fmt\"\n171\t \t\"os\"\n172\t+\t\"strings\"\n173\t \n174\t \t\"github.com/spf13/cobra\"\n175\t \n176\t@@ -13,6 +14,7 @@ import (\n177\t )\n178\t \n179\t var runTopicNew = ktopic.New\n180\t+var runTopicNewWithMode = ktopic.NewWithMode\n181\t var runTopicList = ktopic.List\n182\t var runTopicInfo = ktopic.Info\n183\t var topicGetwd = os.Getwd\n184\t@@ -37,7 +39,8 @@ func newTopicCommand() *cobra.Command {\n185\t }\n186\t \n187\t func newTopicNewCommand() *cobra.Command {\n188\t-\treturn \u0026cobra.Command{\n189\t+\tvar mode string\n190\t+\tcommand := \u0026cobra.Command{\n191\t \t\tUse: \"new \u003cslug\u003e \u003ctitle\u003e \u003cdomain\u003e\",\n192\t \t\tShort: \"Scaffold a new knowledge base topic\",\n193\t \t\tArgs: cobra.ExactArgs(3),\n194\t@@ -47,7 +50,16 @@ func newTopicNewCommand() *cobra.Command {\n195\t \t\t\t\treturn err\n196\t \t\t\t}\n197\t \n198\t-\t\t\tinfo, err := runTopicNew(vaultPath, args[0], args[1], args[2])\n199\t+\t\t\ttopicMode, err := parseTopicMode(mode)\n200\t+\t\t\tif err != nil {\n201\t+\t\t\t\treturn err\n202\t+\t\t\t}\n203\t+\t\t\tvar info models.TopicInfo\n204\t+\t\t\tif topicMode == models.TopicModeWiki {\n205\t+\t\t\t\tinfo, err = runTopicNew(vaultPath, args[0], args[1], args[2])\n206\t+\t\t\t} else {\n207\t+\t\t\t\tinfo, err = runTopicNewWithMode(vaultPath, args[0], args[1], args[2], topicMode)\n208\t+\t\t\t}\n209\t \t\t\tif err != nil {\n210\t \t\t\t\treturn err\n211\t \t\t\t}\n212\t@@ -55,6 +67,8 @@ func newTopicNewCommand() *cobra.Command {\n213\t \t\t\treturn writeTopicInfoJSON(cmd, info)\n214\t \t\t},\n215\t \t}\n216\t+\tcommand.Flags().StringVar(\u0026mode, \"mode\", string(models.TopicModeWiki), \"Topic mode (wiki|okf)\")\n217\t+\treturn command\n218\t }\n219\t \n220\t func newTopicListCommand() *cobra.Command {\n221\t@@ -98,6 +112,17 @@ func newTopicListCommand() *cobra.Command {\n222\t \t}\n223\t }\n224\t \n225\t+func parseTopicMode(value string) (models.TopicMode, error) {\n226\t+\tswitch strings.TrimSpace(value) {\n227\t+\tcase \"\", string(models.TopicModeWiki):\n228\t+\t\treturn models.TopicModeWiki, nil\n229\t+\tcase string(models.TopicModeOKF):\n230\t+\t\treturn models.TopicModeOKF, nil\n231\t+\tdefault:\n232\t+\t\treturn \"\", fmt.Errorf(`invalid --mode %q: expected \"wiki\" or \"okf\"`, value)\n233\t+\t}\n234\t+}\n235\t+\n236\t func newTopicInfoCommand() *cobra.Command {\n237\t \treturn \u0026cobra.Command{\n238\t \t\tUse: \"info \u003cslug\u003e\",\n239\tdiff --git a/internal/cli/topic_test.go b/internal/cli/topic_test.go\n240\tindex 44d7b67..1f49e4d 100644\n241\t--- a/internal/cli/topic_test.go\n242\t+++ b/internal/cli/topic_test.go\n243\t@@ -79,6 +79,66 @@ func TestTopicNewCommandPassesArgsAndPrintsJSON(t *testing.T) {\n244\t \t}\n245\t }\n246\t \n247\t+func TestTopicNewCommandPassesOKFMode(t *testing.T) {\n248\t+\toriginalNewWithMode := runTopicNewWithMode\n249\t+\toriginalGetwd := topicGetwd\n250\t+\tt.Cleanup(func() {\n251\t+\t\trunTopicNewWithMode = originalNewWithMode\n252\t+\t\ttopicGetwd = originalGetwd\n253\t+\t})\n254\t+\n255\t+\tvar gotMode models.TopicMode\n256\t+\trunTopicNewWithMode = func(vaultPath, slug, title, domain string, mode models.TopicMode) (models.TopicInfo, error) {\n257\t+\t\tgotMode = mode\n258\t+\t\treturn models.TopicInfo{\n259\t+\t\t\tSlug: slug,\n260\t+\t\t\tTitle: title,\n261\t+\t\t\tDomain: domain,\n262\t+\t\t\tMode: mode,\n263\t+\t\t\tRootPath: filepath.Join(vaultPath, slug),\n264\t+\t\t}, nil\n265\t+\t}\n266\t+\ttopicGetwd = func() (string, error) {\n267\t+\t\treturn \"/workspace/repo\", nil\n268\t+\t}\n269\t+\n270\t+\tcommand := newRootCommand()\n271\t+\tvar stdout bytes.Buffer\n272\t+\tcommand.SetOut(\u0026stdout)\n273\t+\tcommand.SetErr(new(bytes.Buffer))\n274\t+\tcommand.SetArgs([]string{\"topic\", \"new\", \"ops-catalog\", \"Ops Catalog\", \"ops\", \"--mode\", \"okf\", \"--vault\", \"/tmp/vault\"})\n275\t+\n276\t+\tif err := command.ExecuteContext(context.Background()); err != nil {\n277\t+\t\tt.Fatalf(\"ExecuteContext returned error: %v\", err)\n278\t+\t}\n279\t+\tif gotMode != models.TopicModeOKF {\n280\t+\t\tt.Fatalf(\"mode = %q, want okf\", gotMode)\n281\t+\t}\n282\t+\n283\t+\tvar info models.TopicInfo\n284\t+\tif err := json.Unmarshal(stdout.Bytes(), \u0026info); err != nil {\n285\t+\t\tt.Fatalf(\"stdout did not contain JSON: %v\\n%s\", err, stdout.String())\n286\t+\t}\n287\t+\tif info.Mode != models.TopicModeOKF {\n288\t+\t\tt.Fatalf(\"payload mode = %q, want okf\", info.Mode)\n289\t+\t}\n290\t+}\n291\t+\n292\t+func TestTopicNewCommandRejectsInvalidMode(t *testing.T) {\n293\t+\tcommand := newRootCommand()\n294\t+\tcommand.SetOut(new(bytes.Buffer))\n295\t+\tcommand.SetErr(new(bytes.Buffer))\n296\t+\tcommand.SetArgs([]string{\"topic\", \"new\", \"ops\", \"Ops\", \"ops\", \"--mode\", \"catalog\", \"--vault\", \"/tmp/vault\"})\n297\t+\n298\t+\terr := command.ExecuteContext(context.Background())\n299\t+\tif err == nil {\n300\t+\t\tt.Fatal(\"expected invalid mode error\")\n301\t+\t}\n302\t+\tif !strings.Contains(err.Error(), \"invalid --mode\") {\n303\t+\t\tt.Fatalf(\"unexpected error: %v\", err)\n304\t+\t}\n305\t+}\n306\t+\n307\t func TestTopicNewCommandRequiresThreeArgs(t *testing.T) {\n308\t \tcommand := newRootCommand()\n309\t \tcommand.SetOut(new(bytes.Buffer))\n310\tdiff --git a/internal/config/config.go b/internal/config/config.go\n311\tindex 14d3417..5083362 100644\n312\t--- a/internal/config/config.go\n313\t+++ b/internal/config/config.go\n314\t@@ -47,6 +47,7 @@ type Config struct {\n315\t \tApp AppConfig `toml:\"app\"`\n316\t \tLog LogConfig `toml:\"log\"`\n317\t \tVault VaultConfig `toml:\"vault\"`\n318\t+\tOKF OKFConfig `toml:\"okf\"`\n319\t \tFirecrawl FirecrawlConfig `toml:\"firecrawl\"`\n320\t \tOpenRouter OpenRouterConfig `toml:\"openrouter\"`\n321\t \tSTT STTConfig `toml:\"stt\"`\n322\t@@ -71,6 +72,11 @@ type VaultConfig struct {\n323\t \tTopicGlobs []string `toml:\"topic_globs\"`\n324\t }\n325\t \n326\t+// OKFConfig controls local Open Knowledge Format producer standards.\n327\t+type OKFConfig struct {\n328\t+\tTypes []string `toml:\"types\"`\n329\t+}\n330\t+\n331\t // FirecrawlConfig controls URL scraping API access.\n332\t type FirecrawlConfig struct {\n333\t \tAPIKey string `toml:\"api_key\"`\n334\t@@ -211,6 +217,7 @@ func (c *Config) applyDefaults() {\n335\t \tif len(c.Vault.TopicGlobs) == 0 {\n336\t \t\tc.Vault.TopicGlobs = []string{defaultTopicGlob}\n337\t \t}\n338\t+\tc.OKF.Types = normalizeConfigStringList(c.OKF.Types)\n339\t \tif strings.TrimSpace(c.YouTube.YTDLPPath) == \"\" {\n340\t \t\tc.YouTube.YTDLPPath = defaultYouTubeYTDLPPath\n341\t \t}\n342\tdiff --git a/internal/config/config_test.go b/internal/config/config_test.go\n343\tindex 9edc0c1..e4c3c2e 100644\n344\t--- a/internal/config/config_test.go\n345\t+++ b/internal/config/config_test.go\n346\t@@ -59,6 +59,9 @@ func TestDefaultConfigHasValidDefaults(t *testing.T) {\n347\t \tif len(cfg.Vault.TopicGlobs) != 1 || cfg.Vault.TopicGlobs[0] != \"*\" {\n348\t \t\tt.Errorf(\"expected default vault.topic_globs [*], got %#v\", cfg.Vault.TopicGlobs)\n349\t \t}\n350\t+\tif len(cfg.OKF.Types) != 0 {\n351\t+\t\tt.Errorf(\"expected default okf.types to be empty, got %#v\", cfg.OKF.Types)\n352\t+\t}\n353\t \tif cfg.Firecrawl.APIURL != defaultFirecrawlAPIURL {\n354\t \t\tt.Errorf(\"expected default firecrawl.api_url %q, got %q\", defaultFirecrawlAPIURL, cfg.Firecrawl.APIURL)\n355\t \t}\n356\t@@ -118,6 +121,9 @@ level = \"debug\"\n357\t root = \".\"\n358\t topic_globs = [\"*\", \"harness/*\"]\n359\t \n360\t+[okf]\n361\t+types = [\"Playbook\", \" Reference \", \"\"]\n362\t+\n363\t [firecrawl]\n364\t api_key = \"firecrawl-key\"\n365\t api_url = \"https://firecrawl.internal\"\n366\t@@ -172,6 +178,9 @@ allow_translated_captions = true\n367\t \tif len(cfg.Vault.TopicGlobs) != 2 || cfg.Vault.TopicGlobs[1] != \"harness/*\" {\n368\t \t\tt.Errorf(\"expected vault.topic_globs to include harness/*, got %#v\", cfg.Vault.TopicGlobs)\n369\t \t}\n370\t+\tif !reflect.DeepEqual(cfg.OKF.Types, []string{\"Playbook\", \"Reference\"}) {\n371\t+\t\tt.Errorf(\"expected normalized okf.types, got %#v\", cfg.OKF.Types)\n372\t+\t}\n373\t \tif cfg.Firecrawl.APIKey != \"firecrawl-key\" {\n374\t \t\tt.Errorf(\"expected firecrawl.api_key 'firecrawl-key', got %q\", cfg.Firecrawl.APIKey)\n375\t \t}\n376\tdiff --git a/internal/generate/generate.go b/internal/generate/generate.go\n377\tindex be6a9a1..987e572 100644\n378\t--- a/internal/generate/generate.go\n379\t+++ b/internal/generate/generate.go\n380\t@@ -453,6 +453,7 @@ func (r runner) createTopicMetadata(target generationTarget, opts models.Generat\n381\t \t\tTitle: title,\n382\t \t\tSlug: target.TopicSlug,\n383\t \t\tDomain: vault.DeriveTopicDomain(domainSource),\n384\t+\t\tMode: models.TopicModeWiki,\n385\t \t\tToday: r.now().Format(\"2006-01-02\"),\n386\t \t\tVaultPath: target.VaultPath,\n387\t \t\tTopicPath: filepath.Join(target.VaultPath, target.TopicSlug),\n388\tdiff --git a/internal/models/kb_models.go b/internal/models/kb_models.go\n389\tindex 11d0ef2..2087683 100644\n390\t--- a/internal/models/kb_models.go\n391\t+++ b/internal/models/kb_models.go\n392\t@@ -114,11 +114,12 @@ type LintIssue struct {\n393\t \n394\t // TopicInfo captures topic metadata for list and info operations.\n395\t type TopicInfo struct {\n396\t-\tSlug string `json:\"slug\"`\n397\t-\tTitle string `json:\"title\"`\n398\t-\tDomain string `json:\"domain\"`\n399\t-\tRootPath string `json:\"rootPath\"`\n400\t-\tArticleCount int `json:\"articleCount\"`\n401\t-\tSourceCount int `json:\"sourceCount\"`\n402\t-\tLastLogEntry string `json:\"lastLogEntry,omitempty\"`\n403\t+\tSlug string `json:\"slug\"`\n404\t+\tTitle string `json:\"title\"`\n405\t+\tDomain string `json:\"domain\"`\n406\t+\tMode TopicMode `json:\"mode\"`\n407\t+\tRootPath string `json:\"rootPath\"`\n408\t+\tArticleCount int `json:\"articleCount\"`\n409\t+\tSourceCount int `json:\"sourceCount\"`\n410\t+\tLastLogEntry string `json:\"lastLogEntry,omitempty\"`\n411\t }\n412\tdiff --git a/internal/models/models.go b/internal/models/models.go\n413\tindex 3352045..061f0aa 100644\n414\t--- a/internal/models/models.go\n415\t+++ b/internal/models/models.go\n416\t@@ -102,6 +102,16 @@ const (\n417\t \tDocIndex DocumentKind = \"index\"\n418\t )\n419\t \n420\t+// TopicMode identifies the lifecycle and file contract for a topic.\n421\t+type TopicMode string\n422\t+\n423\t+const (\n424\t+\t// TopicModeWiki preserves the existing LLM-wiki topic lifecycle.\n425\t+\tTopicModeWiki TopicMode = \"wiki\"\n426\t+\t// TopicModeOKF uses the Open Knowledge Format bundle lifecycle.\n427\t+\tTopicModeOKF TopicMode = \"okf\"\n428\t+)\n429\t+\n430\t // ManagedArea identifies the managed subtree within a generated topic.\n431\t type ManagedArea string\n432\t \n433\t@@ -269,13 +279,14 @@ type RenderedDocument struct {\n434\t \n435\t // TopicMetadata captures the derived topic information for a vault render.\n436\t type TopicMetadata struct {\n437\t-\tRootPath string `json:\"rootPath\"`\n438\t-\tTitle string `json:\"title\"`\n439\t-\tSlug string `json:\"slug\"`\n440\t-\tDomain string `json:\"domain\"`\n441\t-\tToday string `json:\"today\"`\n442\t-\tVaultPath string `json:\"vaultPath\"`\n443\t-\tTopicPath string `json:\"topicPath\"`\n444\t+\tRootPath string `json:\"rootPath\"`\n445\t+\tTitle string `json:\"title\"`\n446\t+\tSlug string `json:\"slug\"`\n447\t+\tDomain string `json:\"domain\"`\n448\t+\tMode TopicMode `json:\"mode\"`\n449\t+\tToday string `json:\"today\"`\n450\t+\tVaultPath string `json:\"vaultPath\"`\n451\t+\tTopicPath string `json:\"topicPath\"`\n452\t }\n453\t \n454\t // GenerateOptions configures a full knowledge-base generation run.\n455\tdiff --git a/internal/topic/topic.go b/internal/topic/topic.go\n456\tindex 1097c4e..74b6323 100644\n457\t--- a/internal/topic/topic.go\n458\t+++ b/internal/topic/topic.go\n459\t@@ -23,6 +23,7 @@ const (\n460\t \tconceptIndexTemplatePath = \"assets/concept-index-template.md\"\n461\t \tdashboardTemplatePath = \"assets/dashboard-template.md\"\n462\t \tlogTemplatePath = \"assets/log-template.md\"\n463\t+\tokfClaudeTemplatePath = \"assets/okf-claude-template.md\"\n464\t \tsourceIndexTemplatePath = \"assets/source-index-template.md\"\n465\t \ttopicMarkerFile = \"CLAUDE.md\"\n466\t \ttopicMetadataFileName = \"topic.yaml\"\n467\t@@ -105,6 +106,7 @@ type topicMetadataFile struct {\n468\t \tSlug string `yaml:\"slug\"`\n469\t \tTitle string `yaml:\"title\"`\n470\t \tDomain string `yaml:\"domain\"`\n471\t+\tMode string `yaml:\"mode,omitempty\"`\n472\t \tCategory string `yaml:\"category,omitempty\"`\n473\t \tPath string `yaml:\"path,omitempty\"`\n474\t \tQMDCollection string `yaml:\"qmd_collection,omitempty\"`\n475\t@@ -112,7 +114,12 @@ type topicMetadataFile struct {\n476\t \n477\t // New scaffolds a new topic underneath the provided vault root.\n478\t func New(vaultPath, slug, title, domain string) (models.TopicInfo, error) {\n479\t-\treturn newWithDate(vaultPath, slug, title, domain, time.Now())\n480\t+\treturn NewWithMode(vaultPath, slug, title, domain, models.TopicModeWiki)\n481\t+}\n482\t+\n483\t+// NewWithMode scaffolds a new topic with an explicit lifecycle mode.\n484\t+func NewWithMode(vaultPath, slug, title, domain string, mode models.TopicMode) (models.TopicInfo, error) {\n485\t+\treturn newWithDateWithMode(vaultPath, slug, title, domain, mode, time.Now())\n486\t }\n487\t \n488\t // List returns the topics discovered under the provided vault root.\n489\t@@ -184,6 +191,10 @@ func Info(vaultPath, slug string) (models.TopicInfo, error) {\n490\t }\n491\t \n492\t func newWithDate(vaultPath, slug, title, domain string, now time.Time) (models.TopicInfo, error) {\n493\t+\treturn newWithDateWithMode(vaultPath, slug, title, domain, models.TopicModeWiki, now)\n494\t+}\n495\t+\n496\t+func newWithDateWithMode(vaultPath, slug, title, domain string, mode models.TopicMode, now time.Time) (models.TopicInfo, error) {\n497\t \tcleanVaultPath, err := normalizeVaultPath(vaultPath)\n498\t \tif err != nil {\n499\t \t\treturn models.TopicInfo{}, fmt.Errorf(\"new topic: %w\", err)\n500\t@@ -195,6 +206,10 @@ func newWithDate(vaultPath, slug, title, domain string, now time.Time) (models.T\n501\t \t}\n502\t \tcleanTitle := strings.TrimSpace(title)\n503\t \tcleanDomain := strings.TrimSpace(domain)\n504\t+\tcleanMode, err := normalizeTopicMode(mode)\n505\t+\tif err != nil {\n506\t+\t\treturn models.TopicInfo{}, fmt.Errorf(\"new topic: %w\", err)\n507\t+\t}\n508\t \n509\t \tswitch {\n510\t \tcase cleanTitle == \"\":\n511\t@@ -214,7 +229,7 @@ func newWithDate(vaultPath, slug, title, domain string, now time.Time) (models.T\n512\t \t\treturn models.TopicInfo{}, fmt.Errorf(\"new topic: inspect topic path %q: %w\", topicPath, err)\n513\t \t}\n514\t \n515\t-\tif err := createTopicSkeleton(topicPath); err != nil {\n516\t+\tif err := createTopicSkeleton(topicPath, cleanMode); err != nil {\n517\t \t\treturn models.TopicInfo{}, fmt.Errorf(\"new topic: create topic skeleton: %w\", err)\n518\t \t}\n519\t \n520\t@@ -225,19 +240,26 @@ func newWithDate(vaultPath, slug, title, domain string, now time.Time) (models.T\n521\t \t\tToday: now.Format(frontmatter.DateLayout),\n522\t \t}\n523\t \n524\t-\tif err := installTemplates(topicPath, context); err != nil {\n525\t+\tif err := installTemplatesWithMode(topicPath, context, cleanMode); err != nil {\n526\t \t\treturn models.TopicInfo{}, fmt.Errorf(\"new topic: install templates: %w\", err)\n527\t \t}\n528\t-\tif err := writeMetadataFile(topicPath, topicMetadataForRef(topicRef, cleanTitle, cleanDomain)); err != nil {\n529\t+\tif err := writeMetadataFile(topicPath, topicMetadataForRef(topicRef, cleanTitle, cleanDomain, cleanMode)); err != nil {\n530\t \t\treturn models.TopicInfo{}, fmt.Errorf(\"new topic: write topic metadata: %w\", err)\n531\t \t}\n532\t \tif err := ensureAgentsSymlink(topicPath); err != nil {\n533\t \t\treturn models.TopicInfo{}, fmt.Errorf(\"new topic: ensure AGENTS.md symlink: %w\", err)\n534\t \t}\n535\t-\tif err := ensureGitkeeps(topicPath); err != nil {\n536\t-\t\treturn models.TopicInfo{}, fmt.Errorf(\"new topic: ensure gitkeep files: %w\", err)\n537\t+\tif cleanMode == models.TopicModeWiki {\n538\t+\t\tif err := ensureGitkeeps(topicPath); err != nil {\n539\t+\t\t\treturn models.TopicInfo{}, fmt.Errorf(\"new topic: ensure gitkeep files: %w\", err)\n540\t+\t\t}\n541\t \t}\n542\t-\tif err := appendScaffoldEntry(filepath.Join(topicPath, \"log.md\"), context); err != nil {\n543\t+\tlogPath := filepath.Join(topicPath, \"log.md\")\n544\t+\tif cleanMode == models.TopicModeOKF {\n545\t+\t\tif err := appendOKFScaffoldEntry(logPath, context); err != nil {\n546\t+\t\t\treturn models.TopicInfo{}, fmt.Errorf(\"new topic: append scaffold log entry: %w\", err)\n547\t+\t\t}\n548\t+\t} else if err := appendScaffoldEntry(logPath, context); err != nil {\n549\t \t\treturn models.TopicInfo{}, fmt.Errorf(\"new topic: append scaffold log entry: %w\", err)\n550\t \t}\n551\t \n552\t@@ -412,7 +434,10 @@ func ensureDirectory(path string) error {\n553\t \treturn nil\n554\t }\n555\t \n556\t-func createTopicSkeleton(topicPath string) error {\n557\t+func createTopicSkeleton(topicPath string, mode models.TopicMode) error {\n558\t+\tif mode == models.TopicModeOKF {\n559\t+\t\treturn os.MkdirAll(topicPath, 0o755)\n560\t+\t}\n561\t \tfor _, relativePath := range currentTopicDirectories {\n562\t \t\tdirectoryPath := filepath.Join(topicPath, filepath.FromSlash(relativePath))\n563\t \t\tif err := os.MkdirAll(directoryPath, 0o755); err != nil {\n564\t@@ -430,7 +455,7 @@ func EnsureCurrentSkeleton(topicPath string) error {\n565\t \t\treturn fmt.Errorf(\"topic path is required\")\n566\t \t}\n567\t \n568\t-\tif err := createTopicSkeleton(topicPath); err != nil {\n569\t+\tif err := createTopicSkeleton(topicPath, models.TopicModeWiki); err != nil {\n570\t \t\treturn err\n571\t \t}\n572\t \tif err := ensureTopicLog(topicPath); err != nil {\n573\t@@ -485,7 +510,17 @@ func RenderSourceIndexTemplate(slug, title, domain, today string) (string, error\n574\t }\n575\t \n576\t func installTemplates(topicPath string, context templateContext) error {\n577\t-\tfor _, file := range topicTemplates {\n578\t+\treturn installTemplatesWithMode(topicPath, context, models.TopicModeWiki)\n579\t+}\n580\t+\n581\t+func installTemplatesWithMode(topicPath string, context templateContext, mode models.TopicMode) error {\n582\t+\ttemplates := topicTemplates\n583\t+\tif mode == models.TopicModeOKF {\n584\t+\t\ttemplates = []templateFile{\n585\t+\t\t\t{assetPath: okfClaudeTemplatePath, outputPath: \"CLAUDE.md\"},\n586\t+\t\t}\n587\t+\t}\n588\t+\tfor _, file := range templates {\n589\t \t\trendered, err := renderTemplate(file.assetPath, context)\n590\t \t\tif err != nil {\n591\t \t\t\treturn err\n592\t@@ -497,6 +532,19 @@ func installTemplates(topicPath string, context templateContext) error {\n593\t \t\t}\n594\t \t}\n595\t \n596\t+\tif mode == models.TopicModeOKF {\n597\t+\t\tindex, err := frontmatter.Generate(map[string]any{\"okf_version\": \"0.1\"}, \"# OKF Bundle Index\\n\")\n598\t+\t\tif err != nil {\n599\t+\t\t\treturn fmt.Errorf(\"generate OKF index: %w\", err)\n600\t+\t\t}\n601\t+\t\tif err := os.WriteFile(filepath.Join(topicPath, \"index.md\"), []byte(index), 0o644); err != nil {\n602\t+\t\t\treturn fmt.Errorf(\"write OKF index: %w\", err)\n603\t+\t\t}\n604\t+\t\tif err := os.WriteFile(filepath.Join(topicPath, \"log.md\"), []byte(\"# Directory Update Log\\n\"), 0o644); err != nil {\n605\t+\t\t\treturn fmt.Errorf(\"write OKF log: %w\", err)\n606\t+\t\t}\n607\t+\t}\n608\t+\n609\t \treturn nil\n610\t }\n611\t \n612\t@@ -506,14 +554,15 @@ func WriteMetadataFile(topicPath, slug, title, domain string) error {\n613\t \tif err != nil {\n614\t \t\treturn fmt.Errorf(\"validate topic metadata slug: %w\", err)\n615\t \t}\n616\t-\treturn writeMetadataFile(topicPath, topicMetadataForRef(topicRef, title, domain))\n617\t+\treturn writeMetadataFile(topicPath, topicMetadataForRef(topicRef, title, domain, \"\"))\n618\t }\n619\t \n620\t-func topicMetadataForRef(topicRef TopicRef, title, domain string) topicMetadataFile {\n621\t+func topicMetadataForRef(topicRef TopicRef, title, domain string, mode models.TopicMode) topicMetadataFile {\n622\t \tmetadata := topicMetadataFile{\n623\t \t\tSlug: topicRef.Leaf,\n624\t \t\tTitle: strings.TrimSpace(title),\n625\t \t\tDomain: strings.TrimSpace(domain),\n626\t+\t\tMode: string(mode),\n627\t \t}\n628\t \tif topicRef.Category != \"\" {\n629\t \t\tmetadata.Category = topicRef.Category\n630\t@@ -694,6 +743,32 @@ func appendScaffoldEntry(logPath string, context templateContext) error {\n631\t \treturn nil\n632\t }\n633\t \n634\t+func appendOKFScaffoldEntry(logPath string, context templateContext) error {\n635\t+\tcontent, err := os.ReadFile(logPath)\n636\t+\tif err != nil {\n637\t+\t\treturn fmt.Errorf(\"read %q: %w\", logPath, err)\n638\t+\t}\n639\t+\n640\t+\tentry := strings.Join([]string{\n641\t+\t\tfmt.Sprintf(\"## %s\", context.Today),\n642\t+\t\t\"\",\n643\t+\t\tfmt.Sprintf(\"* **Initialization**: Created OKF bundle `%s` for `%s`.\", context.Slug, context.Domain),\n644\t+\t\t\"\",\n645\t+\t}, \"\\n\")\n646\t+\n647\t+\ttrimmed := strings.TrimSpace(string(content))\n648\t+\tvar next string\n649\t+\tif trimmed == \"\" {\n650\t+\t\tnext = \"# Directory Update Log\\n\\n\" + entry\n651\t+\t} else {\n652\t+\t\tnext = trimmed + \"\\n\\n\" + entry\n653\t+\t}\n654\t+\tif err := os.WriteFile(logPath, []byte(next), 0o644); err != nil {\n655\t+\t\treturn fmt.Errorf(\"write %q: %w\", logPath, err)\n656\t+\t}\n657\t+\treturn nil\n658\t+}\n659\t+\n660\t func hasTopicSkeleton(topicPath string) (bool, error) {\n661\t \tinfo, err := os.Stat(topicPath)\n662\t \tif errors.Is(err, os.ErrNotExist) {\n663\t@@ -719,19 +794,14 @@ func hasTopicSkeleton(topicPath string) (bool, error) {\n664\t }\n665\t \n666\t func infoAtPath(topicPath, slug string) (models.TopicInfo, error) {\n667\t-\ttitle, domain, err := readTopicMetadata(topicPath, slug)\n668\t+\ttitle, domain, mode, err := readTopicMetadata(topicPath, slug)\n669\t \tif err != nil {\n670\t \t\treturn models.TopicInfo{}, fmt.Errorf(\"read topic metadata: %w\", err)\n671\t \t}\n672\t \n673\t-\tarticleCount, err := countMarkdownFiles(filepath.Join(topicPath, \"wiki\", \"concepts\"))\n674\t+\tarticleCount, sourceCount, err := topicCounts(topicPath, mode)\n675\t \tif err != nil {\n676\t-\t\treturn models.TopicInfo{}, fmt.Errorf(\"count wiki articles: %w\", err)\n677\t-\t}\n678\t-\n679\t-\tsourceCount, err := countVisibleFiles(filepath.Join(topicPath, \"raw\"))\n680\t-\tif err != nil {\n681\t-\t\treturn models.TopicInfo{}, fmt.Errorf(\"count raw sources: %w\", err)\n682\t+\t\treturn models.TopicInfo{}, fmt.Errorf(\"count topic files: %w\", err)\n683\t \t}\n684\t \n685\t \tlastLogEntry, err := readLastLogEntry(filepath.Join(topicPath, \"log.md\"))\n686\t@@ -743,6 +813,7 @@ func infoAtPath(topicPath, slug string) (models.TopicInfo, error) {\n687\t \t\tSlug: slug,\n688\t \t\tTitle: title,\n689\t \t\tDomain: domain,\n690\t+\t\tMode: mode,\n691\t \t\tRootPath: topicPath,\n692\t \t\tArticleCount: articleCount,\n693\t \t\tSourceCount: sourceCount,\n694\t@@ -750,21 +821,25 @@ func infoAtPath(topicPath, slug string) (models.TopicInfo, error) {\n695\t \t}, nil\n696\t }\n697\t \n698\t-func readTopicMetadata(claudePath, slug string) (string, string, error) {\n699\t+func readTopicMetadata(claudePath, slug string) (string, string, models.TopicMode, error) {\n700\t \tyamlMetadata, err := readTopicYAMLMetadata(filepath.Join(claudePath, topicMetadataFileName))\n701\t \tif err != nil {\n702\t-\t\treturn \"\", \"\", err\n703\t+\t\treturn \"\", \"\", \"\", err\n704\t \t}\n705\t \n706\t \tclaudeTitle, claudeDomain, err := readClaudeMetadata(filepath.Join(claudePath, topicMarkerFile), slug)\n707\t \tif err != nil {\n708\t-\t\treturn \"\", \"\", err\n709\t+\t\treturn \"\", \"\", \"\", err\n710\t \t}\n711\t \n712\t \ttitle := firstNonEmpty(yamlMetadata.Title, claudeTitle, humanizeSlug(slug))\n713\t \tdomain := firstNonEmpty(yamlMetadata.Domain, claudeDomain, slug)\n714\t+\tmode, err := normalizeTopicMode(models.TopicMode(yamlMetadata.Mode))\n715\t+\tif err != nil {\n716\t+\t\treturn \"\", \"\", \"\", err\n717\t+\t}\n718\t \n719\t-\treturn title, domain, nil\n720\t+\treturn title, domain, mode, nil\n721\t }\n722\t \n723\t func readTopicYAMLMetadata(metadataPath string) (topicMetadataFile, error) {\n724\t@@ -784,9 +859,52 @@ func readTopicYAMLMetadata(metadataPath string) (topicMetadataFile, error) {\n725\t \tmetadata.Slug = strings.TrimSpace(metadata.Slug)\n726\t \tmetadata.Title = strings.TrimSpace(metadata.Title)\n727\t \tmetadata.Domain = strings.TrimSpace(metadata.Domain)\n728\t+\tmetadata.Mode = strings.TrimSpace(metadata.Mode)\n729\t \treturn metadata, nil\n730\t }\n731\t \n732\t+func normalizeTopicMode(mode models.TopicMode) (models.TopicMode, error) {\n733\t+\tswitch strings.TrimSpace(string(mode)) {\n734\t+\tcase \"\", string(models.TopicModeWiki):\n735\t+\t\treturn models.TopicModeWiki, nil\n736\t+\tcase string(models.TopicModeOKF):\n737\t+\t\treturn models.TopicModeOKF, nil\n738\t+\tdefault:\n739\t+\t\treturn \"\", fmt.Errorf(\"topic mode must be wiki or okf: %q\", mode)\n740\t+\t}\n741\t+}\n742\t+\n743\t+func topicCounts(topicPath string, mode models.TopicMode) (int, int, error) {\n744\t+\tif mode == models.TopicModeOKF {\n745\t+\t\tconcepts, err := countOKFConceptFiles(topicPath)\n746\t+\t\treturn concepts, 0, err\n747\t+\t}\n748\t+\tarticleCount, err := countMarkdownFiles(filepath.Join(topicPath, \"wiki\", \"concepts\"))\n749\t+\tif err != nil {\n750\t+\t\treturn 0, 0, fmt.Errorf(\"count wiki articles: %w\", err)\n751\t+\t}\n752\t+\tsourceCount, err := countVisibleFiles(filepath.Join(topicPath, \"raw\"))\n753\t+\tif err != nil {\n754\t+\t\treturn 0, 0, fmt.Errorf(\"count raw sources: %w\", err)\n755\t+\t}\n756\t+\treturn articleCount, sourceCount, nil\n757\t+}\n758\t+\n759\t+func countOKFConceptFiles(root string) (int, error) {\n760\t+\treturn countFiles(root, func(entry fs.DirEntry) bool {\n761\t+\t\tname := entry.Name()\n762\t+\t\tif !strings.HasSuffix(strings.ToLower(name), \".md\") || strings.HasPrefix(name, \".\") {\n763\t+\t\t\treturn false\n764\t+\t\t}\n765\t+\t\tswitch strings.ToLower(name) {\n766\t+\t\tcase \"index.md\", \"log.md\", \"claude.md\", \"agents.md\", \"readme.md\", \"license.md\", \"notice.md\", \"attribution.md\":\n767\t+\t\t\treturn false\n768\t+\t\tdefault:\n769\t+\t\t\treturn true\n770\t+\t\t}\n771\t+\t})\n772\t+}\n773\t+\n774\t func readClaudeMetadata(claudePath, slug string) (string, string, error) {\n775\t \tcontent, err := os.ReadFile(claudePath)\n776\t \tif err != nil {\n777\t@@ -827,7 +945,7 @@ func readLastLogEntry(logPath string) (string, error) {\n778\t \tlast := \"\"\n779\t \tfor line := range strings.SplitSeq(string(content), \"\\n\") {\n780\t \t\ttrimmed := strings.TrimSpace(line)\n781\t-\t\tif strings.HasPrefix(trimmed, \"## [\") {\n782\t+\t\tif strings.HasPrefix(trimmed, \"## [\") || isOKFDateHeading(trimmed) {\n783\t \t\t\tlast = trimmed\n784\t \t\t}\n785\t \t}\n786\t@@ -835,6 +953,18 @@ func readLastLogEntry(logPath string) (string, error) {\n787\t \treturn last, nil\n788\t }\n789\t \n790\t+func isOKFDateHeading(line string) bool {\n791\t+\tif !strings.HasPrefix(line, \"## \") {\n792\t+\t\treturn false\n793\t+\t}\n794\t+\tvalue := strings.TrimSpace(strings.TrimPrefix(line, \"## \"))\n795\t+\tif len(value) != len(frontmatter.DateLayout) {\n796\t+\t\treturn false\n797\t+\t}\n798\t+\t_, err := time.Parse(frontmatter.DateLayout, value)\n799\t+\treturn err == nil\n800\t+}\n801\t+\n802\t func countMarkdownFiles(root string) (int, error) {\n803\t \treturn countFiles(root, func(entry fs.DirEntry) bool {\n804\t \t\tname := entry.Name()\n805\tdiff --git a/internal/topic/topic_test.go b/internal/topic/topic_test.go\n806\tindex 2730efa..46dbcee 100644\n807\t--- a/internal/topic/topic_test.go\n808\t+++ b/internal/topic/topic_test.go\n809\t@@ -8,6 +8,7 @@ import (\n810\t \t\"time\"\n811\t \n812\t \t\"github.com/compozy/kb/internal/frontmatter\"\n813\t+\t\"github.com/compozy/kb/internal/models\"\n814\t )\n815\t \n816\t func TestNewCreatesTopicSkeletonAndTemplates(t *testing.T) {\n817\t@@ -35,6 +36,9 @@ func TestNewCreatesTopicSkeletonAndTemplates(t *testing.T) {\n818\t \tif info.Domain != \"rust\" {\n819\t \t\tt.Fatalf(\"domain = %q, want rust\", info.Domain)\n820\t \t}\n821\t+\tif info.Mode != models.TopicModeWiki {\n822\t+\t\tt.Fatalf(\"mode = %q, want wiki\", info.Mode)\n823\t+\t}\n824\t \tif info.ArticleCount != 0 {\n825\t \t\tt.Fatalf(\"article count = %d, want 0\", info.ArticleCount)\n826\t \t}\n827\t@@ -156,6 +160,7 @@ func TestNewCreatesCategorizedTopic(t *testing.T) {\n828\t \t\t\"slug: asimov-academy\",\n829\t \t\t\"title: Asimov Academy\",\n830\t \t\t\"domain: youtube-channel\",\n831\t+\t\t\"mode: wiki\",\n832\t \t\t\"category: yt-channels\",\n833\t \t\t\"path: yt-channels/asimov-academy\",\n834\t \t\t\"qmd_collection: asimov-academy\",\n835\t@@ -207,6 +212,7 @@ func TestNewCreatesClaudeAndAgentsSymlink(t *testing.T) {\n836\t \t\t\"slug: distributed-systems\",\n837\t \t\t\"title: Distributed Systems\",\n838\t \t\t\"domain: distributed\",\n839\t+\t\t\"mode: wiki\",\n840\t \t} {\n841\t \t\tif !strings.Contains(metadataContent, fragment) {\n842\t \t\t\tt.Fatalf(\"topic.yaml missing %q:\\n%s\", fragment, metadataContent)\n843\t@@ -227,6 +233,83 @@ func TestNewCreatesClaudeAndAgentsSymlink(t *testing.T) {\n844\t \t}\n845\t }\n846\t \n847\t+func TestNewWithModeCreatesOKFTopicSkeleton(t *testing.T) {\n848\t+\tt.Parallel()\n849\t+\n850\t+\tvaultPath := t.TempDir()\n851\t+\n852\t+\tinfo, err := newWithDateWithMode(\n853\t+\t\tvaultPath,\n854\t+\t\t\"ops-catalog\",\n855\t+\t\t\"Operations Catalog\",\n856\t+\t\t\"operations\",\n857\t+\t\tmodels.TopicModeOKF,\n858\t+\t\ttime.Date(2026, 6, 27, 12, 0, 0, 0, time.UTC),\n859\t+\t)\n860\t+\tif err != nil {\n861\t+\t\tt.Fatalf(\"newWithDateWithMode returned error: %v\", err)\n862\t+\t}\n863\t+\tif info.Mode != models.TopicModeOKF {\n864\t+\t\tt.Fatalf(\"mode = %q, want okf\", info.Mode)\n865\t+\t}\n866\t+\tif info.ArticleCount != 0 || info.SourceCount != 0 {\n867\t+\t\tt.Fatalf(\"counts = articles %d sources %d, want 0/0\", info.ArticleCount, info.SourceCount)\n868\t+\t}\n869\t+\tif info.LastLogEntry != \"## 2026-06-27\" {\n870\t+\t\tt.Fatalf(\"last log entry = %q, want OKF date heading\", info.LastLogEntry)\n871\t+\t}\n872\t+\n873\t+\ttopicPath := filepath.Join(vaultPath, \"ops-catalog\")\n874\t+\tfor _, relativePath := range []string{\"CLAUDE.md\", \"AGENTS.md\", \"index.md\", \"log.md\", \"topic.yaml\"} {\n875\t+\t\tassertFileExists(t, filepath.Join(topicPath, filepath.FromSlash(relativePath)))\n876\t+\t}\n877\t+\tfor _, relativePath := range []string{\"raw\", \"wiki\", \"outputs\", \"bases\"} {\n878\t+\t\tif _, err := os.Stat(filepath.Join(topicPath, relativePath)); !os.IsNotExist(err) {\n879\t+\t\t\tt.Fatalf(\"OKF scaffold should not create %s\", relativePath)\n880\t+\t\t}\n881\t+\t}\n882\t+\n883\t+\tmetadataContent := readFile(t, filepath.Join(topicPath, \"topic.yaml\"))\n884\t+\tfor _, fragment := range []string{\n885\t+\t\t\"slug: ops-catalog\",\n886\t+\t\t\"title: Operations Catalog\",\n887\t+\t\t\"domain: operations\",\n888\t+\t\t\"mode: okf\",\n889\t+\t} {\n890\t+\t\tif !strings.Contains(metadataContent, fragment) {\n891\t+\t\t\tt.Fatalf(\"topic.yaml missing %q:\\n%s\", fragment, metadataContent)\n892\t+\t\t}\n893\t+\t}\n894\t+\n895\t+\tindexValues, indexBody := parseFrontmatterFile(t, filepath.Join(topicPath, \"index.md\"))\n896\t+\tif got := indexValues[\"okf_version\"]; got != \"0.1\" {\n897\t+\t\tt.Fatalf(\"index okf_version = %#v, want 0.1\", got)\n898\t+\t}\n899\t+\tif !strings.Contains(indexBody, \"# OKF Bundle Index\") {\n900\t+\t\tt.Fatalf(\"index body missing heading:\\n%s\", indexBody)\n901\t+\t}\n902\t+\tlogContent := readFile(t, filepath.Join(topicPath, \"log.md\"))\n903\t+\tif !strings.Contains(logContent, \"## 2026-06-27\") || strings.Contains(logContent, \"## [2026-06-27]\") {\n904\t+\t\tt.Fatalf(\"log.md does not use OKF date heading:\\n%s\", logContent)\n905\t+\t}\n906\t+}\n907\t+\n908\t+func TestReadTopicMetadataDefaultsMissingModeToWiki(t *testing.T) {\n909\t+\tt.Parallel()\n910\t+\n911\t+\ttopicPath := t.TempDir()\n912\t+\twriteFile(t, filepath.Join(topicPath, \"topic.yaml\"), \"slug: old-topic\\ntitle: Old Topic\\ndomain: legacy\\n\")\n913\t+\twriteFile(t, filepath.Join(topicPath, \"CLAUDE.md\"), \"# Old Topic\\n\\n**Domain:** `legacy`\\n\")\n914\t+\n915\t+\tinfo, err := infoAtPath(topicPath, \"old-topic\")\n916\t+\tif err != nil {\n917\t+\t\tt.Fatalf(\"infoAtPath returned error: %v\", err)\n918\t+\t}\n919\t+\tif info.Mode != models.TopicModeWiki {\n920\t+\t\tt.Fatalf(\"mode = %q, want wiki\", info.Mode)\n921\t+\t}\n922\t+}\n923\t+\n924\t func TestNewAppendsScaffoldEntryToLog(t *testing.T) {\n925\t \tt.Parallel()\n926\t \n927\tdiff --git a/internal/vault/pathutils.go b/internal/vault/pathutils.go\n928\tindex d356e3c..002b4df 100644\n929\t--- a/internal/vault/pathutils.go\n930\t+++ b/internal/vault/pathutils.go\n931\t@@ -4,6 +4,7 @@ package vault\n932\t import (\n933\t \t\"fmt\"\n934\t \t\"path\"\n935\t+\t\"path/filepath\"\n936\t \t\"regexp\"\n937\t \t\"strings\"\n938\t \n939\t@@ -251,6 +252,73 @@ func ToTopicWikiLink(topicSlug, documentPath, label string) string {\n940\t \treturn fmt.Sprintf(\"[[%s]]\", target)\n941\t }\n942\t \n943\t+// LinkFormatter renders a link from one topic-relative document to another.\n944\t+type LinkFormatter interface {\n945\t+\tLink(fromDir, targetPath, label string) string\n946\t+}\n947\t+\n948\t+// WikiLinkFormatter renders Obsidian topic-scoped wikilinks.\n949\t+type WikiLinkFormatter struct {\n950\t+\tSlug string\n951\t+}\n952\t+\n953\t+// Link renders a wikilink exactly like ToTopicWikiLink.\n954\t+func (f WikiLinkFormatter) Link(_, targetPath, label string) string {\n955\t+\treturn ToTopicWikiLink(f.Slug, targetPath, label)\n956\t+}\n957\t+\n958\t+// OKFLinkFormatter renders relative Markdown links for OKF bundles.\n959\t+type OKFLinkFormatter struct{}\n960\t+\n961\t+// Link renders a GitHub-safe relative Markdown link.\n962\t+func (OKFLinkFormatter) Link(fromDir, targetPath, label string) string {\n963\t+\tcleanTarget := strings.TrimPrefix(ToPosixPath(strings.TrimSpace(targetPath)), \"/\")\n964\t+\tdisplayLabel := strings.TrimSpace(label)\n965\t+\tif displayLabel == \"\" {\n966\t+\t\tdisplayLabel = HumanizeSlug(SlugifySegment(strings.TrimSuffix(path.Base(cleanTarget), \".md\")))\n967\t+\t}\n968\t+\n969\t+\tfragment := \"\"\n970\t+\tif hashIndex := strings.Index(cleanTarget, \"#\"); hashIndex \u003e= 0 {\n971\t+\t\tfragment = cleanTarget[hashIndex:]\n972\t+\t\tcleanTarget = cleanTarget[:hashIndex]\n973\t+\t}\n974\t+\n975\t+\tcleanFromDir := strings.TrimPrefix(ToPosixPath(strings.TrimSpace(fromDir)), \"/\")\n976\t+\tif cleanFromDir == \"\" {\n977\t+\t\tcleanFromDir = \".\"\n978\t+\t}\n979\t+\n980\t+\trelativePath, err := filepath.Rel(filepath.FromSlash(cleanFromDir), filepath.FromSlash(cleanTarget))\n981\t+\tif err != nil {\n982\t+\t\trelativePath = cleanTarget\n983\t+\t}\n984\t+\trelativePath = ToPosixPath(relativePath)\n985\t+\tif relativePath == \".\" || relativePath == \"\" {\n986\t+\t\trelativePath = path.Base(cleanTarget)\n987\t+\t}\n988\t+\trelativePath = strings.TrimPrefix(relativePath, \"./\")\n989\t+\trelativePath = markdownLinkTarget(relativePath + fragment)\n990\t+\treturn fmt.Sprintf(\"[%s](%s)\", displayLabel, relativePath)\n991\t+}\n992\t+\n993\t+// LinkFormatterFor selects the formatter from the topic mode.\n994\t+func LinkFormatterFor(topic models.TopicMetadata) LinkFormatter {\n995\t+\tif topic.Mode == models.TopicModeOKF {\n996\t+\t\treturn OKFLinkFormatter{}\n997\t+\t}\n998\t+\treturn WikiLinkFormatter{Slug: topic.Slug}\n999\t+}\n1000\t+\n1001\t+func markdownLinkTarget(target string) string {\n1002\t+\treplacer := strings.NewReplacer(\n1003\t+\t\t\" \", \"%20\",\n1004\t+\t\t\"(\", \"%28\",\n1005\t+\t\t\")\", \"%29\",\n1006\t+\t)\n1007\t+\treturn replacer.Replace(target)\n1008\t+}\n1009\t+\n1010\t func stripWikiConceptFilePrefix(articleTitle string) string {\n1011\t \tnormalizedTitle := strings.TrimSpace(articleTitle)\n1012\t \treturn strings.TrimSpace(strings.TrimPrefix(normalizedTitle, legacyWikiConceptPrefix))\n1013\tdiff --git a/internal/vault/pathutils_test.go b/internal/vault/pathutils_test.go\n1014\tindex 927c7d4..6655604 100644\n1015\t--- a/internal/vault/pathutils_test.go\n1016\t+++ b/internal/vault/pathutils_test.go\n1017\t@@ -196,6 +196,44 @@ func TestTopicWikiLinkHelpers(t *testing.T) {\n1018\t \t}\n1019\t }\n1020\t \n1021\t+func TestLinkFormatters(t *testing.T) {\n1022\t+\tt.Parallel()\n1023\t+\n1024\t+\twiki := vault.WikiLinkFormatter{Slug: \"topic-slug\"}\n1025\t+\tif got := wiki.Link(\"ignored\", \"wiki/codebase/concepts/Codebase Overview.md\", \"Overview\"); got != vault.ToTopicWikiLink(\"topic-slug\", \"wiki/codebase/concepts/Codebase Overview.md\", \"Overview\") {\n1026\t+\t\tt.Fatalf(\"wiki formatter = %q\", got)\n1027\t+\t}\n1028\t+\n1029\t+\tokf := vault.OKFLinkFormatter{}\n1030\t+\ttestCases := []struct {\n1031\t+\t\tname string\n1032\t+\t\tfrom string\n1033\t+\t\ttarget string\n1034\t+\t\tlabel string\n1035\t+\t\twant string\n1036\t+\t}{\n1037\t+\t\t{name: \"same directory\", from: \"\", target: \"orders.md\", label: \"Orders\", want: \"[Orders](orders.md)\"},\n1038\t+\t\t{name: \"child\", from: \"\", target: \"tables/orders.md\", label: \"Orders\", want: \"[Orders](tables/orders.md)\"},\n1039\t+\t\t{name: \"parent\", from: \"references\", target: \"tables/customer orders.md#schema\", label: \"Customers\", want: \"[Customers](../tables/customer%20orders.md#schema)\"},\n1040\t+\t\t{name: \"sibling\", from: \"tables\", target: \"references/joins.md\", label: \"Joins\", want: \"[Joins](../references/joins.md)\"},\n1041\t+\t}\n1042\t+\tfor _, testCase := range testCases {\n1043\t+\t\tt.Run(testCase.name, func(t *testing.T) {\n1044\t+\t\t\tt.Parallel()\n1045\t+\t\t\tif got := okf.Link(testCase.from, testCase.target, testCase.label); got != testCase.want {\n1046\t+\t\t\t\tt.Fatalf(\"OKF link = %q, want %q\", got, testCase.want)\n1047\t+\t\t\t}\n1048\t+\t\t})\n1049\t+\t}\n1050\t+\n1051\t+\tif got := vault.LinkFormatterFor(models.TopicMetadata{Slug: \"demo\", Mode: models.TopicModeWiki}).Link(\"\", \"x.md\", \"X\"); got != \"[[demo/x|X]]\" {\n1052\t+\t\tt.Fatalf(\"wiki LinkFormatterFor = %q\", got)\n1053\t+\t}\n1054\t+\tif got := vault.LinkFormatterFor(models.TopicMetadata{Mode: models.TopicModeOKF}).Link(\"\", \"x.md\", \"X\"); got != \"[X](x.md)\" {\n1055\t+\t\tt.Fatalf(\"OKF LinkFormatterFor = %q\", got)\n1056\t+\t}\n1057\t+}\n1058\t+\n1059\t func TestPathHelpersHandleEmptyInputs(t *testing.T) {\n1060\t \tt.Parallel()\n1061\t \n1062\tdiff --git a/internal/vault/render.go b/internal/vault/render.go\n1063\tindex 374133e..b646514 100644\n1064\t--- a/internal/vault/render.go\n1065\t+++ b/internal/vault/render.go\n1066\t@@ -184,7 +184,11 @@ func renderMarkdownDocument(document models.RenderedDocument) string {\n1067\t }\n1068\t \n1069\t func toSourceWikiLink(topic models.TopicMetadata, relativePath, label string) string {\n1070\t-\treturn ToTopicWikiLink(topic.Slug, relativePath, label)\n1071\t+\treturn linkFor(topic, \"\", relativePath, label)\n1072\t+}\n1073\t+\n1074\t+func linkFor(topic models.TopicMetadata, fromDir, relativePath, label string) string {\n1075\t+\treturn LinkFormatterFor(topic).Link(fromDir, relativePath, label)\n1076\t }\n1077\t \n1078\t func createDocumentLookup(graph models.GraphSnapshot) map[string]string {\n1079\t@@ -217,7 +221,7 @@ func linkForNode(\n1080\t \tfallbackLabel string,\n1081\t ) string {\n1082\t \tif documentPath, exists := documentLookup[nodeID]; exists {\n1083\t-\t\treturn toSourceWikiLink(topic, documentPath, fallbackLabel)\n1084\t+\t\treturn linkFor(topic, \"\", documentPath, fallbackLabel)\n1085\t \t}\n1086\t \n1087\t \tif externalNode, exists := externalNodes[nodeID]; exists {\n1088\tdiff --git a/internal/vault/render_test.go b/internal/vault/render_test.go\n1089\tindex fb6ba13..10cd72d 100644\n1090\t--- a/internal/vault/render_test.go\n1091\t+++ b/internal/vault/render_test.go\n1092\t@@ -263,6 +263,32 @@ func TestRenderDocumentsUseTopicWikiLinkSyntax(t *testing.T) {\n1093\t \t}\n1094\t }\n1095\t \n1096\t+func TestRenderDocumentsUseOKFMarkdownLinkSyntax(t *testing.T) {\n1097\t+\tt.Parallel()\n1098\t+\n1099\t+\tgraph := testGraphFixture()\n1100\t+\tmetricResult := metrics.ComputeMetrics(graph)\n1101\t+\ttopic := testTopicFixture()\n1102\t+\ttopic.Mode = models.TopicModeOKF\n1103\t+\tdocuments := vault.RenderDocuments(graph, metricResult, topic)\n1104\t+\n1105\t+\trawDocument := findDocument(t, documents, \"raw/codebase/files/commands/run.ts.md\")\n1106\t+\tif strings.Contains(rawDocument.Body, \"[[\") {\n1107\t+\t\tt.Fatalf(\"expected OKF raw document to avoid wikilinks, got:\\n%s\", rawDocument.Body)\n1108\t+\t}\n1109\t+\tif !strings.Contains(rawDocument.Body, \"[main (function)](raw/codebase/symbols/main--commands-run-ts-l1.md)\") {\n1110\t+\t\tt.Fatalf(\"expected OKF raw document to contain relative markdown link, got:\\n%s\", rawDocument.Body)\n1111\t+\t}\n1112\t+\n1113\t+\tdashboard := findDocument(t, documents, vault.GetWikiIndexPath(vault.CodebaseDashboardTitle))\n1114\t+\tif strings.Contains(dashboard.Body, \"[[\") {\n1115\t+\t\tt.Fatalf(\"expected OKF dashboard to avoid wikilinks, got:\\n%s\", dashboard.Body)\n1116\t+\t}\n1117\t+\tif !strings.Contains(dashboard.Body, \"[Codebase Overview](wiki/codebase/concepts/Codebase%20Overview.md)\") {\n1118\t+\t\tt.Fatalf(\"expected OKF dashboard to link with escaped relative markdown path, got:\\n%s\", dashboard.Body)\n1119\t+\t}\n1120\t+}\n1121\t+\n1122\t func renderFixtureDocuments(t *testing.T) []models.RenderedDocument {\n1123\t \tt.Helper()\n1124\t \n1125\tdiff --git a/internal/vault/render_wiki.go b/internal/vault/render_wiki.go\n1126\tindex 2c3978a..af371b9 100644\n1127\t--- a/internal/vault/render_wiki.go\n1128\t+++ b/internal/vault/render_wiki.go\n1129\t@@ -105,7 +105,7 @@ func renderDashboard(\n1130\t \tfor _, article := range articles {\n1131\t \t\tlines = append(lines, fmt.Sprintf(\n1132\t \t\t\t\"- %s - %s\",\n1133\t-\t\t\tToTopicWikiLink(topic.Slug, GetWikiConceptPath(article.Title), article.Title),\n1134\t+\t\t\tlinkFor(topic, \"\", GetWikiConceptPath(article.Title), article.Title),\n1135\t \t\t\tarticle.Summary,\n1136\t \t\t))\n1137\t \t}\n1138\t@@ -113,8 +113,8 @@ func renderDashboard(\n1139\t \tlines = append(lines,\n1140\t \t\t\"\",\n1141\t \t\t\"## Navigation\",\n1142\t-\t\t\"- \"+ToTopicWikiLink(topic.Slug, GetWikiIndexPath(CodebaseConceptIndexTitle), CodebaseConceptIndexTitle),\n1143\t-\t\t\"- \"+ToTopicWikiLink(topic.Slug, GetWikiIndexPath(CodebaseSourceIndexTitle), CodebaseSourceIndexTitle),\n1144\t+\t\t\"- \"+linkFor(topic, \"\", GetWikiIndexPath(CodebaseConceptIndexTitle), CodebaseConceptIndexTitle),\n1145\t+\t\t\"- \"+linkFor(topic, \"\", GetWikiIndexPath(CodebaseSourceIndexTitle), CodebaseSourceIndexTitle),\n1146\t \t)\n1147\t \n1148\t \treturn models.RenderedDocument{\n1149\t@@ -144,7 +144,7 @@ func renderConceptIndex(\n1150\t \tfor _, article := range orderedArticles {\n1151\t \t\trows = append(rows, fmt.Sprintf(\n1152\t \t\t\t\"| %s | %s |\",\n1153\t-\t\t\tToTopicWikiLink(topic.Slug, GetWikiConceptPath(article.Title), article.Title),\n1154\t+\t\t\tlinkFor(topic, \"\", GetWikiConceptPath(article.Title), article.Title),\n1155\t \t\t\tarticle.Summary,\n1156\t \t\t))\n1157\t \t}\n1158\t@@ -191,7 +191,7 @@ func renderSourceIndex(topic models.TopicMetadata, articles []starterWikiArticle\n1159\t \n1160\t \t\tlinks := make([]string, 0, len(titles))\n1161\t \t\tfor _, title := range titles {\n1162\t-\t\t\tlinks = append(links, ToTopicWikiLink(topic.Slug, GetWikiConceptPath(title), title))\n1163\t+\t\t\tlinks = append(links, linkFor(topic, \"\", GetWikiConceptPath(title), title))\n1164\t \t\t}\n1165\t \n1166\t \t\trows = append(rows, fmt.Sprintf(\n1167\t@@ -311,9 +311,9 @@ func createCodebaseOverviewArticle(\n1168\t \t\t\"\",\n1169\t \t\tfmt.Sprintf(\n1170\t \t\t\t\"Start with %s for coupling, %s for function-level complexity, and %s for likely cleanup candidates.\",\n1171\t-\t\t\tToTopicWikiLink(topic.Slug, GetWikiConceptPath(\"Module Health\"), \"Module Health\"),\n1172\t-\t\t\tToTopicWikiLink(topic.Slug, GetWikiConceptPath(\"Complexity Hotspots\"), \"Complexity Hotspots\"),\n1173\t-\t\t\tToTopicWikiLink(topic.Slug, GetWikiConceptPath(\"Dead Code Report\"), \"Dead Code Report\"),\n1174\t+\t\t\tlinkFor(topic, \"\", GetWikiConceptPath(\"Module Health\"), \"Module Health\"),\n1175\t+\t\t\tlinkFor(topic, \"\", GetWikiConceptPath(\"Complexity Hotspots\"), \"Complexity Hotspots\"),\n1176\t+\t\t\tlinkFor(topic, \"\", GetWikiConceptPath(\"Dead Code Report\"), \"Dead Code Report\"),\n1177\t \t\t),\n1178\t \t\t\"\",\n1179\t \t\t\"## Language Coverage\",\n1180\t@@ -392,8 +392,8 @@ func createDirectoryMapArticle(\n1181\t \t\t\t\"\",\n1182\t \t\t\tfmt.Sprintf(\n1183\t \t\t\t\t\"Cross-check unstable directories against %s and hotspots against %s.\",\n1184\t-\t\t\t\tToTopicWikiLink(topic.Slug, GetWikiConceptPath(\"Module Health\"), \"Module Health\"),\n1185\t-\t\t\t\tToTopicWikiLink(topic.Slug, GetWikiConceptPath(\"Dependency Hotspots\"), \"Dependency Hotspots\"),\n1186\t+\t\t\t\tlinkFor(topic, \"\", GetWikiConceptPath(\"Module Health\"), \"Module Health\"),\n1187\t+\t\t\t\tlinkFor(topic, \"\", GetWikiConceptPath(\"Dependency Hotspots\"), \"Dependency Hotspots\"),\n1188\t \t\t\t),\n1189\t \t\t\t\"\",\n1190\t \t\t\t\"## Sources and Further Reading\",\n1191\t@@ -458,8 +458,8 @@ func createSymbolTaxonomyArticle(\n1192\t \t\t\t\"\",\n1193\t \t\t\tfmt.Sprintf(\n1194\t \t\t\t\t\"Use %s to find bottlenecks and %s to locate function-level smells.\",\n1195\t-\t\t\t\tToTopicWikiLink(topic.Slug, GetWikiConceptPath(\"High-Impact Symbols\"), \"High-Impact Symbols\"),\n1196\t-\t\t\t\tToTopicWikiLink(topic.Slug, GetWikiConceptPath(\"Code Smells\"), \"Code Smells\"),\n1197\t+\t\t\t\tlinkFor(topic, \"\", GetWikiConceptPath(\"High-Impact Symbols\"), \"High-Impact Symbols\"),\n```"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-013457-686051000-25106f92d0f1a262","time":"2026-06-28T01:35:09.30343Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":""}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-013457-686051000-25106f92d0f1a262","time":"2026-06-28T01:35:09.303575Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":"Let me continue reading the diff patch"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-013457-686051000-25106f92d0f1a262","time":"2026-06-28T01:35:09.796418Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":"."}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-013457-686051000-25106f92d0f1a262","time":"2026-06-28T01:35:09.799045Z","turn":1,"type":"session.update","update":{"kind":"tool_call_started","tool_call_id":"toolu_01AZFbbPs9sQapYAxiuz2X8H","tool_call_state":"pending","blocks":[{"type":"tool_use","id":"toolu_01AZFbbPs9sQapYAxiuz2X8H","name":"Read","title":"Read File","raw_input":{}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-013457-686051000-25106f92d0f1a262","time":"2026-06-28T01:35:11.107279Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01AZFbbPs9sQapYAxiuz2X8H","blocks":[{"type":"tool_use","id":"toolu_01AZFbbPs9sQapYAxiuz2X8H","name":"Read","title":"Read .compozy/tasks/okf-dual-mode/qa/impl-review-diff-round2.patch (1198 - 2394)","input":{"file_path":"/Users/pedronauck/Dev/compozy/kb/.compozy/tasks/okf-dual-mode/qa/impl-review-diff-round2.patch"},"raw_input":{"file_path":"/Users/pedronauck/Dev/compozy/kb/.compozy/tasks/okf-dual-mode/qa/impl-review-diff-round2.patch","limit":1197,"offset":1198}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-013457-686051000-25106f92d0f1a262","time":"2026-06-28T01:35:11.612533Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01AZFbbPs9sQapYAxiuz2X8H","usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-013457-686051000-25106f92d0f1a262","time":"2026-06-28T01:35:11.620447Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01AZFbbPs9sQapYAxiuz2X8H","tool_call_state":"completed","blocks":[{"type":"tool_result","tool_use_id":"toolu_01AZFbbPs9sQapYAxiuz2X8H","content":"```\n1198\t+\t\t\t\tlinkFor(topic, \"\", GetWikiConceptPath(\"Code Smells\"), \"Code Smells\"),\n1199\t \t\t\t),\n1200\t \t\t\t\"\",\n1201\t \t\t\t\"## Sources and Further Reading\",\n1202\t@@ -535,7 +535,7 @@ func createDependencyHotspotsArticle(\n1203\t \t\t\t\"\",\n1204\t \t\t\tfmt.Sprintf(\n1205\t \t\t\t\t\"A high relation count usually indicates a coordination layer, a shared utility, or an entry point. Cross-check these files against %s to distinguish stable modules from unstable ones.\",\n1206\t-\t\t\t\tToTopicWikiLink(topic.Slug, GetWikiConceptPath(\"Module Health\"), \"Module Health\"),\n1207\t+\t\t\t\tlinkFor(topic, \"\", GetWikiConceptPath(\"Module Health\"), \"Module Health\"),\n1208\t \t\t\t),\n1209\t \t\t\t\"\",\n1210\t \t\t\t\"## Sources and Further Reading\",\n1211\t@@ -613,7 +613,7 @@ func createComplexityHotspotsArticle(\n1212\t \t\t\t\"\",\n1213\t \t\t\tfmt.Sprintf(\n1214\t \t\t\t\t\"Compare these hotspots against %s to distinguish locally complex functions from high-blast-radius functions.\",\n1215\t-\t\t\t\tToTopicWikiLink(topic.Slug, GetWikiConceptPath(\"High-Impact Symbols\"), \"High-Impact Symbols\"),\n1216\t+\t\t\t\tlinkFor(topic, \"\", GetWikiConceptPath(\"High-Impact Symbols\"), \"High-Impact Symbols\"),\n1217\t \t\t\t),\n1218\t \t\t}, \"\\n\"),\n1219\t \t}\n1220\tdiff --git a/internal/vault/writer.go b/internal/vault/writer.go\n1221\tindex 571c946..c85f5f3 100644\n1222\t--- a/internal/vault/writer.go\n1223\t+++ b/internal/vault/writer.go\n1224\t@@ -446,9 +446,9 @@ func buildTopicClaudeManagedBlock(\n1225\t \t\t\"\",\n1226\t \t\t\"### Generated codebase navigation\",\n1227\t \t\t\"\",\n1228\t-\t\t\"- \" + ToTopicWikiLink(topic.Slug, GetWikiIndexPath(CodebaseDashboardTitle), CodebaseDashboardTitle),\n1229\t-\t\t\"- \" + ToTopicWikiLink(topic.Slug, GetWikiIndexPath(CodebaseConceptIndexTitle), CodebaseConceptIndexTitle),\n1230\t-\t\t\"- \" + ToTopicWikiLink(topic.Slug, GetWikiIndexPath(CodebaseSourceIndexTitle), CodebaseSourceIndexTitle),\n1231\t+\t\t\"- \" + linkFor(topic, \"\", GetWikiIndexPath(CodebaseDashboardTitle), CodebaseDashboardTitle),\n1232\t+\t\t\"- \" + linkFor(topic, \"\", GetWikiIndexPath(CodebaseConceptIndexTitle), CodebaseConceptIndexTitle),\n1233\t+\t\t\"- \" + linkFor(topic, \"\", GetWikiIndexPath(CodebaseSourceIndexTitle), CodebaseSourceIndexTitle),\n1234\t \t\t\"\",\n1235\t \t\t\"### Generated codebase articles\",\n1236\t \t\t\"\",\n1237\t@@ -457,7 +457,7 @@ func buildTopicClaudeManagedBlock(\n1238\t \t\tlines = append(lines, \"_None generated._\")\n1239\t \t} else {\n1240\t \t\tfor _, articleTitle := range conceptDocuments {\n1241\t-\t\t\tlines = append(lines, \"- \"+ToTopicWikiLink(topic.Slug, GetWikiConceptPath(articleTitle), articleTitle))\n1242\t+\t\t\tlines = append(lines, \"- \"+linkFor(topic, \"\", GetWikiConceptPath(articleTitle), articleTitle))\n1243\t \t\t}\n1244\t \t}\n1245\t \tlines = append(lines, \"\", codebaseManagedBlockEnd)\n1246\t@@ -472,9 +472,9 @@ func buildTopicIndexBridgeManagedBlock(topic models.TopicMetadata) string {\n1247\t \t\t\"\",\n1248\t \t\t\"Latest `kb ingest codebase` output is published under `wiki/codebase/` so the top-level topic indexes can stay manually curated.\",\n1249\t \t\t\"\",\n1250\t-\t\t\"- \" + ToTopicWikiLink(topic.Slug, GetWikiIndexPath(CodebaseDashboardTitle), CodebaseDashboardTitle),\n1251\t-\t\t\"- \" + ToTopicWikiLink(topic.Slug, GetWikiIndexPath(CodebaseConceptIndexTitle), CodebaseConceptIndexTitle),\n1252\t-\t\t\"- \" + ToTopicWikiLink(topic.Slug, GetWikiIndexPath(CodebaseSourceIndexTitle), CodebaseSourceIndexTitle),\n1253\t+\t\t\"- \" + linkFor(topic, \"\", GetWikiIndexPath(CodebaseDashboardTitle), CodebaseDashboardTitle),\n1254\t+\t\t\"- \" + linkFor(topic, \"\", GetWikiIndexPath(CodebaseConceptIndexTitle), CodebaseConceptIndexTitle),\n1255\t+\t\t\"- \" + linkFor(topic, \"\", GetWikiIndexPath(CodebaseSourceIndexTitle), CodebaseSourceIndexTitle),\n1256\t \t\t\"\",\n1257\t \t\tcodebaseIndexBlockEnd,\n1258\t \t}\n1259\tdiff --git a/internal/topic/assets/okf-claude-template.md b/internal/topic/assets/okf-claude-template.md\n1260\tnew file mode 100644\n1261\tindex 0000000..3a18157\n1262\t--- /dev/null\n1263\t+++ b/internal/topic/assets/okf-claude-template.md\n1264\t@@ -0,0 +1,24 @@\n1265\t+# TOPIC_TITLE\n1266\t+\n1267\t+**Topic scope:** portable Open Knowledge Format bundle for `TOPIC_DOMAIN`.\n1268\t+\n1269\t+**Domain:** `TOPIC_DOMAIN`\n1270\t+\n1271\t+This topic is an OKF bundle, not an LLM-wiki research lab. Concepts live as typed markdown files at the bundle root for the MVP, with standard markdown links and YAML frontmatter.\n1272\t+\n1273\t+## Bundle contract\n1274\t+\n1275\t+- Every concept markdown file has `type`, `title`, `description`, and `timestamp` frontmatter.\n1276\t+- `index.md` is generated by `kb` and groups concepts by type.\n1277\t+- `log.md` records bundle updates using ISO date headings.\n1278\t+- Links should be relative markdown links so the bundle renders in GitHub and other markdown tools.\n1279\t+\n1280\t+## Operations\n1281\t+\n1282\t+- Promote a wiki concept into this bundle with `kb promote \u003cwiki-doc\u003e --to TOPIC_SLUG --type \u003cType\u003e`.\n1283\t+- Check conformance with `kb okf check TOPIC_SLUG`.\n1284\t+\n1285\t+## Catalog gaps\n1286\t+\n1287\t+- Gap 1\n1288\t+- Gap 2\n1289\tdiff --git a/internal/okf/okf.go b/internal/okf/okf.go\n1290\tnew file mode 100644\n1291\tindex 0000000..55c4122\n1292\t--- /dev/null\n1293\t+++ b/internal/okf/okf.go\n1294\t@@ -0,0 +1,757 @@\n1295\t+// Package okf implements Open Knowledge Format promotion and conformance checks.\n1296\t+package okf\n1297\t+\n1298\t+import (\n1299\t+\t\"context\"\n1300\t+\t\"errors\"\n1301\t+\t\"fmt\"\n1302\t+\t\"io/fs\"\n1303\t+\t\"os\"\n1304\t+\t\"path\"\n1305\t+\t\"path/filepath\"\n1306\t+\t\"regexp\"\n1307\t+\t\"sort\"\n1308\t+\t\"strings\"\n1309\t+\t\"time\"\n1310\t+\n1311\t+\t\"github.com/compozy/kb/internal/frontmatter\"\n1312\t+\t\"github.com/compozy/kb/internal/models\"\n1313\t+\t\"github.com/compozy/kb/internal/vault\"\n1314\t+)\n1315\t+\n1316\t+const (\n1317\t+\tOKFVersion = \"0.1\"\n1318\t+)\n1319\t+\n1320\t+var (\n1321\t+\twikilinkPattern = regexp.MustCompile(`\\[\\[([^\\[\\]]+)\\]\\]`)\n1322\t+\tnonSentenceSpace = regexp.MustCompile(`\\s+`)\n1323\t+\tmarkdownLinkPattern = regexp.MustCompile(`\\[[^\\]]+\\]\\([^)]+\\)`)\n1324\t+\tmarkdownSyntaxRunes = strings.NewReplacer(\"#\", \"\", \"*\", \"\", \"`\", \"\", \"_\", \"\", \"\u003e\", \"\", \"|\", \" \")\n1325\t+\terrTargetNotOKFTopic = errors.New(\"target topic must use mode okf\")\n1326\t+)\n1327\t+\n1328\t+// PromoteInput carries the data needed to promote one wiki document into an OKF bundle.\n1329\t+type PromoteInput struct {\n1330\t+\tSourceDocPath string\n1331\t+\tVaultPath string\n1332\t+\tTargetTopic models.TopicInfo\n1333\t+\tType string\n1334\t+\tDescription string\n1335\t+\tTypes []string\n1336\t+\tClock func() time.Time\n1337\t+}\n1338\t+\n1339\t+// ConceptResult reports the concept written by Promote.\n1340\t+type ConceptResult struct {\n1341\t+\tWrittenPath string `json:\"writtenPath\"`\n1342\t+\tType string `json:\"type\"`\n1343\t+\tLinksRewritten int `json:\"linksRewritten\"`\n1344\t+\tUnresolvedLinks []string `json:\"unresolvedLinks\"`\n1345\t+\tWarnings []string `json:\"warnings,omitempty\"`\n1346\t+}\n1347\t+\n1348\t+// CheckOptions configures OKF conformance checking.\n1349\t+type CheckOptions struct {\n1350\t+\tTypes []string\n1351\t+\tStrict bool\n1352\t+}\n1353\t+\n1354\t+// Promote performs a mechanical, non-destructive wiki-to-OKF conversion.\n1355\t+func Promote(ctx context.Context, input PromoteInput) (ConceptResult, error) {\n1356\t+\tif ctx == nil {\n1357\t+\t\tctx = context.Background()\n1358\t+\t}\n1359\t+\tif err := ctx.Err(); err != nil {\n1360\t+\t\treturn ConceptResult{}, fmt.Errorf(\"promote: %w\", err)\n1361\t+\t}\n1362\t+\tif input.TargetTopic.Mode != models.TopicModeOKF {\n1363\t+\t\treturn ConceptResult{}, fmt.Errorf(\"promote: %w: %s\", errTargetNotOKFTopic, input.TargetTopic.Slug)\n1364\t+\t}\n1365\t+\tconceptType := strings.TrimSpace(input.Type)\n1366\t+\tif conceptType == \"\" {\n1367\t+\t\treturn ConceptResult{}, fmt.Errorf(\"promote: --type is required\")\n1368\t+\t}\n1369\t+\tif strings.TrimSpace(input.TargetTopic.RootPath) == \"\" {\n1370\t+\t\treturn ConceptResult{}, fmt.Errorf(\"promote: target topic root path is required\")\n1371\t+\t}\n1372\t+\n1373\t+\tsourcePath, sourceTopicRoot, sourceTopicSlug, sourceRelativePath, err := resolveSourceDocument(input)\n1374\t+\tif err != nil {\n1375\t+\t\treturn ConceptResult{}, fmt.Errorf(\"promote: %w\", err)\n1376\t+\t}\n1377\t+\t_ = sourceTopicRoot\n1378\t+\n1379\t+\tsourceBytes, err := os.ReadFile(sourcePath)\n1380\t+\tif err != nil {\n1381\t+\t\treturn ConceptResult{}, fmt.Errorf(\"promote: read source %q: %w\", sourcePath, err)\n1382\t+\t}\n1383\t+\tsourceValues, body, err := frontmatter.Parse(string(sourceBytes))\n1384\t+\tif err != nil {\n1385\t+\t\treturn ConceptResult{}, fmt.Errorf(\"promote: parse source frontmatter: %w\", err)\n1386\t+\t}\n1387\t+\n1388\t+\tkey := conceptKey(sourceRelativePath)\n1389\t+\twrittenPath, absoluteTargetPath, err := allocateConceptPath(input.TargetTopic.RootPath, key)\n1390\t+\tif err != nil {\n1391\t+\t\treturn ConceptResult{}, fmt.Errorf(\"promote: allocate concept path: %w\", err)\n1392\t+\t}\n1393\t+\n1394\t+\twarnings := typeWarnings(conceptType, input.Types)\n1395\t+\tdescription, descriptionWarnings := resolveDescription(input.Description, body)\n1396\t+\twarnings = append(warnings, descriptionWarnings...)\n1397\t+\ttitle := strings.TrimSpace(frontmatter.GetString(sourceValues, \"title\"))\n1398\t+\tif title == \"\" {\n1399\t+\t\ttitle = vault.HumanizeSlug(key)\n1400\t+\t}\n1401\t+\tclock := input.Clock\n1402\t+\tif clock == nil {\n1403\t+\t\tclock = func() time.Time { return time.Now().UTC() }\n1404\t+\t}\n1405\t+\tnow := clock().UTC()\n1406\t+\n1407\t+\ttransformedBody, rewriteCount, unresolvedLinks := transformWikiLinks(body, sourceTopicSlug, input.TargetTopic.RootPath, path.Dir(writtenPath), writtenPath)\n1408\t+\tvalues := map[string]any{\n1409\t+\t\t\"description\": description,\n1410\t+\t\t\"timestamp\": now.Format(time.RFC3339),\n1411\t+\t\t\"title\": title,\n1412\t+\t\t\"type\": conceptType,\n1413\t+\t}\n1414\t+\tif tags := frontmatter.GetStringSlice(sourceValues, \"tags\"); len(tags) \u003e 0 {\n1415\t+\t\tvalues[\"tags\"] = tags\n1416\t+\t}\n1417\t+\n1418\t+\tdocument, err := frontmatter.Generate(values, transformedBody)\n1419\t+\tif err != nil {\n1420\t+\t\treturn ConceptResult{}, fmt.Errorf(\"promote: generate concept frontmatter: %w\", err)\n1421\t+\t}\n1422\t+\tif err := os.WriteFile(absoluteTargetPath, []byte(document), 0o644); err != nil {\n1423\t+\t\treturn ConceptResult{}, fmt.Errorf(\"promote: write concept %q: %w\", absoluteTargetPath, err)\n1424\t+\t}\n1425\t+\n1426\t+\tif err := RegenerateIndex(input.TargetTopic.RootPath); err != nil {\n1427\t+\t\treturn ConceptResult{}, fmt.Errorf(\"promote: regenerate index: %w\", err)\n1428\t+\t}\n1429\t+\tif err := InsertLogEntry(input.TargetTopic.RootPath, now, title, writtenPath, sourceRelativePath); err != nil {\n1430\t+\t\treturn ConceptResult{}, fmt.Errorf(\"promote: update log: %w\", err)\n1431\t+\t}\n1432\t+\n1433\t+\treturn ConceptResult{\n1434\t+\t\tWrittenPath: writtenPath,\n1435\t+\t\tType: conceptType,\n1436\t+\t\tLinksRewritten: rewriteCount,\n1437\t+\t\tUnresolvedLinks: unresolvedLinks,\n1438\t+\t\tWarnings: warnings,\n1439\t+\t}, nil\n1440\t+}\n1441\t+\n1442\t+// Check validates an OKF bundle and returns diagnostics.\n1443\t+func Check(ctx context.Context, bundlePath string, options CheckOptions) ([]models.LintIssue, error) {\n1444\t+\tif ctx == nil {\n1445\t+\t\tctx = context.Background()\n1446\t+\t}\n1447\t+\tcleanBundlePath := strings.TrimSpace(bundlePath)\n1448\t+\tif cleanBundlePath == \"\" {\n1449\t+\t\treturn nil, fmt.Errorf(\"okf check: bundle path is required\")\n1450\t+\t}\n1451\t+\tinfo, err := os.Stat(cleanBundlePath)\n1452\t+\tif err != nil {\n1453\t+\t\treturn nil, fmt.Errorf(\"okf check: stat bundle path %q: %w\", cleanBundlePath, err)\n1454\t+\t}\n1455\t+\tif !info.IsDir() {\n1456\t+\t\treturn nil, fmt.Errorf(\"okf check: bundle path must be a directory: %s\", cleanBundlePath)\n1457\t+\t}\n1458\t+\n1459\t+\ttypeSet := normalizeTypeSet(options.Types)\n1460\t+\tissues := make([]models.LintIssue, 0)\n1461\t+\terr = filepath.WalkDir(cleanBundlePath, func(currentPath string, entry fs.DirEntry, walkErr error) error {\n1462\t+\t\tif walkErr != nil {\n1463\t+\t\t\treturn walkErr\n1464\t+\t\t}\n1465\t+\t\tif err := ctx.Err(); err != nil {\n1466\t+\t\t\treturn err\n1467\t+\t\t}\n1468\t+\t\tif currentPath == cleanBundlePath {\n1469\t+\t\t\treturn nil\n1470\t+\t\t}\n1471\t+\t\tif entry.Type()\u0026fs.ModeSymlink != 0 {\n1472\t+\t\t\treturn nil\n1473\t+\t\t}\n1474\t+\t\tif entry.IsDir() {\n1475\t+\t\t\tif strings.HasPrefix(entry.Name(), \".\") {\n1476\t+\t\t\t\treturn filepath.SkipDir\n1477\t+\t\t\t}\n1478\t+\t\t\treturn nil\n1479\t+\t\t}\n1480\t+\t\tif !strings.EqualFold(filepath.Ext(entry.Name()), \".md\") {\n1481\t+\t\t\treturn nil\n1482\t+\t\t}\n1483\t+\n1484\t+\t\trelativePath, err := filepath.Rel(cleanBundlePath, currentPath)\n1485\t+\t\tif err != nil {\n1486\t+\t\t\treturn fmt.Errorf(\"derive relative path for %q: %w\", currentPath, err)\n1487\t+\t\t}\n1488\t+\t\trelativePath = vault.ToPosixPath(relativePath)\n1489\t+\t\tswitch {\n1490\t+\t\tcase isIndexFile(relativePath):\n1491\t+\t\t\tissues = append(issues, checkIndexFile(cleanBundlePath, currentPath, relativePath, options.Strict)...)\n1492\t+\t\tcase isLogFile(relativePath):\n1493\t+\t\t\tissues = append(issues, checkLogFile(currentPath, relativePath, options.Strict)...)\n1494\t+\t\tcase isExcludedMarkdown(entry.Name()):\n1495\t+\t\t\treturn nil\n1496\t+\t\tdefault:\n1497\t+\t\t\tissues = append(issues, checkConceptFile(currentPath, relativePath, typeSet, options.Strict)...)\n1498\t+\t\t}\n1499\t+\t\treturn nil\n1500\t+\t})\n1501\t+\tif err != nil {\n1502\t+\t\treturn nil, fmt.Errorf(\"okf check: %w\", err)\n1503\t+\t}\n1504\t+\n1505\t+\tsortIssues(issues)\n1506\t+\treturn issues, nil\n1507\t+}\n1508\t+\n1509\t+// Columns returns the stable output column order for OKF diagnostics.\n1510\t+func Columns() []string {\n1511\t+\treturn []string{\"severity\", \"kind\", \"filePath\", \"target\", \"message\"}\n1512\t+}\n1513\t+\n1514\t+// Rows converts issues into formatter rows.\n1515\t+func Rows(issues []models.LintIssue) []map[string]any {\n1516\t+\trows := make([]map[string]any, 0, len(issues))\n1517\t+\tfor _, issue := range issues {\n1518\t+\t\trows = append(rows, map[string]any{\n1519\t+\t\t\t\"severity\": issue.Severity,\n1520\t+\t\t\t\"kind\": issue.Kind,\n1521\t+\t\t\t\"filePath\": issue.FilePath,\n1522\t+\t\t\t\"target\": issue.Target,\n1523\t+\t\t\t\"message\": issue.Message,\n1524\t+\t\t})\n1525\t+\t}\n1526\t+\treturn rows\n1527\t+}\n1528\t+\n1529\t+// HasErrors reports whether any diagnostic should fail the command.\n1530\t+func HasErrors(issues []models.LintIssue) bool {\n1531\t+\tfor _, issue := range issues {\n1532\t+\t\tif issue.Severity == models.SeverityError {\n1533\t+\t\t\treturn true\n1534\t+\t\t}\n1535\t+\t}\n1536\t+\treturn false\n1537\t+}\n1538\t+\n1539\t+// RegenerateIndex rewrites the root OKF index from concept frontmatter.\n1540\t+func RegenerateIndex(bundlePath string) error {\n1541\t+\tconcepts, okfVersion, err := loadConcepts(bundlePath)\n1542\t+\tif err != nil {\n1543\t+\t\treturn err\n1544\t+\t}\n1545\t+\tsort.Slice(concepts, func(i, j int) bool {\n1546\t+\t\tif concepts[i].Type != concepts[j].Type {\n1547\t+\t\t\treturn concepts[i].Type \u003c concepts[j].Type\n1548\t+\t\t}\n1549\t+\t\tif concepts[i].Title != concepts[j].Title {\n1550\t+\t\t\treturn concepts[i].Title \u003c concepts[j].Title\n1551\t+\t\t}\n1552\t+\t\treturn concepts[i].RelativePath \u003c concepts[j].RelativePath\n1553\t+\t})\n1554\t+\n1555\t+\tvar body strings.Builder\n1556\t+\tbody.WriteString(\"# OKF Bundle Index\\n\")\n1557\t+\tcurrentType := \"\"\n1558\t+\tfor _, concept := range concepts {\n1559\t+\t\tif concept.Type != currentType {\n1560\t+\t\t\tcurrentType = concept.Type\n1561\t+\t\t\tbody.WriteString(\"\\n## \")\n1562\t+\t\t\tbody.WriteString(currentType)\n1563\t+\t\t\tbody.WriteString(\"\\n\\n\")\n1564\t+\t\t}\n1565\t+\t\tbody.WriteString(\"* \")\n1566\t+\t\tbody.WriteString(vault.OKFLinkFormatter{}.Link(\"\", concept.RelativePath, concept.Title))\n1567\t+\t\tif concept.Description != \"\" {\n1568\t+\t\t\tbody.WriteString(\" - \")\n1569\t+\t\t\tbody.WriteString(concept.Description)\n1570\t+\t\t}\n1571\t+\t\tbody.WriteString(\"\\n\")\n1572\t+\t}\n1573\t+\n1574\t+\tvalues := map[string]any{\"okf_version\": firstNonEmpty(okfVersion, OKFVersion)}\n1575\t+\tcontent, err := frontmatter.Generate(values, body.String())\n1576\t+\tif err != nil {\n1577\t+\t\treturn fmt.Errorf(\"generate index frontmatter: %w\", err)\n1578\t+\t}\n1579\t+\tif err := os.WriteFile(filepath.Join(bundlePath, \"index.md\"), []byte(content), 0o644); err != nil {\n1580\t+\t\treturn fmt.Errorf(\"write index.md: %w\", err)\n1581\t+\t}\n1582\t+\treturn nil\n1583\t+}\n1584\t+\n1585\t+// InsertLogEntry inserts a newest-first OKF log entry at the bundle root.\n1586\t+func InsertLogEntry(bundlePath string, when time.Time, title, conceptPath, sourcePath string) error {\n1587\t+\tlogPath := filepath.Join(bundlePath, \"log.md\")\n1588\t+\tcontent, err := os.ReadFile(logPath)\n1589\t+\tif errors.Is(err, os.ErrNotExist) {\n1590\t+\t\tcontent = []byte(\"# Directory Update Log\\n\")\n1591\t+\t} else if err != nil {\n1592\t+\t\treturn fmt.Errorf(\"read log.md: %w\", err)\n1593\t+\t}\n1594\t+\tentry := fmt.Sprintf(\n1595\t+\t\t\"* **Creation**: Promoted %s from `%s`.\",\n1596\t+\t\tvault.OKFLinkFormatter{}.Link(\"\", conceptPath, strings.TrimSpace(title)),\n1597\t+\t\tsourcePath,\n1598\t+\t)\n1599\t+\tnext := insertLogEntry(string(content), when.UTC().Format(frontmatter.DateLayout), entry)\n1600\t+\tif err := os.WriteFile(logPath, []byte(next), 0o644); err != nil {\n1601\t+\t\treturn fmt.Errorf(\"write log.md: %w\", err)\n1602\t+\t}\n1603\t+\treturn nil\n1604\t+}\n1605\t+\n1606\t+type conceptInfo struct {\n1607\t+\tRelativePath string\n1608\t+\tType string\n1609\t+\tTitle string\n1610\t+\tDescription string\n1611\t+}\n1612\t+\n1613\t+func resolveSourceDocument(input PromoteInput) (absolutePath, topicRoot, topicSlug, topicRelativePath string, err error) {\n1614\t+\tcandidates := make([]string, 0, 3)\n1615\t+\tsource := strings.TrimSpace(input.SourceDocPath)\n1616\t+\tif source == \"\" {\n1617\t+\t\treturn \"\", \"\", \"\", \"\", fmt.Errorf(\"source document path is required\")\n1618\t+\t}\n1619\t+\tif filepath.IsAbs(source) {\n1620\t+\t\tcandidates = append(candidates, source)\n1621\t+\t} else {\n1622\t+\t\tcandidates = append(candidates, source)\n1623\t+\t\tif strings.TrimSpace(input.VaultPath) != \"\" {\n1624\t+\t\t\tcandidates = append(candidates, filepath.Join(input.VaultPath, filepath.FromSlash(source)))\n1625\t+\t\t}\n1626\t+\t}\n1627\t+\tfor _, candidate := range candidates {\n1628\t+\t\tinfo, statErr := os.Stat(candidate)\n1629\t+\t\tif statErr != nil {\n1630\t+\t\t\tcontinue\n1631\t+\t\t}\n1632\t+\t\tif info.IsDir() {\n1633\t+\t\t\treturn \"\", \"\", \"\", \"\", fmt.Errorf(\"source document %q must be a file\", candidate)\n1634\t+\t\t}\n1635\t+\t\tabsolutePath, err := filepath.Abs(candidate)\n1636\t+\t\tif err != nil {\n1637\t+\t\t\treturn \"\", \"\", \"\", \"\", fmt.Errorf(\"resolve source path %q: %w\", candidate, err)\n1638\t+\t\t}\n1639\t+\t\ttopicRoot, topicSlug = discoverTopicRoot(input.VaultPath, absolutePath)\n1640\t+\t\tif topicRoot != \"\" {\n1641\t+\t\t\trelative, relErr := filepath.Rel(topicRoot, absolutePath)\n1642\t+\t\t\tif relErr != nil {\n1643\t+\t\t\t\treturn \"\", \"\", \"\", \"\", fmt.Errorf(\"derive source topic path: %w\", relErr)\n1644\t+\t\t\t}\n1645\t+\t\t\treturn absolutePath, topicRoot, topicSlug, vault.ToPosixPath(relative), nil\n1646\t+\t\t}\n1647\t+\t\treturn absolutePath, \"\", \"\", vault.ToPosixPath(filepath.Base(absolutePath)), nil\n1648\t+\t}\n1649\t+\treturn \"\", \"\", \"\", \"\", fmt.Errorf(\"source document not found: %s\", source)\n1650\t+}\n1651\t+\n1652\t+func discoverTopicRoot(vaultPath, sourcePath string) (string, string) {\n1653\t+\tcleanVault := strings.TrimSpace(vaultPath)\n1654\t+\tcurrent := filepath.Dir(sourcePath)\n1655\t+\tfor {\n1656\t+\t\tif cleanVault != \"\" \u0026\u0026 !vault.IsPathInside(cleanVault, current) {\n1657\t+\t\t\treturn \"\", \"\"\n1658\t+\t\t}\n1659\t+\t\tif isRegularFile(filepath.Join(current, \"CLAUDE.md\")) {\n1660\t+\t\t\tif cleanVault == \"\" {\n1661\t+\t\t\t\treturn current, filepath.Base(current)\n1662\t+\t\t\t}\n1663\t+\t\t\trelative, err := filepath.Rel(cleanVault, current)\n1664\t+\t\t\tif err != nil {\n1665\t+\t\t\t\treturn current, filepath.Base(current)\n1666\t+\t\t\t}\n1667\t+\t\t\treturn current, vault.ToPosixPath(relative)\n1668\t+\t\t}\n1669\t+\t\tparent := filepath.Dir(current)\n1670\t+\t\tif parent == current {\n1671\t+\t\t\tbreak\n1672\t+\t\t}\n1673\t+\t\tcurrent = parent\n1674\t+\t}\n1675\t+\treturn \"\", \"\"\n1676\t+}\n1677\t+\n1678\t+func isRegularFile(filePath string) bool {\n1679\t+\tinfo, err := os.Stat(filePath)\n1680\t+\treturn err == nil \u0026\u0026 !info.IsDir()\n1681\t+}\n1682\t+\n1683\t+func allocateConceptPath(bundleRoot, key string) (string, string, error) {\n1684\t+\tfor suffix := 1; ; suffix++ {\n1685\t+\t\tname := key\n1686\t+\t\tif suffix \u003e 1 {\n1687\t+\t\t\tname = fmt.Sprintf(\"%s-%d\", key, suffix)\n1688\t+\t\t}\n1689\t+\t\trelativePath := name + \".md\"\n1690\t+\t\tabsolutePath := filepath.Join(bundleRoot, filepath.FromSlash(relativePath))\n1691\t+\t\tif _, err := os.Stat(absolutePath); errors.Is(err, os.ErrNotExist) {\n1692\t+\t\t\treturn relativePath, absolutePath, nil\n1693\t+\t\t} else if err != nil {\n1694\t+\t\t\treturn \"\", \"\", err\n1695\t+\t\t}\n1696\t+\t}\n1697\t+}\n1698\t+\n1699\t+func conceptKey(documentPath string) string {\n1700\t+\tbase := path.Base(vault.StripMarkdownExtension(vault.ToPosixPath(documentPath)))\n1701\t+\treturn vault.SlugifySegment(base)\n1702\t+}\n1703\t+\n1704\t+func typeWarnings(conceptType string, types []string) []string {\n1705\t+\ttypeSet := normalizeTypeSet(types)\n1706\t+\tif len(typeSet) == 0 {\n1707\t+\t\treturn nil\n1708\t+\t}\n1709\t+\tif _, ok := typeSet[conceptType]; ok {\n1710\t+\t\treturn nil\n1711\t+\t}\n1712\t+\treturn []string{fmt.Sprintf(\"type %q is outside the configured OKF vocabulary\", conceptType)}\n1713\t+}\n1714\t+\n1715\t+func normalizeTypeSet(types []string) map[string]struct{} {\n1716\t+\ttypeSet := make(map[string]struct{})\n1717\t+\tfor _, value := range types {\n1718\t+\t\tvalue = strings.TrimSpace(value)\n1719\t+\t\tif value == \"\" {\n1720\t+\t\t\tcontinue\n1721\t+\t\t}\n1722\t+\t\ttypeSet[value] = struct{}{}\n1723\t+\t}\n1724\t+\treturn typeSet\n1725\t+}\n1726\t+\n1727\t+func resolveDescription(flagValue, body string) (string, []string) {\n1728\t+\tif description := strings.TrimSpace(flagValue); description != \"\" {\n1729\t+\t\treturn description, nil\n1730\t+\t}\n1731\t+\tif sentence := firstBodySentence(body); sentence != \"\" {\n1732\t+\t\treturn sentence, nil\n1733\t+\t}\n1734\t+\treturn \"\", []string{\"description is empty because the source body has no sentence fallback\"}\n1735\t+}\n1736\t+\n1737\t+func firstBodySentence(body string) string {\n1738\t+\tnormalized := markdownSyntaxRunes.Replace(markdownLinkPattern.ReplaceAllString(body, \" \"))\n1739\t+\tnormalized = nonSentenceSpace.ReplaceAllString(strings.TrimSpace(normalized), \" \")\n1740\t+\tfor index := 0; index \u003c len(normalized); index++ {\n1741\t+\t\tswitch normalized[index] {\n1742\t+\t\tcase '.', '!', '?':\n1743\t+\t\t\tif index == len(normalized)-1 || normalized[index+1] == ' ' {\n1744\t+\t\t\t\treturn strings.TrimSpace(normalized[:index+1])\n1745\t+\t\t\t}\n1746\t+\t\t}\n1747\t+\t}\n1748\t+\treturn \"\"\n1749\t+}\n1750\t+\n1751\t+func transformWikiLinks(body, sourceTopicSlug, bundleRoot, fromDir, currentPath string) (string, int, []string) {\n1752\t+\tcount := 0\n1753\t+\tunresolved := make([]string, 0)\n1754\t+\tformatter := vault.OKFLinkFormatter{}\n1755\t+\ttransformed := wikilinkPattern.ReplaceAllStringFunc(body, func(match string) string {\n1756\t+\t\tinner := strings.TrimSuffix(strings.TrimPrefix(match, \"[[\"), \"]]\")\n1757\t+\t\ttarget, label := splitWikiLink(inner)\n1758\t+\t\ttargetPath := mappedTargetPath(target, sourceTopicSlug)\n1759\t+\t\tif strings.TrimSpace(label) == \"\" {\n1760\t+\t\t\tlabel = fallbackWikiLabel(target)\n1761\t+\t\t}\n1762\t+\t\tcount++\n1763\t+\t\tif stripFragment(targetPath) != stripFragment(currentPath) \u0026\u0026 !isRegularFile(filepath.Join(bundleRoot, filepath.FromSlash(stripFragment(targetPath)))) {\n1764\t+\t\t\tunresolved = append(unresolved, targetPath)\n1765\t+\t\t}\n1766\t+\t\treturn formatter.Link(fromDir, targetPath, label)\n1767\t+\t})\n1768\t+\tsort.Strings(unresolved)\n1769\t+\treturn transformed, count, dedupeStrings(unresolved)\n1770\t+}\n1771\t+\n1772\t+func splitWikiLink(inner string) (string, string) {\n1773\t+\tparts := strings.SplitN(inner, \"|\", 2)\n1774\t+\ttarget := strings.TrimSpace(parts[0])\n1775\t+\tif len(parts) == 1 {\n1776\t+\t\treturn target, \"\"\n1777\t+\t}\n1778\t+\treturn target, strings.TrimSpace(parts[1])\n1779\t+}\n1780\t+\n1781\t+func mappedTargetPath(target, sourceTopicSlug string) string {\n1782\t+\ttarget = vault.ToPosixPath(strings.TrimSpace(target))\n1783\t+\tfragment := \"\"\n1784\t+\tif before, after, found := strings.Cut(target, \"#\"); found {\n1785\t+\t\ttarget = before\n1786\t+\t\tfragment = \"#\" + after\n1787\t+\t}\n1788\t+\ttarget = strings.TrimPrefix(target, \"/\")\n1789\t+\tif sourceTopicSlug != \"\" {\n1790\t+\t\tprefix := strings.Trim(sourceTopicSlug, \"/\") + \"/\"\n1791\t+\t\ttarget = strings.TrimPrefix(target, prefix)\n1792\t+\t}\n1793\t+\treturn conceptKey(target) + \".md\" + fragment\n1794\t+}\n1795\t+\n1796\t+func stripFragment(target string) string {\n1797\t+\tif before, _, found := strings.Cut(target, \"#\"); found {\n1798\t+\t\treturn before\n1799\t+\t}\n1800\t+\treturn target\n1801\t+}\n1802\t+\n1803\t+func fallbackWikiLabel(target string) string {\n1804\t+\ttarget = stripFragment(target)\n1805\t+\treturn vault.HumanizeSlug(conceptKey(target))\n1806\t+}\n1807\t+\n1808\t+func dedupeStrings(values []string) []string {\n1809\t+\tif len(values) == 0 {\n1810\t+\t\treturn nil\n1811\t+\t}\n1812\t+\tdeduped := values[:0]\n1813\t+\tvar previous string\n1814\t+\tfor index, value := range values {\n1815\t+\t\tif index \u003e 0 \u0026\u0026 value == previous {\n1816\t+\t\t\tcontinue\n1817\t+\t\t}\n1818\t+\t\tdeduped = append(deduped, value)\n1819\t+\t\tprevious = value\n1820\t+\t}\n1821\t+\treturn append([]string(nil), deduped...)\n1822\t+}\n1823\t+\n1824\t+func loadConcepts(bundlePath string) ([]conceptInfo, string, error) {\n1825\t+\tokfVersion := OKFVersion\n1826\t+\tindexPath := filepath.Join(bundlePath, \"index.md\")\n1827\t+\tif content, err := os.ReadFile(indexPath); err == nil {\n1828\t+\t\tvalues, _, parseErr := frontmatter.Parse(string(content))\n1829\t+\t\tif parseErr != nil {\n1830\t+\t\t\treturn nil, \"\", fmt.Errorf(\"parse index.md: %w\", parseErr)\n1831\t+\t\t}\n1832\t+\t\tif version := strings.TrimSpace(frontmatter.GetString(values, \"okf_version\")); version != \"\" {\n1833\t+\t\t\tokfVersion = version\n1834\t+\t\t}\n1835\t+\t} else if !errors.Is(err, os.ErrNotExist) {\n1836\t+\t\treturn nil, \"\", fmt.Errorf(\"read index.md: %w\", err)\n1837\t+\t}\n1838\t+\n1839\t+\tconcepts := make([]conceptInfo, 0)\n1840\t+\terr := filepath.WalkDir(bundlePath, func(currentPath string, entry fs.DirEntry, walkErr error) error {\n1841\t+\t\tif walkErr != nil {\n1842\t+\t\t\treturn walkErr\n1843\t+\t\t}\n1844\t+\t\tif currentPath == bundlePath {\n1845\t+\t\t\treturn nil\n1846\t+\t\t}\n1847\t+\t\tif entry.Type()\u0026fs.ModeSymlink != 0 {\n1848\t+\t\t\treturn nil\n1849\t+\t\t}\n1850\t+\t\tif entry.IsDir() {\n1851\t+\t\t\tif strings.HasPrefix(entry.Name(), \".\") {\n1852\t+\t\t\t\treturn filepath.SkipDir\n1853\t+\t\t\t}\n1854\t+\t\t\treturn nil\n1855\t+\t\t}\n1856\t+\t\tif !strings.EqualFold(filepath.Ext(entry.Name()), \".md\") || isExcludedMarkdown(entry.Name()) {\n1857\t+\t\t\treturn nil\n1858\t+\t\t}\n1859\t+\t\trelativePath, err := filepath.Rel(bundlePath, currentPath)\n1860\t+\t\tif err != nil {\n1861\t+\t\t\treturn err\n1862\t+\t\t}\n1863\t+\t\trelativePath = vault.ToPosixPath(relativePath)\n1864\t+\t\tif isIndexFile(relativePath) || isLogFile(relativePath) {\n1865\t+\t\t\treturn nil\n1866\t+\t\t}\n1867\t+\t\tcontent, err := os.ReadFile(currentPath)\n1868\t+\t\tif err != nil {\n1869\t+\t\t\treturn err\n1870\t+\t\t}\n1871\t+\t\tvalues, _, err := frontmatter.Parse(string(content))\n1872\t+\t\tif err != nil || len(values) == 0 {\n1873\t+\t\t\treturn nil\n1874\t+\t\t}\n1875\t+\t\tconceptType := strings.TrimSpace(frontmatter.GetString(values, \"type\"))\n1876\t+\t\ttitle := strings.TrimSpace(frontmatter.GetString(values, \"title\"))\n1877\t+\t\tif title == \"\" {\n1878\t+\t\t\ttitle = vault.HumanizeSlug(conceptKey(relativePath))\n1879\t+\t\t}\n1880\t+\t\tconcepts = append(concepts, conceptInfo{\n1881\t+\t\t\tRelativePath: relativePath,\n1882\t+\t\t\tType: firstNonEmpty(conceptType, \"Concept\"),\n1883\t+\t\t\tTitle: title,\n1884\t+\t\t\tDescription: strings.TrimSpace(frontmatter.GetString(values, \"description\")),\n1885\t+\t\t})\n1886\t+\t\treturn nil\n1887\t+\t})\n1888\t+\tif err != nil {\n1889\t+\t\treturn nil, \"\", fmt.Errorf(\"scan concepts: %w\", err)\n1890\t+\t}\n1891\t+\treturn concepts, okfVersion, nil\n1892\t+}\n1893\t+\n1894\t+func checkConceptFile(filePath, relativePath string, typeSet map[string]struct{}, strict bool) []models.LintIssue {\n1895\t+\tcontent, err := os.ReadFile(filePath)\n1896\t+\tif err != nil {\n1897\t+\t\treturn []models.LintIssue{newIssue(models.SeverityError, relativePath, \"\", fmt.Sprintf(\"read concept: %v\", err))}\n1898\t+\t}\n1899\t+\tvalues, _, err := frontmatter.Parse(string(content))\n1900\t+\tif err != nil {\n1901\t+\t\treturn []models.LintIssue{newIssue(models.SeverityError, relativePath, \"\", fmt.Sprintf(\"frontmatter is invalid: %v\", err))}\n1902\t+\t}\n1903\t+\tif len(values) == 0 {\n1904\t+\t\treturn []models.LintIssue{newIssue(models.SeverityError, relativePath, \"type\", \"concept must start with YAML frontmatter\")}\n1905\t+\t}\n1906\t+\tissues := make([]models.LintIssue, 0)\n1907\t+\tconceptType := strings.TrimSpace(frontmatter.GetString(values, \"type\"))\n1908\t+\tif conceptType == \"\" {\n1909\t+\t\tissues = append(issues, newIssue(models.SeverityError, relativePath, \"type\", \"concept frontmatter must include non-empty type\"))\n1910\t+\t}\n1911\t+\tfor _, field := range []string{\"title\", \"description\", \"timestamp\"} {\n1912\t+\t\tif strings.TrimSpace(frontmatter.GetString(values, field)) == \"\" {\n1913\t+\t\t\tissues = append(issues, newIssue(warningSeverity(strict), relativePath, field, fmt.Sprintf(\"producer field %q is missing\", field)))\n1914\t+\t\t}\n1915\t+\t}\n1916\t+\tif len(typeSet) \u003e 0 \u0026\u0026 conceptType != \"\" {\n1917\t+\t\tif _, ok := typeSet[conceptType]; !ok {\n1918\t+\t\t\tissues = append(issues, newIssue(warningSeverity(strict), relativePath, \"type\", fmt.Sprintf(\"type %q is outside the configured OKF vocabulary\", conceptType)))\n1919\t+\t\t}\n1920\t+\t}\n1921\t+\treturn issues\n1922\t+}\n1923\t+\n1924\t+func checkIndexFile(bundlePath, filePath, relativePath string, strict bool) []models.LintIssue {\n1925\t+\tcontent, err := os.ReadFile(filePath)\n1926\t+\tif err != nil {\n1927\t+\t\treturn []models.LintIssue{newIssue(models.SeverityError, relativePath, \"\", fmt.Sprintf(\"read index: %v\", err))}\n1928\t+\t}\n1929\t+\tvalues, _, err := frontmatter.Parse(string(content))\n1930\t+\tif err != nil {\n1931\t+\t\treturn []models.LintIssue{newIssue(models.SeverityError, relativePath, \"\", fmt.Sprintf(\"index frontmatter is invalid: %v\", err))}\n1932\t+\t}\n1933\t+\tif len(values) == 0 {\n1934\t+\t\treturn nil\n1935\t+\t}\n1936\t+\tif relativePath != \"index.md\" {\n1937\t+\t\treturn []models.LintIssue{newIssue(models.SeverityError, relativePath, \"frontmatter\", \"only the bundle-root index.md may declare okf_version frontmatter\")}\n1938\t+\t}\n1939\t+\tfor key := range values {\n1940\t+\t\tif key != \"okf_version\" {\n1941\t+\t\t\treturn []models.LintIssue{newIssue(models.SeverityError, relativePath, key, \"root index.md frontmatter may only contain okf_version\")}\n1942\t+\t\t}\n1943\t+\t}\n1944\t+\t_ = bundlePath\n1945\t+\t_ = strict\n1946\t+\treturn nil\n1947\t+}\n1948\t+\n1949\t+func checkLogFile(filePath, relativePath string, strict bool) []models.LintIssue {\n1950\t+\tcontent, err := os.ReadFile(filePath)\n1951\t+\tif err != nil {\n1952\t+\t\treturn []models.LintIssue{newIssue(models.SeverityError, relativePath, \"\", fmt.Sprintf(\"read log: %v\", err))}\n1953\t+\t}\n1954\t+\tissues := make([]models.LintIssue, 0)\n1955\t+\tfor line := range strings.SplitSeq(string(content), \"\\n\") {\n1956\t+\t\ttrimmed := strings.TrimSpace(line)\n1957\t+\t\tif !strings.HasPrefix(trimmed, \"## \") {\n1958\t+\t\t\tcontinue\n1959\t+\t\t}\n1960\t+\t\tdateValue := strings.TrimSpace(strings.TrimPrefix(trimmed, \"## \"))\n1961\t+\t\tif _, err := time.Parse(frontmatter.DateLayout, dateValue); err != nil {\n1962\t+\t\t\tissues = append(issues, newIssue(models.SeverityError, relativePath, \"date\", fmt.Sprintf(\"log heading %q must use YYYY-MM-DD\", trimmed)))\n1963\t+\t\t}\n1964\t+\t}\n1965\t+\t_ = strict\n1966\t+\treturn issues\n1967\t+}\n1968\t+\n1969\t+func newIssue(severity models.DiagnosticSeverity, filePath, target, message string) models.LintIssue {\n1970\t+\treturn models.LintIssue{\n1971\t+\t\tKind: models.LintIssueKindFormat,\n1972\t+\t\tSeverity: severity,\n1973\t+\t\tFilePath: filePath,\n1974\t+\t\tTarget: target,\n1975\t+\t\tMessage: message,\n1976\t+\t}\n1977\t+}\n1978\t+\n1979\t+func warningSeverity(strict bool) models.DiagnosticSeverity {\n1980\t+\tif strict {\n1981\t+\t\treturn models.SeverityError\n1982\t+\t}\n1983\t+\treturn models.SeverityWarning\n1984\t+}\n1985\t+\n1986\t+func sortIssues(issues []models.LintIssue) {\n1987\t+\tsort.Slice(issues, func(i, j int) bool {\n1988\t+\t\tif issues[i].Severity != issues[j].Severity {\n1989\t+\t\t\treturn issues[i].Severity \u003c issues[j].Severity\n1990\t+\t\t}\n1991\t+\t\tif issues[i].FilePath != issues[j].FilePath {\n1992\t+\t\t\treturn issues[i].FilePath \u003c issues[j].FilePath\n1993\t+\t\t}\n1994\t+\t\tif issues[i].Target != issues[j].Target {\n1995\t+\t\t\treturn issues[i].Target \u003c issues[j].Target\n1996\t+\t\t}\n1997\t+\t\treturn issues[i].Message \u003c issues[j].Message\n1998\t+\t})\n1999\t+}\n2000\t+\n2001\t+func isIndexFile(relativePath string) bool {\n2002\t+\treturn strings.EqualFold(path.Base(vault.ToPosixPath(relativePath)), \"index.md\")\n2003\t+}\n2004\t+\n2005\t+func isLogFile(relativePath string) bool {\n2006\t+\treturn strings.EqualFold(path.Base(vault.ToPosixPath(relativePath)), \"log.md\")\n2007\t+}\n2008\t+\n2009\t+func isExcludedMarkdown(name string) bool {\n2010\t+\tlower := strings.ToLower(strings.TrimSpace(name))\n2011\t+\tswitch lower {\n2012\t+\tcase \"claude.md\", \"agents.md\", \"readme.md\", \"license.md\", \"notice.md\", \"attribution.md\":\n2013\t+\t\treturn true\n2014\t+\tdefault:\n2015\t+\t\treturn strings.HasPrefix(lower, \"license.\") || strings.HasPrefix(lower, \"notice.\") || strings.HasPrefix(lower, \"attribution.\")\n2016\t+\t}\n2017\t+}\n2018\t+\n2019\t+func insertLogEntry(content, dateHeading, entry string) string {\n2020\t+\tlines := strings.Split(strings.ReplaceAll(content, \"\\r\\n\", \"\\n\"), \"\\n\")\n2021\t+\tif len(lines) == 0 || strings.TrimSpace(lines[0]) == \"\" {\n2022\t+\t\tlines = []string{\"# Directory Update Log\"}\n2023\t+\t}\n2024\t+\theading := \"## \" + dateHeading\n2025\t+\tfor index, line := range lines {\n2026\t+\t\tif strings.TrimSpace(line) != heading {\n2027\t+\t\t\tcontinue\n2028\t+\t\t}\n2029\t+\t\tnext := append([]string{}, lines[:index+1]...)\n2030\t+\t\tnext = append(next, entry)\n2031\t+\t\tnext = append(next, lines[index+1:]...)\n2032\t+\t\treturn strings.TrimRight(strings.Join(next, \"\\n\"), \"\\n\") + \"\\n\"\n2033\t+\t}\n2034\t+\tnext := []string{strings.TrimRight(strings.Join(lines[:1], \"\\n\"), \"\\n\"), \"\", heading, \"\", entry}\n2035\t+\tif len(lines) \u003e 1 {\n2036\t+\t\trest := strings.TrimSpace(strings.Join(lines[1:], \"\\n\"))\n2037\t+\t\tif rest != \"\" {\n2038\t+\t\t\tnext = append(next, \"\", rest)\n2039\t+\t\t}\n2040\t+\t}\n2041\t+\treturn strings.TrimRight(strings.Join(next, \"\\n\"), \"\\n\") + \"\\n\"\n2042\t+}\n2043\t+\n2044\t+func firstNonEmpty(values ...string) string {\n2045\t+\tfor _, value := range values {\n2046\t+\t\tif trimmed := strings.TrimSpace(value); trimmed != \"\" {\n2047\t+\t\t\treturn trimmed\n2048\t+\t\t}\n2049\t+\t}\n2050\t+\treturn \"\"\n2051\t+}\n2052\tdiff --git a/internal/okf/okf_test.go b/internal/okf/okf_test.go\n2053\tnew file mode 100644\n2054\tindex 0000000..051c01e\n2055\t--- /dev/null\n2056\t+++ b/internal/okf/okf_test.go\n2057\t@@ -0,0 +1,259 @@\n2058\t+package okf\n2059\t+\n2060\t+import (\n2061\t+\t\"context\"\n2062\t+\t\"os\"\n2063\t+\t\"path/filepath\"\n2064\t+\t\"strings\"\n2065\t+\t\"testing\"\n2066\t+\t\"time\"\n2067\t+\n2068\t+\t\"github.com/compozy/kb/internal/frontmatter\"\n2069\t+\t\"github.com/compozy/kb/internal/models\"\n2070\t+)\n2071\t+\n2072\t+func TestPromoteWritesConceptIndexAndLog(t *testing.T) {\n2073\t+\tt.Parallel()\n2074\t+\n2075\t+\tvaultPath := t.TempDir()\n2076\t+\tsourceTopic := filepath.Join(vaultPath, \"research\")\n2077\t+\ttargetTopic := filepath.Join(vaultPath, \"catalog\")\n2078\t+\tmkdirAll(t, filepath.Join(sourceTopic, \"wiki\", \"concepts\"))\n2079\t+\tmkdirAll(t, targetTopic)\n2080\t+\twriteFile(t, filepath.Join(sourceTopic, \"CLAUDE.md\"), \"# Research\\n\")\n2081\t+\twriteFile(t, filepath.Join(targetTopic, \"CLAUDE.md\"), \"# Catalog\\n\")\n2082\t+\twriteFile(t, filepath.Join(targetTopic, \"index.md\"), \"---\\nokf_version: \\\"0.1\\\"\\n---\\n# Old Index\\n\")\n2083\t+\twriteFile(t, filepath.Join(targetTopic, \"log.md\"), \"# Directory Update Log\\n\")\n2084\t+\tsourcePath := filepath.Join(sourceTopic, \"wiki\", \"concepts\", \"Alpha Note.md\")\n2085\t+\tsourceContent := strings.Join([]string{\n2086\t+\t\t\"---\",\n2087\t+\t\t\"title: Alpha Note\",\n2088\t+\t\t\"type: wiki\",\n2089\t+\t\t\"stage: compiled\",\n2090\t+\t\t\"tags: [systems, alpha]\",\n2091\t+\t\t\"---\",\n2092\t+\t\t\"Alpha note explains the operational flow. See [[research/wiki/concepts/Beta Note|Beta]] and [[research/wiki/concepts/Alpha Note#details|details]].\",\n2093\t+\t\t\"\",\n2094\t+\t\t\"## Details\",\n2095\t+\t}, \"\\n\")\n2096\t+\twriteFile(t, sourcePath, sourceContent)\n2097\t+\n2098\t+\tresult, err := Promote(context.Background(), PromoteInput{\n2099\t+\t\tSourceDocPath: sourcePath,\n2100\t+\t\tVaultPath: vaultPath,\n2101\t+\t\tTargetTopic: models.TopicInfo{\n2102\t+\t\t\tSlug: \"catalog\",\n2103\t+\t\t\tMode: models.TopicModeOKF,\n2104\t+\t\t\tRootPath: targetTopic,\n2105\t+\t\t},\n2106\t+\t\tType: \"Playbook\",\n2107\t+\t\tDescription: \"Operational alpha note.\",\n2108\t+\t\tTypes: []string{\"Playbook\"},\n2109\t+\t\tClock: func() time.Time {\n2110\t+\t\t\treturn time.Date(2026, 6, 27, 10, 11, 12, 0, time.UTC)\n2111\t+\t\t},\n2112\t+\t})\n2113\t+\tif err != nil {\n2114\t+\t\tt.Fatalf(\"Promote returned error: %v\", err)\n2115\t+\t}\n2116\t+\tif result.WrittenPath != \"alpha-note.md\" {\n2117\t+\t\tt.Fatalf(\"written path = %q, want alpha-note.md\", result.WrittenPath)\n2118\t+\t}\n2119\t+\tif result.LinksRewritten != 2 {\n2120\t+\t\tt.Fatalf(\"links rewritten = %d, want 2\", result.LinksRewritten)\n2121\t+\t}\n2122\t+\tif len(result.UnresolvedLinks) != 1 || result.UnresolvedLinks[0] != \"beta-note.md\" {\n2123\t+\t\tt.Fatalf(\"unresolved links = %#v, want beta-note.md\", result.UnresolvedLinks)\n2124\t+\t}\n2125\t+\tif got := readFile(t, sourcePath); got != sourceContent {\n2126\t+\t\tt.Fatalf(\"source document changed:\\n%s\", got)\n2127\t+\t}\n2128\t+\n2129\t+\tvalues, body := parseMarkdown(t, filepath.Join(targetTopic, \"alpha-note.md\"))\n2130\t+\tfor key, want := range map[string]any{\n2131\t+\t\t\"type\": \"Playbook\",\n2132\t+\t\t\"title\": \"Alpha Note\",\n2133\t+\t\t\"description\": \"Operational alpha note.\",\n2134\t+\t\t\"timestamp\": \"2026-06-27T10:11:12Z\",\n2135\t+\t} {\n2136\t+\t\tif got := values[key]; got != want {\n2137\t+\t\t\tt.Fatalf(\"frontmatter[%s] = %#v, want %#v\", key, got, want)\n2138\t+\t\t}\n2139\t+\t}\n2140\t+\tif _, ok := values[\"stage\"]; ok {\n2141\t+\t\tt.Fatalf(\"wiki stage leaked into OKF frontmatter: %#v\", values)\n2142\t+\t}\n2143\t+\tif !strings.Contains(body, \"[Beta](beta-note.md)\") || !strings.Contains(body, \"[details](alpha-note.md#details)\") {\n2144\t+\t\tt.Fatalf(\"body missing transformed links:\\n%s\", body)\n2145\t+\t}\n2146\t+\n2147\t+\tindex := readFile(t, filepath.Join(targetTopic, \"index.md\"))\n2148\t+\tfor _, fragment := range []string{\"okf_version: \\\"0.1\\\"\", \"## Playbook\", \"[Alpha Note](alpha-note.md)\", \"Operational alpha note.\"} {\n2149\t+\t\tif !strings.Contains(index, fragment) {\n2150\t+\t\t\tt.Fatalf(\"index.md missing %q:\\n%s\", fragment, index)\n2151\t+\t\t}\n2152\t+\t}\n2153\t+\tlog := readFile(t, filepath.Join(targetTopic, \"log.md\"))\n2154\t+\tfor _, fragment := range []string{\"## 2026-06-27\", \"**Creation**\", \"[Alpha Note](alpha-note.md)\", \"`wiki/concepts/Alpha Note.md`\"} {\n2155\t+\t\tif !strings.Contains(log, fragment) {\n2156\t+\t\t\tt.Fatalf(\"log.md missing %q:\\n%s\", fragment, log)\n2157\t+\t\t}\n2158\t+\t}\n2159\t+}\n2160\t+\n2161\t+func TestPromoteRejectsNonOKFTargetBeforeWriting(t *testing.T) {\n2162\t+\tt.Parallel()\n2163\t+\n2164\t+\ttargetTopic := t.TempDir()\n2165\t+\t_, err := Promote(context.Background(), PromoteInput{\n2166\t+\t\tSourceDocPath: \"missing.md\",\n2167\t+\t\tTargetTopic: models.TopicInfo{\n2168\t+\t\t\tSlug: \"wiki-topic\",\n2169\t+\t\t\tMode: models.TopicModeWiki,\n2170\t+\t\t\tRootPath: targetTopic,\n2171\t+\t\t},\n2172\t+\t\tType: \"Reference\",\n2173\t+\t})\n2174\t+\tif err == nil || !strings.Contains(err.Error(), \"target topic must use mode okf\") {\n2175\t+\t\tt.Fatalf(\"error = %v, want non-OKF target rejection\", err)\n2176\t+\t}\n2177\t+\tif entries, readErr := os.ReadDir(targetTopic); readErr != nil || len(entries) != 0 {\n2178\t+\t\tt.Fatalf(\"target changed before rejection: entries=%d err=%v\", len(entries), readErr)\n2179\t+\t}\n2180\t+}\n2181\t+\n2182\t+func TestPromoteWarnsWhenSourceBodyHasNoSentenceFallback(t *testing.T) {\n2183\t+\tt.Parallel()\n2184\t+\n2185\t+\tvaultPath := t.TempDir()\n2186\t+\tsourceTopic := filepath.Join(vaultPath, \"research\")\n2187\t+\ttargetTopic := filepath.Join(vaultPath, \"catalog\")\n2188\t+\tmkdirAll(t, filepath.Join(sourceTopic, \"wiki\", \"concepts\"))\n2189\t+\tmkdirAll(t, targetTopic)\n2190\t+\twriteFile(t, filepath.Join(sourceTopic, \"CLAUDE.md\"), \"# Research\\n\")\n2191\t+\twriteFile(t, filepath.Join(targetTopic, \"CLAUDE.md\"), \"# Catalog\\n\")\n2192\t+\twriteFile(t, filepath.Join(targetTopic, \"index.md\"), \"---\\nokf_version: \\\"0.1\\\"\\n---\\n# Index\\n\")\n2193\t+\twriteFile(t, filepath.Join(targetTopic, \"log.md\"), \"# Directory Update Log\\n\")\n2194\t+\tsourcePath := filepath.Join(sourceTopic, \"wiki\", \"concepts\", \"No Sentence.md\")\n2195\t+\twriteFile(t, sourcePath, \"---\\ntitle: No Sentence\\n---\\nbody text without punctuation\")\n2196\t+\n2197\t+\tresult, err := Promote(context.Background(), PromoteInput{\n2198\t+\t\tSourceDocPath: sourcePath,\n2199\t+\t\tVaultPath: vaultPath,\n2200\t+\t\tTargetTopic: models.TopicInfo{\n2201\t+\t\t\tSlug: \"catalog\",\n2202\t+\t\t\tMode: models.TopicModeOKF,\n2203\t+\t\t\tRootPath: targetTopic,\n2204\t+\t\t},\n2205\t+\t\tType: \"Reference\",\n2206\t+\t\tClock: func() time.Time {\n2207\t+\t\t\treturn time.Date(2026, 6, 27, 10, 11, 12, 0, time.UTC)\n2208\t+\t\t},\n2209\t+\t})\n2210\t+\tif err != nil {\n2211\t+\t\tt.Fatalf(\"Promote returned error: %v\", err)\n2212\t+\t}\n2213\t+\tif len(result.Warnings) != 1 || !strings.Contains(result.Warnings[0], \"source body has no sentence fallback\") {\n2214\t+\t\tt.Fatalf(\"warnings = %#v, want no sentence fallback warning\", result.Warnings)\n2215\t+\t}\n2216\t+\n2217\t+\tvalues, _ := parseMarkdown(t, filepath.Join(targetTopic, \"no-sentence.md\"))\n2218\t+\tif got := values[\"description\"]; got != \"\" {\n2219\t+\t\tt.Fatalf(\"description = %#v, want empty string\", got)\n2220\t+\t}\n2221\t+}\n2222\t+\n2223\t+func TestCheckReportsConformanceAndStrictWarnings(t *testing.T) {\n2224\t+\tt.Parallel()\n2225\t+\n2226\t+\tbundle := t.TempDir()\n2227\t+\twriteFile(t, filepath.Join(bundle, \"CLAUDE.md\"), \"# Catalog\\n\")\n2228\t+\twriteFile(t, filepath.Join(bundle, \"index.md\"), \"---\\nokf_version: \\\"0.1\\\"\\n---\\n# Index\\n\")\n2229\t+\twriteFile(t, filepath.Join(bundle, \"log.md\"), \"# Directory Update Log\\n\\n## 2026-06-27\\n* **Creation**: Created bundle.\\n\")\n2230\t+\twriteFile(t, filepath.Join(bundle, \"good.md\"), \"---\\ntype: Playbook\\ntitle: Good\\ndescription: Good concept.\\ntimestamp: 2026-06-27T10:00:00Z\\n---\\nBody with [broken](missing.md).\\n\")\n2231\t+\twriteFile(t, filepath.Join(bundle, \"missing-type.md\"), \"---\\ntitle: Missing\\n---\\nBody.\\n\")\n2232\t+\twriteFile(t, filepath.Join(bundle, \"missing-fields.md\"), \"---\\ntype: Unknown\\n---\\nBody.\\n\")\n2233\t+\n2234\t+\tissues, err := Check(context.Background(), bundle, CheckOptions{\n2235\t+\t\tTypes: []string{\"Playbook\"},\n2236\t+\t})\n2237\t+\tif err != nil {\n2238\t+\t\tt.Fatalf(\"Check returned error: %v\", err)\n2239\t+\t}\n2240\t+\tassertIssue(t, issues, models.SeverityError, \"missing-type.md\", \"type\")\n2241\t+\tassertIssue(t, issues, models.SeverityWarning, \"missing-fields.md\", \"title\")\n2242\t+\tassertIssue(t, issues, models.SeverityWarning, \"missing-fields.md\", \"type\")\n2243\t+\tif !HasErrors(issues) {\n2244\t+\t\tt.Fatal(\"HasErrors = false, want true for missing type\")\n2245\t+\t}\n2246\t+\n2247\t+\tstrictIssues, err := Check(context.Background(), bundle, CheckOptions{\n2248\t+\t\tTypes: []string{\"Playbook\"},\n2249\t+\t\tStrict: true,\n2250\t+\t})\n2251\t+\tif err != nil {\n2252\t+\t\tt.Fatalf(\"strict Check returned error: %v\", err)\n2253\t+\t}\n2254\t+\tassertIssue(t, strictIssues, models.SeverityError, \"missing-fields.md\", \"title\")\n2255\t+}\n2256\t+\n2257\t+func TestCheckAllowsLenientExternalBundleTraits(t *testing.T) {\n2258\t+\tt.Parallel()\n2259\t+\n2260\t+\tbundle := t.TempDir()\n2261\t+\twriteFile(t, filepath.Join(bundle, \"index.md\"), \"# Index\\n\")\n2262\t+\twriteFile(t, filepath.Join(bundle, \"concept.md\"), \"---\\ntype: External Type\\n---\\nSee [missing](missing.md).\\n\")\n2263\t+\n2264\t+\tissues, err := Check(context.Background(), bundle, CheckOptions{})\n2265\t+\tif err != nil {\n2266\t+\t\tt.Fatalf(\"Check returned error: %v\", err)\n2267\t+\t}\n2268\t+\tif HasErrors(issues) {\n2269\t+\t\tt.Fatalf(\"lenient external bundle should not have errors: %#v\", issues)\n2270\t+\t}\n2271\t+}\n2272\t+\n2273\t+func assertIssue(t *testing.T, issues []models.LintIssue, severity models.DiagnosticSeverity, filePath, target string) {\n2274\t+\tt.Helper()\n2275\t+\tfor _, issue := range issues {\n2276\t+\t\tif issue.Severity == severity \u0026\u0026 issue.FilePath == filePath \u0026\u0026 issue.Target == target {\n2277\t+\t\t\treturn\n2278\t+\t\t}\n2279\t+\t}\n2280\t+\tt.Fatalf(\"missing issue severity=%s file=%s target=%s in %#v\", severity, filePath, target, issues)\n2281\t+}\n2282\t+\n2283\t+func parseMarkdown(t *testing.T, filePath string) (map[string]any, string) {\n2284\t+\tt.Helper()\n2285\t+\tvalues, body, err := frontmatter.Parse(readFile(t, filePath))\n2286\t+\tif err != nil {\n2287\t+\t\tt.Fatalf(\"parse %s: %v\", filePath, err)\n2288\t+\t}\n2289\t+\treturn values, body\n2290\t+}\n2291\t+\n2292\t+func mkdirAll(t *testing.T, dir string) {\n2293\t+\tt.Helper()\n2294\t+\tif err := os.MkdirAll(dir, 0o755); err != nil {\n2295\t+\t\tt.Fatalf(\"mkdir %s: %v\", dir, err)\n2296\t+\t}\n2297\t+}\n2298\t+\n2299\t+func writeFile(t *testing.T, filePath string, content string) {\n2300\t+\tt.Helper()\n2301\t+\tif err := os.MkdirAll(filepath.Dir(filePath), 0o755); err != nil {\n2302\t+\t\tt.Fatalf(\"mkdir parent for %s: %v\", filePath, err)\n2303\t+\t}\n2304\t+\tif err := os.WriteFile(filePath, []byte(content), 0o644); err != nil {\n2305\t+\t\tt.Fatalf(\"write %s: %v\", filePath, err)\n2306\t+\t}\n2307\t+}\n2308\t+\n2309\t+func readFile(t *testing.T, filePath string) string {\n2310\t+\tt.Helper()\n2311\t+\tcontent, err := os.ReadFile(filePath)\n2312\t+\tif err != nil {\n2313\t+\t\tt.Fatalf(\"read %s: %v\", filePath, err)\n2314\t+\t}\n2315\t+\treturn string(content)\n2316\t+}\n2317\tdiff --git a/internal/okf/official_integration_test.go b/internal/okf/official_integration_test.go\n2318\tnew file mode 100644\n2319\tindex 0000000..6331317\n2320\t--- /dev/null\n2321\t+++ b/internal/okf/official_integration_test.go\n2322\t@@ -0,0 +1,27 @@\n2323\t+//go:build integration\n2324\t+\n2325\t+package okf\n2326\t+\n2327\t+import (\n2328\t+\t\"context\"\n2329\t+\t\"path/filepath\"\n2330\t+\t\"testing\"\n2331\t+)\n2332\t+\n2333\t+func TestOfficialBundlesPassLenientConformance(t *testing.T) {\n2334\t+\tt.Parallel()\n2335\t+\n2336\t+\tfor _, bundle := range []string{\"ga4\", \"stackoverflow\", \"crypto_bitcoin\"} {\n2337\t+\t\tbundle := bundle\n2338\t+\t\tt.Run(bundle, func(t *testing.T) {\n2339\t+\t\t\tt.Parallel()\n2340\t+\t\t\tissues, err := Check(context.Background(), filepath.Join(\"testdata\", \"official\", bundle), CheckOptions{})\n2341\t+\t\t\tif err != nil {\n2342\t+\t\t\t\tt.Fatalf(\"Check returned error: %v\", err)\n2343\t+\t\t\t}\n2344\t+\t\t\tif HasErrors(issues) {\n2345\t+\t\t\t\tt.Fatalf(\"official bundle %s has error issues: %#v\", bundle, issues)\n2346\t+\t\t\t}\n2347\t+\t\t})\n2348\t+\t}\n2349\t+}\n2350\tdiff --git a/internal/okf/testdata/official/README.md b/internal/okf/testdata/official/README.md\n2351\tnew file mode 100644\n2352\tindex 0000000..b3caa76\n2353\t--- /dev/null\n2354\t+++ b/internal/okf/testdata/official/README.md\n2355\t@@ -0,0 +1,10 @@\n2356\t+# Official OKF Fixtures\n2357\t+\n2358\t+These fixtures are copied from the Apache-2.0 licensed Google Cloud\n2359\t+`GoogleCloudPlatform/knowledge-catalog` repository:\n2360\t+\n2361\t+- Source repository: https://github.com/GoogleCloudPlatform/knowledge-catalog\n2362\t+- Fixture source path: `okf/bundles/{ga4,stackoverflow,crypto_bitcoin}`\n2363\t+- License: `LICENSE.md` in this directory\n2364\t+\n2365\t+They are used to verify lenient OKF v0.1 conformance against real sample bundles.\n2366\tdiff --git a/internal/okf/testdata/official/LICENSE.md b/internal/okf/testdata/official/LICENSE.md\n2367\tnew file mode 100644\n2368\tindex 0000000..6b0b127\n2369\t--- /dev/null\n2370\t+++ b/internal/okf/testdata/official/LICENSE.md\n2371\t@@ -0,0 +1,203 @@\n2372\t+\n2373\t+ Apache License\n2374\t+ Version 2.0, January 2004\n2375\t+ http://www.apache.org/licenses/\n2376\t+\n2377\t+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n2378\t+\n2379\t+ 1. Definitions.\n2380\t+\n2381\t+ \"License\" shall mean the terms and conditions for use, reproduction,\n2382\t+ and distribution as defined by Sections 1 through 9 of this document.\n2383\t+\n2384\t+ \"Licensor\" shall mean the copyright owner or entity authorized by\n2385\t+ the copyright owner that is granting the License.\n2386\t+\n2387\t+ \"Legal Entity\" shall mean the union of the acting entity and all\n2388\t+ other entities that control, are controlled by, or are under common\n2389\t+ control with that entity. For the purposes of this definition,\n2390\t+ \"control\" means (i) the power, direct or indirect, to cause the\n2391\t+ direction or management of such entity, whether by contract or\n2392\t+ otherwise, or (ii) ownership of fifty percent (50%) or more of the\n2393\t+ outstanding shares, or (iii) beneficial ownership of such entity.\n2394\t+\n```"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-013457-686051000-25106f92d0f1a262","time":"2026-06-28T01:35:15.30205Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":""}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-013457-686051000-25106f92d0f1a262","time":"2026-06-28T01:35:15.302117Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":"Let me continue reading the rest of the diff patch."}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-013457-686051000-25106f92d0f1a262","time":"2026-06-28T01:35:15.834824Z","turn":1,"type":"session.update","update":{"kind":"tool_call_started","tool_call_id":"toolu_01JR6MJB1nGcNiEreUb8GhxH","tool_call_state":"pending","blocks":[{"type":"tool_use","id":"toolu_01JR6MJB1nGcNiEreUb8GhxH","name":"Read","title":"Read File","raw_input":{}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-013457-686051000-25106f92d0f1a262","time":"2026-06-28T01:35:16.857807Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01JR6MJB1nGcNiEreUb8GhxH","blocks":[{"type":"tool_use","id":"toolu_01JR6MJB1nGcNiEreUb8GhxH","name":"Read","title":"Read .compozy/tasks/okf-dual-mode/qa/impl-review-diff-round2.patch (2395 - 2996)","input":{"file_path":"/Users/pedronauck/Dev/compozy/kb/.compozy/tasks/okf-dual-mode/qa/impl-review-diff-round2.patch"},"raw_input":{"file_path":"/Users/pedronauck/Dev/compozy/kb/.compozy/tasks/okf-dual-mode/qa/impl-review-diff-round2.patch","limit":602,"offset":2395}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-013457-686051000-25106f92d0f1a262","time":"2026-06-28T01:35:16.859868Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01JR6MJB1nGcNiEreUb8GhxH","usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-013457-686051000-25106f92d0f1a262","time":"2026-06-28T01:35:16.863341Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01JR6MJB1nGcNiEreUb8GhxH","tool_call_state":"completed","blocks":[{"type":"tool_result","tool_use_id":"toolu_01JR6MJB1nGcNiEreUb8GhxH","content":"```\n2395\t+ \"You\" (or \"Your\") shall mean an individual or Legal Entity\n2396\t+ exercising permissions granted by this License.\n2397\t+\n2398\t+ \"Source\" form shall mean the preferred form for making modifications,\n2399\t+ including but not limited to software source code, documentation\n2400\t+ source, and configuration files.\n2401\t+\n2402\t+ \"Object\" form shall mean any form resulting from mechanical\n2403\t+ transformation or translation of a Source form, including but\n2404\t+ not limited to compiled object code, generated documentation,\n2405\t+ and conversions to other media types.\n2406\t+\n2407\t+ \"Work\" shall mean the work of authorship, whether in Source or\n2408\t+ Object form, made available under the License, as indicated by a\n2409\t+ copyright notice that is included in or attached to the work\n2410\t+ (an example is provided in the Appendix below).\n2411\t+\n2412\t+ \"Derivative Works\" shall mean any work, whether in Source or Object\n2413\t+ form, that is based on (or derived from) the Work and for which the\n2414\t+ editorial revisions, annotations, elaborations, or other modifications\n2415\t+ represent, as a whole, an original work of authorship. For the purposes\n2416\t+ of this License, Derivative Works shall not include works that remain\n2417\t+ separable from, or merely link (or bind by name) to the interfaces of,\n2418\t+ the Work and Derivative Works thereof.\n2419\t+\n2420\t+ \"Contribution\" shall mean any work of authorship, including\n2421\t+ the original version of the Work and any modifications or additions\n2422\t+ to that Work or Derivative Works thereof, that is intentionally\n2423\t+ submitted to Licensor for inclusion in the Work by the copyright owner\n2424\t+ or by an individual or Legal Entity authorized to submit on behalf of\n2425\t+ the copyright owner. For the purposes of this definition, \"submitted\"\n2426\t+ means any form of electronic, verbal, or written communication sent\n2427\t+ to the Licensor or its representatives, including but not limited to\n2428\t+ communication on electronic mailing lists, source code control systems,\n2429\t+ and issue tracking systems that are managed by, or on behalf of, the\n2430\t+ Licensor for the purpose of discussing and improving the Work, but\n2431\t+ excluding communication that is conspicuously marked or otherwise\n2432\t+ designated in writing by the copyright owner as \"Not a Contribution.\"\n2433\t+\n2434\t+ \"Contributor\" shall mean Licensor and any individual or Legal Entity\n2435\t+ on behalf of whom a Contribution has been received by Licensor and\n2436\t+ subsequently incorporated within the Work.\n2437\t+\n2438\t+ 2. Grant of Copyright License. Subject to the terms and conditions of\n2439\t+ this License, each Contributor hereby grants to You a perpetual,\n2440\t+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n2441\t+ copyright license to reproduce, prepare Derivative Works of,\n2442\t+ publicly display, publicly perform, sublicense, and distribute the\n2443\t+ Work and such Derivative Works in Source or Object form.\n2444\t+\n2445\t+ 3. Grant of Patent License. Subject to the terms and conditions of\n2446\t+ this License, each Contributor hereby grants to You a perpetual,\n2447\t+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n2448\t+ (except as stated in this section) patent license to make, have made,\n2449\t+ use, offer to sell, sell, import, and otherwise transfer the Work,\n2450\t+ where such license applies only to those patent claims licensable\n2451\t+ by such Contributor that are necessarily infringed by their\n2452\t+ Contribution(s) alone or by combination of their Contribution(s)\n2453\t+ with the Work to which such Contribution(s) was submitted. If You\n2454\t+ institute patent litigation against any entity (including a\n2455\t+ cross-claim or counterclaim in a lawsuit) alleging that the Work\n2456\t+ or a Contribution incorporated within the Work constitutes direct\n2457\t+ or contributory patent infringement, then any patent licenses\n2458\t+ granted to You under this License for that Work shall terminate\n2459\t+ as of the date such litigation is filed.\n2460\t+\n2461\t+ 4. Redistribution. You may reproduce and distribute copies of the\n2462\t+ Work or Derivative Works thereof in any medium, with or without\n2463\t+ modifications, and in Source or Object form, provided that You\n2464\t+ meet the following conditions:\n2465\t+\n2466\t+ (a) You must give any other recipients of the Work or\n2467\t+ Derivative Works a copy of this License; and\n2468\t+\n2469\t+ (b) You must cause any modified files to carry prominent notices\n2470\t+ stating that You changed the files; and\n2471\t+\n2472\t+ (c) You must retain, in the Source form of any Derivative Works\n2473\t+ that You distribute, all copyright, patent, trademark, and\n2474\t+ attribution notices from the Source form of the Work,\n2475\t+ excluding those notices that do not pertain to any part of\n2476\t+ the Derivative Works; and\n2477\t+\n2478\t+ (d) If the Work includes a \"NOTICE\" text file as part of its\n2479\t+ distribution, then any Derivative Works that You distribute must\n2480\t+ include a readable copy of the attribution notices contained\n2481\t+ within such NOTICE file, excluding those notices that do not\n2482\t+ pertain to any part of the Derivative Works, in at least one\n2483\t+ of the following places: within a NOTICE text file distributed\n2484\t+ as part of the Derivative Works; within the Source form or\n2485\t+ documentation, if provided along with the Derivative Works; or,\n2486\t+ within a display generated by the Derivative Works, if and\n2487\t+ wherever such third-party notices normally appear. The contents\n2488\t+ of the NOTICE file are for informational purposes only and\n2489\t+ do not modify the License. You may add Your own attribution\n2490\t+ notices within Derivative Works that You distribute, alongside\n2491\t+ or as an addendum to the NOTICE text from the Work, provided\n2492\t+ that such additional attribution notices cannot be construed\n2493\t+ as modifying the License.\n2494\t+\n2495\t+ You may add Your own copyright statement to Your modifications and\n2496\t+ may provide additional or different license terms and conditions\n2497\t+ for use, reproduction, or distribution of Your modifications, or\n2498\t+ for any such Derivative Works as a whole, provided Your use,\n2499\t+ reproduction, and distribution of the Work otherwise complies with\n2500\t+ the conditions stated in this License.\n2501\t+\n2502\t+ 5. Submission of Contributions. Unless You explicitly state otherwise,\n2503\t+ any Contribution intentionally submitted for inclusion in the Work\n2504\t+ by You to the Licensor shall be under the terms and conditions of\n2505\t+ this License, without any additional terms or conditions.\n2506\t+ Notwithstanding the above, nothing herein shall supersede or modify\n2507\t+ the terms of any separate license agreement you may have executed\n2508\t+ with Licensor regarding such Contributions.\n2509\t+\n2510\t+ 6. Trademarks. This License does not grant permission to use the trade\n2511\t+ names, trademarks, service marks, or product names of the Licensor,\n2512\t+ except as required for reasonable and customary use in describing the\n2513\t+ origin of the Work and reproducing the content of the NOTICE file.\n2514\t+\n2515\t+ 7. Disclaimer of Warranty. Unless required by applicable law or\n2516\t+ agreed to in writing, Licensor provides the Work (and each\n2517\t+ Contributor provides its Contributions) on an \"AS IS\" BASIS,\n2518\t+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n2519\t+ implied, including, without limitation, any warranties or conditions\n2520\t+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n2521\t+ PARTICULAR PURPOSE. You are solely responsible for determining the\n2522\t+ appropriateness of using or redistributing the Work and assume any\n2523\t+ risks associated with Your exercise of permissions under this License.\n2524\t+\n2525\t+ 8. Limitation of Liability. In no event and under no legal theory,\n2526\t+ whether in tort (including negligence), contract, or otherwise,\n2527\t+ unless required by applicable law (such as deliberate and grossly\n2528\t+ negligent acts) or agreed to in writing, shall any Contributor be\n2529\t+ liable to You for damages, including any direct, indirect, special,\n2530\t+ incidental, or consequential damages of any character arising as a\n2531\t+ result of this License or out of the use or inability to use the\n2532\t+ Work (including but not limited to damages for loss of goodwill,\n2533\t+ work stoppage, computer failure or malfunction, or any and all\n2534\t+ other commercial damages or losses), even if such Contributor\n2535\t+ has been advised of the possibility of such damages.\n2536\t+\n2537\t+ 9. Accepting Warranty or Additional Liability. While redistributing\n2538\t+ the Work or Derivative Works thereof, You may choose to offer,\n2539\t+ and charge a fee for, acceptance of support, warranty, indemnity,\n2540\t+ or other liability obligations and/or rights consistent with this\n2541\t+ License. However, in accepting such obligations, You may act only\n2542\t+ on Your own behalf and on Your sole responsibility, not on behalf\n2543\t+ of any other Contributor, and only if You agree to indemnify,\n2544\t+ defend, and hold each Contributor harmless for any liability\n2545\t+ incurred by, or claims asserted against, such Contributor by reason\n2546\t+ of your accepting any such warranty or additional liability.\n2547\t+\n2548\t+ END OF TERMS AND CONDITIONS\n2549\t+\n2550\t+ APPENDIX: How to apply the Apache License to your work.\n2551\t+\n2552\t+ To apply the Apache License to your work, attach the following\n2553\t+ boilerplate notice, with the fields enclosed by brackets \"[]\"\n2554\t+ replaced with your own identifying information. (Don't include\n2555\t+ the brackets!) The text should be enclosed in the appropriate\n2556\t+ comment syntax for the file format. We also recommend that a\n2557\t+ file or class name and description of purpose be included on the\n2558\t+ same \"printed page\" as the copyright notice for easier\n2559\t+ identification within third-party archives.\n2560\t+\n2561\t+ Copyright [yyyy] [name of copyright owner]\n2562\t+\n2563\t+ Licensed under the Apache License, Version 2.0 (the \"License\");\n2564\t+ you may not use this file except in compliance with the License.\n2565\t+ You may obtain a copy of the License at\n2566\t+\n2567\t+ http://www.apache.org/licenses/LICENSE-2.0\n2568\t+\n2569\t+ Unless required by applicable law or agreed to in writing, software\n2570\t+ distributed under the License is distributed on an \"AS IS\" BASIS,\n2571\t+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n2572\t+ See the License for the specific language governing permissions and\n2573\t+ limitations under the License.\n2574\t+\n2575\tdiff --git a/internal/cli/promote.go b/internal/cli/promote.go\n2576\tnew file mode 100644\n2577\tindex 0000000..9b332bb\n2578\t--- /dev/null\n2579\t+++ b/internal/cli/promote.go\n2580\t@@ -0,0 +1,61 @@\n2581\t+package cli\n2582\t+\n2583\t+import (\n2584\t+\t\"fmt\"\n2585\t+\t\"os\"\n2586\t+\n2587\t+\t\"github.com/spf13/cobra\"\n2588\t+\n2589\t+\tkokf \"github.com/compozy/kb/internal/okf\"\n2590\t+\tktopic \"github.com/compozy/kb/internal/topic\"\n2591\t+)\n2592\t+\n2593\t+var runPromote = kokf.Promote\n2594\t+var runPromoteTopicInfo = ktopic.Info\n2595\t+var promoteGetwd = os.Getwd\n2596\t+\n2597\t+func newPromoteCommand() *cobra.Command {\n2598\t+\tvar targetTopic string\n2599\t+\tvar conceptType string\n2600\t+\tvar description string\n2601\t+\n2602\t+\tcommand := \u0026cobra.Command{\n2603\t+\t\tUse: \"promote \u003cwiki-doc\u003e\",\n2604\t+\t\tShort: \"Promote a wiki document into an OKF topic\",\n2605\t+\t\tArgs: cobra.ExactArgs(1),\n2606\t+\t\tRunE: func(cmd *cobra.Command, args []string) error {\n2607\t+\t\t\tvaultPath, err := resolveCommandVaultPath(cmd, promoteGetwd, \"promote\")\n2608\t+\t\t\tif err != nil {\n2609\t+\t\t\t\treturn err\n2610\t+\t\t\t}\n2611\t+\t\t\tcfg, err := loadCLIConfig()\n2612\t+\t\t\tif err != nil {\n2613\t+\t\t\t\treturn fmt.Errorf(\"promote: %w\", err)\n2614\t+\t\t\t}\n2615\t+\t\t\ttarget, err := runPromoteTopicInfo(vaultPath, targetTopic)\n2616\t+\t\t\tif err != nil {\n2617\t+\t\t\t\treturn fmt.Errorf(\"promote: %w\", err)\n2618\t+\t\t\t}\n2619\t+\n2620\t+\t\t\tresult, err := runPromote(commandContext(cmd), kokf.PromoteInput{\n2621\t+\t\t\t\tSourceDocPath: args[0],\n2622\t+\t\t\t\tVaultPath: vaultPath,\n2623\t+\t\t\t\tTargetTopic: target,\n2624\t+\t\t\t\tType: conceptType,\n2625\t+\t\t\t\tDescription: description,\n2626\t+\t\t\t\tTypes: cfg.OKF.Types,\n2627\t+\t\t\t})\n2628\t+\t\t\tif err != nil {\n2629\t+\t\t\t\treturn err\n2630\t+\t\t\t}\n2631\t+\t\t\treturn writeJSON(cmd, result)\n2632\t+\t\t},\n2633\t+\t}\n2634\t+\n2635\t+\tcommand.Flags().StringVar(\u0026targetTopic, \"to\", \"\", \"Target OKF topic slug\")\n2636\t+\tcommand.Flags().StringVar(\u0026conceptType, \"type\", \"\", \"OKF concept type\")\n2637\t+\tcommand.Flags().StringVar(\u0026description, \"description\", \"\", \"OKF concept description\")\n2638\t+\t_ = command.MarkFlagRequired(\"to\")\n2639\t+\t_ = command.MarkFlagRequired(\"type\")\n2640\t+\treturn command\n2641\t+}\n2642\tdiff --git a/internal/cli/okf.go b/internal/cli/okf.go\n2643\tnew file mode 100644\n2644\tindex 0000000..c3dca3a\n2645\t--- /dev/null\n2646\t+++ b/internal/cli/okf.go\n2647\t@@ -0,0 +1,90 @@\n2648\t+package cli\n2649\t+\n2650\t+import (\n2651\t+\t\"fmt\"\n2652\t+\t\"os\"\n2653\t+\n2654\t+\t\"github.com/spf13/cobra\"\n2655\t+\n2656\t+\tkokf \"github.com/compozy/kb/internal/okf\"\n2657\t+\t\"github.com/compozy/kb/internal/output\"\n2658\t+\tktopic \"github.com/compozy/kb/internal/topic\"\n2659\t+)\n2660\t+\n2661\t+type okfCheckOptions struct {\n2662\t+\tFormat string\n2663\t+\tStrict bool\n2664\t+}\n2665\t+\n2666\t+var runOKFCheck = kokf.Check\n2667\t+var runOKFTopicInfo = ktopic.Info\n2668\t+var okfGetwd = os.Getwd\n2669\t+\n2670\t+func newOKFCommand() *cobra.Command {\n2671\t+\tcommand := \u0026cobra.Command{\n2672\t+\t\tUse: \"okf\",\n2673\t+\t\tShort: \"Work with Open Knowledge Format bundles\",\n2674\t+\t\tArgs: cobra.NoArgs,\n2675\t+\t\tRunE: func(cmd *cobra.Command, args []string) error {\n2676\t+\t\t\treturn cmd.Help()\n2677\t+\t\t},\n2678\t+\t}\n2679\t+\tcommand.AddCommand(newOKFCheckCommand())\n2680\t+\treturn command\n2681\t+}\n2682\t+\n2683\t+func newOKFCheckCommand() *cobra.Command {\n2684\t+\toptions := \u0026okfCheckOptions{\n2685\t+\t\tFormat: string(output.OutputFormatTable),\n2686\t+\t}\n2687\t+\tcommand := \u0026cobra.Command{\n2688\t+\t\tUse: \"check \u003ctopic\u003e\",\n2689\t+\t\tShort: \"Check an OKF topic for conformance\",\n2690\t+\t\tArgs: cobra.ExactArgs(1),\n2691\t+\t\tRunE: func(cmd *cobra.Command, args []string) error {\n2692\t+\t\t\treturn runOKFCheckCommand(cmd, options, args[0])\n2693\t+\t\t},\n2694\t+\t}\n2695\t+\tcommand.Flags().StringVar(\u0026options.Format, \"format\", string(output.OutputFormatTable), \"Output format (table|json|tsv)\")\n2696\t+\tcommand.Flags().BoolVar(\u0026options.Strict, \"strict\", false, \"Promote local-standard warnings to errors\")\n2697\t+\treturn command\n2698\t+}\n2699\t+\n2700\t+func runOKFCheckCommand(cmd *cobra.Command, options *okfCheckOptions, topicSlug string) error {\n2701\t+\tformat, err := parseInspectOutputFormat(options.Format)\n2702\t+\tif err != nil {\n2703\t+\t\treturn err\n2704\t+\t}\n2705\t+\tvaultPath, err := resolveCommandVaultPath(cmd, okfGetwd, \"okf check\")\n2706\t+\tif err != nil {\n2707\t+\t\treturn err\n2708\t+\t}\n2709\t+\tcfg, err := loadCLIConfig()\n2710\t+\tif err != nil {\n2711\t+\t\treturn fmt.Errorf(\"okf check: %w\", err)\n2712\t+\t}\n2713\t+\ttopicInfo, err := runOKFTopicInfo(vaultPath, topicSlug)\n2714\t+\tif err != nil {\n2715\t+\t\treturn fmt.Errorf(\"okf check: %w\", err)\n2716\t+\t}\n2717\t+\n2718\t+\tissues, err := runOKFCheck(commandContext(cmd), topicInfo.RootPath, kokf.CheckOptions{\n2719\t+\t\tTypes: cfg.OKF.Types,\n2720\t+\t\tStrict: options.Strict,\n2721\t+\t})\n2722\t+\tif err != nil {\n2723\t+\t\treturn err\n2724\t+\t}\n2725\t+\t_, writeErr := cmd.OutOrStdout().Write([]byte(output.FormatOutput(output.FormatOptions{\n2726\t+\t\tFormat: format,\n2727\t+\t\tColumns: kokf.Columns(),\n2728\t+\t\tData: kokf.Rows(issues),\n2729\t+\t})))\n2730\t+\tif writeErr != nil {\n2731\t+\t\treturn fmt.Errorf(\"okf check: write output: %w\", writeErr)\n2732\t+\t}\n2733\t+\tif kokf.HasErrors(issues) {\n2734\t+\t\treturn fmt.Errorf(\"okf check: found %d issue(s)\", len(issues))\n2735\t+\t}\n2736\t+\treturn nil\n2737\t+}\n2738\tdiff --git a/internal/cli/okf_test.go b/internal/cli/okf_test.go\n2739\tnew file mode 100644\n2740\tindex 0000000..dc9d6ba\n2741\t--- /dev/null\n2742\t+++ b/internal/cli/okf_test.go\n2743\t@@ -0,0 +1,135 @@\n2744\t+package cli\n2745\t+\n2746\t+import (\n2747\t+\t\"bytes\"\n2748\t+\t\"context\"\n2749\t+\t\"encoding/json\"\n2750\t+\t\"fmt\"\n2751\t+\t\"os\"\n2752\t+\t\"path/filepath\"\n2753\t+\t\"strings\"\n2754\t+\t\"testing\"\n2755\t+\n2756\t+\tkconfig \"github.com/compozy/kb/internal/config\"\n2757\t+\t\"github.com/compozy/kb/internal/models\"\n2758\t+\tkokf \"github.com/compozy/kb/internal/okf\"\n2759\t+)\n2760\t+\n2761\t+func TestPromoteCommandResolvesTargetAndPrintsJSON(t *testing.T) {\n2762\t+\toriginalPromote := runPromote\n2763\t+\toriginalTopicInfo := runPromoteTopicInfo\n2764\t+\tt.Cleanup(func() {\n2765\t+\t\trunPromote = originalPromote\n2766\t+\t\trunPromoteTopicInfo = originalTopicInfo\n2767\t+\t})\n2768\t+\tt.Setenv(kconfig.EnvConfigPath, writeCLIConfig(t, \"[okf]\\ntypes = [\\\"Playbook\\\"]\\n\"))\n2769\t+\n2770\t+\tvar gotInput kokf.PromoteInput\n2771\t+\trunPromoteTopicInfo = func(vaultPath, slug string) (models.TopicInfo, error) {\n2772\t+\t\treturn models.TopicInfo{\n2773\t+\t\t\tSlug: slug,\n2774\t+\t\t\tMode: models.TopicModeOKF,\n2775\t+\t\t\tRootPath: filepath.Join(vaultPath, slug),\n2776\t+\t\t}, nil\n2777\t+\t}\n2778\t+\trunPromote = func(ctx context.Context, input kokf.PromoteInput) (kokf.ConceptResult, error) {\n2779\t+\t\tgotInput = input\n2780\t+\t\treturn kokf.ConceptResult{\n2781\t+\t\t\tWrittenPath: \"alpha.md\",\n2782\t+\t\t\tType: input.Type,\n2783\t+\t\t\tLinksRewritten: 1,\n2784\t+\t\t}, nil\n2785\t+\t}\n2786\t+\n2787\t+\tcommand := newRootCommand()\n2788\t+\tvar stdout bytes.Buffer\n2789\t+\tcommand.SetOut(\u0026stdout)\n2790\t+\tcommand.SetErr(new(bytes.Buffer))\n2791\t+\tcommand.SetArgs([]string{\n2792\t+\t\t\"promote\", \"research/wiki/concepts/Alpha.md\",\n2793\t+\t\t\"--to\", \"catalog\",\n2794\t+\t\t\"--type\", \"Playbook\",\n2795\t+\t\t\"--description\", \"Alpha description.\",\n2796\t+\t\t\"--vault\", \"/tmp/vault\",\n2797\t+\t})\n2798\t+\n2799\t+\tif err := command.ExecuteContext(context.Background()); err != nil {\n2800\t+\t\tt.Fatalf(\"ExecuteContext returned error: %v\", err)\n2801\t+\t}\n2802\t+\tif gotInput.VaultPath != \"/tmp/vault\" || gotInput.TargetTopic.Slug != \"catalog\" || gotInput.Type != \"Playbook\" {\n2803\t+\t\tt.Fatalf(\"unexpected promote input: %#v\", gotInput)\n2804\t+\t}\n2805\t+\tif gotInput.Description != \"Alpha description.\" {\n2806\t+\t\tt.Fatalf(\"description = %q\", gotInput.Description)\n2807\t+\t}\n2808\t+\tif len(gotInput.Types) != 1 || gotInput.Types[0] != \"Playbook\" {\n2809\t+\t\tt.Fatalf(\"types = %#v, want Playbook\", gotInput.Types)\n2810\t+\t}\n2811\t+\n2812\t+\tvar result kokf.ConceptResult\n2813\t+\tif err := json.Unmarshal(stdout.Bytes(), \u0026result); err != nil {\n2814\t+\t\tt.Fatalf(\"stdout did not contain JSON: %v\\n%s\", err, stdout.String())\n2815\t+\t}\n2816\t+\tif result.WrittenPath != \"alpha.md\" || result.Type != \"Playbook\" {\n2817\t+\t\tt.Fatalf(\"unexpected result: %#v\", result)\n2818\t+\t}\n2819\t+}\n2820\t+\n2821\t+func TestOKFCheckCommandRendersIssuesAndFailsOnErrors(t *testing.T) {\n2822\t+\toriginalCheck := runOKFCheck\n2823\t+\toriginalTopicInfo := runOKFTopicInfo\n2824\t+\tt.Cleanup(func() {\n2825\t+\t\trunOKFCheck = originalCheck\n2826\t+\t\trunOKFTopicInfo = originalTopicInfo\n2827\t+\t})\n2828\t+\tt.Setenv(kconfig.EnvConfigPath, writeCLIConfig(t, \"[okf]\\ntypes = [\\\"Playbook\\\"]\\n\"))\n2829\t+\n2830\t+\trunOKFTopicInfo = func(vaultPath, slug string) (models.TopicInfo, error) {\n2831\t+\t\treturn models.TopicInfo{\n2832\t+\t\t\tSlug: slug,\n2833\t+\t\t\tMode: models.TopicModeOKF,\n2834\t+\t\t\tRootPath: filepath.Join(vaultPath, slug),\n2835\t+\t\t}, nil\n2836\t+\t}\n2837\t+\trunOKFCheck = func(ctx context.Context, bundlePath string, options kokf.CheckOptions) ([]models.LintIssue, error) {\n2838\t+\t\tif bundlePath != \"/tmp/vault/catalog\" {\n2839\t+\t\t\treturn nil, fmt.Errorf(\"bundle path = %q\", bundlePath)\n2840\t+\t\t}\n2841\t+\t\tif !options.Strict || len(options.Types) != 1 || options.Types[0] != \"Playbook\" {\n2842\t+\t\t\treturn nil, fmt.Errorf(\"unexpected options: %#v\", options)\n2843\t+\t\t}\n2844\t+\t\treturn []models.LintIssue{{\n2845\t+\t\t\tKind: models.LintIssueKindFormat,\n2846\t+\t\t\tSeverity: models.SeverityError,\n2847\t+\t\t\tFilePath: \"bad.md\",\n2848\t+\t\t\tTarget: \"type\",\n2849\t+\t\t\tMessage: \"missing type\",\n2850\t+\t\t}}, nil\n2851\t+\t}\n2852\t+\n2853\t+\tcommand := newRootCommand()\n2854\t+\tvar stdout bytes.Buffer\n2855\t+\tcommand.SetOut(\u0026stdout)\n2856\t+\tcommand.SetErr(new(bytes.Buffer))\n2857\t+\tcommand.SetArgs([]string{\"okf\", \"check\", \"catalog\", \"--strict\", \"--format\", \"json\", \"--vault\", \"/tmp/vault\"})\n2858\t+\n2859\t+\terr := command.ExecuteContext(context.Background())\n2860\t+\tif err == nil {\n2861\t+\t\tt.Fatal(\"expected okf check to fail on error issues\")\n2862\t+\t}\n2863\t+\tif !strings.Contains(err.Error(), \"found 1 issue\") {\n2864\t+\t\tt.Fatalf(\"unexpected error: %v\", err)\n2865\t+\t}\n2866\t+\tif !strings.Contains(stdout.String(), `\"filePath\": \"bad.md\"`) {\n2867\t+\t\tt.Fatalf(\"stdout missing issue JSON:\\n%s\", stdout.String())\n2868\t+\t}\n2869\t+}\n2870\t+\n2871\t+func writeCLIConfig(t *testing.T, content string) string {\n2872\t+\tt.Helper()\n2873\t+\tpath := filepath.Join(t.TempDir(), \"kb.toml\")\n2874\t+\tif err := os.WriteFile(path, []byte(content), 0o644); err != nil {\n2875\t+\t\tt.Fatalf(\"write config: %v\", err)\n2876\t+\t}\n2877\t+\treturn path\n2878\t+}\n2879\tdiff --git a/internal/cli/okf_integration_test.go b/internal/cli/okf_integration_test.go\n2880\tnew file mode 100644\n2881\tindex 0000000..2f65552\n2882\t--- /dev/null\n2883\t+++ b/internal/cli/okf_integration_test.go\n2884\t@@ -0,0 +1,112 @@\n2885\t+//go:build integration\n2886\t+\n2887\t+package cli\n2888\t+\n2889\t+import (\n2890\t+\t\"encoding/json\"\n2891\t+\t\"os\"\n2892\t+\t\"path/filepath\"\n2893\t+\t\"strings\"\n2894\t+\t\"testing\"\n2895\t+\n2896\t+\t\"github.com/compozy/kb/internal/frontmatter\"\n2897\t+\t\"github.com/compozy/kb/internal/models\"\n2898\t+\tkokf \"github.com/compozy/kb/internal/okf\"\n2899\t+)\n2900\t+\n2901\t+func TestCLIIntegrationOKFPromoteAndCheck(t *testing.T) {\n2902\t+\tvaultRoot := t.TempDir()\n2903\t+\twikiTopic := scaffoldTopicForIntegration(t, vaultRoot, \"research\", \"Research\", \"ops\")\n2904\t+\tokfTopic := runCLIJSON[models.TopicInfo](t,\n2905\t+\t\t\"topic\", \"new\", \"catalog\", \"Catalog\", \"ops\",\n2906\t+\t\t\"--mode\", \"okf\",\n2907\t+\t\t\"--vault\", vaultRoot,\n2908\t+\t)\n2909\t+\tif okfTopic.Mode != models.TopicModeOKF {\n2910\t+\t\tt.Fatalf(\"OKF topic mode = %q, want okf\", okfTopic.Mode)\n2911\t+\t}\n2912\t+\n2913\t+\tsourcePath := filepath.Join(wikiTopic.RootPath, \"wiki\", \"concepts\", \"Alpha Note.md\")\n2914\t+\twriteFile(t, sourcePath, strings.Join([]string{\n2915\t+\t\t\"---\",\n2916\t+\t\t\"title: Alpha Note\",\n2917\t+\t\t\"type: wiki\",\n2918\t+\t\t\"stage: compiled\",\n2919\t+\t\t\"tags: [ops]\",\n2920\t+\t\t\"---\",\n2921\t+\t\t\"Alpha note explains the operations flow. See [[research/wiki/concepts/Beta Note|Beta]].\",\n2922\t+\t}, \"\\n\"))\n2923\t+\n2924\t+\tpromoteOutput := runCLI(t,\n2925\t+\t\t\"promote\", sourcePath,\n2926\t+\t\t\"--to\", okfTopic.Slug,\n2927\t+\t\t\"--type\", \"Playbook\",\n2928\t+\t\t\"--description\", \"Operational flow.\",\n2929\t+\t\t\"--vault\", vaultRoot,\n2930\t+\t)\n2931\t+\tvar result kokf.ConceptResult\n2932\t+\tif err := json.Unmarshal([]byte(promoteOutput), \u0026result); err != nil {\n2933\t+\t\tt.Fatalf(\"promote output is not JSON: %v\\n%s\", err, promoteOutput)\n2934\t+\t}\n2935\t+\tif result.WrittenPath != \"alpha-note.md\" {\n2936\t+\t\tt.Fatalf(\"written path = %q, want alpha-note.md\", result.WrittenPath)\n2937\t+\t}\n2938\t+\n2939\t+\tsourceAfter := readFile(t, sourcePath)\n2940\t+\tif !strings.Contains(sourceAfter, \"stage: compiled\") {\n2941\t+\t\tt.Fatalf(\"source was unexpectedly changed:\\n%s\", sourceAfter)\n2942\t+\t}\n2943\t+\tconceptPath := filepath.Join(okfTopic.RootPath, \"alpha-note.md\")\n2944\t+\tvalues, body := readMarkdownDocument(t, conceptPath)\n2945\t+\tif values[\"type\"] != \"Playbook\" || values[\"description\"] != \"Operational flow.\" {\n2946\t+\t\tt.Fatalf(\"unexpected concept frontmatter: %#v\", values)\n2947\t+\t}\n2948\t+\tif !strings.Contains(body, \"[Beta](beta-note.md)\") {\n2949\t+\t\tt.Fatalf(\"body missing relative OKF link:\\n%s\", body)\n2950\t+\t}\n2951\t+\tif _, ok := values[\"stage\"]; ok {\n2952\t+\t\tt.Fatalf(\"wiki stage leaked into OKF concept: %#v\", values)\n2953\t+\t}\n2954\t+\n2955\t+\tcheckOutput := runCLI(t, \"okf\", \"check\", okfTopic.Slug, \"--format\", \"json\", \"--vault\", vaultRoot)\n2956\t+\tvar issues []models.LintIssue\n2957\t+\tif err := json.Unmarshal([]byte(checkOutput), \u0026issues); err != nil {\n2958\t+\t\tt.Fatalf(\"check output is not JSON: %v\\n%s\", err, checkOutput)\n2959\t+\t}\n2960\t+\tif len(issues) != 0 {\n2961\t+\t\tt.Fatalf(\"freshly promoted bundle has issues: %#v\", issues)\n2962\t+\t}\n2963\t+\n2964\t+\tindex := readFile(t, filepath.Join(okfTopic.RootPath, \"index.md\"))\n2965\t+\tif !strings.Contains(index, \"## Playbook\") || !strings.Contains(index, \"[Alpha Note](alpha-note.md)\") {\n2966\t+\t\tt.Fatalf(\"index.md missing promoted concept:\\n%s\", index)\n2967\t+\t}\n2968\t+\tlogContent := readFile(t, filepath.Join(okfTopic.RootPath, \"log.md\"))\n2969\t+\tif !strings.Contains(logContent, \"## \"+frontmatter.DateLayout[:4]) \u0026\u0026 !strings.Contains(logContent, \"**Creation**\") {\n2970\t+\t\tt.Fatalf(\"log.md missing promotion entry:\\n%s\", logContent)\n2971\t+\t}\n2972\t+}\n2973\t+\n2974\t+func TestCLIIntegrationOKFCheckFailsStrictWarnings(t *testing.T) {\n2975\t+\tvaultRoot := t.TempDir()\n2976\t+\tokfTopic := runCLIJSON[models.TopicInfo](t,\n2977\t+\t\t\"topic\", \"new\", \"catalog\", \"Catalog\", \"ops\",\n2978\t+\t\t\"--mode\", \"okf\",\n2979\t+\t\t\"--vault\", vaultRoot,\n2980\t+\t)\n2981\t+\twriteFile(t, filepath.Join(okfTopic.RootPath, \"concept.md\"), \"---\\ntype: Unknown\\n---\\nBody.\\n\")\n2982\t+\n2983\t+\terrText := runCLIError(t, \"okf\", \"check\", okfTopic.Slug, \"--strict\", \"--vault\", vaultRoot)\n2984\t+\tif !strings.Contains(errText, \"found\") {\n2985\t+\t\tt.Fatalf(\"strict check error = %q\", errText)\n2986\t+\t}\n2987\t+}\n2988\t+\n2989\t+func readFile(t *testing.T, filePath string) string {\n2990\t+\tt.Helper()\n2991\t+\tcontent, err := os.ReadFile(filePath)\n2992\t+\tif err != nil {\n2993\t+\t\tt.Fatalf(\"read %s: %v\", filePath, err)\n2994\t+\t}\n2995\t+\treturn string(content)\n2996\t+}\n```"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-013457-686051000-25106f92d0f1a262","time":"2026-06-28T01:35:22.159017Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":""}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-013457-686051000-25106f92d0f1a262","time":"2026-06-28T01:35:22.159492Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":"Let me read the round-1 findings, remediation, ADRs, and the plan file."}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-013457-686051000-25106f92d0f1a262","time":"2026-06-28T01:35:22.628315Z","turn":1,"type":"session.update","update":{"kind":"tool_call_started","tool_call_id":"toolu_01UWZ7LkAG83yWWCJ5LVXwvt","tool_call_state":"pending","blocks":[{"type":"tool_use","id":"toolu_01UWZ7LkAG83yWWCJ5LVXwvt","name":"Read","title":"Read File","raw_input":{}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-013457-686051000-25106f92d0f1a262","time":"2026-06-28T01:35:23.679031Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01UWZ7LkAG83yWWCJ5LVXwvt","blocks":[{"type":"tool_use","id":"toolu_01UWZ7LkAG83yWWCJ5LVXwvt","name":"Read","title":"Read .compozy/tasks/okf-dual-mode/qa/impl-review-findings-round1.md","input":{"file_path":"/Users/pedronauck/Dev/compozy/kb/.compozy/tasks/okf-dual-mode/qa/impl-review-findings-round1.md"},"raw_input":{"file_path":"/Users/pedronauck/Dev/compozy/kb/.compozy/tasks/okf-dual-mode/qa/impl-review-findings-round1.md"}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-013457-686051000-25106f92d0f1a262","time":"2026-06-28T01:35:23.681765Z","turn":1,"type":"session.update","update":{"kind":"tool_call_started","tool_call_id":"toolu_01KGjtCddGmWYz4Xt76MwSpQ","tool_call_state":"pending","blocks":[{"type":"tool_use","id":"toolu_01KGjtCddGmWYz4Xt76MwSpQ","name":"Read","title":"Read File","raw_input":{}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-013457-686051000-25106f92d0f1a262","time":"2026-06-28T01:35:23.68404Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01UWZ7LkAG83yWWCJ5LVXwvt","usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-013457-686051000-25106f92d0f1a262","time":"2026-06-28T01:35:23.691297Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01UWZ7LkAG83yWWCJ5LVXwvt","tool_call_state":"completed","blocks":[{"type":"tool_result","tool_use_id":"toolu_01UWZ7LkAG83yWWCJ5LVXwvt","content":"```\n1\t---\n2\tschema_version: 1\n3\treview_kind: implementation\n4\tround: 1\n5\tverdict: FIX_BEFORE_SHIP\n6\treviewer_runtime: claude\n7\treviewer_model: opus\n8\tgenerated_at: 2026-06-28T01:28:15Z\n9\t---\n10\t\n11\t# Summary\n12\t\n13\tThe OKF dual-mode MVP is implemented coherently and matches the accepted ADRs: the `mode` field, OKF scaffold, `[okf].types` config, mode-aware `LinkFormatter` with the full ~43-site migration, the mechanical/non-destructive `promote`, the lenient `kb okf check`, both documented deviations (four producer fields + relative links), and the vendored official fixtures all build, lint clean, and pass unit + integration tests (including the three official-bundle conformance tests and the end-to-end promote — the PRD's explicit success metrics). The one blocker is the omission of the ADR-004/TechSpec-mandated N-001 \"OKF render smoke\" test, which was the accepted condition for migrating the dormant render-pipeline OKF branch now rather than in Phase 3, leaving that branch shipped without any test exercising it through a render function.\n14\t\n15\t# Blockers\n16\t\n17\t## B-001 — N-001 OKF render-smoke test mandated by ADR-004 is missing\n18\t\n19\t- File: internal/vault/render.go\n20\t- Line: 191\n21\t- Issue: ADR-004 (Risks → \"Dormant OKF render branch ships as dead code in the MVP (N-001)\") and the TechSpec Testing Approach (\"OKF render smoke (N-001) — render one document for a `mode: okf` topic and assert the OKF link branch and its `fromDir` plumbing execute and emit a relative markdown link, so the wired render branch is live, not shipped dead\") make this smoke test the explicit, accepted justification for migrating all ~43 link call sites now. The migration was done (every site routes through `linkFor(topic, \"\", target, label)`), but no test ever invokes a render function (`RenderDocuments`/`renderDashboard`/`renderConceptIndex`/etc.) with `TopicMetadata{Mode: TopicModeOKF}`. The only OKF-mode coverage is `internal/vault/pathutils_test.go:232`, which calls `vault.LinkFormatterFor(...).Link(...)` directly — it exercises the formatter selection but not the render-site `linkFor` plumbing with `fromDir`. The render-site OKF branch therefore ships effectively untested, which is precisely the dead-code outcome N-001 was added to prevent. The codex plan's Test Plan silently dropped N-001 from the TechSpec.\n22\t- Rationale: This is an architectural decision recorded in an accepted ADR that is \"implemented differently than specified\" — the migration landed but its required guard test did not. Project rules (CLAUDE.md / AGENTS.md \"Treat test failures as behavior bugs first\"; the global \"no partial deliverables\" rule) and the review's dead-code / test-shape criteria make shipping a wired-but-unexercised branch without the spec-mandated smoke test a fix-before-ship gap. Remediation is local and the change shape is correct.\n23\t- Suggested fix: Add one table-friendly test in `internal/vault` that builds a minimal graph/metrics fixture (reuse `testTopicFixture()`), sets `topic.Mode = models.TopicModeOKF`, calls `vault.RenderDocuments(...)` (or a single render helper), and asserts at least one emitted body contains a relative markdown link (`[Label](...md)`) and no `[[wikilink]]`, confirming the OKF branch and `fromDir` plumbing execute through the render path.\n24\t\n25\t# Risks\n26\t\n27\t## R-001 — No structured logging despite the TechSpec observability contract\n28\t\n29\t- File: internal/okf/okf.go\n30\t- Line: 1\n31\t- Issue: The TechSpec \"Monitoring and Observability\" section enumerates required `internal/logger` (slog) events for both verbs — `promote` source/target/type, link-transform count, broken-link count, `promote rejected: target not okf` (N-202), `description fallback used`; and `okf check` files-scanned, `reserved file skipped` (N-205), errors/warnings/strict flag. The `internal/okf` package and the new `internal/cli/promote.go` / `internal/cli/okf.go` import no logger and emit none of these. Operators get only the final JSON/table output and have no debug trail for unresolved links, description fallbacks, or skipped files.\n32\t- Suggested fix: Thread `internal/logger` into `Promote`/`Check` (or log at the CLI layer from `ConceptResult`/issues) for the events listed in the TechSpec, at debug/info level.\n33\t\n34\t## R-002 — New user-facing commands are not documented in the project CLI surface\n35\t\n36\t- File: CLAUDE.md\n37\t- Line: null\n38\t- Issue: `kb promote`, the `kb okf` group / `kb okf check`, and `topic new --mode` are absent from the CLI command tables in CLAUDE.md (\"CLI Surface\"), AGENTS.md (\"CLI Commands\"), and README. `config.example.toml` was correctly updated with `[okf]`, but the command docs now drift from the shipped surface, and these files are the canonical agent/operator references for what `kb` exposes. A hard-cut change should touch code, config, and docs together.\n39\t- Suggested fix: Add `kb promote`, `kb okf {check}`, and the `topic new --mode wiki|okf` flag to the CLI tables/notes in CLAUDE.md and AGENTS.md (and README if it lists commands), in the same change.\n40\t\n41\t## R-003 — `kb okf check` cannot validate arbitrary/external bundles the checker supports\n42\t\n43\t- File: internal/cli/okf.go\n44\t- Line: 66\n45\t- Issue: ADR-005 states the checker \"takes the resolved bundle (topic) path; it does not require `mode: okf` so it can validate arbitrary directories and external bundles.\" The underlying `okf.Check` honors this (the integration test runs it on raw `testdata/official/...` paths). The CLI, however, resolves the argument through `ktopic.Info(vaultPath, topicSlug)`, which requires a valid kb topic (e.g. a readable `CLAUDE.md`/`topic.yaml`), so `kb okf check \u003csome-external-dir\u003e` cannot be run from the CLI. This is acceptable for the MVP (the operator's own bundle is a kb topic and the success metric is met via the direct `Check` integration test), but it narrows the documented capability and will need revisiting for Phase-2 ingest.\n46\t- Suggested fix: Note the MVP limitation, or allow `okf check` to fall back to treating the argument as a direct bundle path when topic resolution fails, so it matches the `Check` contract.\n47\t\n48\t# Nits\n49\t\n50\t## N-001 — Dead exported function `WriteMetadataFileWithMode`\n51\t\n52\t- File: internal/topic/topic.go\n53\t- Line: 561\n54\t- Issue: `WriteMetadataFileWithMode` is exported but has zero callers (production or test); `internal/vault/writer.go` still uses the mode-less `WriteMetadataFile`. It is speculative dead API surface (the linter won't catch it because it is exported).\n55\t- Suggested fix: Delete `WriteMetadataFileWithMode`, or route the one real caller (`writer.go:106`) through it with an explicit wiki mode if a written `mode:` line is desired there.\n56\t\n57\t## N-002 — `firstBodySentence` returns the entire body when no sentence delimiter exists\n58\t\n59\t- File: internal/okf/okf.go\n60\t- Line: 446\n61\t- Issue: The TechSpec description fallback is \"first non-empty body sentence (markdown-stripped) → else empty + warning.\" When the body has text but no `. `/`! `/`? ` delimiter, `firstBodySentence` returns the whole normalized body, producing a potentially very long `description` with no warning instead of a single sentence.\n62\t- Suggested fix: Cap the no-delimiter fallback (e.g. first line, or first N chars) or emit the empty-description warning when no sentence boundary is found.\n63\t\n64\t## N-003 — Unreachable symlink-directory branch in the conformance walk\n65\t\n66\t- File: internal/okf/okf.go\n67\t- Line: 177\n68\t- Issue: In `Check` (and identically in `loadConcepts`), `if entry.Type()\u0026fs.ModeSymlink != 0 { if entry.IsDir() { return filepath.SkipDir } ... }` — for a symlink, `fs.DirEntry.IsDir()` is always false (it reflects `Type().IsDir()`), and `filepath.WalkDir` never descends into symlinked directories anyway, so the inner `SkipDir` branch is dead.\n69\t- Suggested fix: Drop the `entry.IsDir()` sub-branch for symlinks (always `return nil`), or add a comment noting it is defensive.\n70\t\n71\t# Evidence\n72\t\n73\t- Read in full: PRD (`_prd.md`), TechSpec (`_techspec.md`), ADR-001..006, codex plan `20260627-220335-okf-dual-mode.md`; project rules CLAUDE.md, AGENTS.md, CONTRIBUTING.md.\n74\t- Read in full: `internal/okf/okf.go`, `internal/okf/okf_test.go`, `internal/okf/official_integration_test.go`, `internal/cli/promote.go`, `internal/cli/okf.go`, `internal/cli/okf_test.go`, `internal/cli/okf_integration_test.go`, `internal/cli/topic.go` (diff), `internal/topic/topic.go` (diff + on-disk), `internal/vault/pathutils.go`, `internal/frontmatter/frontmatter.go`, `internal/models/kb_models.go`; reviewed the raw patch hunks for `models.go`, `config.go`, `config_test.go`, `generate.go`, `render.go`, `render_wiki.go`, `writer.go`, `topic_test.go`, `pathutils_test.go`, and the `okf-claude-template.md` asset.\n75\t- `go build ./...` — Success.\n76\t- `go test ./internal/okf/... ./internal/cli/... ./internal/topic/... ./internal/vault/... ./internal/config/...` — all `ok`.\n77\t- `go test -tags integration ./internal/okf/... ./internal/cli/...` — all `ok` (official ga4/stackoverflow/crypto_bitcoin lenient conformance + end-to-end promote/check pass).\n78\t- `golangci-lint run` (v2.11.4) over the changed packages — `0 issues.`\n79\t- Inspected `internal/okf/testdata/official`: ga4 (17 md), stackoverflow (53 md), crypto_bitcoin (8 md); root and nested `index.md` files carry no frontmatter; every concept `.md` has a `type:`; no `log.md`/README/LICENSE inside the individual bundles; only `viz.html` non-md files — confirming the lenient checker classifies the corpus correctly.\n80\t- Verified zero-regression coverage: existing `internal/vault/render_test.go` asserts exact `[[demo-repo/...|...]]` wikilink output through `RenderDocuments`, so the wiki branch of the migration is byte-verified by the existing suite (ADR-004 mitigation satisfied for the wiki side only).\n81\t- Confirmed no logger usage in `internal/okf` / new CLI files, and no mention of the new commands in CLAUDE.md/AGENTS.md/README.\n82\t- Limitation: did not run the full `make verify` (mage/mise gating noted in the plan); evidence above used direct `go`/`golangci-lint` invocations on the affected packages.\n83\t\n84\t# Deferred Or Follow-Up\n85\t\n86\t- `--to \u003ctopic\u003e/\u003csubdir\u003e` promotion is intentionally deferred (codex plan Assumptions); flat-root concepts only for MVP — no action needed now, but track for Phase 2.\n87\t- Phase 2/3 surfaces (`kb okf export`, `kb okf ingest`, codebase→OKF) are out of scope and correctly absent.\n88\t- Consider consolidating the duplicated bundle-walk logic shared by `Check` and `loadConcepts` (entry filtering, exclusion set, posix-rel derivation) into one helper before Phase 2 grows the package.\n89\t\n```"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-013457-686051000-25106f92d0f1a262","time":"2026-06-28T01:35:24.619719Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01KGjtCddGmWYz4Xt76MwSpQ","blocks":[{"type":"tool_use","id":"toolu_01KGjtCddGmWYz4Xt76MwSpQ","name":"Read","title":"Read .compozy/tasks/okf-dual-mode/qa/impl-review-remediation-round1.md","input":{"file_path":"/Users/pedronauck/Dev/compozy/kb/.compozy/tasks/okf-dual-mode/qa/impl-review-remediation-round1.md"},"raw_input":{"file_path":"/Users/pedronauck/Dev/compozy/kb/.compozy/tasks/okf-dual-mode/qa/impl-review-remediation-round1.md"}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-013457-686051000-25106f92d0f1a262","time":"2026-06-28T01:35:24.624065Z","turn":1,"type":"session.update","update":{"kind":"tool_call_started","tool_call_id":"toolu_01X6bupnse6my54YkW8eDTsX","tool_call_state":"pending","blocks":[{"type":"tool_use","id":"toolu_01X6bupnse6my54YkW8eDTsX","name":"Read","title":"Read File","raw_input":{}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-013457-686051000-25106f92d0f1a262","time":"2026-06-28T01:35:24.626896Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01KGjtCddGmWYz4Xt76MwSpQ","usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-013457-686051000-25106f92d0f1a262","time":"2026-06-28T01:35:24.634704Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01KGjtCddGmWYz4Xt76MwSpQ","tool_call_state":"completed","blocks":[{"type":"tool_result","tool_use_id":"toolu_01KGjtCddGmWYz4Xt76MwSpQ","content":"```\n1\t# Implementation Peer Review Round 1 Remediation\n2\t\n3\t## Incorporated\n4\t\n5\t- B-001: Added `TestRenderDocumentsUseOKFMarkdownLinkSyntax` in `internal/vault/render_test.go` to exercise `RenderDocuments` with `TopicModeOKF`, assert relative Markdown links, and assert wikilinks are not emitted.\n6\t- N-001: Removed unused exported `WriteMetadataFileWithMode`.\n7\t- N-002: Changed promotion description fallback to require an actual sentence boundary, with a regression proving no-punctuation bodies emit the existing warning and an empty description.\n8\t- N-003: Removed unreachable symlink-directory branches in the OKF bundle walkers.\n9\t- R-002: Updated `README.md`, `AGENTS.md`, and `CLAUDE.md` with `topic new --mode`, `kb promote`, `kb okf check`, and `[okf].types`.\n10\t\n11\t## Deferred\n12\t\n13\t- R-001: Structured logging remains deferred because there is no established package-level logger convention in the touched packages and the review classified it as a non-blocking risk.\n14\t- R-003: Direct external-bundle CLI checking remains deferred; the MVP CLI operates on kb topics while the underlying `okf.Check` supports arbitrary bundle paths.\n15\t\n16\t## Verification\n17\t\n18\t- `rtk go test ./internal/okf ./internal/topic ./internal/vault ./internal/cli`: passed, 260 tests.\n19\t- `rtk go test -tags integration ./internal/okf ./internal/cli`: passed, 181 tests.\n20\t- `rtk make verify MAGE=`: passed, 860 tests, 1 expected skip, zero lint issues, build, and boundaries green.\n21\t\n```"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-013457-686051000-25106f92d0f1a262","time":"2026-06-28T01:35:24.917328Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01X6bupnse6my54YkW8eDTsX","blocks":[{"type":"tool_use","id":"toolu_01X6bupnse6my54YkW8eDTsX","name":"Read","title":"Read .compozy/tasks/okf-dual-mode/adrs/adr-004.md","input":{"file_path":"/Users/pedronauck/Dev/compozy/kb/.compozy/tasks/okf-dual-mode/adrs/adr-004.md"},"raw_input":{"file_path":"/Users/pedronauck/Dev/compozy/kb/.compozy/tasks/okf-dual-mode/adrs/adr-004.md"}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-013457-686051000-25106f92d0f1a262","time":"2026-06-28T01:35:24.921688Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01X6bupnse6my54YkW8eDTsX","usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-013457-686051000-25106f92d0f1a262","time":"2026-06-28T01:35:24.929128Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01X6bupnse6my54YkW8eDTsX","tool_call_state":"completed","blocks":[{"type":"tool_result","tool_use_id":"toolu_01X6bupnse6my54YkW8eDTsX","content":"```\n1\t# ADR-004: A `LinkFormatter` abstraction and full migration of the link call sites\n2\t\n3\t## Status\n4\t\n5\tAccepted\n6\t\n7\t## Date\n8\t\n9\t2026-06-27\n10\t\n11\t## Context\n12\t\n13\tLink emission is hardcoded to Obsidian `[[wikilinks]]`:\n14\t\n15\t- `internal/vault/pathutils.go:244` `ToTopicWikiLink(topicSlug, documentPath,\n16\t label)` → `[[slug/path|label]]`, with a thin wrapper `toSourceWikiLink(topic,\n17\t relativePath, label)` at `render.go:186`.\n18\t- These are called from **~43 sites**, all in `internal/vault/render.go` and\n19\t `render_wiki.go` — the **codebase→wiki** render pipeline.\n20\t- There is **no `Renderer`/`Writer` struct** in `internal/vault`; render functions\n21\t receive `topic models.TopicMetadata` directly, so there is no constructor to\n22\t inject a formatter into.\n23\t\n24\tOKF requires standard markdown links instead of wikilinks (ADR-002: relative,\n25\tGitHub-safe). The question is how the link format becomes mode-aware, and how much\n26\tof the refactor lands in the MVP. The user chose to introduce the full abstraction\n27\tand migrate all call sites in the MVP, rather than defer the render-pipeline\n28\trefactor to Phase 3.\n29\t\n30\tA key technical subtlety: an OKF **relative** link needs the **source document's\n31\tdirectory** to compute the path to the target. The current call sites pass only\n32\tthe target path and a label.\n33\t\n34\t## Decision\n35\t\n36\t**1. Define a `LinkFormatter` interface in `internal/vault`** (rendering's home;\n37\tkeeps the `okf`→`vault` dependency one-way and cycle-free):\n38\t\n39\t```go\n40\t// LinkFormatter renders a link from the document being written (fromDir,\n41\t// bundle-relative) to a target document, with an optional label.\n42\ttype LinkFormatter interface {\n43\t Link(fromDir, targetPath, label string) string\n44\t}\n45\t```\n46\t\n47\t- `WikiLinkFormatter` — ignores `fromDir`; emits `[[slug/target|label]]` exactly\n48\t as `ToTopicWikiLink` does today (`ToTopicWikiLink` becomes its implementation).\n49\t- `OKFLinkFormatter` — emits a relative, GitHub-safe markdown link\n50\t (`[label](relpath)`), computing `relpath` from `fromDir` to `targetPath`.\n51\t\n52\t**2. Resolve the formatter from `topic.Mode`** via\n53\t`vault.LinkFormatterFor(topic) LinkFormatter`, and route every call site through a\n54\tsingle chokepoint helper `linkFor(topic, fromDir, targetPath, label)`. The direct\n55\t`ToTopicWikiLink(topic.Slug, …)` calls in `render_wiki.go` are converted to use this\n56\thelper too. (Verified call-site spread: ~7 sites in `render.go` plus ~34 matching\n57\tlines in `render_wiki.go`; treat \"~43 sites\" as the working estimate.)\n58\t\n59\t**3. Scope the MVP correctly.** The 43 render sites belong to the codebase→wiki\n60\tpipeline, which produces **wiki-mode** topics; codebase→OKF is Phase 3. Therefore\n61\tin the MVP these sites continue to resolve `WikiLinkFormatter` (mode = wiki) and\n62\temit byte-identical output. The **active** MVP consumer of `OKFLinkFormatter` is\n63\t`promote`'s body transform (which knows the concept's own directory) and\n64\t`index.md` generation. The OKF branch at the render sites is wired but dormant\n65\tuntil Phase 3.\n66\t\n67\t## Alternatives Considered\n68\t\n69\t### Alternative 1: Defer the render-site refactor; body-transform only in MVP\n70\t\n71\t- **Description**: MVP does only `[[wikilink]]`→markdown in `promote` body text and\n72\t index generation; the 43 sites are untouched until Phase 3.\n73\t- **Pros**: Smallest MVP; least risk.\n74\t- **Cons**: The abstraction lands later; two passes over the render code.\n75\t- **Why rejected**: The user chose to do the foundational abstraction now.\n76\t\n77\t### Alternative 2: Add a `fromDir`/formatter argument to each call site signature\n78\t\n79\t- **Description**: Thread source context explicitly through 43 signatures.\n80\t- **Pros**: Fully explicit.\n81\t- **Cons**: Churns 43 signatures for no MVP-exercised benefit.\n82\t- **Why rejected**: The chokepoint helper + `topic`-resolved formatter achieves the\n83\t same with far less churn.\n84\t\n85\t### Alternative 3: Package-global formatter strategy\n86\t\n87\t- **Description**: A mutable package-level formatter set per run.\n88\t- **Pros**: No threading.\n89\t- **Cons**: Not concurrency-safe; hidden global state; hard to test.\n90\t- **Why rejected**: Violates testability.\n91\t\n92\t## Consequences\n93\t\n94\t### Positive\n95\t\n96\t- One mode-aware seam for all link emission; Phase 3 only flips render topics to\n97\t OKF mode and supplies `fromDir`.\n98\t- `promote` and index generation reuse the same formatter.\n99\t\n100\t### Negative\n101\t\n102\t- A large mechanical refactor whose OKF branch is not exercised at render sites in\n103\t the MVP.\n104\t\n105\t### Risks\n106\t\n107\t- **Silent regression** in wiki output during the refactor → mitigate with golden\n108\t tests asserting byte-identical wiki render output before/after, and keep the\n109\t existing `vault` test suite green.\n110\t- **Relative-path edge cases** (siblings, parents, root) in `OKFLinkFormatter` →\n111\t cover with table-driven unit tests including `fromDir == targetDir` and nested\n112\t cases.\n113\t- **Dormant OKF render branch ships as dead code in the MVP** (N-001) → add one\n114\t render-site-in-`okf`-mode smoke test so the wired branch and its `fromDir`\n115\t plumbing are exercised, keeping the user's \"migrate now\" decision without shipping\n116\t untested code.\n117\t\n118\t## Implementation Notes\n119\t\n120\t`OKFLinkFormatter.Link` uses `filepath.Rel(fromDir, targetPath)` semantics on\n121\tforward-slash bundle paths, never emitting a leading `/` (GitHub-safe, per\n122\tADR-002). `WikiLinkFormatter` preserves `StripMarkdownExtension` behavior. The\n123\tformatter is selected once per document render from `topic.Mode`.\n124\t\n125\t## References\n126\t\n127\t- ADR-002 (relative links), ADR-003 (okf package boundary)\n128\t- `internal/vault/pathutils.go:244`, `render.go:186`, `render_wiki.go`\n129\t- TechSpec: `../_techspec.md`\n130\t\n```"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-013457-686051000-25106f92d0f1a262","time":"2026-06-28T01:36:32.877834Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":""}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-013457-686051000-25106f92d0f1a262","time":"2026-06-28T01:36:32.878212Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":"Now let me verify the current on-disk state of the key changed files, since the diff can hide sur"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-013457-686051000-25106f92d0f1a262","time":"2026-06-28T01:36:33.001128Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":"rounding context. Let me read the full implementation files and remaining ADRs/plan."}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-013457-686051000-25106f92d0f1a262","time":"2026-06-28T01:36:33.459736Z","turn":1,"type":"session.update","update":{"kind":"tool_call_started","tool_call_id":"toolu_01Wu3DQfqJFGVjNcRQqW8Urv","tool_call_state":"pending","blocks":[{"type":"tool_use","id":"toolu_01Wu3DQfqJFGVjNcRQqW8Urv","name":"Read","title":"Read File","raw_input":{}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-013457-686051000-25106f92d0f1a262","time":"2026-06-28T01:36:34.514276Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01Wu3DQfqJFGVjNcRQqW8Urv","blocks":[{"type":"tool_use","id":"toolu_01Wu3DQfqJFGVjNcRQqW8Urv","name":"Read","title":"Read internal/okf/okf.go","input":{"file_path":"/Users/pedronauck/Dev/compozy/kb/internal/okf/okf.go"},"raw_input":{"file_path":"/Users/pedronauck/Dev/compozy/kb/internal/okf/okf.go"}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-013457-686051000-25106f92d0f1a262","time":"2026-06-28T01:36:34.518365Z","turn":1,"type":"session.update","update":{"kind":"tool_call_started","tool_call_id":"toolu_01Y39VNUoQKhSwPtAfvf98Nr","tool_call_state":"pending","blocks":[{"type":"tool_use","id":"toolu_01Y39VNUoQKhSwPtAfvf98Nr","name":"Read","title":"Read File","raw_input":{}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-013457-686051000-25106f92d0f1a262","time":"2026-06-28T01:36:34.522585Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01Wu3DQfqJFGVjNcRQqW8Urv","usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-013457-686051000-25106f92d0f1a262","time":"2026-06-28T01:36:34.536382Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01Wu3DQfqJFGVjNcRQqW8Urv","tool_call_state":"completed","blocks":[{"type":"tool_result","tool_use_id":"toolu_01Wu3DQfqJFGVjNcRQqW8Urv","content":"```\n1\t// Package okf implements Open Knowledge Format promotion and conformance checks.\n2\tpackage okf\n3\t\n4\timport (\n5\t\t\"context\"\n6\t\t\"errors\"\n7\t\t\"fmt\"\n8\t\t\"io/fs\"\n9\t\t\"os\"\n10\t\t\"path\"\n11\t\t\"path/filepath\"\n12\t\t\"regexp\"\n13\t\t\"sort\"\n14\t\t\"strings\"\n15\t\t\"time\"\n16\t\n17\t\t\"github.com/compozy/kb/internal/frontmatter\"\n18\t\t\"github.com/compozy/kb/internal/models\"\n19\t\t\"github.com/compozy/kb/internal/vault\"\n20\t)\n21\t\n22\tconst (\n23\t\tOKFVersion = \"0.1\"\n24\t)\n25\t\n26\tvar (\n27\t\twikilinkPattern = regexp.MustCompile(`\\[\\[([^\\[\\]]+)\\]\\]`)\n28\t\tnonSentenceSpace = regexp.MustCompile(`\\s+`)\n29\t\tmarkdownLinkPattern = regexp.MustCompile(`\\[[^\\]]+\\]\\([^)]+\\)`)\n30\t\tmarkdownSyntaxRunes = strings.NewReplacer(\"#\", \"\", \"*\", \"\", \"`\", \"\", \"_\", \"\", \"\u003e\", \"\", \"|\", \" \")\n31\t\terrTargetNotOKFTopic = errors.New(\"target topic must use mode okf\")\n32\t)\n33\t\n34\t// PromoteInput carries the data needed to promote one wiki document into an OKF bundle.\n35\ttype PromoteInput struct {\n36\t\tSourceDocPath string\n37\t\tVaultPath string\n38\t\tTargetTopic models.TopicInfo\n39\t\tType string\n40\t\tDescription string\n41\t\tTypes []string\n42\t\tClock func() time.Time\n43\t}\n44\t\n45\t// ConceptResult reports the concept written by Promote.\n46\ttype ConceptResult struct {\n47\t\tWrittenPath string `json:\"writtenPath\"`\n48\t\tType string `json:\"type\"`\n49\t\tLinksRewritten int `json:\"linksRewritten\"`\n50\t\tUnresolvedLinks []string `json:\"unresolvedLinks\"`\n51\t\tWarnings []string `json:\"warnings,omitempty\"`\n52\t}\n53\t\n54\t// CheckOptions configures OKF conformance checking.\n55\ttype CheckOptions struct {\n56\t\tTypes []string\n57\t\tStrict bool\n58\t}\n59\t\n60\t// Promote performs a mechanical, non-destructive wiki-to-OKF conversion.\n61\tfunc Promote(ctx context.Context, input PromoteInput) (ConceptResult, error) {\n62\t\tif ctx == nil {\n63\t\t\tctx = context.Background()\n64\t\t}\n65\t\tif err := ctx.Err(); err != nil {\n66\t\t\treturn ConceptResult{}, fmt.Errorf(\"promote: %w\", err)\n67\t\t}\n68\t\tif input.TargetTopic.Mode != models.TopicModeOKF {\n69\t\t\treturn ConceptResult{}, fmt.Errorf(\"promote: %w: %s\", errTargetNotOKFTopic, input.TargetTopic.Slug)\n70\t\t}\n71\t\tconceptType := strings.TrimSpace(input.Type)\n72\t\tif conceptType == \"\" {\n73\t\t\treturn ConceptResult{}, fmt.Errorf(\"promote: --type is required\")\n74\t\t}\n75\t\tif strings.TrimSpace(input.TargetTopic.RootPath) == \"\" {\n76\t\t\treturn ConceptResult{}, fmt.Errorf(\"promote: target topic root path is required\")\n77\t\t}\n78\t\n79\t\tsourcePath, sourceTopicRoot, sourceTopicSlug, sourceRelativePath, err := resolveSourceDocument(input)\n80\t\tif err != nil {\n81\t\t\treturn ConceptResult{}, fmt.Errorf(\"promote: %w\", err)\n82\t\t}\n83\t\t_ = sourceTopicRoot\n84\t\n85\t\tsourceBytes, err := os.ReadFile(sourcePath)\n86\t\tif err != nil {\n87\t\t\treturn ConceptResult{}, fmt.Errorf(\"promote: read source %q: %w\", sourcePath, err)\n88\t\t}\n89\t\tsourceValues, body, err := frontmatter.Parse(string(sourceBytes))\n90\t\tif err != nil {\n91\t\t\treturn ConceptResult{}, fmt.Errorf(\"promote: parse source frontmatter: %w\", err)\n92\t\t}\n93\t\n94\t\tkey := conceptKey(sourceRelativePath)\n95\t\twrittenPath, absoluteTargetPath, err := allocateConceptPath(input.TargetTopic.RootPath, key)\n96\t\tif err != nil {\n97\t\t\treturn ConceptResult{}, fmt.Errorf(\"promote: allocate concept path: %w\", err)\n98\t\t}\n99\t\n100\t\twarnings := typeWarnings(conceptType, input.Types)\n101\t\tdescription, descriptionWarnings := resolveDescription(input.Description, body)\n102\t\twarnings = append(warnings, descriptionWarnings...)\n103\t\ttitle := strings.TrimSpace(frontmatter.GetString(sourceValues, \"title\"))\n104\t\tif title == \"\" {\n105\t\t\ttitle = vault.HumanizeSlug(key)\n106\t\t}\n107\t\tclock := input.Clock\n108\t\tif clock == nil {\n109\t\t\tclock = func() time.Time { return time.Now().UTC() }\n110\t\t}\n111\t\tnow := clock().UTC()\n112\t\n113\t\ttransformedBody, rewriteCount, unresolvedLinks := transformWikiLinks(body, sourceTopicSlug, input.TargetTopic.RootPath, path.Dir(writtenPath), writtenPath)\n114\t\tvalues := map[string]any{\n115\t\t\t\"description\": description,\n116\t\t\t\"timestamp\": now.Format(time.RFC3339),\n117\t\t\t\"title\": title,\n118\t\t\t\"type\": conceptType,\n119\t\t}\n120\t\tif tags := frontmatter.GetStringSlice(sourceValues, \"tags\"); len(tags) \u003e 0 {\n121\t\t\tvalues[\"tags\"] = tags\n122\t\t}\n123\t\n124\t\tdocument, err := frontmatter.Generate(values, transformedBody)\n125\t\tif err != nil {\n126\t\t\treturn ConceptResult{}, fmt.Errorf(\"promote: generate concept frontmatter: %w\", err)\n127\t\t}\n128\t\tif err := os.WriteFile(absoluteTargetPath, []byte(document), 0o644); err != nil {\n129\t\t\treturn ConceptResult{}, fmt.Errorf(\"promote: write concept %q: %w\", absoluteTargetPath, err)\n130\t\t}\n131\t\n132\t\tif err := RegenerateIndex(input.TargetTopic.RootPath); err != nil {\n133\t\t\treturn ConceptResult{}, fmt.Errorf(\"promote: regenerate index: %w\", err)\n134\t\t}\n135\t\tif err := InsertLogEntry(input.TargetTopic.RootPath, now, title, writtenPath, sourceRelativePath); err != nil {\n136\t\t\treturn ConceptResult{}, fmt.Errorf(\"promote: update log: %w\", err)\n137\t\t}\n138\t\n139\t\treturn ConceptResult{\n140\t\t\tWrittenPath: writtenPath,\n141\t\t\tType: conceptType,\n142\t\t\tLinksRewritten: rewriteCount,\n143\t\t\tUnresolvedLinks: unresolvedLinks,\n144\t\t\tWarnings: warnings,\n145\t\t}, nil\n146\t}\n147\t\n148\t// Check validates an OKF bundle and returns diagnostics.\n149\tfunc Check(ctx context.Context, bundlePath string, options CheckOptions) ([]models.LintIssue, error) {\n150\t\tif ctx == nil {\n151\t\t\tctx = context.Background()\n152\t\t}\n153\t\tcleanBundlePath := strings.TrimSpace(bundlePath)\n154\t\tif cleanBundlePath == \"\" {\n155\t\t\treturn nil, fmt.Errorf(\"okf check: bundle path is required\")\n156\t\t}\n157\t\tinfo, err := os.Stat(cleanBundlePath)\n158\t\tif err != nil {\n159\t\t\treturn nil, fmt.Errorf(\"okf check: stat bundle path %q: %w\", cleanBundlePath, err)\n160\t\t}\n161\t\tif !info.IsDir() {\n162\t\t\treturn nil, fmt.Errorf(\"okf check: bundle path must be a directory: %s\", cleanBundlePath)\n163\t\t}\n164\t\n165\t\ttypeSet := normalizeTypeSet(options.Types)\n166\t\tissues := make([]models.LintIssue, 0)\n167\t\terr = filepath.WalkDir(cleanBundlePath, func(currentPath string, entry fs.DirEntry, walkErr error) error {\n168\t\t\tif walkErr != nil {\n169\t\t\t\treturn walkErr\n170\t\t\t}\n171\t\t\tif err := ctx.Err(); err != nil {\n172\t\t\t\treturn err\n173\t\t\t}\n174\t\t\tif currentPath == cleanBundlePath {\n175\t\t\t\treturn nil\n176\t\t\t}\n177\t\t\tif entry.Type()\u0026fs.ModeSymlink != 0 {\n178\t\t\t\treturn nil\n179\t\t\t}\n180\t\t\tif entry.IsDir() {\n181\t\t\t\tif strings.HasPrefix(entry.Name(), \".\") {\n182\t\t\t\t\treturn filepath.SkipDir\n183\t\t\t\t}\n184\t\t\t\treturn nil\n185\t\t\t}\n186\t\t\tif !strings.EqualFold(filepath.Ext(entry.Name()), \".md\") {\n187\t\t\t\treturn nil\n188\t\t\t}\n189\t\n190\t\t\trelativePath, err := filepath.Rel(cleanBundlePath, currentPath)\n191\t\t\tif err != nil {\n192\t\t\t\treturn fmt.Errorf(\"derive relative path for %q: %w\", currentPath, err)\n193\t\t\t}\n194\t\t\trelativePath = vault.ToPosixPath(relativePath)\n195\t\t\tswitch {\n196\t\t\tcase isIndexFile(relativePath):\n197\t\t\t\tissues = append(issues, checkIndexFile(cleanBundlePath, currentPath, relativePath, options.Strict)...)\n198\t\t\tcase isLogFile(relativePath):\n199\t\t\t\tissues = append(issues, checkLogFile(currentPath, relativePath, options.Strict)...)\n200\t\t\tcase isExcludedMarkdown(entry.Name()):\n201\t\t\t\treturn nil\n202\t\t\tdefault:\n203\t\t\t\tissues = append(issues, checkConceptFile(currentPath, relativePath, typeSet, options.Strict)...)\n204\t\t\t}\n205\t\t\treturn nil\n206\t\t})\n207\t\tif err != nil {\n208\t\t\treturn nil, fmt.Errorf(\"okf check: %w\", err)\n209\t\t}\n210\t\n211\t\tsortIssues(issues)\n212\t\treturn issues, nil\n213\t}\n214\t\n215\t// Columns returns the stable output column order for OKF diagnostics.\n216\tfunc Columns() []string {\n217\t\treturn []string{\"severity\", \"kind\", \"filePath\", \"target\", \"message\"}\n218\t}\n219\t\n220\t// Rows converts issues into formatter rows.\n221\tfunc Rows(issues []models.LintIssue) []map[string]any {\n222\t\trows := make([]map[string]any, 0, len(issues))\n223\t\tfor _, issue := range issues {\n224\t\t\trows = append(rows, map[string]any{\n225\t\t\t\t\"severity\": issue.Severity,\n226\t\t\t\t\"kind\": issue.Kind,\n227\t\t\t\t\"filePath\": issue.FilePath,\n228\t\t\t\t\"target\": issue.Target,\n229\t\t\t\t\"message\": issue.Message,\n230\t\t\t})\n231\t\t}\n232\t\treturn rows\n233\t}\n234\t\n235\t// HasErrors reports whether any diagnostic should fail the command.\n236\tfunc HasErrors(issues []models.LintIssue) bool {\n237\t\tfor _, issue := range issues {\n238\t\t\tif issue.Severity == models.SeverityError {\n239\t\t\t\treturn true\n240\t\t\t}\n241\t\t}\n242\t\treturn false\n243\t}\n244\t\n245\t// RegenerateIndex rewrites the root OKF index from concept frontmatter.\n246\tfunc RegenerateIndex(bundlePath string) error {\n247\t\tconcepts, okfVersion, err := loadConcepts(bundlePath)\n248\t\tif err != nil {\n249\t\t\treturn err\n250\t\t}\n251\t\tsort.Slice(concepts, func(i, j int) bool {\n252\t\t\tif concepts[i].Type != concepts[j].Type {\n253\t\t\t\treturn concepts[i].Type \u003c concepts[j].Type\n254\t\t\t}\n255\t\t\tif concepts[i].Title != concepts[j].Title {\n256\t\t\t\treturn concepts[i].Title \u003c concepts[j].Title\n257\t\t\t}\n258\t\t\treturn concepts[i].RelativePath \u003c concepts[j].RelativePath\n259\t\t})\n260\t\n261\t\tvar body strings.Builder\n262\t\tbody.WriteString(\"# OKF Bundle Index\\n\")\n263\t\tcurrentType := \"\"\n264\t\tfor _, concept := range concepts {\n265\t\t\tif concept.Type != currentType {\n266\t\t\t\tcurrentType = concept.Type\n267\t\t\t\tbody.WriteString(\"\\n## \")\n268\t\t\t\tbody.WriteString(currentType)\n269\t\t\t\tbody.WriteString(\"\\n\\n\")\n270\t\t\t}\n271\t\t\tbody.WriteString(\"* \")\n272\t\t\tbody.WriteString(vault.OKFLinkFormatter{}.Link(\"\", concept.RelativePath, concept.Title))\n273\t\t\tif concept.Description != \"\" {\n274\t\t\t\tbody.WriteString(\" - \")\n275\t\t\t\tbody.WriteString(concept.Description)\n276\t\t\t}\n277\t\t\tbody.WriteString(\"\\n\")\n278\t\t}\n279\t\n280\t\tvalues := map[string]any{\"okf_version\": firstNonEmpty(okfVersion, OKFVersion)}\n281\t\tcontent, err := frontmatter.Generate(values, body.String())\n282\t\tif err != nil {\n283\t\t\treturn fmt.Errorf(\"generate index frontmatter: %w\", err)\n284\t\t}\n285\t\tif err := os.WriteFile(filepath.Join(bundlePath, \"index.md\"), []byte(content), 0o644); err != nil {\n286\t\t\treturn fmt.Errorf(\"write index.md: %w\", err)\n287\t\t}\n288\t\treturn nil\n289\t}\n290\t\n291\t// InsertLogEntry inserts a newest-first OKF log entry at the bundle root.\n292\tfunc InsertLogEntry(bundlePath string, when time.Time, title, conceptPath, sourcePath string) error {\n293\t\tlogPath := filepath.Join(bundlePath, \"log.md\")\n294\t\tcontent, err := os.ReadFile(logPath)\n295\t\tif errors.Is(err, os.ErrNotExist) {\n296\t\t\tcontent = []byte(\"# Directory Update Log\\n\")\n297\t\t} else if err != nil {\n298\t\t\treturn fmt.Errorf(\"read log.md: %w\", err)\n299\t\t}\n300\t\tentry := fmt.Sprintf(\n301\t\t\t\"* **Creation**: Promoted %s from `%s`.\",\n302\t\t\tvault.OKFLinkFormatter{}.Link(\"\", conceptPath, strings.TrimSpace(title)),\n303\t\t\tsourcePath,\n304\t\t)\n305\t\tnext := insertLogEntry(string(content), when.UTC().Format(frontmatter.DateLayout), entry)\n306\t\tif err := os.WriteFile(logPath, []byte(next), 0o644); err != nil {\n307\t\t\treturn fmt.Errorf(\"write log.md: %w\", err)\n308\t\t}\n309\t\treturn nil\n310\t}\n311\t\n312\ttype conceptInfo struct {\n313\t\tRelativePath string\n314\t\tType string\n315\t\tTitle string\n316\t\tDescription string\n317\t}\n318\t\n319\tfunc resolveSourceDocument(input PromoteInput) (absolutePath, topicRoot, topicSlug, topicRelativePath string, err error) {\n320\t\tcandidates := make([]string, 0, 3)\n321\t\tsource := strings.TrimSpace(input.SourceDocPath)\n322\t\tif source == \"\" {\n323\t\t\treturn \"\", \"\", \"\", \"\", fmt.Errorf(\"source document path is required\")\n324\t\t}\n325\t\tif filepath.IsAbs(source) {\n326\t\t\tcandidates = append(candidates, source)\n327\t\t} else {\n328\t\t\tcandidates = append(candidates, source)\n329\t\t\tif strings.TrimSpace(input.VaultPath) != \"\" {\n330\t\t\t\tcandidates = append(candidates, filepath.Join(input.VaultPath, filepath.FromSlash(source)))\n331\t\t\t}\n332\t\t}\n333\t\tfor _, candidate := range candidates {\n334\t\t\tinfo, statErr := os.Stat(candidate)\n335\t\t\tif statErr != nil {\n336\t\t\t\tcontinue\n337\t\t\t}\n338\t\t\tif info.IsDir() {\n339\t\t\t\treturn \"\", \"\", \"\", \"\", fmt.Errorf(\"source document %q must be a file\", candidate)\n340\t\t\t}\n341\t\t\tabsolutePath, err := filepath.Abs(candidate)\n342\t\t\tif err != nil {\n343\t\t\t\treturn \"\", \"\", \"\", \"\", fmt.Errorf(\"resolve source path %q: %w\", candidate, err)\n344\t\t\t}\n345\t\t\ttopicRoot, topicSlug = discoverTopicRoot(input.VaultPath, absolutePath)\n346\t\t\tif topicRoot != \"\" {\n347\t\t\t\trelative, relErr := filepath.Rel(topicRoot, absolutePath)\n348\t\t\t\tif relErr != nil {\n349\t\t\t\t\treturn \"\", \"\", \"\", \"\", fmt.Errorf(\"derive source topic path: %w\", relErr)\n350\t\t\t\t}\n351\t\t\t\treturn absolutePath, topicRoot, topicSlug, vault.ToPosixPath(relative), nil\n352\t\t\t}\n353\t\t\treturn absolutePath, \"\", \"\", vault.ToPosixPath(filepath.Base(absolutePath)), nil\n354\t\t}\n355\t\treturn \"\", \"\", \"\", \"\", fmt.Errorf(\"source document not found: %s\", source)\n356\t}\n357\t\n358\tfunc discoverTopicRoot(vaultPath, sourcePath string) (string, string) {\n359\t\tcleanVault := strings.TrimSpace(vaultPath)\n360\t\tcurrent := filepath.Dir(sourcePath)\n361\t\tfor {\n362\t\t\tif cleanVault != \"\" \u0026\u0026 !vault.IsPathInside(cleanVault, current) {\n363\t\t\t\treturn \"\", \"\"\n364\t\t\t}\n365\t\t\tif isRegularFile(filepath.Join(current, \"CLAUDE.md\")) {\n366\t\t\t\tif cleanVault == \"\" {\n367\t\t\t\t\treturn current, filepath.Base(current)\n368\t\t\t\t}\n369\t\t\t\trelative, err := filepath.Rel(cleanVault, current)\n370\t\t\t\tif err != nil {\n371\t\t\t\t\treturn current, filepath.Base(current)\n372\t\t\t\t}\n373\t\t\t\treturn current, vault.ToPosixPath(relative)\n374\t\t\t}\n375\t\t\tparent := filepath.Dir(current)\n376\t\t\tif parent == current {\n377\t\t\t\tbreak\n378\t\t\t}\n379\t\t\tcurrent = parent\n380\t\t}\n381\t\treturn \"\", \"\"\n382\t}\n383\t\n384\tfunc isRegularFile(filePath string) bool {\n385\t\tinfo, err := os.Stat(filePath)\n386\t\treturn err == nil \u0026\u0026 !info.IsDir()\n387\t}\n388\t\n389\tfunc allocateConceptPath(bundleRoot, key string) (string, string, error) {\n390\t\tfor suffix := 1; ; suffix++ {\n391\t\t\tname := key\n392\t\t\tif suffix \u003e 1 {\n393\t\t\t\tname = fmt.Sprintf(\"%s-%d\", key, suffix)\n394\t\t\t}\n395\t\t\trelativePath := name + \".md\"\n396\t\t\tabsolutePath := filepath.Join(bundleRoot, filepath.FromSlash(relativePath))\n397\t\t\tif _, err := os.Stat(absolutePath); errors.Is(err, os.ErrNotExist) {\n398\t\t\t\treturn relativePath, absolutePath, nil\n399\t\t\t} else if err != nil {\n400\t\t\t\treturn \"\", \"\", err\n401\t\t\t}\n402\t\t}\n403\t}\n404\t\n405\tfunc conceptKey(documentPath string) string {\n406\t\tbase := path.Base(vault.StripMarkdownExtension(vault.ToPosixPath(documentPath)))\n407\t\treturn vault.SlugifySegment(base)\n408\t}\n409\t\n410\tfunc typeWarnings(conceptType string, types []string) []string {\n411\t\ttypeSet := normalizeTypeSet(types)\n412\t\tif len(typeSet) == 0 {\n413\t\t\treturn nil\n414\t\t}\n415\t\tif _, ok := typeSet[conceptType]; ok {\n416\t\t\treturn nil\n417\t\t}\n418\t\treturn []string{fmt.Sprintf(\"type %q is outside the configured OKF vocabulary\", conceptType)}\n419\t}\n420\t\n421\tfunc normalizeTypeSet(types []string) map[string]struct{} {\n422\t\ttypeSet := make(map[string]struct{})\n423\t\tfor _, value := range types {\n424\t\t\tvalue = strings.TrimSpace(value)\n425\t\t\tif value == \"\" {\n426\t\t\t\tcontinue\n427\t\t\t}\n428\t\t\ttypeSet[value] = struct{}{}\n429\t\t}\n430\t\treturn typeSet\n431\t}\n432\t\n433\tfunc resolveDescription(flagValue, body string) (string, []string) {\n434\t\tif description := strings.TrimSpace(flagValue); description != \"\" {\n435\t\t\treturn description, nil\n436\t\t}\n437\t\tif sentence := firstBodySentence(body); sentence != \"\" {\n438\t\t\treturn sentence, nil\n439\t\t}\n440\t\treturn \"\", []string{\"description is empty because the source body has no sentence fallback\"}\n441\t}\n442\t\n443\tfunc firstBodySentence(body string) string {\n444\t\tnormalized := markdownSyntaxRunes.Replace(markdownLinkPattern.ReplaceAllString(body, \" \"))\n445\t\tnormalized = nonSentenceSpace.ReplaceAllString(strings.TrimSpace(normalized), \" \")\n446\t\tfor index := 0; index \u003c len(normalized); index++ {\n447\t\t\tswitch normalized[index] {\n448\t\t\tcase '.', '!', '?':\n449\t\t\t\tif index == len(normalized)-1 || normalized[index+1] == ' ' {\n450\t\t\t\t\treturn strings.TrimSpace(normalized[:index+1])\n451\t\t\t\t}\n452\t\t\t}\n453\t\t}\n454\t\treturn \"\"\n455\t}\n456\t\n457\tfunc transformWikiLinks(body, sourceTopicSlug, bundleRoot, fromDir, currentPath string) (string, int, []string) {\n458\t\tcount := 0\n459\t\tunresolved := make([]string, 0)\n460\t\tformatter := vault.OKFLinkFormatter{}\n461\t\ttransformed := wikilinkPattern.ReplaceAllStringFunc(body, func(match string) string {\n462\t\t\tinner := strings.TrimSuffix(strings.TrimPrefix(match, \"[[\"), \"]]\")\n463\t\t\ttarget, label := splitWikiLink(inner)\n464\t\t\ttargetPath := mappedTargetPath(target, sourceTopicSlug)\n465\t\t\tif strings.TrimSpace(label) == \"\" {\n466\t\t\t\tlabel = fallbackWikiLabel(target)\n467\t\t\t}\n468\t\t\tcount++\n469\t\t\tif stripFragment(targetPath) != stripFragment(currentPath) \u0026\u0026 !isRegularFile(filepath.Join(bundleRoot, filepath.FromSlash(stripFragment(targetPath)))) {\n470\t\t\t\tunresolved = append(unresolved, targetPath)\n471\t\t\t}\n472\t\t\treturn formatter.Link(fromDir, targetPath, label)\n473\t\t})\n474\t\tsort.Strings(unresolved)\n475\t\treturn transformed, count, dedupeStrings(unresolved)\n476\t}\n477\t\n478\tfunc splitWikiLink(inner string) (string, string) {\n479\t\tparts := strings.SplitN(inner, \"|\", 2)\n480\t\ttarget := strings.TrimSpace(parts[0])\n481\t\tif len(parts) == 1 {\n482\t\t\treturn target, \"\"\n483\t\t}\n484\t\treturn target, strings.TrimSpace(parts[1])\n485\t}\n486\t\n487\tfunc mappedTargetPath(target, sourceTopicSlug string) string {\n488\t\ttarget = vault.ToPosixPath(strings.TrimSpace(target))\n489\t\tfragment := \"\"\n490\t\tif before, after, found := strings.Cut(target, \"#\"); found {\n491\t\t\ttarget = before\n492\t\t\tfragment = \"#\" + after\n493\t\t}\n494\t\ttarget = strings.TrimPrefix(target, \"/\")\n495\t\tif sourceTopicSlug != \"\" {\n496\t\t\tprefix := strings.Trim(sourceTopicSlug, \"/\") + \"/\"\n497\t\t\ttarget = strings.TrimPrefix(target, prefix)\n498\t\t}\n499\t\treturn conceptKey(target) + \".md\" + fragment\n500\t}\n501\t\n502\tfunc stripFragment(target string) string {\n503\t\tif before, _, found := strings.Cut(target, \"#\"); found {\n504\t\t\treturn before\n505\t\t}\n506\t\treturn target\n507\t}\n508\t\n509\tfunc fallbackWikiLabel(target string) string {\n510\t\ttarget = stripFragment(target)\n511\t\treturn vault.HumanizeSlug(conceptKey(target))\n512\t}\n513\t\n514\tfunc dedupeStrings(values []string) []string {\n515\t\tif len(values) == 0 {\n516\t\t\treturn nil\n517\t\t}\n518\t\tdeduped := values[:0]\n519\t\tvar previous string\n520\t\tfor index, value := range values {\n521\t\t\tif index \u003e 0 \u0026\u0026 value == previous {\n522\t\t\t\tcontinue\n523\t\t\t}\n524\t\t\tdeduped = append(deduped, value)\n525\t\t\tprevious = value\n526\t\t}\n527\t\treturn append([]string(nil), deduped...)\n528\t}\n529\t\n530\tfunc loadConcepts(bundlePath string) ([]conceptInfo, string, error) {\n531\t\tokfVersion := OKFVersion\n532\t\tindexPath := filepath.Join(bundlePath, \"index.md\")\n533\t\tif content, err := os.ReadFile(indexPath); err == nil {\n534\t\t\tvalues, _, parseErr := frontmatter.Parse(string(content))\n535\t\t\tif parseErr != nil {\n536\t\t\t\treturn nil, \"\", fmt.Errorf(\"parse index.md: %w\", parseErr)\n537\t\t\t}\n538\t\t\tif version := strings.TrimSpace(frontmatter.GetString(values, \"okf_version\")); version != \"\" {\n539\t\t\t\tokfVersion = version\n540\t\t\t}\n541\t\t} else if !errors.Is(err, os.ErrNotExist) {\n542\t\t\treturn nil, \"\", fmt.Errorf(\"read index.md: %w\", err)\n543\t\t}\n544\t\n545\t\tconcepts := make([]conceptInfo, 0)\n546\t\terr := filepath.WalkDir(bundlePath, func(currentPath string, entry fs.DirEntry, walkErr error) error {\n547\t\t\tif walkErr != nil {\n548\t\t\t\treturn walkErr\n549\t\t\t}\n550\t\t\tif currentPath == bundlePath {\n551\t\t\t\treturn nil\n552\t\t\t}\n553\t\t\tif entry.Type()\u0026fs.ModeSymlink != 0 {\n554\t\t\t\treturn nil\n555\t\t\t}\n556\t\t\tif entry.IsDir() {\n557\t\t\t\tif strings.HasPrefix(entry.Name(), \".\") {\n558\t\t\t\t\treturn filepath.SkipDir\n559\t\t\t\t}\n560\t\t\t\treturn nil\n561\t\t\t}\n562\t\t\tif !strings.EqualFold(filepath.Ext(entry.Name()), \".md\") || isExcludedMarkdown(entry.Name()) {\n563\t\t\t\treturn nil\n564\t\t\t}\n565\t\t\trelativePath, err := filepath.Rel(bundlePath, currentPath)\n566\t\t\tif err != nil {\n567\t\t\t\treturn err\n568\t\t\t}\n569\t\t\trelativePath = vault.ToPosixPath(relativePath)\n570\t\t\tif isIndexFile(relativePath) || isLogFile(relativePath) {\n571\t\t\t\treturn nil\n572\t\t\t}\n573\t\t\tcontent, err := os.ReadFile(currentPath)\n574\t\t\tif err != nil {\n575\t\t\t\treturn err\n576\t\t\t}\n577\t\t\tvalues, _, err := frontmatter.Parse(string(content))\n578\t\t\tif err != nil || len(values) == 0 {\n579\t\t\t\treturn nil\n580\t\t\t}\n581\t\t\tconceptType := strings.TrimSpace(frontmatter.GetString(values, \"type\"))\n582\t\t\ttitle := strings.TrimSpace(frontmatter.GetString(values, \"title\"))\n583\t\t\tif title == \"\" {\n584\t\t\t\ttitle = vault.HumanizeSlug(conceptKey(relativePath))\n585\t\t\t}\n586\t\t\tconcepts = append(concepts, conceptInfo{\n587\t\t\t\tRelativePath: relativePath,\n588\t\t\t\tType: firstNonEmpty(conceptType, \"Concept\"),\n589\t\t\t\tTitle: title,\n590\t\t\t\tDescription: strings.TrimSpace(frontmatter.GetString(values, \"description\")),\n591\t\t\t})\n592\t\t\treturn nil\n593\t\t})\n594\t\tif err != nil {\n595\t\t\treturn nil, \"\", fmt.Errorf(\"scan concepts: %w\", err)\n596\t\t}\n597\t\treturn concepts, okfVersion, nil\n598\t}\n599\t\n600\tfunc checkConceptFile(filePath, relativePath string, typeSet map[string]struct{}, strict bool) []models.LintIssue {\n601\t\tcontent, err := os.ReadFile(filePath)\n602\t\tif err != nil {\n603\t\t\treturn []models.LintIssue{newIssue(models.SeverityError, relativePath, \"\", fmt.Sprintf(\"read concept: %v\", err))}\n604\t\t}\n605\t\tvalues, _, err := frontmatter.Parse(string(content))\n606\t\tif err != nil {\n607\t\t\treturn []models.LintIssue{newIssue(models.SeverityError, relativePath, \"\", fmt.Sprintf(\"frontmatter is invalid: %v\", err))}\n608\t\t}\n609\t\tif len(values) == 0 {\n610\t\t\treturn []models.LintIssue{newIssue(models.SeverityError, relativePath, \"type\", \"concept must start with YAML frontmatter\")}\n611\t\t}\n612\t\tissues := make([]models.LintIssue, 0)\n613\t\tconceptType := strings.TrimSpace(frontmatter.GetString(values, \"type\"))\n614\t\tif conceptType == \"\" {\n615\t\t\tissues = append(issues, newIssue(models.SeverityError, relativePath, \"type\", \"concept frontmatter must include non-empty type\"))\n616\t\t}\n617\t\tfor _, field := range []string{\"title\", \"description\", \"timestamp\"} {\n618\t\t\tif strings.TrimSpace(frontmatter.GetString(values, field)) == \"\" {\n619\t\t\t\tissues = append(issues, newIssue(warningSeverity(strict), relativePath, field, fmt.Sprintf(\"producer field %q is missing\", field)))\n620\t\t\t}\n621\t\t}\n622\t\tif len(typeSet) \u003e 0 \u0026\u0026 conceptType != \"\" {\n623\t\t\tif _, ok := typeSet[conceptType]; !ok {\n624\t\t\t\tissues = append(issues, newIssue(warningSeverity(strict), relativePath, \"type\", fmt.Sprintf(\"type %q is outside the configured OKF vocabulary\", conceptType)))\n625\t\t\t}\n626\t\t}\n627\t\treturn issues\n628\t}\n629\t\n630\tfunc checkIndexFile(bundlePath, filePath, relativePath string, strict bool) []models.LintIssue {\n631\t\tcontent, err := os.ReadFile(filePath)\n632\t\tif err != nil {\n633\t\t\treturn []models.LintIssue{newIssue(models.SeverityError, relativePath, \"\", fmt.Sprintf(\"read index: %v\", err))}\n634\t\t}\n635\t\tvalues, _, err := frontmatter.Parse(string(content))\n636\t\tif err != nil {\n637\t\t\treturn []models.LintIssue{newIssue(models.SeverityError, relativePath, \"\", fmt.Sprintf(\"index frontmatter is invalid: %v\", err))}\n638\t\t}\n639\t\tif len(values) == 0 {\n640\t\t\treturn nil\n641\t\t}\n642\t\tif relativePath != \"index.md\" {\n643\t\t\treturn []models.LintIssue{newIssue(models.SeverityError, relativePath, \"frontmatter\", \"only the bundle-root index.md may declare okf_version frontmatter\")}\n644\t\t}\n645\t\tfor key := range values {\n646\t\t\tif key != \"okf_version\" {\n647\t\t\t\treturn []models.LintIssue{newIssue(models.SeverityError, relativePath, key, \"root index.md frontmatter may only contain okf_version\")}\n648\t\t\t}\n649\t\t}\n650\t\t_ = bundlePath\n651\t\t_ = strict\n652\t\treturn nil\n653\t}\n654\t\n655\tfunc checkLogFile(filePath, relativePath string, strict bool) []models.LintIssue {\n656\t\tcontent, err := os.ReadFile(filePath)\n657\t\tif err != nil {\n658\t\t\treturn []models.LintIssue{newIssue(models.SeverityError, relativePath, \"\", fmt.Sprintf(\"read log: %v\", err))}\n659\t\t}\n660\t\tissues := make([]models.LintIssue, 0)\n661\t\tfor line := range strings.SplitSeq(string(content), \"\\n\") {\n662\t\t\ttrimmed := strings.TrimSpace(line)\n663\t\t\tif !strings.HasPrefix(trimmed, \"## \") {\n664\t\t\t\tcontinue\n665\t\t\t}\n666\t\t\tdateValue := strings.TrimSpace(strings.TrimPrefix(trimmed, \"## \"))\n667\t\t\tif _, err := time.Parse(frontmatter.DateLayout, dateValue); err != nil {\n668\t\t\t\tissues = append(issues, newIssue(models.SeverityError, relativePath, \"date\", fmt.Sprintf(\"log heading %q must use YYYY-MM-DD\", trimmed)))\n669\t\t\t}\n670\t\t}\n671\t\t_ = strict\n672\t\treturn issues\n673\t}\n674\t\n675\tfunc newIssue(severity models.DiagnosticSeverity, filePath, target, message string) models.LintIssue {\n676\t\treturn models.LintIssue{\n677\t\t\tKind: models.LintIssueKindFormat,\n678\t\t\tSeverity: severity,\n679\t\t\tFilePath: filePath,\n680\t\t\tTarget: target,\n681\t\t\tMessage: message,\n682\t\t}\n683\t}\n684\t\n685\tfunc warningSeverity(strict bool) models.DiagnosticSeverity {\n686\t\tif strict {\n687\t\t\treturn models.SeverityError\n688\t\t}\n689\t\treturn models.SeverityWarning\n690\t}\n691\t\n692\tfunc sortIssues(issues []models.LintIssue) {\n693\t\tsort.Slice(issues, func(i, j int) bool {\n694\t\t\tif issues[i].Severity != issues[j].Severity {\n695\t\t\t\treturn issues[i].Severity \u003c issues[j].Severity\n696\t\t\t}\n697\t\t\tif issues[i].FilePath != issues[j].FilePath {\n698\t\t\t\treturn issues[i].FilePath \u003c issues[j].FilePath\n699\t\t\t}\n700\t\t\tif issues[i].Target != issues[j].Target {\n701\t\t\t\treturn issues[i].Target \u003c issues[j].Target\n702\t\t\t}\n703\t\t\treturn issues[i].Message \u003c issues[j].Message\n704\t\t})\n705\t}\n706\t\n707\tfunc isIndexFile(relativePath string) bool {\n708\t\treturn strings.EqualFold(path.Base(vault.ToPosixPath(relativePath)), \"index.md\")\n709\t}\n710\t\n711\tfunc isLogFile(relativePath string) bool {\n712\t\treturn strings.EqualFold(path.Base(vault.ToPosixPath(relativePath)), \"log.md\")\n713\t}\n714\t\n715\tfunc isExcludedMarkdown(name string) bool {\n716\t\tlower := strings.ToLower(strings.TrimSpace(name))\n717\t\tswitch lower {\n718\t\tcase \"claude.md\", \"agents.md\", \"readme.md\", \"license.md\", \"notice.md\", \"attribution.md\":\n719\t\t\treturn true\n720\t\tdefault:\n721\t\t\treturn strings.HasPrefix(lower, \"license.\") || strings.HasPrefix(lower, \"notice.\") || strings.HasPrefix(lower, \"attribution.\")\n722\t\t}\n723\t}\n724\t\n725\tfunc insertLogEntry(content, dateHeading, entry string) string {\n726\t\tlines := strings.Split(strings.ReplaceAll(content, \"\\r\\n\", \"\\n\"), \"\\n\")\n727\t\tif len(lines) == 0 || strings.TrimSpace(lines[0]) == \"\" {\n728\t\t\tlines = []string{\"# Directory Update Log\"}\n729\t\t}\n730\t\theading := \"## \" + dateHeading\n731\t\tfor index, line := range lines {\n732\t\t\tif strings.TrimSpace(line) != heading {\n733\t\t\t\tcontinue\n734\t\t\t}\n735\t\t\tnext := append([]string{}, lines[:index+1]...)\n736\t\t\tnext = append(next, entry)\n737\t\t\tnext = append(next, lines[index+1:]...)\n738\t\t\treturn strings.TrimRight(strings.Join(next, \"\\n\"), \"\\n\") + \"\\n\"\n739\t\t}\n740\t\tnext := []string{strings.TrimRight(strings.Join(lines[:1], \"\\n\"), \"\\n\"), \"\", heading, \"\", entry}\n741\t\tif len(lines) \u003e 1 {\n742\t\t\trest := strings.TrimSpace(strings.Join(lines[1:], \"\\n\"))\n743\t\t\tif rest != \"\" {\n744\t\t\t\tnext = append(next, \"\", rest)\n745\t\t\t}\n746\t\t}\n747\t\treturn strings.TrimRight(strings.Join(next, \"\\n\"), \"\\n\") + \"\\n\"\n748\t}\n749\t\n750\tfunc firstNonEmpty(values ...string) string {\n751\t\tfor _, value := range values {\n752\t\t\tif trimmed := strings.TrimSpace(value); trimmed != \"\" {\n753\t\t\t\treturn trimmed\n754\t\t\t}\n755\t\t}\n756\t\treturn \"\"\n757\t}\n758\t\n```"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-013457-686051000-25106f92d0f1a262","time":"2026-06-28T01:36:34.678264Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01Y39VNUoQKhSwPtAfvf98Nr","blocks":[{"type":"tool_use","id":"toolu_01Y39VNUoQKhSwPtAfvf98Nr","name":"Read","title":"Read internal/vault/render_test.go","input":{"file_path":"/Users/pedronauck/Dev/compozy/kb/internal/vault/render_test.go"},"raw_input":{"file_path":"/Users/pedronauck/Dev/compozy/kb/internal/vault/render_test.go"}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-013457-686051000-25106f92d0f1a262","time":"2026-06-28T01:36:34.681861Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01Y39VNUoQKhSwPtAfvf98Nr","usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-013457-686051000-25106f92d0f1a262","time":"2026-06-28T01:36:34.690956Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01Y39VNUoQKhSwPtAfvf98Nr","tool_call_state":"completed","blocks":[{"type":"tool_result","tool_use_id":"toolu_01Y39VNUoQKhSwPtAfvf98Nr","content":"```\n1\tpackage vault_test\n2\t\n3\timport (\n4\t\t\"strings\"\n5\t\t\"testing\"\n6\t\n7\t\t\"github.com/compozy/kb/internal/metrics\"\n8\t\t\"github.com/compozy/kb/internal/models\"\n9\t\t\"github.com/compozy/kb/internal/vault\"\n10\t\t\"gopkg.in/yaml.v3\"\n11\t)\n12\t\n13\tfunc TestRenderDocumentsProducesRawWikiAndBaseSurfaces(t *testing.T) {\n14\t\tt.Parallel()\n15\t\n16\t\tgraph := testGraphFixture()\n17\t\tmetricResult := metrics.ComputeMetrics(graph)\n18\t\n19\t\tdocuments := vault.RenderDocuments(graph, metricResult, testTopicFixture())\n20\t\tbaseFiles := vault.RenderBaseFiles(metricResult)\n21\t\n22\t\tif len(documents) == 0 {\n23\t\t\tt.Fatal(\"expected rendered documents\")\n24\t\t}\n25\t\n26\t\tif len(baseFiles) != 11 {\n27\t\t\tt.Fatalf(\"expected 11 base files from the reference renderer, got %d\", len(baseFiles))\n28\t\t}\n29\t\n30\t\tif findDocument(t, documents, \"raw/codebase/files/src/alpha.ts.md\").Kind != models.DocRaw {\n31\t\t\tt.Fatal(\"expected raw file document to have raw kind\")\n32\t\t}\n33\t\n34\t\tif findDocument(t, documents, vault.GetWikiConceptPath(\"Codebase Overview\")).Kind != models.DocWiki {\n35\t\t\tt.Fatal(\"expected concept article to have wiki kind\")\n36\t\t}\n37\t\n38\t\tif findDocument(t, documents, vault.GetWikiIndexPath(vault.CodebaseDashboardTitle)).Kind != models.DocIndex {\n39\t\t\tt.Fatal(\"expected dashboard to have index kind\")\n40\t\t}\n41\t}\n42\t\n43\tfunc TestRenderDocumentsRawFileFrontmatterAndBody(t *testing.T) {\n44\t\tt.Parallel()\n45\t\n46\t\tdocuments := renderFixtureDocuments(t)\n47\t\tdocument := findDocument(t, documents, \"raw/codebase/files/src/alpha.ts.md\")\n48\t\n49\t\tif document.ManagedArea != models.AreaRawCodebase {\n50\t\t\tt.Fatalf(\"managed area = %q, want %q\", document.ManagedArea, models.AreaRawCodebase)\n51\t\t}\n52\t\n53\t\tif got := document.Frontmatter[\"source_path\"]; got != \"src/alpha.ts\" {\n54\t\t\tt.Fatalf(\"source_path = %#v, want src/alpha.ts\", got)\n55\t\t}\n56\t\n57\t\tif got := document.Frontmatter[\"language\"]; got != \"ts\" {\n58\t\t\tt.Fatalf(\"language = %#v, want ts\", got)\n59\t\t}\n60\t\n61\t\tif !strings.Contains(document.Body, \"title: \\\"Codebase File: src/alpha.ts\\\"\") {\n62\t\t\tt.Fatalf(\"expected file title frontmatter in body, got:\\n%s\", document.Body)\n63\t\t}\n64\t\n65\t\tif !strings.Contains(document.Body, \"[[demo-repo/raw/codebase/symbols/alpha--src-alpha-ts-l10|Alpha (function)]]\") {\n66\t\t\tt.Fatalf(\"expected symbol wiki-link in raw file document, got:\\n%s\", document.Body)\n67\t\t}\n68\t}\n69\t\n70\tfunc TestRenderDocumentsRawSymbolFrontmatterAndSignature(t *testing.T) {\n71\t\tt.Parallel()\n72\t\n73\t\tdocuments := renderFixtureDocuments(t)\n74\t\tdocument := findDocument(t, documents, \"raw/codebase/symbols/branchy--src-orphan-ts-l1.md\")\n75\t\n76\t\tif got := document.Frontmatter[\"symbol_kind\"]; got != \"function\" {\n77\t\t\tt.Fatalf(\"symbol_kind = %#v, want function\", got)\n78\t\t}\n79\t\n80\t\tif got := document.Frontmatter[\"start_line\"]; got != 1 {\n81\t\t\tt.Fatalf(\"start_line = %#v, want 1\", got)\n82\t\t}\n83\t\n84\t\tif got := document.Frontmatter[\"end_line\"]; got != 80 {\n85\t\t\tt.Fatalf(\"end_line = %#v, want 80\", got)\n86\t\t}\n87\t\n88\t\tif !strings.Contains(document.Body, \"cyclomatic_complexity: 12\") {\n89\t\t\tt.Fatalf(\"expected cyclomatic complexity in symbol frontmatter, got:\\n%s\", document.Body)\n90\t\t}\n91\t\n92\t\tif !strings.Contains(document.Body, \"function Branchy(input: string): string\") {\n93\t\t\tt.Fatalf(\"expected signature block in symbol document, got:\\n%s\", document.Body)\n94\t\t}\n95\t}\n96\t\n97\tfunc TestRenderDocumentsDirectoryIndexUsesWikiLinks(t *testing.T) {\n98\t\tt.Parallel()\n99\t\n100\t\tdocuments := renderFixtureDocuments(t)\n101\t\tdocument := findDocument(t, documents, \"raw/codebase/indexes/directories/src.md\")\n102\t\n103\t\texpectedLinks := []string{\n104\t\t\t\"[[demo-repo/raw/codebase/files/src/alpha.ts|src/alpha.ts]]\",\n105\t\t\t\"[[demo-repo/raw/codebase/files/src/beta.ts|src/beta.ts]]\",\n106\t\t\t\"[[demo-repo/raw/codebase/files/src/orphan.ts|src/orphan.ts]]\",\n107\t\t}\n108\t\n109\t\tfor _, link := range expectedLinks {\n110\t\t\tif !strings.Contains(document.Body, link) {\n111\t\t\t\tt.Fatalf(\"expected directory index to contain %s, got:\\n%s\", link, document.Body)\n112\t\t\t}\n113\t\t}\n114\t}\n115\t\n116\tfunc TestRenderDocumentsCodebaseOverviewContainsSummary(t *testing.T) {\n117\t\tt.Parallel()\n118\t\n119\t\tdocuments := renderFixtureDocuments(t)\n120\t\tdocument := findDocument(t, documents, vault.GetWikiConceptPath(\"Codebase Overview\"))\n121\t\n122\t\tif !strings.Contains(document.Body, \"The corpus contains 4 parsed source files, 4 symbols, and 10 extracted relations.\") {\n123\t\t\tt.Fatalf(\"expected overview counts in body, got:\\n%s\", document.Body)\n124\t\t}\n125\t\n126\t\tif !strings.Contains(document.Body, vault.ToTopicWikiLink(\"demo-repo\", vault.GetWikiConceptPath(\"Module Health\"), \"Module Health\")) {\n127\t\t\tt.Fatalf(\"expected module health cross-reference in overview, got:\\n%s\", document.Body)\n128\t\t}\n129\t}\n130\t\n131\tfunc TestRenderDocumentsDependencyHotspotsListsTopFiles(t *testing.T) {\n132\t\tt.Parallel()\n133\t\n134\t\tdocuments := renderFixtureDocuments(t)\n135\t\tdocument := findDocument(t, documents, vault.GetWikiConceptPath(\"Dependency Hotspots\"))\n136\t\n137\t\tif !strings.Contains(document.Body, \"[[demo-repo/raw/codebase/files/src/alpha.ts|src/alpha.ts]]\") {\n138\t\t\tt.Fatalf(\"expected alpha hotspot in dependency article, got:\\n%s\", document.Body)\n139\t\t}\n140\t}\n141\t\n142\tfunc TestRenderDocumentsCircularDependenciesListsGroups(t *testing.T) {\n143\t\tt.Parallel()\n144\t\n145\t\tdocuments := renderFixtureDocuments(t)\n146\t\tdocument := findDocument(t, documents, vault.GetWikiConceptPath(\"Circular Dependencies\"))\n147\t\n148\t\tif !strings.Contains(document.Body, \"[[demo-repo/raw/codebase/files/src/alpha.ts|src/alpha.ts]] · [[demo-repo/raw/codebase/files/src/beta.ts|src/beta.ts]]\") {\n149\t\t\tt.Fatalf(\"expected cyclic group links in circular dependency article, got:\\n%s\", document.Body)\n150\t\t}\n151\t}\n152\t\n153\tfunc TestRenderDocumentsRawSourcesIncludeScrapedFrontmatter(t *testing.T) {\n154\t\tt.Parallel()\n155\t\n156\t\tdocuments := renderFixtureDocuments(t)\n157\t\twantScraped := \"2026-04-09\"\n158\t\n159\t\tfor _, relativePath := range []string{\n160\t\t\t\"raw/codebase/files/src/alpha.ts.md\",\n161\t\t\t\"raw/codebase/symbols/alpha--src-alpha-ts-l10.md\",\n162\t\t\t\"raw/codebase/indexes/directories/src.md\",\n163\t\t\t\"raw/codebase/indexes/languages/ts.md\",\n164\t\t} {\n165\t\t\tdocument := findDocument(t, documents, relativePath)\n166\t\t\tif got := document.Frontmatter[\"scraped\"]; got != wantScraped {\n167\t\t\t\tt.Fatalf(\"%s scraped = %#v, want %q\", relativePath, got, wantScraped)\n168\t\t\t}\n169\t\t}\n170\t}\n171\t\n172\tfunc TestRenderDocumentsCircularDependenciesKeepSourceEvidenceWithoutCycles(t *testing.T) {\n173\t\tt.Parallel()\n174\t\n175\t\tdocuments := renderFixtureDocuments(t)\n176\t\tdocument := findDocument(t, documents, vault.GetWikiConceptPath(\"Circular Dependencies\"))\n177\t\n178\t\tsources, ok := document.Frontmatter[\"sources\"].([]string)\n179\t\tif !ok {\n180\t\t\tt.Fatalf(\"sources type = %T, want []string\", document.Frontmatter[\"sources\"])\n181\t\t}\n182\t\tif len(sources) == 0 {\n183\t\t\tt.Fatal(\"expected circular dependency article to keep source evidence even when no cycles are detected\")\n184\t\t}\n185\t}\n186\t\n187\tfunc TestRenderDocumentsDashboardLinksToAllConceptArticles(t *testing.T) {\n188\t\tt.Parallel()\n189\t\n190\t\tdocuments := renderFixtureDocuments(t)\n191\t\tdocument := findDocument(t, documents, vault.GetWikiIndexPath(vault.CodebaseDashboardTitle))\n192\t\n193\t\tfor _, title := range []string{\n194\t\t\t\"Codebase Overview\",\n195\t\t\t\"Directory Map\",\n196\t\t\t\"Symbol Taxonomy\",\n197\t\t\t\"Dependency Hotspots\",\n198\t\t\t\"Complexity Hotspots\",\n199\t\t\t\"Module Health\",\n200\t\t\t\"Dead Code Report\",\n201\t\t\t\"Code Smells\",\n202\t\t\t\"Circular Dependencies\",\n203\t\t\t\"High-Impact Symbols\",\n204\t\t} {\n205\t\t\tlink := vault.ToTopicWikiLink(\"demo-repo\", vault.GetWikiConceptPath(title), title)\n206\t\t\tif !strings.Contains(document.Body, link) {\n207\t\t\t\tt.Fatalf(\"expected dashboard to link to %s, got:\\n%s\", title, document.Body)\n208\t\t\t}\n209\t\t}\n210\t}\n211\t\n212\tfunc TestRenderBaseDefinitionsProduceValidYAML(t *testing.T) {\n213\t\tt.Parallel()\n214\t\n215\t\tbaseFiles := vault.RenderBaseFiles(metrics.ComputeMetrics(testGraphFixture()))\n216\t\tfor _, baseFile := range baseFiles {\n217\t\t\trendered := vault.RenderBaseDefinition(baseFile.Definition)\n218\t\n219\t\t\tvar parsed map[string]any\n220\t\t\tif err := yaml.Unmarshal([]byte(rendered), \u0026parsed); err != nil {\n221\t\t\t\tt.Fatalf(\"base file %s did not parse as YAML: %v\\n%s\", baseFile.RelativePath, err, rendered)\n222\t\t\t}\n223\t\n224\t\t\tif _, exists := parsed[\"views\"]; !exists {\n225\t\t\t\tt.Fatalf(\"base file %s missing views: %#v\", baseFile.RelativePath, parsed)\n226\t\t\t}\n227\t\t}\n228\t}\n229\t\n230\tfunc TestRenderDocumentsBodiesHaveValidFrontmatterAndKinds(t *testing.T) {\n231\t\tt.Parallel()\n232\t\n233\t\tdocuments := renderFixtureDocuments(t)\n234\t\tfor _, document := range documents {\n235\t\t\tif strings.TrimSpace(document.Body) == \"\" {\n236\t\t\t\tt.Fatalf(\"document %s has empty body\", document.RelativePath)\n237\t\t\t}\n238\t\n239\t\t\tswitch document.Kind {\n240\t\t\tcase models.DocRaw, models.DocWiki, models.DocIndex:\n241\t\t\tdefault:\n242\t\t\t\tt.Fatalf(\"document %s has invalid kind %q\", document.RelativePath, document.Kind)\n243\t\t\t}\n244\t\n245\t\t\tfrontmatter, markdownBody := parseFrontmatter(t, document.Body)\n246\t\t\tif len(frontmatter) == 0 {\n247\t\t\t\tt.Fatalf(\"document %s has empty frontmatter\", document.RelativePath)\n248\t\t\t}\n249\t\t\tif strings.TrimSpace(markdownBody) == \"\" {\n250\t\t\t\tt.Fatalf(\"document %s has empty markdown body\", document.RelativePath)\n251\t\t\t}\n252\t\t}\n253\t}\n254\t\n255\tfunc TestRenderDocumentsUseTopicWikiLinkSyntax(t *testing.T) {\n256\t\tt.Parallel()\n257\t\n258\t\tdocuments := renderFixtureDocuments(t)\n259\t\tdocument := findDocument(t, documents, \"raw/codebase/files/commands/run.ts.md\")\n260\t\n261\t\tif !strings.Contains(document.Body, \"[[demo-repo/raw/codebase/symbols/main--commands-run-ts-l1|main (function)]]\") {\n262\t\t\tt.Fatalf(\"expected topic wiki-link syntax in raw document, got:\\n%s\", document.Body)\n263\t\t}\n264\t}\n265\t\n266\tfunc TestRenderDocumentsUseOKFMarkdownLinkSyntax(t *testing.T) {\n267\t\tt.Parallel()\n268\t\n269\t\tgraph := testGraphFixture()\n270\t\tmetricResult := metrics.ComputeMetrics(graph)\n271\t\ttopic := testTopicFixture()\n272\t\ttopic.Mode = models.TopicModeOKF\n273\t\tdocuments := vault.RenderDocuments(graph, metricResult, topic)\n274\t\n275\t\trawDocument := findDocument(t, documents, \"raw/codebase/files/commands/run.ts.md\")\n276\t\tif strings.Contains(rawDocument.Body, \"[[\") {\n277\t\t\tt.Fatalf(\"expected OKF raw document to avoid wikilinks, got:\\n%s\", rawDocument.Body)\n278\t\t}\n279\t\tif !strings.Contains(rawDocument.Body, \"[main (function)](raw/codebase/symbols/main--commands-run-ts-l1.md)\") {\n280\t\t\tt.Fatalf(\"expected OKF raw document to contain relative markdown link, got:\\n%s\", rawDocument.Body)\n281\t\t}\n282\t\n283\t\tdashboard := findDocument(t, documents, vault.GetWikiIndexPath(vault.CodebaseDashboardTitle))\n284\t\tif strings.Contains(dashboard.Body, \"[[\") {\n285\t\t\tt.Fatalf(\"expected OKF dashboard to avoid wikilinks, got:\\n%s\", dashboard.Body)\n286\t\t}\n287\t\tif !strings.Contains(dashboard.Body, \"[Codebase Overview](wiki/codebase/concepts/Codebase%20Overview.md)\") {\n288\t\t\tt.Fatalf(\"expected OKF dashboard to link with escaped relative markdown path, got:\\n%s\", dashboard.Body)\n289\t\t}\n290\t}\n291\t\n292\tfunc renderFixtureDocuments(t *testing.T) []models.RenderedDocument {\n293\t\tt.Helper()\n294\t\n295\t\tgraph := testGraphFixture()\n296\t\tmetricResult := metrics.ComputeMetrics(graph)\n297\t\treturn vault.RenderDocuments(graph, metricResult, testTopicFixture())\n298\t}\n299\t\n300\tfunc findDocument(t *testing.T, documents []models.RenderedDocument, relativePath string) models.RenderedDocument {\n301\t\tt.Helper()\n302\t\n303\t\tfor _, document := range documents {\n304\t\t\tif document.RelativePath == relativePath {\n305\t\t\t\treturn document\n306\t\t\t}\n307\t\t}\n308\t\n309\t\tt.Fatalf(\"document %s not found\", relativePath)\n310\t\treturn models.RenderedDocument{}\n311\t}\n312\t\n313\tfunc parseFrontmatter(t *testing.T, body string) (map[string]any, string) {\n314\t\tt.Helper()\n315\t\n316\t\tif !strings.HasPrefix(body, \"---\\n\") {\n317\t\t\tt.Fatalf(\"body missing leading frontmatter delimiter:\\n%s\", body)\n318\t\t}\n319\t\n320\t\tremainder := strings.TrimPrefix(body, \"---\\n\")\n321\t\tbefore, after, ok := strings.Cut(remainder, \"\\n---\\n\")\n322\t\tif !ok {\n323\t\t\tt.Fatalf(\"body missing closing frontmatter delimiter:\\n%s\", body)\n324\t\t}\n325\t\n326\t\tvar parsed map[string]any\n327\t\tfrontmatterBlock := before\n328\t\tif err := yaml.Unmarshal([]byte(frontmatterBlock), \u0026parsed); err != nil {\n329\t\t\tt.Fatalf(\"frontmatter did not parse as YAML: %v\\n%s\", err, frontmatterBlock)\n330\t\t}\n331\t\n332\t\treturn parsed, after\n333\t}\n334\t\n335\tfunc testTopicFixture() models.TopicMetadata {\n336\t\treturn models.TopicMetadata{\n337\t\t\tRootPath: \"/repo\",\n338\t\t\tSlug: \"demo-repo\",\n339\t\t\tTitle: \"Demo Repo\",\n340\t\t\tDomain: \"demo-repo\",\n341\t\t\tToday: \"2026-04-09\",\n342\t\t\tVaultPath: \"/repo/.kb/vault\",\n343\t\t\tTopicPath: \"/repo/.kb/vault/demo-repo\",\n344\t\t}\n345\t}\n346\t\n347\tfunc testGraphFixture() models.GraphSnapshot {\n348\t\trunFileID := \"file:commands/run.ts\"\n349\t\talphaFileID := \"file:src/alpha.ts\"\n350\t\tbetaFileID := \"file:src/beta.ts\"\n351\t\torphanFileID := \"file:src/orphan.ts\"\n352\t\n353\t\tmainID := \"symbol:commands/run.ts:main:function:1:12\"\n354\t\talphaID := \"symbol:src/alpha.ts:Alpha:function:10:30\"\n355\t\tbetaID := \"symbol:src/beta.ts:Beta:function:5:18\"\n356\t\tbranchyID := \"symbol:src/orphan.ts:Branchy:function:1:80\"\n357\t\n358\t\treturn models.GraphSnapshot{\n359\t\t\tRootPath: \"/repo\",\n360\t\t\tFiles: []models.GraphFile{\n361\t\t\t\t{\n362\t\t\t\t\tID: runFileID,\n363\t\t\t\t\tNodeType: \"file\",\n364\t\t\t\t\tFilePath: \"commands/run.ts\",\n365\t\t\t\t\tLanguage: models.LangTS,\n366\t\t\t\t\tModuleDoc: \"Entrypoint for the topic.\",\n367\t\t\t\t\tSymbolIDs: []string{mainID},\n368\t\t\t\t},\n369\t\t\t\t{\n370\t\t\t\t\tID: alphaFileID,\n371\t\t\t\t\tNodeType: \"file\",\n372\t\t\t\t\tFilePath: \"src/alpha.ts\",\n373\t\t\t\t\tLanguage: models.LangTS,\n374\t\t\t\t\tModuleDoc: \"Coordinates alpha flows.\",\n375\t\t\t\t\tSymbolIDs: []string{alphaID},\n376\t\t\t\t},\n377\t\t\t\t{\n378\t\t\t\t\tID: betaFileID,\n379\t\t\t\t\tNodeType: \"file\",\n380\t\t\t\t\tFilePath: \"src/beta.ts\",\n381\t\t\t\t\tLanguage: models.LangTS,\n382\t\t\t\t\tModuleDoc: \"Supports alpha with beta helpers.\",\n383\t\t\t\t\tSymbolIDs: []string{betaID},\n384\t\t\t\t},\n385\t\t\t\t{\n386\t\t\t\t\tID: orphanFileID,\n387\t\t\t\t\tNodeType: \"file\",\n388\t\t\t\t\tFilePath: \"src/orphan.ts\",\n389\t\t\t\t\tLanguage: models.LangTS,\n390\t\t\t\t\tModuleDoc: \"Contains an unused branchy function.\",\n391\t\t\t\t\tSymbolIDs: []string{branchyID},\n392\t\t\t\t},\n393\t\t\t},\n394\t\t\tSymbols: []models.SymbolNode{\n395\t\t\t\t{\n396\t\t\t\t\tID: mainID,\n397\t\t\t\t\tNodeType: \"symbol\",\n398\t\t\t\t\tName: \"main\",\n399\t\t\t\t\tSymbolKind: \"function\",\n400\t\t\t\t\tLanguage: models.LangTS,\n401\t\t\t\t\tFilePath: \"commands/run.ts\",\n402\t\t\t\t\tStartLine: 1,\n403\t\t\t\t\tEndLine: 12,\n404\t\t\t\t\tSignature: \"function main(): void\",\n405\t\t\t\t\tDocComment: \"Bootstraps the demo topic.\",\n406\t\t\t\t\tExported: true,\n407\t\t\t\t\tCyclomaticComplexity: 3,\n408\t\t\t\t},\n409\t\t\t\t{\n410\t\t\t\t\tID: alphaID,\n411\t\t\t\t\tNodeType: \"symbol\",\n412\t\t\t\t\tName: \"Alpha\",\n413\t\t\t\t\tSymbolKind: \"function\",\n414\t\t\t\t\tLanguage: models.LangTS,\n415\t\t\t\t\tFilePath: \"src/alpha.ts\",\n416\t\t\t\t\tStartLine: 10,\n417\t\t\t\t\tEndLine: 30,\n418\t\t\t\t\tSignature: \"function Alpha(input: string): string\",\n419\t\t\t\t\tDocComment: \"Coordinates the alpha path.\",\n420\t\t\t\t\tExported: true,\n421\t\t\t\t\tCyclomaticComplexity: 6,\n422\t\t\t\t},\n423\t\t\t\t{\n424\t\t\t\t\tID: betaID,\n425\t\t\t\t\tNodeType: \"symbol\",\n426\t\t\t\t\tName: \"Beta\",\n427\t\t\t\t\tSymbolKind: \"function\",\n428\t\t\t\t\tLanguage: models.LangTS,\n429\t\t\t\t\tFilePath: \"src/beta.ts\",\n430\t\t\t\t\tStartLine: 5,\n431\t\t\t\t\tEndLine: 18,\n432\t\t\t\t\tSignature: \"function Beta(input: string): string\",\n433\t\t\t\t\tDocComment: \"Provides beta helpers.\",\n434\t\t\t\t\tExported: true,\n435\t\t\t\t\tCyclomaticComplexity: 4,\n436\t\t\t\t},\n437\t\t\t\t{\n438\t\t\t\t\tID: branchyID,\n439\t\t\t\t\tNodeType: \"symbol\",\n440\t\t\t\t\tName: \"Branchy\",\n441\t\t\t\t\tSymbolKind: \"function\",\n442\t\t\t\t\tLanguage: models.LangTS,\n443\t\t\t\t\tFilePath: \"src/orphan.ts\",\n444\t\t\t\t\tStartLine: 1,\n445\t\t\t\t\tEndLine: 80,\n446\t\t\t\t\tSignature: \"function Branchy(input: string): string\",\n447\t\t\t\t\tDocComment: \"Contains many branches but no dependents.\",\n448\t\t\t\t\tExported: true,\n449\t\t\t\t\tCyclomaticComplexity: 12,\n450\t\t\t\t},\n451\t\t\t},\n452\t\t\tExternalNodes: []models.ExternalNode{},\n453\t\t\tRelations: []models.RelationEdge{\n454\t\t\t\t{FromID: runFileID, ToID: mainID, Type: models.RelContains, Confidence: models.ConfidenceSemantic},\n455\t\t\t\t{FromID: alphaFileID, ToID: alphaID, Type: models.RelContains, Confidence: models.ConfidenceSemantic},\n456\t\t\t\t{FromID: betaFileID, ToID: betaID, Type: models.RelContains, Confidence: models.ConfidenceSemantic},\n457\t\t\t\t{FromID: orphanFileID, ToID: branchyID, Type: models.RelContains, Confidence: models.ConfidenceSemantic},\n458\t\t\t\t{FromID: runFileID, ToID: alphaFileID, Type: models.RelImports, Confidence: models.ConfidenceSemantic},\n459\t\t\t\t{FromID: alphaFileID, ToID: betaFileID, Type: models.RelImports, Confidence: models.ConfidenceSemantic},\n460\t\t\t\t{FromID: betaFileID, ToID: alphaFileID, Type: models.RelImports, Confidence: models.ConfidenceSemantic},\n461\t\t\t\t{FromID: mainID, ToID: alphaID, Type: models.RelCalls, Confidence: models.ConfidenceSemantic},\n462\t\t\t\t{FromID: alphaID, ToID: betaID, Type: models.RelCalls, Confidence: models.ConfidenceSemantic},\n463\t\t\t\t{FromID: betaID, ToID: alphaID, Type: models.RelReferences, Confidence: models.ConfidenceSemantic},\n464\t\t\t},\n465\t\t\tDiagnostics: []models.StructuredDiagnostic{},\n466\t\t}\n467\t}\n468\t\n```"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-013457-686051000-25106f92d0f1a262","time":"2026-06-28T01:36:55.471509Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":""}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-013457-686051000-25106f92d0f1a262","time":"2026-06-28T01:36:55.471519Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":"Let me verify the dead-code removal and build/test the affected packages for evidence."}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-013457-686051000-25106f92d0f1a262","time":"2026-06-28T01:36:55.472424Z","turn":1,"type":"session.update","update":{"kind":"tool_call_started","tool_call_id":"toolu_014VDDuLPZH57743xTPgNZUq","tool_call_state":"pending","blocks":[{"type":"tool_use","id":"toolu_014VDDuLPZH57743xTPgNZUq","name":"Bash","title":"Terminal","raw_input":{}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-013457-686051000-25106f92d0f1a262","time":"2026-06-28T01:36:57.295181Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_014VDDuLPZH57743xTPgNZUq","blocks":[{"type":"tool_use","id":"toolu_014VDDuLPZH57743xTPgNZUq","name":"Task","title":"grep -rn \"WriteMetadataFileWithMode\" . 2\u003e/dev/null; echo \"---grep-exit=$?---\"","input":{"prompt":"Search for removed dead function references"},"raw_input":{"command":"grep -rn \"WriteMetadataFileWithMode\" . 2\u003e/dev/null; echo \"---grep-exit=$?---\"","description":"Search for removed dead function references"}},{"type":"tool_result","tool_use_id":"toolu_014VDDuLPZH57743xTPgNZUq","content":"Search for removed dead function references"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-013457-686051000-25106f92d0f1a262","time":"2026-06-28T01:36:57.299484Z","turn":1,"type":"session.update","update":{"kind":"tool_call_started","tool_call_id":"toolu_01QHjC96sB4F841Vqdf38J8m","tool_call_state":"pending","blocks":[{"type":"tool_use","id":"toolu_01QHjC96sB4F841Vqdf38J8m","name":"Bash","title":"Terminal","raw_input":{}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-013457-686051000-25106f92d0f1a262","time":"2026-06-28T01:36:58.098291Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01QHjC96sB4F841Vqdf38J8m","blocks":[{"type":"tool_use","id":"toolu_01QHjC96sB4F841Vqdf38J8m","name":"Task","title":"go build ./... 2\u003e\u00261 | head -50; echo \"---build-exit=${PIPESTATUS[0]}---\"","input":{"prompt":"Build all packages"},"raw_input":{"command":"go build ./... 2\u003e\u00261 | head -50; echo \"---build-exit=${PIPESTATUS[0]}---\"","description":"Build all packages"}},{"type":"tool_result","tool_use_id":"toolu_01QHjC96sB4F841Vqdf38J8m","content":"Build all packages"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-013457-686051000-25106f92d0f1a262","time":"2026-06-28T01:36:58.746939Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_014VDDuLPZH57743xTPgNZUq","usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-013457-686051000-25106f92d0f1a262","time":"2026-06-28T01:36:58.748351Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_014VDDuLPZH57743xTPgNZUq","tool_call_state":"completed","blocks":[{"type":"tool_result","tool_use_id":"toolu_014VDDuLPZH57743xTPgNZUq","content":"```console\n\u003cpersisted-output\u003e\nOutput too large (134KB). Full output saved to: /Users/pedronauck/.claude/projects/-Users-pedronauck-Dev-compozy-kb/4b250e08-6852-4b83-9342-d3f23365a26e/tool-results/bvfdi9yip.txt\n\nPreview (first 2KB):\n./.compozy/tasks/okf-dual-mode/qa/impl-review-findings-round1.md:50:## N-001 — Dead exported function `WriteMetadataFileWithMode`\n./.compozy/tasks/okf-dual-mode/qa/impl-review-findings-round1.md:54:- Issue: `WriteMetadataFileWithMode` is exported but has zero callers (production or test); `internal/vault/writer.go` still uses the mode-less `WriteMetadataFile`. It is speculative dead API surface (the linter won't catch it because it is exported).\n./.compozy/tasks/okf-dual-mode/qa/impl-review-findings-round1.md:55:- Suggested fix: Delete `WriteMetadataFileWithMode`, or route the one real caller (`writer.go:106`) through it with an explicit wiki mode if a written `mode:` line is desired there.\n./.compozy/tasks/okf-dual-mode/qa/impl-review-diff-round1.patch:494:+// WriteMetadataFileWithMode writes structured topic metadata with an explicit mode.\n./.compozy/tasks/okf-dual-mode/qa/impl-review-diff-round1.patch:495:+func WriteMetadataFileWithMode(topicPath, slug, title, domain string, mode models.TopicMode) error {\n./.compozy/tasks/okf-dual-mode/qa/impl-review-remediation-round1.md:6:- N-001: Removed unused exported `WriteMetadataFileWithMode`.\n...\n\u003c/persisted-output\u003e\n```"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-013457-686051000-25106f92d0f1a262","time":"2026-06-28T01:37:00.432396Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01QHjC96sB4F841Vqdf38J8m","usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-013457-686051000-25106f92d0f1a262","time":"2026-06-28T01:37:00.433871Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01QHjC96sB4F841Vqdf38J8m","tool_call_state":"completed","blocks":[{"type":"tool_result","tool_use_id":"toolu_01QHjC96sB4F841Vqdf38J8m","content":"```console\nGo build: Success\n---build-exit=---\n```"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-013457-686051000-25106f92d0f1a262","time":"2026-06-28T01:37:04.581843Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":""}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-013457-686051000-25106f92d0f1a262","time":"2026-06-28T01:37:04.582649Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":"Dead function is gone (only referenced in round-1 review artifacts). Build succeeds. Now let me"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-013457-686051000-25106f92d0f1a262","time":"2026-06-28T01:37:04.961287Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":" run the affected test suites."}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-013457-686051000-25106f92d0f1a262","time":"2026-06-28T01:37:04.971669Z","turn":1,"type":"session.update","update":{"kind":"tool_call_started","tool_call_id":"toolu_01MsBf541t59PuUAbCuFALse","tool_call_state":"pending","blocks":[{"type":"tool_use","id":"toolu_01MsBf541t59PuUAbCuFALse","name":"Bash","title":"Terminal","raw_input":{}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-013457-686051000-25106f92d0f1a262","time":"2026-06-28T01:37:06.427823Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01MsBf541t59PuUAbCuFALse","blocks":[{"type":"tool_use","id":"toolu_01MsBf541t59PuUAbCuFALse","name":"Task","title":"go test ./internal/okf/... ./internal/vault/... ./internal/topic/... ./internal/cli/... ./internal/config/... 2\u003e\u00261 | tail -30; echo \"---test-exit=${PIPESTATUS[0]}---\"","input":{"prompt":"Run unit tests for affected packages"},"raw_input":{"command":"go test ./internal/okf/... ./internal/vault/... ./internal/topic/... ./internal/cli/... ./internal/config/... 2\u003e\u00261 | tail -30; echo \"---test-exit=${PIPESTATUS[0]}---\"","description":"Run unit tests for affected packages"}},{"type":"tool_result","tool_use_id":"toolu_01MsBf541t59PuUAbCuFALse","content":"Run unit tests for affected packages"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-013457-686051000-25106f92d0f1a262","time":"2026-06-28T01:37:06.430409Z","turn":1,"type":"session.update","update":{"kind":"tool_call_started","tool_call_id":"toolu_019gmaa6y2sUaMk3gYzX8HuR","tool_call_state":"pending","blocks":[{"type":"tool_use","id":"toolu_019gmaa6y2sUaMk3gYzX8HuR","name":"Bash","title":"Terminal","raw_input":{}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-013457-686051000-25106f92d0f1a262","time":"2026-06-28T01:37:06.792012Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01MsBf541t59PuUAbCuFALse","usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-013457-686051000-25106f92d0f1a262","time":"2026-06-28T01:37:06.79329Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01MsBf541t59PuUAbCuFALse","tool_call_state":"completed","blocks":[{"type":"tool_result","tool_use_id":"toolu_01MsBf541t59PuUAbCuFALse","content":"```console\nGo test: 295 passed in 5 packages\n---test-exit=---\n```"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-013457-686051000-25106f92d0f1a262","time":"2026-06-28T01:37:07.202051Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_019gmaa6y2sUaMk3gYzX8HuR","blocks":[{"type":"tool_use","id":"toolu_019gmaa6y2sUaMk3gYzX8HuR","name":"Task","title":"go test -tags integration ./internal/okf/... ./internal/cli/... 2\u003e\u00261 | tail -30; echo \"---inttest-exit=${PIPESTATUS[0]}---\"","input":{"prompt":"Run integration tests for okf and cli"},"raw_input":{"command":"go test -tags integration ./internal/okf/... ./internal/cli/... 2\u003e\u00261 | tail -30; echo \"---inttest-exit=${PIPESTATUS[0]}---\"","description":"Run integration tests for okf and cli"}},{"type":"tool_result","tool_use_id":"toolu_019gmaa6y2sUaMk3gYzX8HuR","content":"Run integration tests for okf and cli"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-013457-686051000-25106f92d0f1a262","time":"2026-06-28T01:37:11.070443Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_019gmaa6y2sUaMk3gYzX8HuR","usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-013457-686051000-25106f92d0f1a262","time":"2026-06-28T01:37:11.071882Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_019gmaa6y2sUaMk3gYzX8HuR","tool_call_state":"completed","blocks":[{"type":"tool_result","tool_use_id":"toolu_019gmaa6y2sUaMk3gYzX8HuR","content":"```console\nGo test: 181 passed in 2 packages\n---inttest-exit=---\n```"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-013457-686051000-25106f92d0f1a262","time":"2026-06-28T01:38:24.413012Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":""}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-013457-686051000-25106f92d0f1a262","time":"2026-06-28T01:38:24.413449Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":"Tests pass (295 unit, 181 integration). Let me check the"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-013457-686051000-25106f92d0f1a262","time":"2026-06-28T01:38:24.734441Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":" remaining project rule file and confirm a couple of details."}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-013457-686051000-25106f92d0f1a262","time":"2026-06-28T01:38:24.736823Z","turn":1,"type":"session.update","update":{"kind":"tool_call_started","tool_call_id":"toolu_0147QqMtp7k9UeHoXahwFKtp","tool_call_state":"pending","blocks":[{"type":"tool_use","id":"toolu_0147QqMtp7k9UeHoXahwFKtp","name":"Bash","title":"Terminal","raw_input":{}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-013457-686051000-25106f92d0f1a262","time":"2026-06-28T01:38:26.729279Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_0147QqMtp7k9UeHoXahwFKtp","blocks":[{"type":"tool_use","id":"toolu_0147QqMtp7k9UeHoXahwFKtp","name":"Task","title":"ls -la /Users/pedronauck/Dev/compozy/kb/CONTRIBUTING.md 2\u003e\u00261; echo \"===\"; grep -rn \"sourceTopicRoot\\|_ = strict\\|_ = bundlePath\" /Users/pedronauck/Dev/compozy/kb/internal/okf/okf.go","input":{"prompt":"Check CONTRIBUTING.md and unused-discard smells"},"raw_input":{"command":"ls -la /Users/pedronauck/Dev/compozy/kb/CONTRIBUTING.md 2\u003e\u00261; echo \"===\"; grep -rn \"sourceTopicRoot\\|_ = strict\\|_ = bundlePath\" /Users/pedronauck/Dev/compozy/kb/internal/okf/okf.go","description":"Check CONTRIBUTING.md and unused-discard smells"}},{"type":"tool_result","tool_use_id":"toolu_0147QqMtp7k9UeHoXahwFKtp","content":"Check CONTRIBUTING.md and unused-discard smells"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-013457-686051000-25106f92d0f1a262","time":"2026-06-28T01:38:26.735985Z","turn":1,"type":"session.update","update":{"kind":"tool_call_started","tool_call_id":"toolu_01FavaQHGbUZed9pomMJf7kz","tool_call_state":"pending","blocks":[{"type":"tool_use","id":"toolu_01FavaQHGbUZed9pomMJf7kz","name":"Read","title":"Read File","raw_input":{}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-013457-686051000-25106f92d0f1a262","time":"2026-06-28T01:38:26.797719Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_0147QqMtp7k9UeHoXahwFKtp","usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-013457-686051000-25106f92d0f1a262","time":"2026-06-28T01:38:26.799414Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_0147QqMtp7k9UeHoXahwFKtp","tool_call_state":"completed","blocks":[{"type":"tool_result","tool_use_id":"toolu_0147QqMtp7k9UeHoXahwFKtp","content":"```console\n644 /Users/pedronauck/Dev/compozy/kb/CONTRIBUTING.md 9.2K\n===\n/Users/pedronauck/Dev/compozy/kb/internal/okf/okf.go:79:\tsourcePath, sourceTopicRoot, sourceTopicSlug, sourceRelativePath, err := resolveSourceDocument(input)\n/Users/pedronauck/Dev/compozy/kb/internal/okf/okf.go:83:\t_ = sourceTopicRoot\n/Users/pedronauck/Dev/compozy/kb/internal/okf/okf.go:650:\t_ = bundlePath\n/Users/pedronauck/Dev/compozy/kb/internal/okf/okf.go:651:\t_ = strict\n/Users/pedronauck/Dev/compozy/kb/internal/okf/okf.go:671:\t_ = strict\n```"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-013457-686051000-25106f92d0f1a262","time":"2026-06-28T01:38:27.595419Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01FavaQHGbUZed9pomMJf7kz","blocks":[{"type":"tool_use","id":"toolu_01FavaQHGbUZed9pomMJf7kz","name":"Read","title":"Read internal/topic/topic.go (540 - 579)","input":{"file_path":"/Users/pedronauck/Dev/compozy/kb/internal/topic/topic.go"},"raw_input":{"file_path":"/Users/pedronauck/Dev/compozy/kb/internal/topic/topic.go","limit":40,"offset":540}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-013457-686051000-25106f92d0f1a262","time":"2026-06-28T01:38:27.602241Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01FavaQHGbUZed9pomMJf7kz","usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-013457-686051000-25106f92d0f1a262","time":"2026-06-28T01:38:27.605567Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01FavaQHGbUZed9pomMJf7kz","tool_call_state":"completed","blocks":[{"type":"tool_result","tool_use_id":"toolu_01FavaQHGbUZed9pomMJf7kz","content":"```\n540\t\t\tif err := os.WriteFile(filepath.Join(topicPath, \"index.md\"), []byte(index), 0o644); err != nil {\n541\t\t\t\treturn fmt.Errorf(\"write OKF index: %w\", err)\n542\t\t\t}\n543\t\t\tif err := os.WriteFile(filepath.Join(topicPath, \"log.md\"), []byte(\"# Directory Update Log\\n\"), 0o644); err != nil {\n544\t\t\t\treturn fmt.Errorf(\"write OKF log: %w\", err)\n545\t\t\t}\n546\t\t}\n547\t\n548\t\treturn nil\n549\t}\n550\t\n551\t// WriteMetadataFile writes the structured topic.yaml metadata file.\n552\tfunc WriteMetadataFile(topicPath, slug, title, domain string) error {\n553\t\ttopicRef, err := ParseTopicRef(slug)\n554\t\tif err != nil {\n555\t\t\treturn fmt.Errorf(\"validate topic metadata slug: %w\", err)\n556\t\t}\n557\t\treturn writeMetadataFile(topicPath, topicMetadataForRef(topicRef, title, domain, \"\"))\n558\t}\n559\t\n560\tfunc topicMetadataForRef(topicRef TopicRef, title, domain string, mode models.TopicMode) topicMetadataFile {\n561\t\tmetadata := topicMetadataFile{\n562\t\t\tSlug: topicRef.Leaf,\n563\t\t\tTitle: strings.TrimSpace(title),\n564\t\t\tDomain: strings.TrimSpace(domain),\n565\t\t\tMode: string(mode),\n566\t\t}\n567\t\tif topicRef.Category != \"\" {\n568\t\t\tmetadata.Category = topicRef.Category\n569\t\t\tmetadata.Path = topicRef.Path\n570\t\t\tmetadata.QMDCollection = topicRef.Leaf\n571\t\t}\n572\t\treturn metadata\n573\t}\n574\t\n575\tfunc writeMetadataFile(topicPath string, metadata topicMetadataFile) error {\n576\t\tencoded, err := yaml.Marshal(metadata)\n577\t\tif err != nil {\n578\t\t\treturn fmt.Errorf(\"marshal topic metadata: %w\", err)\n579\t\t}\n```"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-013457-686051000-25106f92d0f1a262","time":"2026-06-28T01:38:35.201563Z","turn":1,"type":"session.update","update":{"kind":"tool_call_started","tool_call_id":"toolu_01Htf3qNXrbrHEZMb1X1nns5","tool_call_state":"pending","blocks":[{"type":"tool_use","id":"toolu_01Htf3qNXrbrHEZMb1X1nns5","name":"Read","title":"Read File","raw_input":{}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-013457-686051000-25106f92d0f1a262","time":"2026-06-28T01:38:35.702726Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01Htf3qNXrbrHEZMb1X1nns5","blocks":[{"type":"tool_use","id":"toolu_01Htf3qNXrbrHEZMb1X1nns5","name":"Read","title":"Read CONTRIBUTING.md","input":{"file_path":"/Users/pedronauck/Dev/compozy/kb/CONTRIBUTING.md"},"raw_input":{"file_path":"/Users/pedronauck/Dev/compozy/kb/CONTRIBUTING.md"}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-013457-686051000-25106f92d0f1a262","time":"2026-06-28T01:38:35.70566Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01Htf3qNXrbrHEZMb1X1nns5","usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-013457-686051000-25106f92d0f1a262","time":"2026-06-28T01:38:35.708964Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01Htf3qNXrbrHEZMb1X1nns5","tool_call_state":"completed","blocks":[{"type":"tool_result","tool_use_id":"toolu_01Htf3qNXrbrHEZMb1X1nns5","content":"```\n1\t# Contributing to kb\n2\t\n3\tThanks for your interest in contributing. Whether it's a bug report, a new language adapter, a file converter, or a documentation fix, every contribution helps.\n4\t\n5\t---\n6\t\n7\t## Prerequisites\n8\t\n9\t- [Go](https://go.dev) \u003e= 1.24\n10\t- [Git](https://git-scm.com)\n11\t\n12\tOptional (auto-downloaded by the build system if missing):\n13\t\n14\t- [golangci-lint](https://golangci-lint.run) v2\n15\t- [gotestsum](https://github.com/gotestyourself/gotestsum)\n16\t\n17\t---\n18\t\n19\t## Getting Started\n20\t\n21\t```bash\n22\tgit clone https://github.com/pedronauck/kodebase-go.git\n23\tcd kodebase-go\n24\tmake verify # Verify everything passes before making changes\n25\t```\n26\t\n27\t### Project Structure\n28\t\n29\t```text\n30\tcmd/\n31\t kb/\n32\t main.go # Program entrypoint\n33\tinternal/\n34\t cli/ # Cobra command tree and command adapters\n35\t root.go\n36\t topic.go # kb topic {new,list,info}\n37\t ingest.go # kb ingest parent command + shared helpers\n38\t ingest_url.go # kb ingest url\n39\t ingest_file.go # kb ingest file\n40\t ingest_youtube.go # kb ingest youtube\n41\t ingest_codebase.go # kb ingest codebase\n42\t ingest_bookmarks.go # kb ingest bookmarks\n43\t lint.go # kb lint\n44\t generate.go # Hidden compatibility alias\n45\t inspect.go # Router for inspect subcommands\n46\t inspect_*.go # Inspect subcommand implementations\n47\t search.go\n48\t index.go\n49\t version.go\n50\t topic/ # Topic scaffolding and metadata\n51\t ingest/ # Ingest orchestration and vault writes\n52\t convert/ # Converter registry and format-specific converters\n53\t firecrawl/ # Firecrawl REST API client\n54\t youtube/ # YouTube transcript extraction + STT fallback\n55\t frontmatter/ # YAML frontmatter parsing and generation\n56\t lint/ # KB structural lint engine\n57\t generate/ # Codebase-to-vault pipeline orchestration\n58\t models/ # Domain types, snapshots, and interfaces\n59\t scanner/ # Workspace discovery and ignore filtering\n60\t adapter/ # Tree-sitter parsing adapters\n61\t go_adapter.go\n62\t ts_adapter.go\n63\t treesitter.go\n64\t graph/ # Graph normalization\n65\t metrics/ # File, symbol, and directory metrics\n66\t vault/ # Rendering, writing, reading, query helpers\n67\t qmd/ # QMD shell client integration\n68\t output/ # Table / JSON / TSV output rendering\n69\t config/ # TOML config and env-backed secrets\n70\t logger/ # Structured slog logger\n71\t version/ # Build metadata\n72\tmagefile.go # Mage build tasks (wrapped by Makefile)\n73\t```\n74\t\n75\t---\n76\t\n77\t## Development Workflow\n78\t\n79\t| Command | Description |\n80\t| ----------------------- | ----------------------------------------- |\n81\t| `make fmt` | Format all Go files with gofmt |\n82\t| `make lint` | Run golangci-lint v2 with zero tolerance |\n83\t| `make test` | Unit tests with race detector |\n84\t| `make test-integration` | Unit + integration tests |\n85\t| `make build` | Build binary to `bin/kb` with ldflags |\n86\t| `make verify` | fmt -\u003e lint -\u003e test -\u003e build -\u003e boundaries|\n87\t| `make deps` | Run `go mod tidy` |\n88\t\n89\t**`make verify` must pass before submitting a PR.** The CI pipeline runs the same command.\n90\t\n91\t---\n92\t\n93\t## Code Style\n94\t\n95\t- **File naming:** `snake_case.go` for all Go files\n96\t- **Exports:** Capitalize public symbols; keep internal logic unexported\n97\t- **Formatting:** `gofmt` (standard Go formatting, enforced by `make fmt`)\n98\t- **Linting:** golangci-lint v2 with zero warnings -- warnings are treated as errors\n99\t- **Imports:** Group in order: stdlib, third-party, internal\n100\t- **Dependencies:** Use `go get` for dependency changes, never hand-edit `go.mod`\n101\t- **CLI commands:** Use [Cobra](https://github.com/spf13/cobra). Keep commands thin -- delegate to packages like `internal/topic`, `internal/ingest`, `internal/lint`, `internal/generate`, `internal/vault`, and `internal/qmd`\n102\t\n103\t---\n104\t\n105\t## Commit Messages\n106\t\n107\tWe use [Conventional Commits](https://www.conventionalcommits.org) enforced by CI.\n108\t\n109\t```text\n110\tfeat(cli): add python language adapter\n111\tfix(cli): correct cyclomatic complexity for method receivers\n112\trefactor(cli): extract shared metric computation\n113\ttest(cli): add coverage for blast-radius edge cases\n114\tdocs(repo): update README with search command examples\n115\tchore(repo): update dependencies\n116\t```\n117\t\n118\t**Valid types:** `feat`, `fix`, `refactor`, `test`, `docs`, `chore`, `build`, `ci`\n119\t\n120\t**Valid scopes:** `cli`, `repo`, `docs`, `test`, `ci`\n121\t\n122\tPR titles must follow the same format (enforced by CI).\n123\t\n124\t---\n125\t\n126\t## Testing\n127\t\n128\t- **Framework:** Standard `testing` package with [gotestsum](https://github.com/gotestyourself/gotestsum) runner\n129\t- **Run tests:** `make test` or `make verify`\n130\t- **Test location:** Co-located with source (`*_test.go` next to the file under test)\n131\t- **File pattern:** `\u003cmodule\u003e_test.go`\n132\t\n133\t### Conventions\n134\t\n135\t- Table-driven tests with `t.Run` subtests\n136\t- `t.TempDir()` for filesystem isolation\n137\t- Focused test helpers with `t.Helper()`\n138\t- Integration tests use the `//go:build integration` build tag and run via `make test-integration`\n139\t- Race detector is always enabled (`-race`)\n140\t\n141\t---\n142\t\n143\t## Adding a New Language Adapter\n144\t\n145\tThis is one of the most impactful contributions you can make. `kb` uses a clean adapter interface that makes adding new languages straightforward.\n146\t\n147\t### Step 1: Create the Adapter File\n148\t\n149\t```text\n150\tinternal/adapter/\u003clanguage\u003e_adapter.go\n151\t```\n152\t\n153\t### Step 2: Implement the LanguageAdapter Interface\n154\t\n155\tThe interface is defined in `internal/models/models.go`:\n156\t\n157\t```go\n158\ttype LanguageAdapter interface {\n159\t Supports(lang SupportedLanguage) bool\n160\t ParseFiles(files []ScannedSourceFile, rootPath string) ([]ParsedFile, error)\n161\t}\n162\t```\n163\t\n164\tYour `ParseFiles` method must return, for each file:\n165\t\n166\t- **`File`** -- A `GraphFile` node with the file's path, language, and symbol IDs\n167\t- **`Symbols`** -- Slice of `SymbolNode` entries (functions, classes, interfaces, types, variables, methods)\n168\t- **`ExternalNodes`** -- External module references\n169\t- **`Relations`** -- Edges connecting files and symbols (`imports`, `exports`, `calls`, `references`, `declares`, `contains`)\n170\t- **`Diagnostics`** -- Any parse warnings or errors\n171\t\n172\t### Step 3: Register Language Extensions\n173\t\n174\tAdd your language's file extensions to the `SupportedLanguage` constants in `internal/models/models.go` and extend `SupportedLanguages()`.\n175\t\n176\t### Step 4: Add Tree-sitter Bindings\n177\t\n178\tAdd the tree-sitter grammar dependency and wire it in `internal/adapter/treesitter.go`.\n179\t\n180\t### Step 5: Wire the Adapter\n181\t\n182\tAdd adapter instantiation and file dispatch in `internal/generate/generate.go`, following the pattern of the existing Go and TypeScript adapters.\n183\t\n184\t### Step 6: Write Tests\n185\t\n186\tAdd `\u003clanguage\u003e_adapter_test.go` in `internal/adapter/` with fixture files in `internal/adapter/testdata/`.\n187\t\n188\t### Step 7: Update Documentation\n189\t\n190\tAdd the language to the \"Supported Languages\" table in `README.md`.\n191\t\n192\t### Reference Implementations\n193\t\n194\t- **`go_adapter.go`** -- Tree-sitter-based Go parser. Relation confidence: `syntactic`.\n195\t- **`ts_adapter.go`** -- Tree-sitter-based TypeScript/JavaScript parser. Relation confidence: `syntactic`.\n196\t\n197\t---\n198\t\n199\t## Adding a New File Converter\n200\t\n201\tThe converter registry (`internal/convert`) makes adding new file format support straightforward.\n202\t\n203\t### Step 1: Create the Converter File\n204\t\n205\t```text\n206\tinternal/convert/\u003cformat\u003e.go\n207\t```\n208\t\n209\t### Step 2: Implement the Converter Interface\n210\t\n211\tThe interface is defined in `internal/models/models.go`:\n212\t\n213\t```go\n214\ttype Converter interface {\n215\t Extensions() []string\n216\t Convert(ctx context.Context, input ConvertInput) (*ConvertResult, error)\n217\t}\n218\t```\n219\t\n220\t### Step 3: Register the Converter\n221\t\n222\tAdd your converter to `NewRegistry()` in `internal/convert/registry.go`.\n223\t\n224\t### Step 4: Write Tests\n225\t\n226\tAdd `\u003cformat\u003e_test.go` in `internal/convert/` with fixture files in `internal/convert/testdata/`.\n227\t\n228\t### Reference Implementations\n229\t\n230\t- **`pdf.go`** -- PDF text extraction via pdfcpu\n231\t- **`docx.go`** -- DOCX XML extraction\n232\t- **`html.go`** -- HTML-to-markdown conversion\n233\t\n234\t---\n235\t\n236\t## Pull Request Process\n237\t\n238\t1. Fork the repository and create a feature branch from `main`\n239\t2. Make your changes following the code style guidelines above\n240\t3. Run `make verify` and ensure it passes with zero warnings\n241\t4. Write a clear PR description explaining what changed and why\n242\t5. PR titles must follow Conventional Commits format with a scope\n243\t6. Keep PRs focused -- one feature or fix per PR\n244\t\n245\t---\n246\t\n247\t## Reporting Issues\n248\t\n249\t- **Bugs:** Use the [bug report template](https://github.com/pedronauck/kodebase-go/issues/new?template=bug-report.yml)\n250\t- **Features:** Use the [feature request template](https://github.com/pedronauck/kodebase-go/issues/new?template=feature-request.yml)\n251\t\n252\tInclude the command you ran, the output you got, and the output you expected. `kb version` output and Go version help us reproduce faster.\n253\t\n254\t---\n255\t\n256\t## Code of Conduct\n257\t\n258\tBe respectful and constructive. We follow the [Contributor Covenant](https://www.contributor-covenant.org/version/2/1/code_of_conduct/).\n259\t\n```"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-013457-686051000-25106f92d0f1a262","time":"2026-06-28T01:39:06.493081Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":""}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-013457-686051000-25106f92d0f1a262","time":"2026-06-28T01:39:06.494775Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":"Let me get an accurate timestamp"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-013457-686051000-25106f92d0f1a262","time":"2026-06-28T01:39:07.008826Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":" for the findings file."}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-013457-686051000-25106f92d0f1a262","time":"2026-06-28T01:39:07.010598Z","turn":1,"type":"session.update","update":{"kind":"tool_call_started","tool_call_id":"toolu_01UfSy1eStTfUvZqkVx5BoVk","tool_call_state":"pending","blocks":[{"type":"tool_use","id":"toolu_01UfSy1eStTfUvZqkVx5BoVk","name":"Bash","title":"Terminal","raw_input":{}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-013457-686051000-25106f92d0f1a262","time":"2026-06-28T01:39:07.534944Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01UfSy1eStTfUvZqkVx5BoVk","blocks":[{"type":"tool_use","id":"toolu_01UfSy1eStTfUvZqkVx5BoVk","name":"Task","title":"date -u +\"%Y-%m-%dT%H:%M:%SZ\"","input":{"prompt":"Get current UTC ISO-8601 timestamp"},"raw_input":{"command":"date -u +\"%Y-%m-%dT%H:%M:%SZ\"","description":"Get current UTC ISO-8601 timestamp"}},{"type":"tool_result","tool_use_id":"toolu_01UfSy1eStTfUvZqkVx5BoVk","content":"Get current UTC ISO-8601 timestamp"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-013457-686051000-25106f92d0f1a262","time":"2026-06-28T01:39:07.571652Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01UfSy1eStTfUvZqkVx5BoVk","usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-013457-686051000-25106f92d0f1a262","time":"2026-06-28T01:39:07.57301Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01UfSy1eStTfUvZqkVx5BoVk","tool_call_state":"completed","blocks":[{"type":"tool_result","tool_use_id":"toolu_01UfSy1eStTfUvZqkVx5BoVk","content":"```console\n2026-06-28T01:39:07Z\n```"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-013457-686051000-25106f92d0f1a262","time":"2026-06-28T01:39:12.929057Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":""}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-013457-686051000-25106f92d0f1a262","time":"2026-06-28T01:39:12.92944Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":"All round-1 remediations are verified in the"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-013457-686051000-25106f92d0f1a262","time":"2026-06-28T01:39:13.399237Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":" working tree and the suites are green. Writing the round-2 findings file now."}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-013457-686051000-25106f92d0f1a262","time":"2026-06-28T01:39:13.401775Z","turn":1,"type":"session.update","update":{"kind":"tool_call_started","tool_call_id":"toolu_01FYzCjKyAFnNQmxQT6quPnr","tool_call_state":"pending","blocks":[{"type":"tool_use","id":"toolu_01FYzCjKyAFnNQmxQT6quPnr","name":"Edit","title":"Write","raw_input":{}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-013457-686051000-25106f92d0f1a262","time":"2026-06-28T01:39:55.106338Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01FYzCjKyAFnNQmxQT6quPnr","blocks":[{"type":"tool_use","id":"toolu_01FYzCjKyAFnNQmxQT6quPnr","name":"Edit","title":"Write .compozy/tasks/okf-dual-mode/qa/impl-review-findings-round2.md","input":{"content":"---\nschema_version: 1\nreview_kind: implementation\nround: 2\nverdict: SHIP\nreviewer_runtime: claude\nreviewer_model: opus\ngenerated_at: 2026-06-28T01:39:07Z\n---\n\n# Summary\n\nThe round-1 sole blocker (B-001, the ADR-004/TechSpec N-001 OKF render-smoke test) is now fixed by `TestRenderDocumentsUseOKFMarkdownLinkSyntax`, which drives `RenderDocuments` with `TopicModeOKF` and asserts a relative markdown link plus the absence of wikilinks through the live render branch; the three round-1 nits (dead `WriteMetadataFileWithMode`, whole-body description fallback, unreachable symlink branch) and the docs risk (R-002) are all correctly remediated and the full OKF MVP builds and passes 295 unit + 181 integration tests. No blockers remain and the change shape matches the accepted ADRs and TechSpec, so this ships; the two deferred risks (structured logging, external-bundle CLI checking) and minor test/code nits are acceptable follow-ups.\n\n# Blockers\n\nNone.\n\n# Risks\n\n## R-001 — TechSpec observability contract still unimplemented\n\n- File: internal/okf/okf.go\n- Line: 1\n- Issue: The TechSpec \"Monitoring and Observability\" section enumerates required `internal/logger` (slog) events for both verbs (`promote` source/target/type, link-transform count, broken-link count, `promote rejected: target not okf`, `description fallback used`; `okf check` files-scanned, `reserved file skipped`, errors/warnings/strict flag). `internal/okf`, `internal/cli/promote.go`, and `internal/cli/okf.go` still import no logger and emit none of these. Carried over from round-1 R-001 and explicitly deferred in the remediation. Operators get only the final JSON/table output with no debug trail for unresolved links, description fallbacks, or skipped files. This was classified as a non-blocking risk in round 1 and the success metrics (conformance on official bundles, end-to-end promote, zero regression) do not depend on it, so it does not block ship — but the spec'd contract remains unmet.\n- Suggested fix: Thread `internal/logger` into `Promote`/`Check` (or log at the CLI layer from `ConceptResult`/issues) for the events listed in the TechSpec at debug/info level, in a follow-up.\n\n## R-002 — `okf check` cannot validate arbitrary/external bundles the checker supports\n\n- File: internal/cli/okf.go\n- Line: 70\n- Issue: ADR-005 states the checker \"takes the resolved bundle (topic) path; it does not require `mode: okf` so it can validate arbitrary directories and external bundles,\" and `okf.Check` honors this (the integration test runs it directly on raw `testdata/official/...` paths). The CLI, however, resolves the argument through `ktopic.Info(vaultPath, topicSlug)`, which requires a valid kb topic, so `kb okf check \u003cexternal-dir\u003e` cannot be run from the CLI. Carried over from round-1 R-003 and intentionally deferred. Acceptable for the MVP (the operator's own bundle is a kb topic and the conformance success metric is met via the direct `Check` integration test), but it narrows the documented capability and will need revisiting for Phase-2 ingest. The shipped docs do not overclaim external-dir support, so there is no truthful-UI violation.\n- Suggested fix: In a follow-up, fall back to treating the argument as a direct bundle path when topic resolution fails, matching the `Check` contract.\n\n## R-003 — Lenient `okf check` hard-errors non-date `##` headings in `log.md`\n\n- File: internal/okf/okf.go\n- Line: 655\n- Issue: `checkLogFile` emits `models.SeverityError` for every `## ` heading in `log.md` that does not parse as `YYYY-MM-DD`, and this is not gated by `--strict` (the `strict` parameter is discarded). The PRD/TechSpec posture is lenient-by-default so externally produced bundles pass; a real-world `log.md` containing a prose `## Section` heading would fail conformance hard even without `--strict`. This does not affect the MVP success metric (the three official fixtures ship no `log.md`, confirmed by round-1 evidence and the green integration run), so it is not a blocker, but it is a latent leniency gap for Phase-2 external-bundle ingest.\n- Suggested fix: Treat malformed log headings as a `warningSeverity(strict)` diagnostic (warning by default, error under `--strict`), consistent with the rest of the lenient ruleset.\n\n# Nits\n\n## N-001 — Residual no-op discards and unused parameters in `internal/okf`\n\n- File: internal/okf/okf.go\n- Line: 83\n- Issue: `_ = sourceTopicRoot` (line 83), `_ = bundlePath` / `_ = strict` (lines 650-651), and `_ = strict` (line 671) are dead no-op statements left after the round-2 dead-code sweep; `checkIndexFile`/`checkLogFile` carry parameters they never use, and `resolveSourceDocument` returns a `topicRoot` its only caller discards.\n- Suggested fix: Drop the unused `topicRoot` return from `resolveSourceDocument` and remove the unused `bundlePath`/`strict` parameters (and their `_ =` discards) from `checkIndexFile`/`checkLogFile`.\n\n## N-002 — Weak/incorrect log assertion in the OKF CLI integration test\n\n- File: internal/cli/okf_integration_test.go\n- Line: 87\n- Issue: The promotion-log assertion is `if !strings.Contains(logContent, \"## \"+frontmatter.DateLayout[:4]) \u0026\u0026 !strings.Contains(logContent, \"**Creation**\")` — `frontmatter.DateLayout[:4]` is `\"2006\"`, so the first operand checks for the literal `\"## 2006\"`, which can never match a real date heading, and the `\u0026\u0026` makes the whole check pass on the `**Creation**` term alone. The date-heading half of the assertion provides false confidence and never actually validates the `## \u003cdate\u003e` heading.\n- Suggested fix: Assert both markers with `||` (fail if either is missing) and compare the heading against the formatted promotion date rather than `DateLayout[:4]`.\n\n# Evidence\n\n- Read in full: `_prd.md`, `_techspec.md`, ADR-004, round-1 findings (`impl-review-findings-round1.md`), round-1 remediation (`impl-review-remediation-round1.md`); project rules `CLAUDE.md`, `AGENTS.md`, `CONTRIBUTING.md`.\n- Read in full: the round-2 raw patch (`impl-review-diff-round2.patch`, all 2997 lines), on-disk `internal/okf/okf.go`, `internal/vault/render_test.go`, and the relevant span of `internal/topic/topic.go`.\n- Verified B-001 fix: `TestRenderDocumentsUseOKFMarkdownLinkSyntax` (internal/vault/render_test.go:266) drives `vault.RenderDocuments(...)` with `topic.Mode = models.TopicModeOKF` and asserts a relative markdown link (`[main (function)](raw/codebase/symbols/main--commands-run-ts-l1.md)`, `[Codebase Overview](wiki/codebase/concepts/Codebase%20Overview.md)`) plus no `[[` — exercising the previously-dead OKF render branch and `fromDir` plumbing through the render path, as ADR-004/TechSpec N-001 require.\n- Verified N-001 (round 1) removal: `grep -rn \"WriteMetadataFileWithMode\"` matches only round-1 review artifacts, not production code; `WriteMetadataFile` now routes through `topicMetadataForRef(..., \"\")` (mode omitted via `omitempty`).\n- Verified N-002 (round 1) tightening: `firstBodySentence` returns `\"\"` when no `. `/`! `/`? ` boundary exists, `resolveDescription` then emits the empty-description warning; covered by `TestPromoteWarnsWhenSourceBodyHasNoSentenceFallback` (real assertions on empty description + warning).\n- Verified N-003 (round 1) removal: the symlink branch in both `Check` (okf.go:177) and `loadConcepts` (okf.go:553) is now `if entry.Type()\u0026fs.ModeSymlink != 0 { return nil }` with no dead `IsDir`/`SkipDir` sub-branch (behavior-preserving — `DirEntry.IsDir()` is always false for a symlink and `WalkDir` does not follow symlinks).\n- Verified R-002 (round 1) docs: README.md, AGENTS.md, CLAUDE.md all updated with `topic new --mode wiki|okf`, `kb promote`, `kb okf check`, and `[okf].types`; docs do not overclaim external-dir support.\n- Confirmed non-destructive promote and reserved-file exclusion remain correct (source-unchanged assertions and `kb okf check` clean-on-fresh-bundle assertions pass in unit + integration tests).\n- `go build ./...` — Success.\n- `go test ./internal/okf/... ./internal/vault/... ./internal/topic/... ./internal/cli/... ./internal/config/...` — 295 passed.\n- `go test -tags integration ./internal/okf/... ./internal/cli/...` — 181 passed (official ga4/stackoverflow/crypto_bitcoin lenient conformance + end-to-end promote/check).\n- Limitation: did not run the full `make verify` (mage/lint/boundaries) in this pass; the round-1 remediation reports `make verify` green (860 tests, zero lint), and direct `go build`/`go test` (unit + integration) over the affected packages pass here.\n\n# Deferred Or Follow-Up\n\n- R-001 structured logging and R-003 external-bundle CLI checking remain deferred follow-ups (non-blocking; confirmed acceptable for the MVP).\n- The OKF render branch is now smoke-tested but still emits bundle-root-relative links (`fromDir == \"\"` at every render site); ADR-004 defers correct `fromDir` plumbing for codebase→OKF to Phase 3. No MVP path renders a codebase topic in OKF mode, so this is the accepted dormant-branch behavior — track for Phase 3.\n- `--to \u003ctopic\u003e/\u003csubdir\u003e` promotion and nested/`type`-grouped concept directories remain intentional Phase-2 deferrals.\n- Consider consolidating the duplicated bundle-walk logic shared by `Check` and `loadConcepts` (entry filtering, exclusion set, posix-rel derivation) before Phase 2 grows the package.","file_path":"/Users/pedronauck/Dev/compozy/kb/.compozy/tasks/okf-dual-mode/qa/impl-review-findings-round2.md"},"raw_input":{"content":"---\nschema_version: 1\nreview_kind: implementation\nround: 2\nverdict: SHIP\nreviewer_runtime: claude\nreviewer_model: opus\ngenerated_at: 2026-06-28T01:39:07Z\n---\n\n# Summary\n\nThe round-1 sole blocker (B-001, the ADR-004/TechSpec N-001 OKF render-smoke test) is now fixed by `TestRenderDocumentsUseOKFMarkdownLinkSyntax`, which drives `RenderDocuments` with `TopicModeOKF` and asserts a relative markdown link plus the absence of wikilinks through the live render branch; the three round-1 nits (dead `WriteMetadataFileWithMode`, whole-body description fallback, unreachable symlink branch) and the docs risk (R-002) are all correctly remediated and the full OKF MVP builds and passes 295 unit + 181 integration tests. No blockers remain and the change shape matches the accepted ADRs and TechSpec, so this ships; the two deferred risks (structured logging, external-bundle CLI checking) and minor test/code nits are acceptable follow-ups.\n\n# Blockers\n\nNone.\n\n# Risks\n\n## R-001 — TechSpec observability contract still unimplemented\n\n- File: internal/okf/okf.go\n- Line: 1\n- Issue: The TechSpec \"Monitoring and Observability\" section enumerates required `internal/logger` (slog) events for both verbs (`promote` source/target/type, link-transform count, broken-link count, `promote rejected: target not okf`, `description fallback used`; `okf check` files-scanned, `reserved file skipped`, errors/warnings/strict flag). `internal/okf`, `internal/cli/promote.go`, and `internal/cli/okf.go` still import no logger and emit none of these. Carried over from round-1 R-001 and explicitly deferred in the remediation. Operators get only the final JSON/table output with no debug trail for unresolved links, description fallbacks, or skipped files. This was classified as a non-blocking risk in round 1 and the success metrics (conformance on official bundles, end-to-end promote, zero regression) do not depend on it, so it does not block ship — but the spec'd contract remains unmet.\n- Suggested fix: Thread `internal/logger` into `Promote`/`Check` (or log at the CLI layer from `ConceptResult`/issues) for the events listed in the TechSpec at debug/info level, in a follow-up.\n\n## R-002 — `okf check` cannot validate arbitrary/external bundles the checker supports\n\n- File: internal/cli/okf.go\n- Line: 70\n- Issue: ADR-005 states the checker \"takes the resolved bundle (topic) path; it does not require `mode: okf` so it can validate arbitrary directories and external bundles,\" and `okf.Check` honors this (the integration test runs it directly on raw `testdata/official/...` paths). The CLI, however, resolves the argument through `ktopic.Info(vaultPath, topicSlug)`, which requires a valid kb topic, so `kb okf check \u003cexternal-dir\u003e` cannot be run from the CLI. Carried over from round-1 R-003 and intentionally deferred. Acceptable for the MVP (the operator's own bundle is a kb topic and the conformance success metric is met via the direct `Check` integration test), but it narrows the documented capability and will need revisiting for Phase-2 ingest. The shipped docs do not overclaim external-dir support, so there is no truthful-UI violation.\n- Suggested fix: In a follow-up, fall back to treating the argument as a direct bundle path when topic resolution fails, matching the `Check` contract.\n\n## R-003 — Lenient `okf check` hard-errors non-date `##` headings in `log.md`\n\n- File: internal/okf/okf.go\n- Line: 655\n- Issue: `checkLogFile` emits `models.SeverityError` for every `## ` heading in `log.md` that does not parse as `YYYY-MM-DD`, and this is not gated by `--strict` (the `strict` parameter is discarded). The PRD/TechSpec posture is lenient-by-default so externally produced bundles pass; a real-world `log.md` containing a prose `## Section` heading would fail conformance hard even without `--strict`. This does not affect the MVP success metric (the three official fixtures ship no `log.md`, confirmed by round-1 evidence and the green integration run), so it is not a blocker, but it is a latent leniency gap for Phase-2 external-bundle ingest.\n- Suggested fix: Treat malformed log headings as a `warningSeverity(strict)` diagnostic (warning by default, error under `--strict`), consistent with the rest of the lenient ruleset.\n\n# Nits\n\n## N-001 — Residual no-op discards and unused parameters in `internal/okf`\n\n- File: internal/okf/okf.go\n- Line: 83\n- Issue: `_ = sourceTopicRoot` (line 83), `_ = bundlePath` / `_ = strict` (lines 650-651), and `_ = strict` (line 671) are dead no-op statements left after the round-2 dead-code sweep; `checkIndexFile`/`checkLogFile` carry parameters they never use, and `resolveSourceDocument` returns a `topicRoot` its only caller discards.\n- Suggested fix: Drop the unused `topicRoot` return from `resolveSourceDocument` and remove the unused `bundlePath`/`strict` parameters (and their `_ =` discards) from `checkIndexFile`/`checkLogFile`.\n\n## N-002 — Weak/incorrect log assertion in the OKF CLI integration test\n\n- File: internal/cli/okf_integration_test.go\n- Line: 87\n- Issue: The promotion-log assertion is `if !strings.Contains(logContent, \"## \"+frontmatter.DateLayout[:4]) \u0026\u0026 !strings.Contains(logContent, \"**Creation**\")` — `frontmatter.DateLayout[:4]` is `\"2006\"`, so the first operand checks for the literal `\"## 2006\"`, which can never match a real date heading, and the `\u0026\u0026` makes the whole check pass on the `**Creation**` term alone. The date-heading half of the assertion provides false confidence and never actually validates the `## \u003cdate\u003e` heading.\n- Suggested fix: Assert both markers with `||` (fail if either is missing) and compare the heading against the formatted promotion date rather than `DateLayout[:4]`.\n\n# Evidence\n\n- Read in full: `_prd.md`, `_techspec.md`, ADR-004, round-1 findings (`impl-review-findings-round1.md`), round-1 remediation (`impl-review-remediation-round1.md`); project rules `CLAUDE.md`, `AGENTS.md`, `CONTRIBUTING.md`.\n- Read in full: the round-2 raw patch (`impl-review-diff-round2.patch`, all 2997 lines), on-disk `internal/okf/okf.go`, `internal/vault/render_test.go`, and the relevant span of `internal/topic/topic.go`.\n- Verified B-001 fix: `TestRenderDocumentsUseOKFMarkdownLinkSyntax` (internal/vault/render_test.go:266) drives `vault.RenderDocuments(...)` with `topic.Mode = models.TopicModeOKF` and asserts a relative markdown link (`[main (function)](raw/codebase/symbols/main--commands-run-ts-l1.md)`, `[Codebase Overview](wiki/codebase/concepts/Codebase%20Overview.md)`) plus no `[[` — exercising the previously-dead OKF render branch and `fromDir` plumbing through the render path, as ADR-004/TechSpec N-001 require.\n- Verified N-001 (round 1) removal: `grep -rn \"WriteMetadataFileWithMode\"` matches only round-1 review artifacts, not production code; `WriteMetadataFile` now routes through `topicMetadataForRef(..., \"\")` (mode omitted via `omitempty`).\n- Verified N-002 (round 1) tightening: `firstBodySentence` returns `\"\"` when no `. `/`! `/`? ` boundary exists, `resolveDescription` then emits the empty-description warning; covered by `TestPromoteWarnsWhenSourceBodyHasNoSentenceFallback` (real assertions on empty description + warning).\n- Verified N-003 (round 1) removal: the symlink branch in both `Check` (okf.go:177) and `loadConcepts` (okf.go:553) is now `if entry.Type()\u0026fs.ModeSymlink != 0 { return nil }` with no dead `IsDir`/`SkipDir` sub-branch (behavior-preserving — `DirEntry.IsDir()` is always false for a symlink and `WalkDir` does not follow symlinks).\n- Verified R-002 (round 1) docs: README.md, AGENTS.md, CLAUDE.md all updated with `topic new --mode wiki|okf`, `kb promote`, `kb okf check`, and `[okf].types`; docs do not overclaim external-dir support.\n- Confirmed non-destructive promote and reserved-file exclusion remain correct (source-unchanged assertions and `kb okf check` clean-on-fresh-bundle assertions pass in unit + integration tests).\n- `go build ./...` — Success.\n- `go test ./internal/okf/... ./internal/vault/... ./internal/topic/... ./internal/cli/... ./internal/config/...` — 295 passed.\n- `go test -tags integration ./internal/okf/... ./internal/cli/...` — 181 passed (official ga4/stackoverflow/crypto_bitcoin lenient conformance + end-to-end promote/check).\n- Limitation: did not run the full `make verify` (mage/lint/boundaries) in this pass; the round-1 remediation reports `make verify` green (860 tests, zero lint), and direct `go build`/`go test` (unit + integration) over the affected packages pass here.\n\n# Deferred Or Follow-Up\n\n- R-001 structured logging and R-003 external-bundle CLI checking remain deferred follow-ups (non-blocking; confirmed acceptable for the MVP).\n- The OKF render branch is now smoke-tested but still emits bundle-root-relative links (`fromDir == \"\"` at every render site); ADR-004 defers correct `fromDir` plumbing for codebase→OKF to Phase 3. No MVP path renders a codebase topic in OKF mode, so this is the accepted dormant-branch behavior — track for Phase 3.\n- `--to \u003ctopic\u003e/\u003csubdir\u003e` promotion and nested/`type`-grouped concept directories remain intentional Phase-2 deferrals.\n- Consider consolidating the duplicated bundle-walk logic shared by `Check` and `loadConcepts` (entry filtering, exclusion set, posix-rel derivation) before Phase 2 grows the package.\n","file_path":"/Users/pedronauck/Dev/compozy/kb/.compozy/tasks/okf-dual-mode/qa/impl-review-findings-round2.md"}},{"type":"diff","file_path":"/Users/pedronauck/Dev/compozy/kb/.compozy/tasks/okf-dual-mode/qa/impl-review-findings-round2.md","diff":"+++ /Users/pedronauck/Dev/compozy/kb/.compozy/tasks/okf-dual-mode/qa/impl-review-findings-round2.md\n---\nschema_version: 1\nreview_kind: implementation\nround: 2\nverdict: SHIP\nreviewer_runtime: claude\nreviewer_model: opus\ngenerated_at: 2026-06-28T01:39:07Z\n---\n\n# Summary\n\nThe round-1 sole blocker (B-001, the ADR-004/TechSpec N-001 OKF render-smoke test) is now fixed by `TestRenderDocumentsUseOKFMarkdownLinkSyntax`, which drives `RenderDocuments` with `TopicModeOKF` and asserts a relative markdown link plus the absence of wikilinks through the live render branch; the three round-1 nits (dead `WriteMetadataFileWithMode`, whole-body description fallback, unreachable symlink branch) and the docs risk (R-002) are all correctly remediated and the full OKF MVP builds and passes 295 unit + 181 integration tests. No blockers remain and the change shape matches the accepted ADRs and TechSpec, so this ships; the two deferred risks (structured logging, external-bundle CLI checking) and minor test/code nits are acceptable follow-ups.\n\n# Blockers\n\nNone.\n\n# Risks\n\n## R-001 — TechSpec observability contract still unimplemented\n\n- File: internal/okf/okf.go\n- Line: 1\n- Issue: The TechSpec \"Monitoring and Observability\" section enumerates required `internal/logger` (slog) events for both verbs (`promote` source/target/type, link-transform count, broken-link count, `promote rejected: target not okf`, `description fallback used`; `okf check` files-scanned, `reserved file skipped`, errors/warnings/strict flag). `internal/okf`, `internal/cli/promote.go`, and `internal/cli/okf.go` still import no logger and emit none of these. Carried over from round-1 R-001 and explicitly deferred in the remediation. Operators get only the final JSON/table output with no debug trail for unresolved links, description fallbacks, or skipped files. This was classified as a non-blocking risk in round 1 and the success metrics (conformance on official bundles, end-to-end promote, zero regression) do not depend on it, so it does not block ship — but the spec'd contract remains unmet.\n- Suggested fix: Thread `internal/logger` into `Promote`/`Check` (or log at the CLI layer from `ConceptResult`/issues) for the events listed in the TechSpec at debug/info level, in a follow-up.\n\n## R-002 — `okf check` cannot validate arbitrary/external bundles the checker supports\n\n- File: internal/cli/okf.go\n- Line: 70\n- Issue: ADR-005 states the checker \"takes the resolved bundle (topic) path; it does not require `mode: okf` so it can validate arbitrary directories and external bundles,\" and `okf.Check` honors this (the integration test runs it directly on raw `testdata/official/...` paths). The CLI, however, resolves the argument through `ktopic.Info(vaultPath, topicSlug)`, which requires a valid kb topic, so `kb okf check \u003cexternal-dir\u003e` cannot be run from the CLI. Carried over from round-1 R-003 and intentionally deferred. Acceptable for the MVP (the operator's own bundle is a kb topic and the conformance success metric is met via the direct `Check` integration test), but it narrows the documented capability and will need revisiting for Phase-2 ingest. The shipped docs do not overclaim external-dir support, so there is no truthful-UI violation.\n- Suggested fix: In a follow-up, fall back to treating the argument as a direct bundle path when topic resolution fails, matching the `Check` contract.\n\n## R-003 — Lenient `okf check` hard-errors non-date `##` headings in `log.md`\n\n- File: internal/okf/okf.go\n- Line: 655\n- Issue: `checkLogFile` emits `models.SeverityError` for every `## ` heading in `log.md` that does not parse as `YYYY-MM-DD`, and this is not gated by `--strict` (the `strict` parameter is discarded). The PRD/TechSpec posture is lenient-by-default so externally produced bundles pass; a real-world `log.md` containing a prose `## Section` heading would fail conformance hard even without `--strict`. This does not affect the MVP success metric (the three official fixtures ship no `log.md`, confirmed by round-1 evidence and the green integration run), so it is not a blocker, but it is a latent leniency gap for Phase-2 external-bundle ingest.\n- Suggested fix: Treat malformed log headings as a `warningSeverity(strict)` diagnostic (warning by default, error under `--strict`), consistent with the rest of the lenient ruleset.\n\n# Nits\n\n## N-001 — Residual no-op discards and unused parameters in `internal/okf`\n\n- File: internal/okf/okf.go\n- Line: 83\n- Issue: `_ = sourceTopicRoot` (line 83), `_ = bundlePath` / `_ = strict` (lines 650-651), and `_ = strict` (line 671) are dead no-op statements left after the round-2 dead-code sweep; `checkIndexFile`/`checkLogFile` carry parameters they never use, and `resolveSourceDocument` returns a `topicRoot` its only caller discards.\n- Suggested fix: Drop the unused `topicRoot` return from `resolveSourceDocument` and remove the unused `bundlePath`/`strict` parameters (and their `_ =` discards) from `checkIndexFile`/`checkLogFile`.\n\n## N-002 — Weak/incorrect log assertion in the OKF CLI integration test\n\n- File: internal/cli/okf_integration_test.go\n- Line: 87\n- Issue: The promotion-log assertion is `if !strings.Contains(logContent, \"## \"+frontmatter.DateLayout[:4]) \u0026\u0026 !strings.Contains(logContent, \"**Creation**\")` — `frontmatter.DateLayout[:4]` is `\"2006\"`, so the first operand checks for the literal `\"## 2006\"`, which can never match a real date heading, and the `\u0026\u0026` makes the whole check pass on the `**Creation**` term alone. The date-heading half of the assertion provides false confidence and never actually validates the `## \u003cdate\u003e` heading.\n- Suggested fix: Assert both markers with `||` (fail if either is missing) and compare the heading against the formatted promotion date rather than `DateLayout[:4]`.\n\n# Evidence\n\n- Read in full: `_prd.md`, `_techspec.md`, ADR-004, round-1 findings (`impl-review-findings-round1.md`), round-1 remediation (`impl-review-remediation-round1.md`); project rules `CLAUDE.md`, `AGENTS.md`, `CONTRIBUTING.md`.\n- Read in full: the round-2 raw patch (`impl-review-diff-round2.patch`, all 2997 lines), on-disk `internal/okf/okf.go`, `internal/vault/render_test.go`, and the relevant span of `internal/topic/topic.go`.\n- Verified B-001 fix: `TestRenderDocumentsUseOKFMarkdownLinkSyntax` (internal/vault/render_test.go:266) drives `vault.RenderDocuments(...)` with `topic.Mode = models.TopicModeOKF` and asserts a relative markdown link (`[main (function)](raw/codebase/symbols/main--commands-run-ts-l1.md)`, `[Codebase Overview](wiki/codebase/concepts/Codebase%20Overview.md)`) plus no `[[` — exercising the previously-dead OKF render branch and `fromDir` plumbing through the render path, as ADR-004/TechSpec N-001 require.\n- Verified N-001 (round 1) removal: `grep -rn \"WriteMetadataFileWithMode\"` matches only round-1 review artifacts, not production code; `WriteMetadataFile` now routes through `topicMetadataForRef(..., \"\")` (mode omitted via `omitempty`).\n- Verified N-002 (round 1) tightening: `firstBodySentence` returns `\"\"` when no `. `/`! `/`? ` boundary exists, `resolveDescription` then emits the empty-description warning; covered by `TestPromoteWarnsWhenSourceBodyHasNoSentenceFallback` (real assertions on empty description + warning).\n- Verified N-003 (round 1) removal: the symlink branch in both `Check` (okf.go:177) and `loadConcepts` (okf.go:553) is now `if entry.Type()\u0026fs.ModeSymlink != 0 { return nil }` with no dead `IsDir`/`SkipDir` sub-branch (behavior-preserving — `DirEntry.IsDir()` is always false for a symlink and `WalkDir` does not follow symlinks).\n- Verified R-002 (round 1) docs: README.md, AGENTS.md, CLAUDE.md all updated with `topic new --mode wiki|okf`, `kb promote`, `kb okf check`, and `[okf].types`; docs do not overclaim external-dir support.\n- Confirmed non-destructive promote and reserved-file exclusion remain correct (source-unchanged assertions and `kb okf check` clean-on-fresh-bundle assertions pass in unit + integration tests).\n- `go build ./...` — Success.\n- `go test ./internal/okf/... ./internal/vault/... ./internal/topic/... ./internal/cli/... ./internal/config/...` — 295 passed.\n- `go test -tags integration ./internal/okf/... ./internal/cli/...` — 181 passed (official ga4/stackoverflow/crypto_bitcoin lenient conformance + end-to-end promote/check).\n- Limitation: did not run the full `make verify` (mage/lint/boundaries) in this pass; the round-1 remediation reports `make verify` green (860 tests, zero lint), and direct `go build`/`go test` (unit + integration) over the affected packages pass here.\n\n# Deferred Or Follow-Up\n\n- R-001 structured logging and R-003 external-bundle CLI checking remain deferred follow-ups (non-blocking; confirmed acceptable for the MVP).\n- The OKF render branch is now smoke-tested but still emits bundle-root-relative links (`fromDir == \"\"` at every render site); ADR-004 defers correct `fromDir` plumbing for codebase→OKF to Phase 3. No MVP path renders a codebase topic in OKF mode, so this is the accepted dormant-branch behavior — track for Phase 3.\n- `--to \u003ctopic\u003e/\u003csubdir\u003e` promotion and nested/`type`-grouped concept directories remain intentional Phase-2 deferrals.\n- Consider consolidating the duplicated bundle-walk logic shared by `Check` and `loadConcepts` (entry filtering, exclusion set, posix-rel derivation) before Phase 2 grows the package.\n","new_text":"---\nschema_version: 1\nreview_kind: implementation\nround: 2\nverdict: SHIP\nreviewer_runtime: claude\nreviewer_model: opus\ngenerated_at: 2026-06-28T01:39:07Z\n---\n\n# Summary\n\nThe round-1 sole blocker (B-001, the ADR-004/TechSpec N-001 OKF render-smoke test) is now fixed by `TestRenderDocumentsUseOKFMarkdownLinkSyntax`, which drives `RenderDocuments` with `TopicModeOKF` and asserts a relative markdown link plus the absence of wikilinks through the live render branch; the three round-1 nits (dead `WriteMetadataFileWithMode`, whole-body description fallback, unreachable symlink branch) and the docs risk (R-002) are all correctly remediated and the full OKF MVP builds and passes 295 unit + 181 integration tests. No blockers remain and the change shape matches the accepted ADRs and TechSpec, so this ships; the two deferred risks (structured logging, external-bundle CLI checking) and minor test/code nits are acceptable follow-ups.\n\n# Blockers\n\nNone.\n\n# Risks\n\n## R-001 — TechSpec observability contract still unimplemented\n\n- File: internal/okf/okf.go\n- Line: 1\n- Issue: The TechSpec \"Monitoring and Observability\" section enumerates required `internal/logger` (slog) events for both verbs (`promote` source/target/type, link-transform count, broken-link count, `promote rejected: target not okf`, `description fallback used`; `okf check` files-scanned, `reserved file skipped`, errors/warnings/strict flag). `internal/okf`, `internal/cli/promote.go`, and `internal/cli/okf.go` still import no logger and emit none of these. Carried over from round-1 R-001 and explicitly deferred in the remediation. Operators get only the final JSON/table output with no debug trail for unresolved links, description fallbacks, or skipped files. This was classified as a non-blocking risk in round 1 and the success metrics (conformance on official bundles, end-to-end promote, zero regression) do not depend on it, so it does not block ship — but the spec'd contract remains unmet.\n- Suggested fix: Thread `internal/logger` into `Promote`/`Check` (or log at the CLI layer from `ConceptResult`/issues) for the events listed in the TechSpec at debug/info level, in a follow-up.\n\n## R-002 — `okf check` cannot validate arbitrary/external bundles the checker supports\n\n- File: internal/cli/okf.go\n- Line: 70\n- Issue: ADR-005 states the checker \"takes the resolved bundle (topic) path; it does not require `mode: okf` so it can validate arbitrary directories and external bundles,\" and `okf.Check` honors this (the integration test runs it directly on raw `testdata/official/...` paths). The CLI, however, resolves the argument through `ktopic.Info(vaultPath, topicSlug)`, which requires a valid kb topic, so `kb okf check \u003cexternal-dir\u003e` cannot be run from the CLI. Carried over from round-1 R-003 and intentionally deferred. Acceptable for the MVP (the operator's own bundle is a kb topic and the conformance success metric is met via the direct `Check` integration test), but it narrows the documented capability and will need revisiting for Phase-2 ingest. The shipped docs do not overclaim external-dir support, so there is no truthful-UI violation.\n- Suggested fix: In a follow-up, fall back to treating the argument as a direct bundle path when topic resolution fails, matching the `Check` contract.\n\n## R-003 — Lenient `okf check` hard-errors non-date `##` headings in `log.md`\n\n- File: internal/okf/okf.go\n- Line: 655\n- Issue: `checkLogFile` emits `models.SeverityError` for every `## ` heading in `log.md` that does not parse as `YYYY-MM-DD`, and this is not gated by `--strict` (the `strict` parameter is discarded). The PRD/TechSpec posture is lenient-by-default so externally produced bundles pass; a real-world `log.md` containing a prose `## Section` heading would fail conformance hard even without `--strict`. This does not affect the MVP success metric (the three official fixtures ship no `log.md`, confirmed by round-1 evidence and the green integration run), so it is not a blocker, but it is a latent leniency gap for Phase-2 external-bundle ingest.\n- Suggested fix: Treat malformed log headings as a `warningSeverity(strict)` diagnostic (warning by default, error under `--strict`), consistent with the rest of the lenient ruleset.\n\n# Nits\n\n## N-001 — Residual no-op discards and unused parameters in `internal/okf`\n\n- File: internal/okf/okf.go\n- Line: 83\n- Issue: `_ = sourceTopicRoot` (line 83), `_ = bundlePath` / `_ = strict` (lines 650-651), and `_ = strict` (line 671) are dead no-op statements left after the round-2 dead-code sweep; `checkIndexFile`/`checkLogFile` carry parameters they never use, and `resolveSourceDocument` returns a `topicRoot` its only caller discards.\n- Suggested fix: Drop the unused `topicRoot` return from `resolveSourceDocument` and remove the unused `bundlePath`/`strict` parameters (and their `_ =` discards) from `checkIndexFile`/`checkLogFile`.\n\n## N-002 — Weak/incorrect log assertion in the OKF CLI integration test\n\n- File: internal/cli/okf_integration_test.go\n- Line: 87\n- Issue: The promotion-log assertion is `if !strings.Contains(logContent, \"## \"+frontmatter.DateLayout[:4]) \u0026\u0026 !strings.Contains(logContent, \"**Creation**\")` — `frontmatter.DateLayout[:4]` is `\"2006\"`, so the first operand checks for the literal `\"## 2006\"`, which can never match a real date heading, and the `\u0026\u0026` makes the whole check pass on the `**Creation**` term alone. The date-heading half of the assertion provides false confidence and never actually validates the `## \u003cdate\u003e` heading.\n- Suggested fix: Assert both markers with `||` (fail if either is missing) and compare the heading against the formatted promotion date rather than `DateLayout[:4]`.\n\n# Evidence\n\n- Read in full: `_prd.md`, `_techspec.md`, ADR-004, round-1 findings (`impl-review-findings-round1.md`), round-1 remediation (`impl-review-remediation-round1.md`); project rules `CLAUDE.md`, `AGENTS.md`, `CONTRIBUTING.md`.\n- Read in full: the round-2 raw patch (`impl-review-diff-round2.patch`, all 2997 lines), on-disk `internal/okf/okf.go`, `internal/vault/render_test.go`, and the relevant span of `internal/topic/topic.go`.\n- Verified B-001 fix: `TestRenderDocumentsUseOKFMarkdownLinkSyntax` (internal/vault/render_test.go:266) drives `vault.RenderDocuments(...)` with `topic.Mode = models.TopicModeOKF` and asserts a relative markdown link (`[main (function)](raw/codebase/symbols/main--commands-run-ts-l1.md)`, `[Codebase Overview](wiki/codebase/concepts/Codebase%20Overview.md)`) plus no `[[` — exercising the previously-dead OKF render branch and `fromDir` plumbing through the render path, as ADR-004/TechSpec N-001 require.\n- Verified N-001 (round 1) removal: `grep -rn \"WriteMetadataFileWithMode\"` matches only round-1 review artifacts, not production code; `WriteMetadataFile` now routes through `topicMetadataForRef(..., \"\")` (mode omitted via `omitempty`).\n- Verified N-002 (round 1) tightening: `firstBodySentence` returns `\"\"` when no `. `/`! `/`? ` boundary exists, `resolveDescription` then emits the empty-description warning; covered by `TestPromoteWarnsWhenSourceBodyHasNoSentenceFallback` (real assertions on empty description + warning).\n- Verified N-003 (round 1) removal: the symlink branch in both `Check` (okf.go:177) and `loadConcepts` (okf.go:553) is now `if entry.Type()\u0026fs.ModeSymlink != 0 { return nil }` with no dead `IsDir`/`SkipDir` sub-branch (behavior-preserving — `DirEntry.IsDir()` is always false for a symlink and `WalkDir` does not follow symlinks).\n- Verified R-002 (round 1) docs: README.md, AGENTS.md, CLAUDE.md all updated with `topic new --mode wiki|okf`, `kb promote`, `kb okf check`, and `[okf].types`; docs do not overclaim external-dir support.\n- Confirmed non-destructive promote and reserved-file exclusion remain correct (source-unchanged assertions and `kb okf check` clean-on-fresh-bundle assertions pass in unit + integration tests).\n- `go build ./...` — Success.\n- `go test ./internal/okf/... ./internal/vault/... ./internal/topic/... ./internal/cli/... ./internal/config/...` — 295 passed.\n- `go test -tags integration ./internal/okf/... ./internal/cli/...` — 181 passed (official ga4/stackoverflow/crypto_bitcoin lenient conformance + end-to-end promote/check).\n- Limitation: did not run the full `make verify` (mage/lint/boundaries) in this pass; the round-1 remediation reports `make verify` green (860 tests, zero lint), and direct `go build`/`go test` (unit + integration) over the affected packages pass here.\n\n# Deferred Or Follow-Up\n\n- R-001 structured logging and R-003 external-bundle CLI checking remain deferred follow-ups (non-blocking; confirmed acceptable for the MVP).\n- The OKF render branch is now smoke-tested but still emits bundle-root-relative links (`fromDir == \"\"` at every render site); ADR-004 defers correct `fromDir` plumbing for codebase→OKF to Phase 3. No MVP path renders a codebase topic in OKF mode, so this is the accepted dormant-branch behavior — track for Phase 3.\n- `--to \u003ctopic\u003e/\u003csubdir\u003e` promotion and nested/`type`-grouped concept directories remain intentional Phase-2 deferrals.\n- Consider consolidating the duplicated bundle-walk logic shared by `Check` and `loadConcepts` (entry filtering, exclusion set, posix-rel derivation) before Phase 2 grows the package.\n"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-013457-686051000-25106f92d0f1a262","time":"2026-06-28T01:39:55.10995Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01FYzCjKyAFnNQmxQT6quPnr","usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-013457-686051000-25106f92d0f1a262","time":"2026-06-28T01:39:55.11277Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01FYzCjKyAFnNQmxQT6quPnr","tool_call_state":"completed","blocks":[{"type":"tool_result","tool_use_id":"toolu_01FYzCjKyAFnNQmxQT6quPnr","content":"File created successfully at: /Users/pedronauck/Dev/compozy/kb/.compozy/tasks/okf-dual-mode/qa/impl-review-findings-round2.md (file state is current in your context — no need to Read it back)"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-013457-686051000-25106f92d0f1a262","time":"2026-06-28T01:39:58.395712Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":""}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-013457-686051000-25106f92d0f1a262","time":"2026-06-28T01:39:58.396799Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":"W"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-013457-686051000-25106f92d0f1a262","time":"2026-06-28T01:39:58.397765Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":"rote /Users/pedronauck/Dev/compozy/kb/.compozy/tasks/okf-dual-mode/qa/impl-review-findings-round2.md"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260628-013457-686051000-25106f92d0f1a262","time":"2026-06-28T01:39:58.45216Z","turn":1,"type":"session.update","update":{"usage":{},"status":"completed"},"usage":{}} +{"output":"I'll start by reading all the context files, project rules, and the review diff in full.\n\nLet me continue reading the diff patch.\n\nLet me continue reading the rest of the diff patch.\n\nLet me read the round-1 findings, remediation, ADRs, and the plan file.\n\nNow let me verify the current on-disk state of the key changed files, since the diff can hide surounding context. Let me read the full implementation files and remaining ADRs/plan.\n\nLet me verify the dead-code removal and build/test the affected packages for evidence.\n\nDead function is gone (only referenced in round-1 review artifacts). Build succeeds. Now let me run the affected test suites.\n\nTests pass (295 unit, 181 integration). Let me check the remaining project rule file and confirm a couple of details.\n\nLet me get an accurate timestamp for the findings file.\n\nAll round-1 remediations are verified in the working tree and the suites are green. Writing the round-2 findings file now.\n\nWrote /Users/pedronauck/Dev/compozy/kb/.compozy/tasks/okf-dual-mode/qa/impl-review-findings-round2.md","run_id":"exec-20260628-013457-686051000-25106f92d0f1a262","status":"succeeded","time":"2026-06-28T01:39:58.468836Z","type":"run.succeeded"} diff --git a/.compozy/tasks/okf-dual-mode/qa/impl-review-findings-round1.md b/.compozy/tasks/okf-dual-mode/qa/impl-review-findings-round1.md new file mode 100644 index 0000000..66800a1 --- /dev/null +++ b/.compozy/tasks/okf-dual-mode/qa/impl-review-findings-round1.md @@ -0,0 +1,88 @@ +--- +schema_version: 1 +review_kind: implementation +round: 1 +verdict: FIX_BEFORE_SHIP +reviewer_runtime: claude +reviewer_model: opus +generated_at: 2026-06-28T01:28:15Z +--- + +# Summary + +The OKF dual-mode MVP is implemented coherently and matches the accepted ADRs: the `mode` field, OKF scaffold, `[okf].types` config, mode-aware `LinkFormatter` with the full ~43-site migration, the mechanical/non-destructive `promote`, the lenient `kb okf check`, both documented deviations (four producer fields + relative links), and the vendored official fixtures all build, lint clean, and pass unit + integration tests (including the three official-bundle conformance tests and the end-to-end promote — the PRD's explicit success metrics). The one blocker is the omission of the ADR-004/TechSpec-mandated N-001 "OKF render smoke" test, which was the accepted condition for migrating the dormant render-pipeline OKF branch now rather than in Phase 3, leaving that branch shipped without any test exercising it through a render function. + +# Blockers + +## B-001 — N-001 OKF render-smoke test mandated by ADR-004 is missing + +- File: internal/vault/render.go +- Line: 191 +- Issue: ADR-004 (Risks → "Dormant OKF render branch ships as dead code in the MVP (N-001)") and the TechSpec Testing Approach ("OKF render smoke (N-001) — render one document for a `mode: okf` topic and assert the OKF link branch and its `fromDir` plumbing execute and emit a relative markdown link, so the wired render branch is live, not shipped dead") make this smoke test the explicit, accepted justification for migrating all ~43 link call sites now. The migration was done (every site routes through `linkFor(topic, "", target, label)`), but no test ever invokes a render function (`RenderDocuments`/`renderDashboard`/`renderConceptIndex`/etc.) with `TopicMetadata{Mode: TopicModeOKF}`. The only OKF-mode coverage is `internal/vault/pathutils_test.go:232`, which calls `vault.LinkFormatterFor(...).Link(...)` directly — it exercises the formatter selection but not the render-site `linkFor` plumbing with `fromDir`. The render-site OKF branch therefore ships effectively untested, which is precisely the dead-code outcome N-001 was added to prevent. The codex plan's Test Plan silently dropped N-001 from the TechSpec. +- Rationale: This is an architectural decision recorded in an accepted ADR that is "implemented differently than specified" — the migration landed but its required guard test did not. Project rules (CLAUDE.md / AGENTS.md "Treat test failures as behavior bugs first"; the global "no partial deliverables" rule) and the review's dead-code / test-shape criteria make shipping a wired-but-unexercised branch without the spec-mandated smoke test a fix-before-ship gap. Remediation is local and the change shape is correct. +- Suggested fix: Add one table-friendly test in `internal/vault` that builds a minimal graph/metrics fixture (reuse `testTopicFixture()`), sets `topic.Mode = models.TopicModeOKF`, calls `vault.RenderDocuments(...)` (or a single render helper), and asserts at least one emitted body contains a relative markdown link (`[Label](...md)`) and no `[[wikilink]]`, confirming the OKF branch and `fromDir` plumbing execute through the render path. + +# Risks + +## R-001 — No structured logging despite the TechSpec observability contract + +- File: internal/okf/okf.go +- Line: 1 +- Issue: The TechSpec "Monitoring and Observability" section enumerates required `internal/logger` (slog) events for both verbs — `promote` source/target/type, link-transform count, broken-link count, `promote rejected: target not okf` (N-202), `description fallback used`; and `okf check` files-scanned, `reserved file skipped` (N-205), errors/warnings/strict flag. The `internal/okf` package and the new `internal/cli/promote.go` / `internal/cli/okf.go` import no logger and emit none of these. Operators get only the final JSON/table output and have no debug trail for unresolved links, description fallbacks, or skipped files. +- Suggested fix: Thread `internal/logger` into `Promote`/`Check` (or log at the CLI layer from `ConceptResult`/issues) for the events listed in the TechSpec, at debug/info level. + +## R-002 — New user-facing commands are not documented in the project CLI surface + +- File: CLAUDE.md +- Line: null +- Issue: `kb promote`, the `kb okf` group / `kb okf check`, and `topic new --mode` are absent from the CLI command tables in CLAUDE.md ("CLI Surface"), AGENTS.md ("CLI Commands"), and README. `config.example.toml` was correctly updated with `[okf]`, but the command docs now drift from the shipped surface, and these files are the canonical agent/operator references for what `kb` exposes. A hard-cut change should touch code, config, and docs together. +- Suggested fix: Add `kb promote`, `kb okf {check}`, and the `topic new --mode wiki|okf` flag to the CLI tables/notes in CLAUDE.md and AGENTS.md (and README if it lists commands), in the same change. + +## R-003 — `kb okf check` cannot validate arbitrary/external bundles the checker supports + +- File: internal/cli/okf.go +- Line: 66 +- Issue: ADR-005 states the checker "takes the resolved bundle (topic) path; it does not require `mode: okf` so it can validate arbitrary directories and external bundles." The underlying `okf.Check` honors this (the integration test runs it on raw `testdata/official/...` paths). The CLI, however, resolves the argument through `ktopic.Info(vaultPath, topicSlug)`, which requires a valid kb topic (e.g. a readable `CLAUDE.md`/`topic.yaml`), so `kb okf check <some-external-dir>` cannot be run from the CLI. This is acceptable for the MVP (the operator's own bundle is a kb topic and the success metric is met via the direct `Check` integration test), but it narrows the documented capability and will need revisiting for Phase-2 ingest. +- Suggested fix: Note the MVP limitation, or allow `okf check` to fall back to treating the argument as a direct bundle path when topic resolution fails, so it matches the `Check` contract. + +# Nits + +## N-001 — Dead exported function `WriteMetadataFileWithMode` + +- File: internal/topic/topic.go +- Line: 561 +- Issue: `WriteMetadataFileWithMode` is exported but has zero callers (production or test); `internal/vault/writer.go` still uses the mode-less `WriteMetadataFile`. It is speculative dead API surface (the linter won't catch it because it is exported). +- Suggested fix: Delete `WriteMetadataFileWithMode`, or route the one real caller (`writer.go:106`) through it with an explicit wiki mode if a written `mode:` line is desired there. + +## N-002 — `firstBodySentence` returns the entire body when no sentence delimiter exists + +- File: internal/okf/okf.go +- Line: 446 +- Issue: The TechSpec description fallback is "first non-empty body sentence (markdown-stripped) → else empty + warning." When the body has text but no `. `/`! `/`? ` delimiter, `firstBodySentence` returns the whole normalized body, producing a potentially very long `description` with no warning instead of a single sentence. +- Suggested fix: Cap the no-delimiter fallback (e.g. first line, or first N chars) or emit the empty-description warning when no sentence boundary is found. + +## N-003 — Unreachable symlink-directory branch in the conformance walk + +- File: internal/okf/okf.go +- Line: 177 +- Issue: In `Check` (and identically in `loadConcepts`), `if entry.Type()&fs.ModeSymlink != 0 { if entry.IsDir() { return filepath.SkipDir } ... }` — for a symlink, `fs.DirEntry.IsDir()` is always false (it reflects `Type().IsDir()`), and `filepath.WalkDir` never descends into symlinked directories anyway, so the inner `SkipDir` branch is dead. +- Suggested fix: Drop the `entry.IsDir()` sub-branch for symlinks (always `return nil`), or add a comment noting it is defensive. + +# Evidence + +- Read in full: PRD (`_prd.md`), TechSpec (`_techspec.md`), ADR-001..006, codex plan `20260627-220335-okf-dual-mode.md`; project rules CLAUDE.md, AGENTS.md, CONTRIBUTING.md. +- Read in full: `internal/okf/okf.go`, `internal/okf/okf_test.go`, `internal/okf/official_integration_test.go`, `internal/cli/promote.go`, `internal/cli/okf.go`, `internal/cli/okf_test.go`, `internal/cli/okf_integration_test.go`, `internal/cli/topic.go` (diff), `internal/topic/topic.go` (diff + on-disk), `internal/vault/pathutils.go`, `internal/frontmatter/frontmatter.go`, `internal/models/kb_models.go`; reviewed the raw patch hunks for `models.go`, `config.go`, `config_test.go`, `generate.go`, `render.go`, `render_wiki.go`, `writer.go`, `topic_test.go`, `pathutils_test.go`, and the `okf-claude-template.md` asset. +- `go build ./...` — Success. +- `go test ./internal/okf/... ./internal/cli/... ./internal/topic/... ./internal/vault/... ./internal/config/...` — all `ok`. +- `go test -tags integration ./internal/okf/... ./internal/cli/...` — all `ok` (official ga4/stackoverflow/crypto_bitcoin lenient conformance + end-to-end promote/check pass). +- `golangci-lint run` (v2.11.4) over the changed packages — `0 issues.` +- Inspected `internal/okf/testdata/official`: ga4 (17 md), stackoverflow (53 md), crypto_bitcoin (8 md); root and nested `index.md` files carry no frontmatter; every concept `.md` has a `type:`; no `log.md`/README/LICENSE inside the individual bundles; only `viz.html` non-md files — confirming the lenient checker classifies the corpus correctly. +- Verified zero-regression coverage: existing `internal/vault/render_test.go` asserts exact `[[demo-repo/...|...]]` wikilink output through `RenderDocuments`, so the wiki branch of the migration is byte-verified by the existing suite (ADR-004 mitigation satisfied for the wiki side only). +- Confirmed no logger usage in `internal/okf` / new CLI files, and no mention of the new commands in CLAUDE.md/AGENTS.md/README. +- Limitation: did not run the full `make verify` (mage/mise gating noted in the plan); evidence above used direct `go`/`golangci-lint` invocations on the affected packages. + +# Deferred Or Follow-Up + +- `--to <topic>/<subdir>` promotion is intentionally deferred (codex plan Assumptions); flat-root concepts only for MVP — no action needed now, but track for Phase 2. +- Phase 2/3 surfaces (`kb okf export`, `kb okf ingest`, codebase→OKF) are out of scope and correctly absent. +- Consider consolidating the duplicated bundle-walk logic shared by `Check` and `loadConcepts` (entry filtering, exclusion set, posix-rel derivation) into one helper before Phase 2 grows the package. diff --git a/.compozy/tasks/okf-dual-mode/qa/impl-review-findings-round2.md b/.compozy/tasks/okf-dual-mode/qa/impl-review-findings-round2.md new file mode 100644 index 0000000..2e25537 --- /dev/null +++ b/.compozy/tasks/okf-dual-mode/qa/impl-review-findings-round2.md @@ -0,0 +1,78 @@ +--- +schema_version: 1 +review_kind: implementation +round: 2 +verdict: SHIP +reviewer_runtime: claude +reviewer_model: opus +generated_at: 2026-06-28T01:39:07Z +--- + +# Summary + +The round-1 sole blocker (B-001, the ADR-004/TechSpec N-001 OKF render-smoke test) is now fixed by `TestRenderDocumentsUseOKFMarkdownLinkSyntax`, which drives `RenderDocuments` with `TopicModeOKF` and asserts a relative markdown link plus the absence of wikilinks through the live render branch; the three round-1 nits (dead `WriteMetadataFileWithMode`, whole-body description fallback, unreachable symlink branch) and the docs risk (R-002) are all correctly remediated and the full OKF MVP builds and passes 295 unit + 181 integration tests. No blockers remain and the change shape matches the accepted ADRs and TechSpec, so this ships; the two deferred risks (structured logging, external-bundle CLI checking) and minor test/code nits are acceptable follow-ups. + +# Blockers + +None. + +# Risks + +## R-001 — TechSpec observability contract still unimplemented + +- File: internal/okf/okf.go +- Line: 1 +- Issue: The TechSpec "Monitoring and Observability" section enumerates required `internal/logger` (slog) events for both verbs (`promote` source/target/type, link-transform count, broken-link count, `promote rejected: target not okf`, `description fallback used`; `okf check` files-scanned, `reserved file skipped`, errors/warnings/strict flag). `internal/okf`, `internal/cli/promote.go`, and `internal/cli/okf.go` still import no logger and emit none of these. Carried over from round-1 R-001 and explicitly deferred in the remediation. Operators get only the final JSON/table output with no debug trail for unresolved links, description fallbacks, or skipped files. This was classified as a non-blocking risk in round 1 and the success metrics (conformance on official bundles, end-to-end promote, zero regression) do not depend on it, so it does not block ship — but the spec'd contract remains unmet. +- Suggested fix: Thread `internal/logger` into `Promote`/`Check` (or log at the CLI layer from `ConceptResult`/issues) for the events listed in the TechSpec at debug/info level, in a follow-up. + +## R-002 — `okf check` cannot validate arbitrary/external bundles the checker supports + +- File: internal/cli/okf.go +- Line: 70 +- Issue: ADR-005 states the checker "takes the resolved bundle (topic) path; it does not require `mode: okf` so it can validate arbitrary directories and external bundles," and `okf.Check` honors this (the integration test runs it directly on raw `testdata/official/...` paths). The CLI, however, resolves the argument through `ktopic.Info(vaultPath, topicSlug)`, which requires a valid kb topic, so `kb okf check <external-dir>` cannot be run from the CLI. Carried over from round-1 R-003 and intentionally deferred. Acceptable for the MVP (the operator's own bundle is a kb topic and the conformance success metric is met via the direct `Check` integration test), but it narrows the documented capability and will need revisiting for Phase-2 ingest. The shipped docs do not overclaim external-dir support, so there is no truthful-UI violation. +- Suggested fix: In a follow-up, fall back to treating the argument as a direct bundle path when topic resolution fails, matching the `Check` contract. + +## R-003 — Lenient `okf check` hard-errors non-date `##` headings in `log.md` + +- File: internal/okf/okf.go +- Line: 655 +- Issue: `checkLogFile` emits `models.SeverityError` for every `## ` heading in `log.md` that does not parse as `YYYY-MM-DD`, and this is not gated by `--strict` (the `strict` parameter is discarded). The PRD/TechSpec posture is lenient-by-default so externally produced bundles pass; a real-world `log.md` containing a prose `## Section` heading would fail conformance hard even without `--strict`. This does not affect the MVP success metric (the three official fixtures ship no `log.md`, confirmed by round-1 evidence and the green integration run), so it is not a blocker, but it is a latent leniency gap for Phase-2 external-bundle ingest. +- Suggested fix: Treat malformed log headings as a `warningSeverity(strict)` diagnostic (warning by default, error under `--strict`), consistent with the rest of the lenient ruleset. + +# Nits + +## N-001 — Residual no-op discards and unused parameters in `internal/okf` + +- File: internal/okf/okf.go +- Line: 83 +- Issue: `_ = sourceTopicRoot` (line 83), `_ = bundlePath` / `_ = strict` (lines 650-651), and `_ = strict` (line 671) are dead no-op statements left after the round-2 dead-code sweep; `checkIndexFile`/`checkLogFile` carry parameters they never use, and `resolveSourceDocument` returns a `topicRoot` its only caller discards. +- Suggested fix: Drop the unused `topicRoot` return from `resolveSourceDocument` and remove the unused `bundlePath`/`strict` parameters (and their `_ =` discards) from `checkIndexFile`/`checkLogFile`. + +## N-002 — Weak/incorrect log assertion in the OKF CLI integration test + +- File: internal/cli/okf_integration_test.go +- Line: 87 +- Issue: The promotion-log assertion is `if !strings.Contains(logContent, "## "+frontmatter.DateLayout[:4]) && !strings.Contains(logContent, "**Creation**")` — `frontmatter.DateLayout[:4]` is `"2006"`, so the first operand checks for the literal `"## 2006"`, which can never match a real date heading, and the `&&` makes the whole check pass on the `**Creation**` term alone. The date-heading half of the assertion provides false confidence and never actually validates the `## <date>` heading. +- Suggested fix: Assert both markers with `||` (fail if either is missing) and compare the heading against the formatted promotion date rather than `DateLayout[:4]`. + +# Evidence + +- Read in full: `_prd.md`, `_techspec.md`, ADR-004, round-1 findings (`impl-review-findings-round1.md`), round-1 remediation (`impl-review-remediation-round1.md`); project rules `CLAUDE.md`, `AGENTS.md`, `CONTRIBUTING.md`. +- Read in full: the round-2 raw patch (`impl-review-diff-round2.patch`, all 2997 lines), on-disk `internal/okf/okf.go`, `internal/vault/render_test.go`, and the relevant span of `internal/topic/topic.go`. +- Verified B-001 fix: `TestRenderDocumentsUseOKFMarkdownLinkSyntax` (internal/vault/render_test.go:266) drives `vault.RenderDocuments(...)` with `topic.Mode = models.TopicModeOKF` and asserts a relative markdown link (`[main (function)](raw/codebase/symbols/main--commands-run-ts-l1.md)`, `[Codebase Overview](wiki/codebase/concepts/Codebase%20Overview.md)`) plus no `[[` — exercising the previously-dead OKF render branch and `fromDir` plumbing through the render path, as ADR-004/TechSpec N-001 require. +- Verified N-001 (round 1) removal: `grep -rn "WriteMetadataFileWithMode"` matches only round-1 review artifacts, not production code; `WriteMetadataFile` now routes through `topicMetadataForRef(..., "")` (mode omitted via `omitempty`). +- Verified N-002 (round 1) tightening: `firstBodySentence` returns `""` when no `. `/`! `/`? ` boundary exists, `resolveDescription` then emits the empty-description warning; covered by `TestPromoteWarnsWhenSourceBodyHasNoSentenceFallback` (real assertions on empty description + warning). +- Verified N-003 (round 1) removal: the symlink branch in both `Check` (okf.go:177) and `loadConcepts` (okf.go:553) is now `if entry.Type()&fs.ModeSymlink != 0 { return nil }` with no dead `IsDir`/`SkipDir` sub-branch (behavior-preserving — `DirEntry.IsDir()` is always false for a symlink and `WalkDir` does not follow symlinks). +- Verified R-002 (round 1) docs: README.md, AGENTS.md, CLAUDE.md all updated with `topic new --mode wiki|okf`, `kb promote`, `kb okf check`, and `[okf].types`; docs do not overclaim external-dir support. +- Confirmed non-destructive promote and reserved-file exclusion remain correct (source-unchanged assertions and `kb okf check` clean-on-fresh-bundle assertions pass in unit + integration tests). +- `go build ./...` — Success. +- `go test ./internal/okf/... ./internal/vault/... ./internal/topic/... ./internal/cli/... ./internal/config/...` — 295 passed. +- `go test -tags integration ./internal/okf/... ./internal/cli/...` — 181 passed (official ga4/stackoverflow/crypto_bitcoin lenient conformance + end-to-end promote/check). +- Limitation: did not run the full `make verify` (mage/lint/boundaries) in this pass; the round-1 remediation reports `make verify` green (860 tests, zero lint), and direct `go build`/`go test` (unit + integration) over the affected packages pass here. + +# Deferred Or Follow-Up + +- R-001 structured logging and R-003 external-bundle CLI checking remain deferred follow-ups (non-blocking; confirmed acceptable for the MVP). +- The OKF render branch is now smoke-tested but still emits bundle-root-relative links (`fromDir == ""` at every render site); ADR-004 defers correct `fromDir` plumbing for codebase→OKF to Phase 3. No MVP path renders a codebase topic in OKF mode, so this is the accepted dormant-branch behavior — track for Phase 3. +- `--to <topic>/<subdir>` promotion and nested/`type`-grouped concept directories remain intentional Phase-2 deferrals. +- Consider consolidating the duplicated bundle-walk logic shared by `Check` and `loadConcepts` (entry filtering, exclusion set, posix-rel derivation) before Phase 2 grows the package. diff --git a/.compozy/tasks/okf-dual-mode/qa/impl-review-prompt-round1.md b/.compozy/tasks/okf-dual-mode/qa/impl-review-prompt-round1.md new file mode 100644 index 0000000..81a8b44 --- /dev/null +++ b/.compozy/tasks/okf-dual-mode/qa/impl-review-prompt-round1.md @@ -0,0 +1,166 @@ +<!-- +## Contents (section map for readers; this file is the executable reviewer prompt, passed verbatim) +- Context blocks: SCOPE, USER-PROVIDED CONTEXT FILES, PROJECT RULES, CHANGED FILES, DIFF, COMMIT LIST, TARGET FINDINGS FILE +- SCOPED-WRITE CONTRACT +- YOUR JOB +- CONSTRAINTS +- FINDINGS FILE FORMAT +--> + +You are a senior code reviewer pressure-testing an implementation diff authored by another LLM +or engineer. Bias toward simpler, deletable solutions over compatibility shims when the +requirements allow it. Your job is to find what is wrong, not to be polite. + +SCOPE OF THIS REVIEW: +Implements the accepted OKF dual-mode MVP for the kb CLI: topic metadata mode support with wiki default, OKF scaffold, OKF type vocabulary config, mode-aware link formatting, internal OKF promotion and conformance checks, top-level kb promote, kb okf check, official fixture integration coverage, and focused unit/integration tests. The raw patch is scoped to implementation and representative fixture metadata; the full upstream official fixture corpus is present under internal/okf/testdata/official and validated by integration tests. + +USER-PROVIDED CONTEXT FILES (read fully before reasoning, skip if `none`): +.compozy/tasks/okf-dual-mode/_prd.md +.compozy/tasks/okf-dual-mode/_techspec.md +.compozy/tasks/okf-dual-mode/adrs/adr-001.md +.compozy/tasks/okf-dual-mode/adrs/adr-002.md +.compozy/tasks/okf-dual-mode/adrs/adr-003.md +.compozy/tasks/okf-dual-mode/adrs/adr-004.md +.compozy/tasks/okf-dual-mode/adrs/adr-005.md +.compozy/tasks/okf-dual-mode/adrs/adr-006.md +.codex/plans/20260627-220335-okf-dual-mode.md + +PROJECT RULES (read every one that exists, ignore the rest): +AGENTS.md +CLAUDE.md +CONTRIBUTING.md + +Before reasoning, read every context and rules file above in full. Also read any project +convention files that exist in the repository even if not listed (for example `CLAUDE.md`, +`AGENTS.md`, `.cursor/rules/*`, `.cursorrules`, `CONTRIBUTING.md`) and hold the project's own +rules as the authority for what counts as a blocker. + +CHANGED FILES: +config.example.toml +internal/models/models.go +internal/models/kb_models.go +internal/config/config.go +internal/config/config_test.go +internal/topic/topic.go +internal/topic/topic_test.go +internal/topic/assets/okf-claude-template.md +internal/generate/generate.go +internal/vault/pathutils.go +internal/vault/pathutils_test.go +internal/vault/render.go +internal/vault/render_wiki.go +internal/vault/writer.go +internal/okf/okf.go +internal/okf/okf_test.go +internal/okf/official_integration_test.go +internal/okf/testdata/official/README.md +internal/okf/testdata/official/LICENSE.md +internal/cli/root.go +internal/cli/topic.go +internal/cli/topic_test.go +internal/cli/promote.go +internal/cli/okf.go +internal/cli/okf_test.go +internal/cli/okf_integration_test.go + +DIFF (raw patch): +.compozy/tasks/okf-dual-mode/qa/impl-review-diff-round1.patch + +COMMIT LIST (or `none` for staged-only review): +none; working-tree implementation diff is captured in the raw patch + +TARGET FINDINGS FILE: +/Users/pedronauck/Dev/compozy/kb/.compozy/tasks/okf-dual-mode/qa/impl-review-findings-round1.md + +SCOPED-WRITE CONTRACT: +1. You may write exactly one file: the target findings file above. +2. Do not edit source code, tests, configs, docs, specs, ledgers, prompts, summaries, or any other file. +3. Do not create sibling artifacts, temp files, backups, or alternate output files. +4. If you cannot write the exact target file, stop and report the failure briefly. Do not print the review findings to stdout as a fallback. +5. After writing the file, your final chat response must be one sentence: `Wrote /Users/pedronauck/Dev/compozy/kb/.compozy/tasks/okf-dual-mode/qa/impl-review-findings-round1.md`. + +YOUR JOB: +1. Read every context file fully. Then read every changed file in full (not just the hunks) — diffs hide surrounding state. +2. Cross-check the implementation against any user-provided context (specs, ADRs, RFCs, design docs) when present. Flag any requirement, acceptance criterion, or architectural decision that is missing, partially implemented, or implemented differently than specified. +3. Identify BLOCKERS — issues that must be fixed before this change ships: + - Security regressions: secrets in logs, command/SQL injection, missing authn/authz on a new surface, sensitive tokens crossing a transport/log boundary, unverified-input trust. + - Concurrency bugs: races, deadlocks, goroutine/thread/task leaks, missing cancellation, lock-ordering hazards, unsynchronized shared state. + - Correctness bugs: nil/undefined deref on a hot path, off-by-one, swallowed errors, panics/process-exits in library/handler code. + - Persistence hazards: schema change without a safe migration, irreversible data migration, missing transaction boundary on a state-mutating operation, inverted side-table/blob storage. + - Surface incompleteness: one interface shipped without its peers (e.g. API without client/docs), generated-artifact drift, backend change without the matching frontend/docs impact. + - Test-shape violations: missing behavior assertions, mocks replacing real assertions, status-only checks on responses, persistence-sensitive change with no test touching a real datastore. + - Dead-code / compat-shim violations: leftover dual fields, alias renames, commented-out graveyards, migration code defending against state that never existed. + - Truthful-UI violations: UI rendering controls or metrics the backend does not actually support. +4. Identify RISKS — latent or non-blocking concerns the team should know about: observability gaps, test-density holes, missing doc co-ship, tight coupling that will hurt the next refactor, performance smells that are fine today but bite at scale. +5. Identify NITS — clarity, naming, dead code, comment-policy violations, doc-comment gaps. +6. Issue a VERDICT: SHIP / FIX_BEFORE_SHIP / REWORK. + - SHIP — no blockers; risks/nits acceptable as follow-ups. + - FIX_BEFORE_SHIP — at least one blocker, but the change shape is right; remediation is local. + - REWORK — structural problems require redesign or a new spec (abstraction inverted, parallel system created, wrong layer). + +CONSTRAINTS: +- Prefer "delete the old thing" over "preserve compat" unless the diff gives a concrete reason to keep both. +- Hard cuts: a rename or removal should touch all affected surfaces (code, storage, APIs, CLI, docs, specs) in the same change, not leave a half-migrated state. +- Reuse canonical helpers/primitives over inline re-implementations. +- Errors are wrapped/propagated, not silently discarded; no discarded errors in production code without a written justification. +- Generated or codegen'd artifacts co-ship with their source change in the same diff. +- Honor any additional rules stated in the project convention files you read. + +FINDINGS FILE FORMAT: +Write `/Users/pedronauck/Dev/compozy/kb/.compozy/tasks/okf-dual-mode/qa/impl-review-findings-round1.md` as Markdown with this exact frontmatter and headings: + +--- +schema_version: 1 +review_kind: implementation +round: 1 +verdict: SHIP|FIX_BEFORE_SHIP|REWORK +reviewer_runtime: claude +reviewer_model: opus +generated_at: <ISO-8601 timestamp> +--- + +# Summary + +Two sentences explaining the verdict. + +# Blockers + +Use `None.` when there are no blockers. Otherwise, use one item per blocker: + +## B-NNN — <short title> + +- File: <repo-root path> +- Line: <line number or null> +- Issue: <one paragraph> +- Rationale: <why this blocks shipment, with project rule/constraint reference> +- Suggested fix: <concrete change> + +# Risks + +Use `None.` when there are no risks. Otherwise, use one item per risk: + +## R-NNN — <short title> + +- File: <repo-root path> +- Line: <line number or null> +- Issue: <one paragraph> +- Suggested fix: <concrete change> + +# Nits + +Use `None.` when there are no nits. Otherwise, use one item per nit: + +## N-NNN — <short title> + +- File: <repo-root path> +- Line: <line number or null> +- Issue: <one line> +- Suggested fix: <one line> + +# Evidence + +List files read, tests/build evidence observed, and any limitations. Do not invent evidence. + +# Deferred Or Follow-Up + +List non-blocking follow-ups, or `None.`. diff --git a/.compozy/tasks/okf-dual-mode/qa/impl-review-prompt-round2.md b/.compozy/tasks/okf-dual-mode/qa/impl-review-prompt-round2.md new file mode 100644 index 0000000..b85ec77 --- /dev/null +++ b/.compozy/tasks/okf-dual-mode/qa/impl-review-prompt-round2.md @@ -0,0 +1,172 @@ +<!-- +## Contents (section map for readers; this file is the executable reviewer prompt, passed verbatim) +- Context blocks: SCOPE, USER-PROVIDED CONTEXT FILES, PROJECT RULES, CHANGED FILES, DIFF, COMMIT LIST, TARGET FINDINGS FILE +- SCOPED-WRITE CONTRACT +- YOUR JOB +- CONSTRAINTS +- FINDINGS FILE FORMAT +--> + +You are a senior code reviewer pressure-testing an implementation diff authored by another LLM +or engineer. Bias toward simpler, deletable solutions over compatibility shims when the +requirements allow it. Your job is to find what is wrong, not to be polite. + +SCOPE OF THIS REVIEW: +Round 2 implementation review for the accepted OKF dual-mode MVP after remediating round-1 findings. Round 1 returned FIX_BEFORE_SHIP for missing ADR-004/TechSpec N-001 OKF render-smoke coverage; this diff now adds that render-level OKF Markdown-link smoke test, removes dead/unreachable helper code, tightens promotion description fallback behavior, and updates CLI docs while preserving the full OKF implementation from round 1. The raw patch is scoped to implementation and representative fixture metadata; the full upstream official fixture corpus is present under internal/okf/testdata/official and validated by integration tests. + +USER-PROVIDED CONTEXT FILES (read fully before reasoning, skip if `none`): +.compozy/tasks/okf-dual-mode/_prd.md +.compozy/tasks/okf-dual-mode/_techspec.md +.compozy/tasks/okf-dual-mode/adrs/adr-001.md +.compozy/tasks/okf-dual-mode/adrs/adr-002.md +.compozy/tasks/okf-dual-mode/adrs/adr-003.md +.compozy/tasks/okf-dual-mode/adrs/adr-004.md +.compozy/tasks/okf-dual-mode/adrs/adr-005.md +.compozy/tasks/okf-dual-mode/adrs/adr-006.md +.codex/plans/20260627-220335-okf-dual-mode.md +.compozy/tasks/okf-dual-mode/qa/impl-review-findings-round1.md +.compozy/tasks/okf-dual-mode/qa/impl-review-remediation-round1.md + +PROJECT RULES (read every one that exists, ignore the rest): +AGENTS.md +CLAUDE.md +CONTRIBUTING.md + +Before reasoning, read every context and rules file above in full. Also read any project +convention files that exist in the repository even if not listed (for example `CLAUDE.md`, +`AGENTS.md`, `.cursor/rules/*`, `.cursorrules`, `CONTRIBUTING.md`) and hold the project's own +rules as the authority for what counts as a blocker. + +CHANGED FILES: +AGENTS.md +CLAUDE.md +README.md +config.example.toml +internal/models/models.go +internal/models/kb_models.go +internal/config/config.go +internal/config/config_test.go +internal/topic/topic.go +internal/topic/topic_test.go +internal/topic/assets/okf-claude-template.md +internal/generate/generate.go +internal/vault/pathutils.go +internal/vault/pathutils_test.go +internal/vault/render.go +internal/vault/render_test.go +internal/vault/render_wiki.go +internal/vault/writer.go +internal/okf/okf.go +internal/okf/okf_test.go +internal/okf/official_integration_test.go +internal/okf/testdata/official/README.md +internal/okf/testdata/official/LICENSE.md +internal/cli/root.go +internal/cli/topic.go +internal/cli/topic_test.go +internal/cli/promote.go +internal/cli/okf.go +internal/cli/okf_test.go +internal/cli/okf_integration_test.go + +DIFF (raw patch): +.compozy/tasks/okf-dual-mode/qa/impl-review-diff-round2.patch + +COMMIT LIST (or `none` for staged-only review): +none; working-tree implementation diff is captured in the raw patch + +TARGET FINDINGS FILE: +/Users/pedronauck/Dev/compozy/kb/.compozy/tasks/okf-dual-mode/qa/impl-review-findings-round2.md + +SCOPED-WRITE CONTRACT: +1. You may write exactly one file: the target findings file above. +2. Do not edit source code, tests, configs, docs, specs, ledgers, prompts, summaries, or any other file. +3. Do not create sibling artifacts, temp files, backups, or alternate output files. +4. If you cannot write the exact target file, stop and report the failure briefly. Do not print the review findings to stdout as a fallback. +5. After writing the file, your final chat response must be one sentence: `Wrote /Users/pedronauck/Dev/compozy/kb/.compozy/tasks/okf-dual-mode/qa/impl-review-findings-round2.md`. + +YOUR JOB: +1. Read every context file fully. Then read every changed file in full (not just the hunks) — diffs hide surrounding state. +2. Cross-check the implementation against any user-provided context (specs, ADRs, RFCs, design docs) when present. Flag any requirement, acceptance criterion, or architectural decision that is missing, partially implemented, or implemented differently than specified. +3. Identify BLOCKERS — issues that must be fixed before this change ships: + - Security regressions: secrets in logs, command/SQL injection, missing authn/authz on a new surface, sensitive tokens crossing a transport/log boundary, unverified-input trust. + - Concurrency bugs: races, deadlocks, goroutine/thread/task leaks, missing cancellation, lock-ordering hazards, unsynchronized shared state. + - Correctness bugs: nil/undefined deref on a hot path, off-by-one, swallowed errors, panics/process-exits in library/handler code. + - Persistence hazards: schema change without a safe migration, irreversible data migration, missing transaction boundary on a state-mutating operation, inverted side-table/blob storage. + - Surface incompleteness: one interface shipped without its peers (e.g. API without client/docs), generated-artifact drift, backend change without the matching frontend/docs impact. + - Test-shape violations: missing behavior assertions, mocks replacing real assertions, status-only checks on responses, persistence-sensitive change with no test touching a real datastore. + - Dead-code / compat-shim violations: leftover dual fields, alias renames, commented-out graveyards, migration code defending against state that never existed. + - Truthful-UI violations: UI rendering controls or metrics the backend does not actually support. +4. Identify RISKS — latent or non-blocking concerns the team should know about: observability gaps, test-density holes, missing doc co-ship, tight coupling that will hurt the next refactor, performance smells that are fine today but bite at scale. +5. Identify NITS — clarity, naming, dead code, comment-policy violations, doc-comment gaps. +6. Issue a VERDICT: SHIP / FIX_BEFORE_SHIP / REWORK. + - SHIP — no blockers; risks/nits acceptable as follow-ups. + - FIX_BEFORE_SHIP — at least one blocker, but the change shape is right; remediation is local. + - REWORK — structural problems require redesign or a new spec (abstraction inverted, parallel system created, wrong layer). + +CONSTRAINTS: +- Prefer "delete the old thing" over "preserve compat" unless the diff gives a concrete reason to keep both. +- Hard cuts: a rename or removal should touch all affected surfaces (code, storage, APIs, CLI, docs, specs) in the same change, not leave a half-migrated state. +- Reuse canonical helpers/primitives over inline re-implementations. +- Errors are wrapped/propagated, not silently discarded; no discarded errors in production code without a written justification. +- Generated or codegen'd artifacts co-ship with their source change in the same diff. +- Honor any additional rules stated in the project convention files you read. + +FINDINGS FILE FORMAT: +Write `/Users/pedronauck/Dev/compozy/kb/.compozy/tasks/okf-dual-mode/qa/impl-review-findings-round2.md` as Markdown with this exact frontmatter and headings: + +--- +schema_version: 1 +review_kind: implementation +round: 2 +verdict: SHIP|FIX_BEFORE_SHIP|REWORK +reviewer_runtime: claude +reviewer_model: opus +generated_at: <ISO-8601 timestamp> +--- + +# Summary + +Two sentences explaining the verdict. + +# Blockers + +Use `None.` when there are no blockers. Otherwise, use one item per blocker: + +## B-NNN — <short title> + +- File: <repo-root path> +- Line: <line number or null> +- Issue: <one paragraph> +- Rationale: <why this blocks shipment, with project rule/constraint reference> +- Suggested fix: <concrete change> + +# Risks + +Use `None.` when there are no risks. Otherwise, use one item per risk: + +## R-NNN — <short title> + +- File: <repo-root path> +- Line: <line number or null> +- Issue: <one paragraph> +- Suggested fix: <concrete change> + +# Nits + +Use `None.` when there are no nits. Otherwise, use one item per nit: + +## N-NNN — <short title> + +- File: <repo-root path> +- Line: <line number or null> +- Issue: <one line> +- Suggested fix: <one line> + +# Evidence + +List files read, tests/build evidence observed, and any limitations. Do not invent evidence. + +# Deferred Or Follow-Up + +List non-blocking follow-ups, or `None.`. diff --git a/.compozy/tasks/okf-dual-mode/qa/impl-review-remediation-round1.md b/.compozy/tasks/okf-dual-mode/qa/impl-review-remediation-round1.md new file mode 100644 index 0000000..e0d3986 --- /dev/null +++ b/.compozy/tasks/okf-dual-mode/qa/impl-review-remediation-round1.md @@ -0,0 +1,20 @@ +# Implementation Peer Review Round 1 Remediation + +## Incorporated + +- B-001: Added `TestRenderDocumentsUseOKFMarkdownLinkSyntax` in `internal/vault/render_test.go` to exercise `RenderDocuments` with `TopicModeOKF`, assert relative Markdown links, and assert wikilinks are not emitted. +- N-001: Removed unused exported `WriteMetadataFileWithMode`. +- N-002: Changed promotion description fallback to require an actual sentence boundary, with a regression proving no-punctuation bodies emit the existing warning and an empty description. +- N-003: Removed unreachable symlink-directory branches in the OKF bundle walkers. +- R-002: Updated `README.md`, `AGENTS.md`, and `CLAUDE.md` with `topic new --mode`, `kb promote`, `kb okf check`, and `[okf].types`. + +## Deferred + +- R-001: Structured logging remains deferred because there is no established package-level logger convention in the touched packages and the review classified it as a non-blocking risk. +- R-003: Direct external-bundle CLI checking remains deferred; the MVP CLI operates on kb topics while the underlying `okf.Check` supports arbitrary bundle paths. + +## Verification + +- `rtk go test ./internal/okf ./internal/topic ./internal/vault ./internal/cli`: passed, 260 tests. +- `rtk go test -tags integration ./internal/okf ./internal/cli`: passed, 181 tests. +- `rtk make verify MAGE=`: passed, 860 tests, 1 expected skip, zero lint issues, build, and boundaries green. diff --git a/.compozy/tasks/okf-dual-mode/qa/impl-review-result-round1.err b/.compozy/tasks/okf-dual-mode/qa/impl-review-result-round1.err new file mode 100644 index 0000000..e69de29 diff --git a/.compozy/tasks/okf-dual-mode/qa/impl-review-result-round2.err b/.compozy/tasks/okf-dual-mode/qa/impl-review-result-round2.err new file mode 100644 index 0000000..e69de29 diff --git a/.compozy/tasks/okf-dual-mode/qa/impl-review-status-after-round1.txt b/.compozy/tasks/okf-dual-mode/qa/impl-review-status-after-round1.txt new file mode 100644 index 0000000..109b7dd --- /dev/null +++ b/.compozy/tasks/okf-dual-mode/qa/impl-review-status-after-round1.txt @@ -0,0 +1,56 @@ + M .agents/skills/compozy/SKILL.md + M .agents/skills/compozy/references/cli-reference.md + M .agents/skills/compozy/references/config-reference.md + M .agents/skills/compozy/references/skills-reference.md + M .agents/skills/compozy/references/workflow-guide.md + M .agents/skills/cy-create-prd/SKILL.md + M .agents/skills/cy-create-prd/references/question-protocol.md + M .agents/skills/cy-create-tasks/SKILL.md + M .agents/skills/cy-create-tasks/references/task-context-schema.md + M .agents/skills/cy-create-tasks/references/task-template.md + M .agents/skills/cy-create-techspec/SKILL.md + M .agents/skills/cy-execute-task/SKILL.md + M .agents/skills/cy-execute-task/references/tracking-checklist.md + M .agents/skills/cy-fix-reviews/SKILL.md + D .agents/skills/cy-idea-factory/SKILL.md + D .agents/skills/cy-idea-factory/references/adr-template.md + D .agents/skills/cy-idea-factory/references/business-analyst.md + D .agents/skills/cy-idea-factory/references/council.md + D .agents/skills/cy-idea-factory/references/idea-template.md + D .agents/skills/cy-idea-factory/references/product-strategist.md + D .agents/skills/cy-idea-factory/references/question-protocol.md + M .agents/skills/cy-review-round/SKILL.md + M .agents/skills/git-rebase/SKILL.md + D .agents/skills/git-rebase/references/automation.md + D .agents/skills/git-rebase/references/resolution-patterns.md + D .agents/skills/git-rebase/references/scripts-tools.md + D .agents/skills/git-rebase/references/strategies.md + D .agents/skills/git-rebase/references/troubleshooting.md + D .agents/skills/git-rebase/scripts/analyze-conflicts.sh + D .agents/skills/git-rebase/scripts/pre-rebase-backup.sh + D .agents/skills/git-rebase/scripts/validate-merge.sh + M config.example.toml + M internal/cli/root.go + M internal/cli/topic.go + M internal/cli/topic_test.go + M internal/config/config.go + M internal/config/config_test.go + M internal/generate/generate.go + M internal/models/kb_models.go + M internal/models/models.go + M internal/topic/topic.go + M internal/topic/topic_test.go + M internal/vault/pathutils.go + M internal/vault/pathutils_test.go + M internal/vault/render.go + M internal/vault/render_wiki.go + M internal/vault/writer.go +?? .claude/ledger/2026-06-27-MEMORY-okf-dual-mode-prd.md +?? .codex/plans/20260627-220335-okf-dual-mode.md +?? .compozy/tasks/okf-dual-mode/ +?? internal/cli/okf.go +?? internal/cli/okf_integration_test.go +?? internal/cli/okf_test.go +?? internal/cli/promote.go +?? internal/okf/ +?? internal/topic/assets/okf-claude-template.md \ No newline at end of file diff --git a/.compozy/tasks/okf-dual-mode/qa/impl-review-status-after-round2.txt b/.compozy/tasks/okf-dual-mode/qa/impl-review-status-after-round2.txt new file mode 100644 index 0000000..b0babdf --- /dev/null +++ b/.compozy/tasks/okf-dual-mode/qa/impl-review-status-after-round2.txt @@ -0,0 +1,60 @@ + M .agents/skills/compozy/SKILL.md + M .agents/skills/compozy/references/cli-reference.md + M .agents/skills/compozy/references/config-reference.md + M .agents/skills/compozy/references/skills-reference.md + M .agents/skills/compozy/references/workflow-guide.md + M .agents/skills/cy-create-prd/SKILL.md + M .agents/skills/cy-create-prd/references/question-protocol.md + M .agents/skills/cy-create-tasks/SKILL.md + M .agents/skills/cy-create-tasks/references/task-context-schema.md + M .agents/skills/cy-create-tasks/references/task-template.md + M .agents/skills/cy-create-techspec/SKILL.md + M .agents/skills/cy-execute-task/SKILL.md + M .agents/skills/cy-execute-task/references/tracking-checklist.md + M .agents/skills/cy-fix-reviews/SKILL.md + D .agents/skills/cy-idea-factory/SKILL.md + D .agents/skills/cy-idea-factory/references/adr-template.md + D .agents/skills/cy-idea-factory/references/business-analyst.md + D .agents/skills/cy-idea-factory/references/council.md + D .agents/skills/cy-idea-factory/references/idea-template.md + D .agents/skills/cy-idea-factory/references/product-strategist.md + D .agents/skills/cy-idea-factory/references/question-protocol.md + M .agents/skills/cy-review-round/SKILL.md + M .agents/skills/git-rebase/SKILL.md + D .agents/skills/git-rebase/references/automation.md + D .agents/skills/git-rebase/references/resolution-patterns.md + D .agents/skills/git-rebase/references/scripts-tools.md + D .agents/skills/git-rebase/references/strategies.md + D .agents/skills/git-rebase/references/troubleshooting.md + D .agents/skills/git-rebase/scripts/analyze-conflicts.sh + D .agents/skills/git-rebase/scripts/pre-rebase-backup.sh + D .agents/skills/git-rebase/scripts/validate-merge.sh + M AGENTS.md + M CLAUDE.md + M README.md + M config.example.toml + M internal/cli/root.go + M internal/cli/topic.go + M internal/cli/topic_test.go + M internal/config/config.go + M internal/config/config_test.go + M internal/generate/generate.go + M internal/models/kb_models.go + M internal/models/models.go + M internal/topic/topic.go + M internal/topic/topic_test.go + M internal/vault/pathutils.go + M internal/vault/pathutils_test.go + M internal/vault/render.go + M internal/vault/render_test.go + M internal/vault/render_wiki.go + M internal/vault/writer.go +?? .claude/ledger/2026-06-27-MEMORY-okf-dual-mode-prd.md +?? .codex/plans/20260627-220335-okf-dual-mode.md +?? .compozy/tasks/okf-dual-mode/ +?? internal/cli/okf.go +?? internal/cli/okf_integration_test.go +?? internal/cli/okf_test.go +?? internal/cli/promote.go +?? internal/okf/ +?? internal/topic/assets/okf-claude-template.md \ No newline at end of file diff --git a/.compozy/tasks/okf-dual-mode/qa/impl-review-status-before-round1.txt b/.compozy/tasks/okf-dual-mode/qa/impl-review-status-before-round1.txt new file mode 100644 index 0000000..109b7dd --- /dev/null +++ b/.compozy/tasks/okf-dual-mode/qa/impl-review-status-before-round1.txt @@ -0,0 +1,56 @@ + M .agents/skills/compozy/SKILL.md + M .agents/skills/compozy/references/cli-reference.md + M .agents/skills/compozy/references/config-reference.md + M .agents/skills/compozy/references/skills-reference.md + M .agents/skills/compozy/references/workflow-guide.md + M .agents/skills/cy-create-prd/SKILL.md + M .agents/skills/cy-create-prd/references/question-protocol.md + M .agents/skills/cy-create-tasks/SKILL.md + M .agents/skills/cy-create-tasks/references/task-context-schema.md + M .agents/skills/cy-create-tasks/references/task-template.md + M .agents/skills/cy-create-techspec/SKILL.md + M .agents/skills/cy-execute-task/SKILL.md + M .agents/skills/cy-execute-task/references/tracking-checklist.md + M .agents/skills/cy-fix-reviews/SKILL.md + D .agents/skills/cy-idea-factory/SKILL.md + D .agents/skills/cy-idea-factory/references/adr-template.md + D .agents/skills/cy-idea-factory/references/business-analyst.md + D .agents/skills/cy-idea-factory/references/council.md + D .agents/skills/cy-idea-factory/references/idea-template.md + D .agents/skills/cy-idea-factory/references/product-strategist.md + D .agents/skills/cy-idea-factory/references/question-protocol.md + M .agents/skills/cy-review-round/SKILL.md + M .agents/skills/git-rebase/SKILL.md + D .agents/skills/git-rebase/references/automation.md + D .agents/skills/git-rebase/references/resolution-patterns.md + D .agents/skills/git-rebase/references/scripts-tools.md + D .agents/skills/git-rebase/references/strategies.md + D .agents/skills/git-rebase/references/troubleshooting.md + D .agents/skills/git-rebase/scripts/analyze-conflicts.sh + D .agents/skills/git-rebase/scripts/pre-rebase-backup.sh + D .agents/skills/git-rebase/scripts/validate-merge.sh + M config.example.toml + M internal/cli/root.go + M internal/cli/topic.go + M internal/cli/topic_test.go + M internal/config/config.go + M internal/config/config_test.go + M internal/generate/generate.go + M internal/models/kb_models.go + M internal/models/models.go + M internal/topic/topic.go + M internal/topic/topic_test.go + M internal/vault/pathutils.go + M internal/vault/pathutils_test.go + M internal/vault/render.go + M internal/vault/render_wiki.go + M internal/vault/writer.go +?? .claude/ledger/2026-06-27-MEMORY-okf-dual-mode-prd.md +?? .codex/plans/20260627-220335-okf-dual-mode.md +?? .compozy/tasks/okf-dual-mode/ +?? internal/cli/okf.go +?? internal/cli/okf_integration_test.go +?? internal/cli/okf_test.go +?? internal/cli/promote.go +?? internal/okf/ +?? internal/topic/assets/okf-claude-template.md \ No newline at end of file diff --git a/.compozy/tasks/okf-dual-mode/qa/impl-review-status-before-round2.txt b/.compozy/tasks/okf-dual-mode/qa/impl-review-status-before-round2.txt new file mode 100644 index 0000000..b0babdf --- /dev/null +++ b/.compozy/tasks/okf-dual-mode/qa/impl-review-status-before-round2.txt @@ -0,0 +1,60 @@ + M .agents/skills/compozy/SKILL.md + M .agents/skills/compozy/references/cli-reference.md + M .agents/skills/compozy/references/config-reference.md + M .agents/skills/compozy/references/skills-reference.md + M .agents/skills/compozy/references/workflow-guide.md + M .agents/skills/cy-create-prd/SKILL.md + M .agents/skills/cy-create-prd/references/question-protocol.md + M .agents/skills/cy-create-tasks/SKILL.md + M .agents/skills/cy-create-tasks/references/task-context-schema.md + M .agents/skills/cy-create-tasks/references/task-template.md + M .agents/skills/cy-create-techspec/SKILL.md + M .agents/skills/cy-execute-task/SKILL.md + M .agents/skills/cy-execute-task/references/tracking-checklist.md + M .agents/skills/cy-fix-reviews/SKILL.md + D .agents/skills/cy-idea-factory/SKILL.md + D .agents/skills/cy-idea-factory/references/adr-template.md + D .agents/skills/cy-idea-factory/references/business-analyst.md + D .agents/skills/cy-idea-factory/references/council.md + D .agents/skills/cy-idea-factory/references/idea-template.md + D .agents/skills/cy-idea-factory/references/product-strategist.md + D .agents/skills/cy-idea-factory/references/question-protocol.md + M .agents/skills/cy-review-round/SKILL.md + M .agents/skills/git-rebase/SKILL.md + D .agents/skills/git-rebase/references/automation.md + D .agents/skills/git-rebase/references/resolution-patterns.md + D .agents/skills/git-rebase/references/scripts-tools.md + D .agents/skills/git-rebase/references/strategies.md + D .agents/skills/git-rebase/references/troubleshooting.md + D .agents/skills/git-rebase/scripts/analyze-conflicts.sh + D .agents/skills/git-rebase/scripts/pre-rebase-backup.sh + D .agents/skills/git-rebase/scripts/validate-merge.sh + M AGENTS.md + M CLAUDE.md + M README.md + M config.example.toml + M internal/cli/root.go + M internal/cli/topic.go + M internal/cli/topic_test.go + M internal/config/config.go + M internal/config/config_test.go + M internal/generate/generate.go + M internal/models/kb_models.go + M internal/models/models.go + M internal/topic/topic.go + M internal/topic/topic_test.go + M internal/vault/pathutils.go + M internal/vault/pathutils_test.go + M internal/vault/render.go + M internal/vault/render_test.go + M internal/vault/render_wiki.go + M internal/vault/writer.go +?? .claude/ledger/2026-06-27-MEMORY-okf-dual-mode-prd.md +?? .codex/plans/20260627-220335-okf-dual-mode.md +?? .compozy/tasks/okf-dual-mode/ +?? internal/cli/okf.go +?? internal/cli/okf_integration_test.go +?? internal/cli/okf_test.go +?? internal/cli/promote.go +?? internal/okf/ +?? internal/topic/assets/okf-claude-template.md \ No newline at end of file diff --git a/.compozy/tasks/okf-dual-mode/qa/impl-review-summary-round1.md b/.compozy/tasks/okf-dual-mode/qa/impl-review-summary-round1.md new file mode 100644 index 0000000..6f942b6 --- /dev/null +++ b/.compozy/tasks/okf-dual-mode/qa/impl-review-summary-round1.md @@ -0,0 +1,32 @@ +# Implementation Peer Review Round 1 + +- Verdict: `FIX_BEFORE_SHIP` +- Blockers: 1 +- Risks: 3 +- Nits: 3 + +## Blocker + +- B-001: Missing ADR-004/TechSpec N-001 OKF render-smoke test. The implementation migrated the render path to mode-aware link formatting, but no render-level test proves an OKF-mode topic exercises that branch and emits relative Markdown links. + +## Risks + +- R-001: No structured logging for the TechSpec observability events. +- R-002: New commands and flags are not documented in the project command reference files. +- R-003: `kb okf check` CLI validates kb topics only, while the underlying checker can validate direct external bundle paths. + +## Nits + +- N-001: `WriteMetadataFileWithMode` is exported but currently unused. +- N-002: `firstBodySentence` can return the entire body when no sentence delimiter exists. +- N-003: Symlink-directory branch in conformance walking is unreachable. + +## Artifacts + +- Findings: `.compozy/tasks/okf-dual-mode/qa/impl-review-findings-round1.md` +- Prompt: `.compozy/tasks/okf-dual-mode/qa/impl-review-prompt-round1.md` +- Diff: `.compozy/tasks/okf-dual-mode/qa/impl-review-diff-round1.patch` +- Events: `.compozy/tasks/okf-dual-mode/qa/impl-review-events-round1.jsonl` +- Stderr: `.compozy/tasks/okf-dual-mode/qa/impl-review-result-round1.err` +- Status before: `.compozy/tasks/okf-dual-mode/qa/impl-review-status-before-round1.txt` +- Status after: `.compozy/tasks/okf-dual-mode/qa/impl-review-status-after-round1.txt` diff --git a/.compozy/tasks/okf-dual-mode/qa/impl-review-summary-round2.md b/.compozy/tasks/okf-dual-mode/qa/impl-review-summary-round2.md new file mode 100644 index 0000000..f52c64a --- /dev/null +++ b/.compozy/tasks/okf-dual-mode/qa/impl-review-summary-round2.md @@ -0,0 +1,31 @@ +# Implementation Peer Review Round 2 + +- Verdict: `SHIP` +- Blockers: 0 +- Risks: 3 +- Nits: 2 + +## Summary + +Round 2 validated the round-1 blocker remediation. No blockers remain. + +## Remaining Risks + +- R-001: Structured logging for the TechSpec observability events is still deferred. +- R-002: `kb okf check` CLI accepts kb topics only, while `okf.Check` can validate arbitrary bundle directories directly. +- R-003: Malformed `log.md` `##` headings are errors even in lenient mode; acceptable for MVP because official fixtures have no `log.md`. + +## Remaining Nits + +- N-001: Some no-op discards and unused parameters remain in `internal/okf`. +- N-002: The OKF CLI integration log assertion is weak. + +## Artifacts + +- Findings: `.compozy/tasks/okf-dual-mode/qa/impl-review-findings-round2.md` +- Prompt: `.compozy/tasks/okf-dual-mode/qa/impl-review-prompt-round2.md` +- Diff: `.compozy/tasks/okf-dual-mode/qa/impl-review-diff-round2.patch` +- Events: `.compozy/tasks/okf-dual-mode/qa/impl-review-events-round2.jsonl` +- Stderr: `.compozy/tasks/okf-dual-mode/qa/impl-review-result-round2.err` +- Status before: `.compozy/tasks/okf-dual-mode/qa/impl-review-status-before-round2.txt` +- Status after: `.compozy/tasks/okf-dual-mode/qa/impl-review-status-after-round2.txt` diff --git a/.compozy/tasks/okf-dual-mode/qa/peer-review-events-round1.jsonl b/.compozy/tasks/okf-dual-mode/qa/peer-review-events-round1.jsonl new file mode 100644 index 0000000..41573b2 --- /dev/null +++ b/.compozy/tasks/okf-dual-mode/qa/peer-review-events-round1.jsonl @@ -0,0 +1,114 @@ +{"dry_run":false,"run_id":"exec-20260627-221610-502089000-797361526ed537e0","status":"running","time":"2026-06-27T22:16:28.60098Z","type":"run.started"} +{"run_id":"exec-20260627-221610-502089000-797361526ed537e0","session":{"acp_session_id":"24157073-2124-4b22-96a4-a0f703fbe00e","agent_session_id":"","resumed":false},"time":"2026-06-27T22:16:30.664141Z","turn":1,"type":"session.attached"} +{"run_id":"exec-20260627-221610-502089000-797361526ed537e0","time":"2026-06-27T22:16:33.085134Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":""}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-221610-502089000-797361526ed537e0","time":"2026-06-27T22:16:33.085139Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":"I"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-221610-502089000-797361526ed537e0","time":"2026-06-27T22:16:33.556502Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":"'ll start by reading all the context files in full. Let me read them in parallel."}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-221610-502089000-797361526ed537e0","time":"2026-06-27T22:16:33.557364Z","turn":1,"type":"session.update","update":{"kind":"tool_call_started","tool_call_id":"toolu_01Sk4UE1EuQn1r43jFSn6rEA","tool_call_state":"pending","blocks":[{"type":"tool_use","id":"toolu_01Sk4UE1EuQn1r43jFSn6rEA","name":"Read","title":"Read File","raw_input":{}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-221610-502089000-797361526ed537e0","time":"2026-06-27T22:16:34.971292Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01Sk4UE1EuQn1r43jFSn6rEA","blocks":[{"type":"tool_use","id":"toolu_01Sk4UE1EuQn1r43jFSn6rEA","name":"Read","title":"Read .compozy/tasks/okf-dual-mode/_techspec.md","input":{"file_path":"/Users/pedronauck/Dev/compozy/kb/.compozy/tasks/okf-dual-mode/_techspec.md"},"raw_input":{"file_path":"/Users/pedronauck/Dev/compozy/kb/.compozy/tasks/okf-dual-mode/_techspec.md"}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-221610-502089000-797361526ed537e0","time":"2026-06-27T22:16:34.974354Z","turn":1,"type":"session.update","update":{"kind":"tool_call_started","tool_call_id":"toolu_01KD8XH3EhQCjqGRystNkfwM","tool_call_state":"pending","blocks":[{"type":"tool_use","id":"toolu_01KD8XH3EhQCjqGRystNkfwM","name":"Read","title":"Read File","raw_input":{}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-221610-502089000-797361526ed537e0","time":"2026-06-27T22:16:34.978968Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01Sk4UE1EuQn1r43jFSn6rEA","usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-221610-502089000-797361526ed537e0","time":"2026-06-27T22:16:34.983953Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01Sk4UE1EuQn1r43jFSn6rEA","tool_call_state":"completed","blocks":[{"type":"tool_result","tool_use_id":"toolu_01Sk4UE1EuQn1r43jFSn6rEA","content":"```\n1\t# TechSpec: OKF Dual-Mode for `kb`\n2\t\n3\t\u003e Implements `_prd.md`. Business context (WHAT/WHY/WHO) lives there; this document\n4\t\u003e covers HOW. Phasing follows the PRD: **MVP** = `mode` + OKF scaffold +\n5\t\u003e `kb promote` + `kb okf check`; Phase 2 = export/ingest; Phase 3 = codebase→OKF.\n6\t\n7\t## Executive Summary\n8\t\n9\tAdd OKF as a first-class topic mode to `kb` by (1) introducing a `mode: wiki|okf`\n10\tfield on the topic model, (2) abstracting link emission behind a mode-aware\n11\t`LinkFormatter`, (3) housing all OKF behavior in a new cohesive `internal/okf`\n12\tpackage (frontmatter contract, wikilink→markdown transform, `index.md`/`log.md`\n13\tgeneration, conformance checker, and the `promote` orchestration), and (4) exposing\n14\ta top-level `kb promote` plus a `kb okf` command group. OKF emission stays\n15\t**mechanical and non-LLM**, matching `kb`'s identity; conformance is **lenient per\n16\tOKF §9** with local-standard **warnings** and a `--strict` gate, emitting the four\n17\tproducer fields and relative, GitHub-safe links to match Google's real tooling.\n18\t\n19\t**Primary trade-off:** per the user's decision (ADR-004), the MVP introduces the\n20\tfull `LinkFormatter` abstraction and migrates **all ~43 link call sites** in the\n21\tcodebase→wiki render pipeline now, even though that pipeline produces wiki-mode\n22\ttopics and its OKF branch stays **dormant until Phase 3**. We accept a large\n23\tmechanical refactor with no MVP-exercised OKF render path in exchange for a single,\n24\tfinished mode-aware link seam — guarded against regression by byte-identical golden\n25\ttests of wiki output. The MVP's *active* OKF link consumer is `promote` (body\n26\ttransform) and `index.md` generation.\n27\t\n28\t## System Architecture\n29\t\n30\t### Component Overview\n31\t\n32\t| Component | Home | Responsibility |\n33\t| --- | --- | --- |\n34\t| `mode` field | `internal/models`, `internal/topic` | Mark a topic as `wiki` (default) or `okf`; normalize empty→`wiki`. |\n35\t| `LinkFormatter` | `internal/vault` | Mode-aware link rendering; `WikiLinkFormatter` + `OKFLinkFormatter`. |\n36\t| OKF core | `internal/okf` (new) | Frontmatter contract, link transform, `index.md`/`log.md` gen, conformance checker, `promote`. |\n37\t| Type vocabulary | `internal/config` | `[okf].types` canonical list for local-standard validation. |\n38\t| OKF scaffold | `internal/topic` | `--mode okf` builds a minimal bundle (no wiki pyramid). |\n39\t| CLI | `internal/cli` | `kb promote`, `kb okf check`, `kb topic new --mode`. |\n40\t\n41\t### Data Flow\n42\t\n43\t- **Scaffold:** `kb topic new --mode okf` → `internal/topic` writes `topic.yaml`\n44\t (`mode: okf`), a root `index.md` (`okf_version: \"0.1\"`), `log.md`, an OKF-flavored\n45\t `CLAUDE.md`, and the `AGENTS.md` symlink. No `raw/`/`wiki/`/`outputs/`.\n46\t- **Promote:** `kb promote \u003cwiki-doc\u003e --to \u003ctopic\u003e --type \u003cT\u003e` → `internal/okf`\n47\t reads the source via `vault` + `frontmatter.Parse`, remaps frontmatter to the OKF\n48\t contract, transforms `[[wikilinks]]`→relative markdown links, writes the new\n49\t concept via `frontmatter.Generate` + `vault`, appends to `log.md`, regenerates\n50\t `index.md`. Source doc is untouched.\n51\t- **Check:** `kb okf check \u003ctopic\u003e` → `internal/okf` walks the bundle, validates\n52\t §9 leniently, emits `[]models.LintIssue` rendered by `internal/output`.\n53\t\n54\tDependency direction is one-way: `internal/okf` → {`frontmatter`, `vault`,\n55\t`config`, `models`}; nothing imports `okf` back (ADR-003).\n56\t\n57\t## Implementation Design\n58\t\n59\t### Core Interfaces\n60\t\n61\tMode-aware link rendering, defined in `internal/vault` (ADR-004):\n62\t\n63\t```go\n64\t// LinkFormatter renders a link from the document being written (fromDir,\n65\t// bundle-relative, forward-slash) to a target document, with an optional label.\n66\ttype LinkFormatter interface {\n67\t Link(fromDir, targetPath, label string) string\n68\t}\n69\t\n70\t// LinkFormatterFor selects the formatter from the topic's mode.\n71\tfunc LinkFormatterFor(topic models.TopicMetadata) LinkFormatter {\n72\t if topic.Mode == models.TopicModeOKF {\n73\t return OKFLinkFormatter{}\n74\t }\n75\t return WikiLinkFormatter{Slug: topic.Slug}\n76\t}\n77\t```\n78\t\n79\tOKF promotion and conformance, in `internal/okf`:\n80\t\n81\t```go\n82\ttype PromoteInput struct {\n83\t SourceDocPath string // wiki doc to read (vault-relative)\n84\t TargetTopic models.TopicInfo\n85\t Type string // OKF concept type (validated vs config.OKF.Types)\n86\t}\n87\t\n88\ttype Promoter interface {\n89\t Promote(ctx context.Context, in PromoteInput) (ConceptResult, error)\n90\t}\n91\t\n92\ttype Checker interface {\n93\t // Check walks an OKF bundle; strict promotes warnings to errors.\n94\t Check(ctx context.Context, bundlePath string, strict bool) ([]models.LintIssue, error)\n95\t}\n96\t```\n97\t\n98\t### Data Models\n99\t\n100\tAdditions only; existing structs are unchanged except for one field each.\n101\t\n102\t```go\n103\t// internal/models\n104\ttype TopicMode = string\n105\tconst (\n106\t TopicModeWiki TopicMode = \"wiki\"\n107\t TopicModeOKF TopicMode = \"okf\"\n108\t)\n109\t// TopicMetadata and TopicInfo each gain: Mode TopicMode `json:\"mode\"`\n110\t\n111\t// internal/topic topicMetadataFile gains:\n112\t// Mode string `yaml:\"mode,omitempty\"` // empty normalized to \"wiki\" on read\n113\t\n114\t// internal/config\n115\ttype OKFConfig struct {\n116\t Types []string `toml:\"types\"` // canonical concept types (local vocabulary)\n117\t}\n118\t// Config gains: OKF OKFConfig `toml:\"okf\"`\n119\t```\n120\t\n121\t**OKF frontmatter contract** (the four producer fields written by `promote` and\n122\tauthoring): `type` (from `--type`), `title`, `description`, `timestamp`\n123\t(ISO 8601). Wiki stage markers (`stage`, wiki `type`) are dropped; `tags` carried\n124\tthrough when present.\n125\t\n126\t### Command Surface (CLI)\n127\t\n128\t| Command | Args / Flags | Behavior |\n129\t| --- | --- | --- |\n130\t| `kb topic new \u003cslug\u003e \u003ctitle\u003e \u003cdomain\u003e` | `--mode wiki\\|okf` (default `wiki`) | Wiki scaffold (unchanged) or minimal OKF scaffold. |\n131\t| `kb promote \u003cwiki-doc\u003e` | `--to \u003ctopic\u003e`, `--type \u003cT\u003e` | Mechanical, non-destructive wiki→OKF concept. |\n132\t| `kb okf check \u003ctopic\u003e` | `--strict`, `--format table\\|json\\|tsv` | OKF v0.1 conformance + local-standard warnings. |\n133\t\n134\tPhase 2 grows the group: `kb okf export`, `kb okf ingest` (out of MVP scope).\n135\t\n136\t## Integration Points\n137\t\n138\tNo external services. The only \"integration\" is the **OKF v0.1 file format**\n139\t(`GoogleCloudPlatform/knowledge-catalog` → `okf/SPEC.md`). Two deliberate,\n140\tdocumented deviations from the written spec, matching Google's reference tooling\n141\t(ADR-002): emit the four producer fields (spec mandates only `type`) and emit\n142\trelative links (spec recommends absolute `/path.md`, which breaks GitHub\n143\trendering). The official sample bundles are vendored as test fixtures (ADR-005),\n144\tnot a runtime dependency.\n145\t\n146\t## Impact Analysis\n147\t\n148\t| Component | Impact Type | Description and Risk | Required Action |\n149\t| --- | --- | --- | --- |\n150\t| `internal/okf` | new | Cohesive OKF package; isolated, low blast radius. | Build per ADR-003/004/005. |\n151\t| `internal/vault` (pathutils, render, render_wiki) | modified | `LinkFormatter` + route ~43 call sites; **regression risk** in wiki output. | Golden byte-identical wiki tests; keep suite green. |\n152\t| `internal/models` | modified | Add `Mode` to `TopicMetadata`/`TopicInfo` + mode consts; low risk. | Add fields/consts. |\n153\t| `internal/topic` | modified | `Mode` on `topicMetadataFile`; mode-conditional scaffold + OKF templates; medium risk (scaffold branch). | Normalize empty→wiki; add OKF assets. |\n154\t| `internal/config` | modified | Add `[okf].types`; low risk (additive). | Add struct + example + defaults. |\n155\t| `internal/cli` | modified | `kb promote`, `kb okf` group, `topic new --mode`; low risk. | Wire commands per existing patterns. |\n156\t| `internal/lint` | unchanged | OKF uses a separate checker (ADR-005); wiki lint untouched. | None. |\n157\t| existing wiki topics | unchanged | Default mode wiki ⇒ zero behavior change. | Verify via existing tests. |\n158\t\n159\t## Testing Approach\n160\t\n161\t### Unit Tests\n162\t\n163\t- **`OKFLinkFormatter.Link`** — table-driven relative-path cases: sibling, child,\n164\t parent (`../`), `fromDir == targetDir`, bundle root; never a leading `/`.\n165\t- **`WikiLinkFormatter.Link`** — asserts byte-identical output to today's\n166\t `ToTopicWikiLink` (with and without label).\n167\t- **Wikilink→markdown body transform** — `[[a]]`, `[[a|label]]`, `[[a#anchor]]`,\n168\t links to not-yet-promoted concepts (tolerated/flagged).\n169\t- **Frontmatter remap** — wiki frontmatter → four OKF producer fields; stage\n170\t markers dropped; `timestamp` ISO 8601.\n171\t- **`--type` vocabulary** — in-vocabulary (ok), out-of-vocabulary (warning),\n172\t empty (error).\n173\t- **mode normalization** — empty/absent `mode` → `wiki`; invalid explicit value\n174\t rejected at scaffold.\n175\t- **Conformance rules** — missing `type` (error), reserved files excluded,\n176\t `index.md`/`log.md` shape, `--strict` promotion.\n177\t\n178\t### Integration Tests (`//go:build integration`)\n179\t\n180\t- **Conformance against vendored official bundles** (GA4, Stack Overflow,\n181\t crypto_bitcoin) — all pass leniently (ADR-005).\n182\t- **Negative fixtures** — missing `type`, unterminated frontmatter, list-not-\n183\t mapping frontmatter → expected errors.\n184\t- **Must-tolerate fixtures** — broken link, unknown `type`, frontmatter-less\n185\t `index.md` → no error under lenient mode.\n186\t- **End-to-end promote** — scaffold an OKF topic, ingest+compile a small wiki\n187\t topic, `kb promote` a concept, assert: source untouched, concept written with\n188\t four fields + relative links, `log.md` appended, `index.md` updated, and\n189\t `kb okf check` passes.\n190\t- **Zero-regression** — an existing wiki topic renders byte-identically and\n191\t `kb lint` output is unchanged.\n192\t\n193\tUse `t.TempDir()` for filesystem isolation; co-locate tests with their package.\n194\t\n195\t## Development Sequencing\n196\t\n197\t### Build Order\n198\t\n199\t1. **`mode` data model** — add `TopicMode` consts + `Mode` on\n200\t `models.TopicMetadata`/`TopicInfo`; add `Mode` to `topicMetadataFile` with\n201\t empty→wiki normalization in `readTopicYAMLMetadata`. *No dependencies.*\n202\t2. **`internal/config` `[okf].types`** — add `OKFConfig`, defaults, env/TOML load,\n203\t `config.example.toml`. *No dependencies.*\n204\t3. **`LinkFormatter` in `internal/vault`** — interface + `WikiLinkFormatter`\n205\t (wraps current behavior) + `OKFLinkFormatter` + `LinkFormatterFor`. *Depends on\n206\t step 1 (reads `topic.Mode`).*\n207\t4. **Migrate the ~43 call sites** through `linkFor(topic, fromDir, target, label)`;\n208\t add golden wiki-output tests. *Depends on step 3.*\n209\t5. **OKF scaffold** — `internal/topic` mode-conditional dirs + OKF `CLAUDE.md`/\n210\t `index.md`(`okf_version`)/`log.md` templates. *Depends on step 1.*\n211\t6. **`internal/okf` emission primitives** — frontmatter contract, wikilink→markdown\n212\t transform (reusing step 3's `OKFLinkFormatter`), `index.md`/`log.md` generation.\n213\t *Depends on steps 2, 3.*\n214\t7. **`promote` orchestration** — read source, remap, transform, write concept,\n215\t append log, regenerate index. *Depends on steps 5, 6.*\n216\t8. **Conformance checker** — recursive walk, §9 lenient + local warnings +\n217\t `--strict`, emitting `models.LintIssue`. *Depends on step 2 (vocabulary).*\n218\t9. **CLI wiring** — `topic new --mode`, `kb promote`, `kb okf check`. *Depends on\n219\t steps 5, 7, 8.*\n220\t10. **Fixtures + integration tests** — vendor official bundles, negatives,\n221\t must-tolerate, end-to-end promote, zero-regression. *Depends on steps 7, 8, 9.*\n222\t\n223\t### Technical Dependencies\n224\t\n225\t- None external. Vendoring the official bundles requires keeping their Apache-2.0\n226\t license/attribution under `testdata/`.\n227\t\n228\t## Monitoring and Observability\n229\t\n230\tCLI tool — observability is structured logging via `internal/logger` (slog) and\n231\tcommand output:\n232\t\n233\t- **Log events** (debug/info): `promote` source/target/type, link-transform count,\n234\t broken-link count; `okf check` files-scanned, errors, warnings, strict flag.\n235\t- **Exit codes**: `okf check` returns non-zero on errors (and on warnings under\n236\t `--strict`) for CI gating.\n237\t- **Report output**: `okf check` renders via `internal/output` (table/json/tsv),\n238\t consistent with `kb lint`.\n239\t\n240\t## Technical Considerations\n241\t\n242\t### Key Decisions\n243\t\n244\t- **Cohesive `internal/okf` package** (ADR-003). *Rationale:* one testable home,\n245\t clean phase growth. *Trade-off:* a new package vs maximal reuse.\n246\t- **Full `LinkFormatter` migration in the MVP** (ADR-004). *Rationale:* one\n247\t finished mode-aware seam. *Trade-off:* large refactor whose OKF render branch is\n248\t dormant until Phase 3; mitigated by golden tests.\n249\t- **Dedicated conformance checker, lenient + `--strict`** (ADR-002/005).\n250\t *Rationale:* OKF is structurally unlike the wiki pyramid and is permissive by\n251\t spec. *Trade-off:* two checking engines coexist.\n252\t- **Non-LLM mechanical `promote`** (ADR-002). *Rationale:* preserves `kb`'s\n253\t identity and determinism. *Trade-off:* distillation stays a human/agent job.\n254\t- **`kb promote` top-level + `kb okf` group** (ADR-006). *Rationale:* prominence\n255\t for the daily verb, cohesive namespace for the ecosystem.\n256\t\n257\t### Known Risks\n258\t\n259\t- **Wiki render regression** during the 43-site migration (medium likelihood,\n260\t high impact) → byte-identical golden tests + keep the `vault` suite green;\n261\t default formatter is wiki when `mode != okf`.\n262\t- **OKF relative-path correctness** (medium) → exhaustive table-driven tests\n263\t including parent/sibling/root.\n264\t- **OKF v0.1 is a draft** (medium) → isolate the ruleset behind a versioned module;\n265\t track the spec repo.\n266\t- **Vocabulary ignored in practice** (low) → `--strict` in CI enforces it where it\n267\t matters.\n268\t- **Vendored-fixture drift / license** (low) → pin a commit, retain attribution.\n269\t\n270\t## Architecture Decision Records\n271\t\n272\t- [ADR-001: OKF as a first-class authoring mode, not an export-only projection](adrs/adr-001.md) — OKF is a first-class per-topic mode with phased delivery (product).\n273\t- [ADR-002: promote is a mechanical non-LLM transform; type governance and a spec-compatible conformance posture](adrs/adr-002.md) — mechanical/non-destructive promote, `--type` + `kb.toml` vocabulary, emit-four + relative links, lenient validation (product).\n274\t- [ADR-003: A cohesive `internal/okf` package and a `mode` field on the topic model](adrs/adr-003.md) — one-way-dependency OKF package + `mode` on the topic model (default wiki).\n275\t- [ADR-004: A `LinkFormatter` abstraction and full migration of the link call sites](adrs/adr-004.md) — mode-aware `LinkFormatter` in `vault`; migrate all ~43 sites in the MVP; OKF render branch dormant until Phase 3.\n276\t- [ADR-005: A dedicated OKF conformance checker with vendored official fixtures](adrs/adr-005.md) — separate checker in `internal/okf`, lenient §9 + local warnings + `--strict`, vendored official bundles as fixtures.\n277\t- [ADR-006: CLI surface — `kb promote` top-level and a `kb okf` command group](adrs/adr-006.md) — top-level `kb promote`, `kb okf check` group, `topic new --mode`.\n278\t\n```"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-221610-502089000-797361526ed537e0","time":"2026-06-27T22:16:35.5036Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01KD8XH3EhQCjqGRystNkfwM","blocks":[{"type":"tool_use","id":"toolu_01KD8XH3EhQCjqGRystNkfwM","name":"Read","title":"Read .compozy/tasks/okf-dual-mode/_prd.md","input":{"file_path":"/Users/pedronauck/Dev/compozy/kb/.compozy/tasks/okf-dual-mode/_prd.md"},"raw_input":{"file_path":"/Users/pedronauck/Dev/compozy/kb/.compozy/tasks/okf-dual-mode/_prd.md"}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-221610-502089000-797361526ed537e0","time":"2026-06-27T22:16:35.505949Z","turn":1,"type":"session.update","update":{"kind":"tool_call_started","tool_call_id":"toolu_01AgTD5FUbdeDx9wqaUKsV5H","tool_call_state":"pending","blocks":[{"type":"tool_use","id":"toolu_01AgTD5FUbdeDx9wqaUKsV5H","name":"Read","title":"Read File","raw_input":{}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-221610-502089000-797361526ed537e0","time":"2026-06-27T22:16:35.508231Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01KD8XH3EhQCjqGRystNkfwM","usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-221610-502089000-797361526ed537e0","time":"2026-06-27T22:16:35.514673Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01KD8XH3EhQCjqGRystNkfwM","tool_call_state":"completed","blocks":[{"type":"tool_result","tool_use_id":"toolu_01KD8XH3EhQCjqGRystNkfwM","content":"```\n1\t# PRD: OKF Dual-Mode for `kb`\n2\t\n3\t## Overview\n4\t\n5\t`kb` today implements one knowledge lifecycle: the Karpathy **LLM-Wiki** — a\n6\tpersonal research *lab* where raw sources are ingested, compiled into a wiki,\n7\tqueried, and linted, linked with Obsidian `[[wikilinks]]`.\n8\t\n9\tThe **Open Knowledge Format (OKF)** v0.1 (Google Cloud, 2026-06-12) standardizes\n10\tthe same markdown + YAML-frontmatter substrate for a different purpose: a\n11\tportable, vendor-neutral **catalog** that other people's agents and tools consume.\n12\tIt requires exactly one thing of every concept — a `type` field — uses plain\n13\tmarkdown links, and is meant to be *declared and shared*, not *explored*.\n14\t\n15\tThese are two lifecycles, not one derived from the other:\n16\t\n17\t- **LLM-Wiki** = a lab: `ingest → compile → query → lint`; for the operator to\n18\t research.\n19\t- **OKF** = a catalog/API: `declare → consume`; for others (humans and agents) to\n20\t consume; portable.\n21\t\n22\tThis duality already exists by hand in the operator's workspace: a `research/`\n23\tarea (LLM-wiki, where they research) and a `second-brain/` area (OKF, where they\n24\tdeclare \"what is mine\" for operations). The daily, highest-leverage move is\n25\t**distilling research into the operational catalog** — done manually today.\n26\t\n27\tThis feature makes both lifecycles first-class in `kb` via a per-topic\n28\t**`mode: wiki | okf`** (default `wiki`, so nothing regresses), and ships the\n29\tdistill loop as a one-command **`promote`** plus an OKF **conformance check**. It\n30\tis valuable because it turns a hand-maintained workflow into a tool-enforced one,\n31\tand positions `kb` as an OKF-conformant producer in a brand-new, vendor-neutral\n32\tecosystem.\n33\t\n34\t## Goals\n35\t\n36\t- Let an operator maintain LLM-Wiki research and OKF catalogs in the same tool,\n37\t with the research → catalog distill loop tool-enforced.\n38\t- Ship OKF as a first-class authoring mode (not an export-only projection).\n39\t- Produce bundles that are conformant with OKF v0.1 and aligned with Google's real\n40\t sample bundles and tooling.\n41\t- Preserve a consistent, drift-free local standard for concept types.\n42\t- Guarantee zero regression for existing wiki topics.\n43\t- Milestone: MVP delivers `mode` + OKF scaffold + `promote` + conformance check;\n44\t Phase 2 adds publish/consume interop; Phase 3 adds codebase → OKF.\n45\t\n46\t## User Stories\n47\t\n48\t**Primary persona — the Knowledge Operator** (researches in wiki mode, declares\n49\t\"what's mine\" in OKF mode, distills daily):\n50\t\n51\t- As an operator, I want to mark a topic as an OKF bundle so `kb` scaffolds and\n52\t lints it as a portable catalog instead of a research lab.\n53\t- As an operator, I want to promote a finished wiki concept into my OKF bundle\n54\t with one command so my research compounds into my operational catalog without\n55\t manual reformatting or re-linking.\n56\t- As an operator, I want my source research to stay untouched when I promote, so\n57\t the lab remains the immutable record and the catalog is the distilled view.\n58\t- As an operator, I want `kb` to validate my bundle against OKF conformance so my\n59\t catalog stays portable and consumable by agents.\n60\t- As an operator, I want a canonical list of concept types so my bundle stays\n61\t consistent and avoids type drift over time.\n62\t- As an operator, I want my existing wiki topics to keep working exactly as before\n63\t when OKF mode lands.\n64\t\n65\t**Secondary personas** (later phases):\n66\t\n67\t- As a knowledge publisher, I want to export a wiki topic as a portable OKF bundle\n68\t so others can consume it (Phase 2).\n69\t- As an AI agent / consuming tool, I want to read a `kb`-produced bundle as\n70\t standard OKF so I can use it without a custom integration (enabled throughout).\n71\t\n72\t## Core Features\n73\t\n74\tGrouped by priority. MVP features first.\n75\t\n76\t### 1. OKF mode for topics (MVP)\n77\t\n78\t- A per-topic **`mode: wiki | okf`** setting, defaulting to `wiki`.\n79\t- `kb topic new --mode okf` scaffolds an **OKF bundle** (flat typed concepts,\n80\t auto-maintained `index.md` and `log.md`) instead of the wiki pyramid\n81\t (`raw/` + `wiki/` + `outputs/`).\n82\t- An OKF bundle is a `kb` topic; it reuses the topic, vault, lint, and index\n83\t machinery. `mode` selects scaffold shape, link format, frontmatter contract, and\n84\t lint ruleset.\n85\t\n86\t### 2. `kb promote` — wiki → OKF distill (MVP)\n87\t\n88\t- `kb promote \u003cwiki-doc\u003e --to \u003cokf-topic\u003e --type \u003cConceptType\u003e` creates a new\n89\t typed OKF concept in the target bundle from a compiled wiki document.\n90\t- **Mechanical and non-LLM**: converts `[[wikilinks]]` to relative markdown links,\n91\t remaps wiki frontmatter to the OKF contract (`type`, `title`, `description`,\n92\t `timestamp`), and appends a dated entry to the bundle's `log.md`.\n93\t- **Non-destructive**: the source wiki document stays in research; a new concept is\n94\t born in the catalog.\n95\t- Any intelligent rewriting/condensing is left to the operator or an external\n96\t agent — `kb` does the structural work only.\n97\t\n98\t### 3. OKF conformance check (MVP)\n99\t\n100\t- A conformance command for an OKF bundle that validates OKF v0.1:\n101\t - every non-reserved `.md` has parseable frontmatter with a non-empty `type`;\n102\t - `index.md` / `log.md` follow their required shapes when present.\n103\t- **Lenient by default** (per spec §9): tolerates broken cross-links, unknown\n104\t `type` values, and missing optional fields (so externally produced bundles pass).\n105\t- **Local standards as warnings** on the operator's own bundles: all four producer\n106\t fields present, and `type` within the configured vocabulary.\n107\t- A `--strict` option promotes warnings to errors for CI gates.\n108\t\n109\t### 4. Concept-type governance (MVP)\n110\t\n111\t- A canonical **type vocabulary** in `kb.toml` (e.g. `[okf].types`).\n112\t- `promote` and authoring take `type` explicitly; a value outside the vocabulary\n113\t produces a lint warning. The vocabulary is extended by editing `kb.toml`.\n114\t- Prevents type drift (`Voice Profile` vs `voice-profile`) and keeps a consistent\n115\t local standard, while staying compatible with OKF's \"no global registry\" model.\n116\t\n117\t### 5. OKF-native emission (MVP, underpins 1–4)\n118\t\n119\t- Relative, GitHub-safe markdown links (not `[[wikilinks]]`, not absolute paths).\n120\t- The four producer fields on every concept; auto-maintained `index.md`\n121\t (type-grouped bullet lists) and `log.md` (ISO-date entries, newest first).\n122\t\n123\t### Later phases (not MVP)\n124\t\n125\t- **`kb export okf`** — publish an existing wiki topic as a portable bundle\n126\t (Phase 2).\n127\t- **`kb ingest okf`** — consume an external OKF bundle into research (Phase 2).\n128\t- **Codebase → OKF** — emit the typed symbol/relation graph as a bundle\n129\t (Phase 3, differentiator demo).\n130\t\n131\t## User Experience\n132\t\n133\t**Operator journey (MVP):**\n134\t\n135\t1. **Create a catalog.** `kb topic new \u003cname\u003e --mode okf` scaffolds an OKF bundle.\n136\t Existing topics are unaffected (they remain `mode: wiki`).\n137\t2. **Research as usual.** Ingest and compile in wiki-mode topics — unchanged.\n138\t3. **Distill.** When a wiki concept is ready, `kb promote \u003cwiki-doc\u003e --to \u003cbundle\u003e\n139\t --type \u003cConceptType\u003e`. The concept appears in the catalog with relative links,\n140\t the four producer fields, and a `log.md` entry; the source stays put.\n141\t4. **Verify.** Run the conformance check on the bundle; fix any warnings (missing\n142\t fields, off-vocabulary types). Use `--strict` in CI.\n143\t5. **Consume / share.** The bundle is plain markdown + frontmatter — readable in\n144\t Obsidian/GitHub and parseable by any OKF-aware agent.\n145\t\n146\t**UX considerations:**\n147\t\n148\t- `mode` is invisible to existing users; OKF behavior is opt-in per topic.\n149\t- `promote` is one command with a predictable, mechanical result (no surprises\n150\t from an LLM in the loop).\n151\t- Conformance output is actionable (what failed, where, how to fix) and\n152\t distinguishes hard errors from local-standard warnings.\n153\t- Discoverability: OKF options surface under existing `kb topic` / `kb lint`-style\n154\t help; `kb.toml` documents the type vocabulary.\n155\t\n156\t## High-Level Technical Constraints\n157\t\n158\tProduct boundaries (not implementation):\n159\t\n160\t- **Conform to OKF v0.1**, and remain aligned with Google's reference samples\n161\t (GA4, Stack Overflow, Bitcoin) and producer behavior.\n162\t- **Two deliberate, documented deviations** from the written spec, matching real\n163\t tooling: emit the four producer fields (not just `type`), and emit relative\n164\t links (not the spec-recommended absolute links, which break GitHub rendering).\n165\t- **Stay non-LLM.** The core workflow, including `promote`, performs no model\n166\t inference; `kb` remains \"the non-LLM workflow.\"\n167\t- **Zero regression.** Default `mode: wiki`; existing topics scaffold, lint, and\n168\t build identically; no migration required.\n169\t- **Portable output.** Bundles are usable without `kb`: plain files renderable in\n170\t Obsidian/GitHub and parseable by any OKF consumer.\n171\t- OKF is a v0.1 draft; conformance rules must be pinned to v0.1 and isolated so the\n172\t spec can evolve without disrupting the rest of the tool.\n173\t\n174\t## Non-Goals (Out of Scope)\n175\t\n176\t- **Export and ingest interop** (`kb export okf`, `kb ingest okf`) — Phase 2.\n177\t- **Codebase → OKF** emission — Phase 3.\n178\t- **LLM-assisted distillation** inside `promote` (no `--distill`) — possibly a\n179\t later flag, not now.\n180\t- **A global/shared type registry** — only a local per-vault vocabulary.\n181\t- **Whole-vault or cross-topic OKF operations** — OKF is per-topic/bundle.\n182\t- **An HTML/graph visualizer** for bundles.\n183\t- **Migrating existing wiki topics** to OKF automatically.\n184\t- **Changing the LLM-Wiki lifecycle** — wiki mode behavior is unchanged.\n185\t\n186\t## Phased Rollout Plan\n187\t\n188\t### MVP (Phase 1) — the distill loop\n189\t\n190\t- OKF mode (`mode: wiki | okf`) + `kb topic new --mode okf` scaffold.\n191\t- `kb promote` (mechanical, non-destructive, non-LLM).\n192\t- OKF conformance check (lenient + local warnings + `--strict`).\n193\t- Concept-type vocabulary in `kb.toml`.\n194\t- OKF-native emission (relative links, four producer fields, auto `index.md` /\n195\t `log.md`).\n196\t- **Success criteria to proceed:** the conformance check passes on all three\n197\t official sample bundles; the operator's catalog passes the check; a real wiki\n198\t concept promotes end-to-end and the result conforms; existing wiki topics show\n199\t zero behavior change.\n200\t\n201\t### Phase 2 — publish \u0026 consume interop\n202\t\n203\t- `kb export okf` (wiki topic → portable bundle).\n204\t- `kb ingest okf` (external bundle → research), accepting real-world bundles under\n205\t lenient conformance.\n206\t- **Success criteria to proceed:** a wiki topic round-trips through export and back\n207\t through ingest without loss of conceptual content; third-party sample bundles\n208\t ingest cleanly.\n209\t\n210\t### Phase 3 — codebase → OKF\n211\t\n212\t- Emit the existing typed symbol/relation graph as an OKF bundle (concepts =\n213\t files/symbols, links = relations).\n214\t- **Long-term success:** a real codebase produces a conformant, navigable OKF\n215\t bundle usable as agent context.\n216\t\n217\t## Success Metrics\n218\t\n219\t- **Spec correctness:** the conformance check passes on all three official OKF\n220\t sample bundles (GA4, Stack Overflow, Bitcoin).\n221\t- **Loop works end-to-end:** a real wiki concept promotes into a bundle and the\n222\t result conforms, with links resolved and the four producer fields present.\n223\t- **Operator catalog conformant:** the existing `second-brain/` catalog passes the\n224\t conformance check (with local standards) once adopted.\n225\t- **Zero regression:** existing wiki topics scaffold, lint, and build identically\n226\t to before (verified by the existing suite).\n227\t- **No type drift:** every concept type in the operator's bundle is within the\n228\t `kb.toml` vocabulary.\n229\t- **Adoption:** the operator stops distilling by hand — `promote` replaces the\n230\t manual copy/reformat step.\n231\t\n232\t## Risks and Mitigations\n233\t\n234\t- **Adoption risk** — if `promote` is not clearly lower-friction than copy/paste,\n235\t it won't be used. *Mitigation:* one command, mechanical, predictable output;\n236\t non-destructive so it's safe to run freely.\n237\t- **Spec-churn risk** — OKF is a v0.1 draft and may change. *Mitigation:* pin to\n238\t v0.1; isolate conformance rules; track the spec repo.\n239\t- **Scope risk** — the MVP already touches several surfaces. *Mitigation:* strict\n240\t phasing; export/ingest/codebase are explicit non-goals for the MVP.\n241\t- **Standard-drift risk** — free-text types fragment the catalog. *Mitigation:*\n242\t `kb.toml` vocabulary + lint warning + `--strict` in CI.\n243\t- **Identity-drift risk** — pressure to add LLM \"magic\" to `promote`.\n244\t *Mitigation:* keep the core non-LLM by decision (ADR-002); defer any\n245\t `--distill` flag.\n246\t- **Interop-expectation risk** — bundles must be consumable outside `kb`.\n247\t *Mitigation:* validate against official samples; emit what real tooling expects\n248\t (four fields, relative links).\n249\t\n250\t## Architecture Decision Records\n251\t\n252\t- [ADR-001: OKF as a first-class authoring mode, not an export-only projection](adrs/adr-001.md)\n253\t — OKF is a first-class per-topic `mode`, an OKF bundle is a `kb` topic, and the\n254\t MVP optimizes the research → operations distill loop with phased delivery.\n255\t- [ADR-002: promote is a mechanical non-LLM transform; type governance and a spec-compatible conformance posture](adrs/adr-002.md)\n256\t — `promote` is mechanical, non-destructive, and non-LLM; `type` is explicit and\n257\t governed by a local `kb.toml` vocabulary; conformance emits four fields + relative\n258\t links and validates lenient with local warnings and `--strict`.\n259\t\n260\t## Open Questions\n261\t\n262\t- **Conformance command surface** — `kb okf check \u003cbundle\u003e` vs `kb lint --okf`\n263\t (naming/placement). Defer to TechSpec.\n264\t- **Catalog granularity** — within the operator's `second-brain/`, do sub-areas\n265\t (`identidade/`, `estudos/`, `conteudo/`, `vendas/`) map to one OKF topic with\n266\t nested concept directories, or to multiple OKF topics? Needs confirmation; both\n267\t are valid OKF.\n268\t- **index.md / log.md ownership** — assumed auto-maintained by `kb`\n269\t (generated/updated on scaffold + promote). Confirm whether operators ever\n270\t hand-edit them.\n271\t- **Off-vocabulary type handling** — assumed \"warn + allow + extend via\n272\t `kb.toml`\". Confirm whether `promote` should suggest the closest existing type.\n273\t- **Broken-link reporting on promote** — promoting a concept whose wikilinks point\n274\t to not-yet-promoted concepts yields tolerated broken links (per §9). Confirm\n275\t whether the operator wants a warning listing them.\n276\t\n```"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-221610-502089000-797361526ed537e0","time":"2026-06-27T22:16:36.446161Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01AgTD5FUbdeDx9wqaUKsV5H","blocks":[{"type":"tool_use","id":"toolu_01AgTD5FUbdeDx9wqaUKsV5H","name":"Read","title":"Read .compozy/tasks/okf-dual-mode/adrs/adr-001.md","input":{"file_path":"/Users/pedronauck/Dev/compozy/kb/.compozy/tasks/okf-dual-mode/adrs/adr-001.md"},"raw_input":{"file_path":"/Users/pedronauck/Dev/compozy/kb/.compozy/tasks/okf-dual-mode/adrs/adr-001.md"}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-221610-502089000-797361526ed537e0","time":"2026-06-27T22:16:36.447236Z","turn":1,"type":"session.update","update":{"kind":"tool_call_started","tool_call_id":"toolu_01BXsYfJmvP5QyA6PzHomiuz","tool_call_state":"pending","blocks":[{"type":"tool_use","id":"toolu_01BXsYfJmvP5QyA6PzHomiuz","name":"Read","title":"Read File","raw_input":{}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-221610-502089000-797361526ed537e0","time":"2026-06-27T22:16:36.448743Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01AgTD5FUbdeDx9wqaUKsV5H","usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-221610-502089000-797361526ed537e0","time":"2026-06-27T22:16:36.4532Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01AgTD5FUbdeDx9wqaUKsV5H","tool_call_state":"completed","blocks":[{"type":"tool_result","tool_use_id":"toolu_01AgTD5FUbdeDx9wqaUKsV5H","content":"```\n1\t# ADR-001: OKF as a first-class authoring mode, not an export-only projection\n2\t\n3\t## Status\n4\t\n5\tAccepted\n6\t\n7\t## Date\n8\t\n9\t2026-06-27\n10\t\n11\t## Context\n12\t\n13\tThe `kb` CLI implements the Karpathy \"LLM-wiki\" pattern: a research lab where raw\n14\tsources are ingested, compiled into a wiki, queried, and linted. Google Cloud\n15\treleased the Open Knowledge Format (OKF) v0.1 (2026-06-12), which formalizes the\n16\tsame markdown+YAML-frontmatter substrate but optimizes for a different purpose: a\n17\tportable, vendor-neutral **catalog/interchange** artifact that other people's\n18\tagents consume.\n19\t\n20\tThese are two distinct knowledge lifecycles on the same substrate:\n21\t\n22\t- **LLM-Wiki** = a *lab* (ingest → compile → query → lint; `[[wikilinks]]`;\n23\t `raw/` + `wiki/`; stage markers; for the operator to explore).\n24\t- **OKF** = a *catalog/API* (declare → consume; required `type` per concept;\n25\t markdown links; flat typed concepts; for others to consume; portable).\n26\t\n27\tThis mirrors the duality the user already maintains by hand in their workspace:\n28\t`research/` (LLM-wiki) where they research, and `second-brain/` (OKF) where they\n29\tdeclare \"what is mine\" for operations.\n30\t\n31\tAn earlier internal proposal treated OKF as an **export-only projection** of the\n32\twiki. The user found this too thin: it captures only the \"publish\" direction and\n33\tignores the daily, high-leverage flow of distilling research into the operational\n34\tcatalog. The question is whether OKF is a derived output of the wiki or a\n35\tfirst-class mode in its own right.\n36\t\n37\t## Decision\n38\t\n39\tMake OKF a **first-class authoring mode** of `kb`, selectable per topic, with\n40\tthe LLM-Wiki mode remaining the default.\n41\t\n42\t- Introduce a per-topic **`mode: wiki | okf`** (default `wiki`).\n43\t- An **OKF bundle is modeled as a `kb` topic with `mode: okf`** — it reuses the\n44\t existing topic, vault, lint, and index machinery rather than becoming a parallel\n45\t standalone concept. `mode` disambiguates scaffold shape, link format, frontmatter\n46\t contract, and lint ruleset.\n47\t- The MVP optimizes the **research → operations distill loop**: native OKF\n48\t authoring plus a `promote` interop verb (see ADR-002), not export.\n49\t- Deliver in phases:\n50\t - **MVP (Phase 1):** `mode` field, OKF scaffold (`kb topic new --mode okf`),\n51\t `kb promote` (wiki → OKF), `kb okf check` (conformance).\n52\t - **Phase 2:** `kb export okf` (publish a wiki topic as a portable bundle) and\n53\t `kb ingest okf` (consume an external bundle into research).\n54\t - **Phase 3:** codebase → OKF (the typed symbol/relation graph emitted as a\n55\t bundle) — the standout differentiator demo.\n56\t\n57\t## Alternatives Considered\n58\t\n59\t### Alternative 1: Export-only projection (OKF as a derived output)\n60\t\n61\t- **Description**: Add `kb export okf` that renders an existing wiki topic into an\n62\t OKF bundle; no OKF authoring, no `mode`, no interop loop.\n63\t- **Pros**: Smallest surface; no scaffold or link-format refactor; quick to ship.\n64\t- **Cons**: Captures only \"publish\"; cannot author or maintain operational\n65\t bundles; ignores the daily research→org distill the user lives; treats OKF as\n66\t second-class.\n67\t- **Why rejected**: Too thin; misses the highest-leverage use case.\n68\t\n69\t### Alternative 2: Standalone OKF concept (separate from topics)\n70\t\n71\t- **Description**: A new `kb okf init`/`kb okf …` surface independent of topics,\n72\t with its own metadata and storage.\n73\t- **Pros**: Clean conceptual separation between lab and catalog.\n74\t- **Cons**: Duplicates topic, vault, lint, and index machinery; two parallel\n75\t models to maintain; more user-facing surface to learn.\n76\t- **Why rejected**: `mode` on the existing topic achieves the same separation with\n77\t far less duplication and a smaller learning curve.\n78\t\n79\t### Alternative 3: Single unified model (one mode, derive both views)\n80\t\n81\t- **Description**: Keep one internal model and render either wiki or OKF on demand.\n82\t- **Pros**: No mode flag.\n83\t- **Cons**: The two lifecycles diverge on link format, frontmatter contract, and\n84\t conformance; forcing one model creates leaky, ambiguous behavior (e.g., the\n85\t `type` field means \"stage\" in wiki and \"concept kind\" in OKF).\n86\t- **Why rejected**: `mode` cleanly disambiguates the conflicts instead of hiding\n87\t them.\n88\t\n89\t## Consequences\n90\t\n91\t### Positive\n92\t\n93\t- The user's existing `research/` vs `second-brain/` split becomes\n94\t tool-enforced rather than maintained by hand.\n95\t- Default `mode: wiki` means zero regression for existing topics.\n96\t- Reusing the topic model keeps the new surface small and learnable.\n97\t- Phasing keeps an already-large MVP focused on the daily loop.\n98\t\n99\t### Negative\n100\t\n101\t- The MVP is larger than an export-only effort (mode + scaffold + two new verbs).\n102\t- A shared link-emission path must support both wikilinks and OKF markdown links.\n103\t\n104\t### Risks\n105\t\n106\t- **OKF is a v0.1 draft** → pin conformance to v0.1 and isolate the ruleset so it\n107\t can evolve without touching the rest of the tool.\n108\t- **Scope creep** (the MVP touches several surfaces) → strict phasing; export,\n109\t ingest, and codebase→OKF are explicit non-goals for the MVP.\n110\t\n111\t## Implementation Notes\n112\t\n113\tImplementation detail belongs in the TechSpec. At a product level: the link\n114\temission path is currently wiki-only and must become mode-aware; the topic\n115\tscaffold and lint ruleset must branch on `mode`. No existing behavior changes when\n116\t`mode` is absent or `wiki`.\n117\t\n118\t## References\n119\t\n120\t- PRD: `../_prd.md`\n121\t- ADR-002: promote semantics, type governance, and conformance posture\n122\t- OKF spec v0.1: `GoogleCloudPlatform/knowledge-catalog` → `okf/SPEC.md`\n123\t- Announcement: \"How the Open Knowledge Format can improve data sharing\" (Google\n124\t Cloud Blog, 2026-06-12)\n125\t\n```"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-221610-502089000-797361526ed537e0","time":"2026-06-27T22:16:36.919266Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01BXsYfJmvP5QyA6PzHomiuz","blocks":[{"type":"tool_use","id":"toolu_01BXsYfJmvP5QyA6PzHomiuz","name":"Read","title":"Read .compozy/tasks/okf-dual-mode/adrs/adr-002.md","input":{"file_path":"/Users/pedronauck/Dev/compozy/kb/.compozy/tasks/okf-dual-mode/adrs/adr-002.md"},"raw_input":{"file_path":"/Users/pedronauck/Dev/compozy/kb/.compozy/tasks/okf-dual-mode/adrs/adr-002.md"}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-221610-502089000-797361526ed537e0","time":"2026-06-27T22:16:36.92068Z","turn":1,"type":"session.update","update":{"kind":"tool_call_started","tool_call_id":"toolu_01LsvokgZ5xDbnTYGN6rpSi6","tool_call_state":"pending","blocks":[{"type":"tool_use","id":"toolu_01LsvokgZ5xDbnTYGN6rpSi6","name":"Read","title":"Read File","raw_input":{}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-221610-502089000-797361526ed537e0","time":"2026-06-27T22:16:36.922641Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01BXsYfJmvP5QyA6PzHomiuz","usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-221610-502089000-797361526ed537e0","time":"2026-06-27T22:16:36.926386Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01BXsYfJmvP5QyA6PzHomiuz","tool_call_state":"completed","blocks":[{"type":"tool_result","tool_use_id":"toolu_01BXsYfJmvP5QyA6PzHomiuz","content":"```\n1\t# ADR-002: promote is a mechanical non-LLM transform; type governance and a spec-compatible conformance posture\n2\t\n3\t## Status\n4\t\n5\tAccepted\n6\t\n7\t## Date\n8\t\n9\t2026-06-27\n10\t\n11\t## Context\n12\t\n13\tADR-001 makes the research → operations distill loop the MVP's north star. That\n14\tloop has three behavioral questions the PRD must settle, each with product\n15\tconsequences:\n16\t\n17\t1. **What does `kb promote` do to the content?** It can be a mechanical\n18\t format transform, or it can re-synthesize the content with an LLM.\n19\t2. **Where does the OKF `type` come from?** OKF requires exactly one field —\n20\t `type` — and its value drives how consumers route, filter, and present a\n21\t concept. Free-text typing risks drift (`Voice Profile` vs `voice-profile`).\n22\t3. **How strict is conformance, and what does `kb` emit?** Research surfaced two\n23\t contradictions between the OKF spec and Google's own tooling:\n24\t - The spec requires only `type`, but Google's reference producer requires\n25\t `type` + `title` + `description` + `timestamp`, and all sample bundles carry\n26\t them.\n27\t - The spec *recommends* absolute `/path.md` links, but Google's tooling forbids\n28\t them (they break GitHub rendering) and every sample bundle uses relative\n29\t links.\n30\t - The spec (§9) requires consumers to *tolerate* broken links, unknown types,\n31\t and missing optional fields.\n32\t\n33\t`kb`'s identity, per its own `CLAUDE.md`, is \"the non-LLM workflow\": scaffolding,\n34\tingestion, structural linting. The user also explicitly values a consistent local\n35\tstandard for concept types.\n36\t\n37\t## Decision\n38\t\n39\t**1. `promote` is a mechanical, non-destructive, non-LLM transform.**\n40\t\n41\t`kb promote \u003cwiki-doc\u003e --to \u003cokf-topic\u003e` copies the source content into the OKF\n42\tbundle as a new typed concept and performs only structural work: convert\n43\t`[[wikilinks]]` to relative markdown links, remap wiki frontmatter to the OKF\n44\tcontract, set the concept `type`, and append an entry to the bundle's `log.md`.\n45\tThe source wiki document stays in `research/` (the immutable lab). No LLM is\n46\tinvoked — any intelligent distillation/rewriting remains the job of the operator\n47\tor an external agent calling `kb`, exactly as the user's `second-brain/` was built.\n48\t\n49\t**2. `type` is supplied explicitly and governed by a local vocabulary.**\n50\t\n51\t`promote` (and native authoring) takes the concept kind via `--type`; a canonical\n52\tlist of concept types lives in `kb.toml` (e.g. an `[okf].types` vocabulary). The\n53\tvocabulary exists to prevent duplication and enforce a per-vault standard. OKF\n54\tdeliberately has no *global* type registry; a *local* vocabulary is fully\n55\tcompatible and adds value. A type outside the vocabulary is a lint warning, not a\n56\thard failure, and the vocabulary is extended by editing `kb.toml`.\n57\t\n58\t**3. Conformance is compatible with the real-world tooling, lenient by default.**\n59\t\n60\t- **Emit**: all four producer fields (`type`, `title`, `description`,\n61\t `timestamp`) and **relative, GitHub-safe** markdown links.\n62\t- **Validate**: lenient per spec §9 — tolerate broken cross-links, unknown\n63\t `type` values, and missing optional fields (so externally produced bundles are\n64\t accepted in Phase 2).\n65\t- **Local standards as warnings** on the operator's own bundles: all four fields\n66\t present, and `type` within the `kb.toml` vocabulary.\n67\t- **`--strict`** promotes warnings to errors for CI gates.\n68\t\n69\t## Alternatives Considered\n70\t\n71\t### Alternative 1: LLM-assisted `promote`\n72\t\n73\t- **Description**: `promote` re-synthesizes the wiki document into a concise OKF\n74\t concept via a configured provider.\n75\t- **Pros**: One-command \"magic\"; less manual rewriting.\n76\t- **Cons**: Injects an LLM dependency and nondeterminism into a tool whose\n77\t identity is the non-LLM workflow; harder to test; unpredictable output.\n78\t- **Why rejected**: Contradicts `kb`'s stated identity; distillation already\n79\t belongs to the operator/agent layer. (A future `--distill` flag is possible but\n80\t out of scope.)\n81\t\n82\t### Alternative 2: Derived or defaulted `type`\n83\t\n84\t- **Description**: Infer `type` from the wiki document's `source_kind`/`domain`,\n85\t or default everything to a generic `Reference`.\n86\t- **Pros**: Less typing.\n87\t- **Cons**: The wiki `type` is a stage marker (always `wiki`/`source`), so\n88\t derivation carries no concept semantics; a generic default produces lazy\n89\t bundles where every concept is `Reference`, defeating OKF's routing value.\n90\t- **Why rejected**: Kills the value of the one field OKF requires.\n91\t\n92\t### Alternative 3: Spec-minimal or maximum-rigor conformance\n93\t\n94\t- **Description**: Either emit/validate only `type` (spec-minimal), or make every\n95\t rule a hard error by default (maximum rigor).\n96\t- **Pros**: Minimal is \"pure\"; maximum guarantees an iron standard.\n97\t- **Cons**: Minimal yields impoverished bundles misaligned with the official\n98\t samples; maximum fights OKF's permissive philosophy and would reject\n99\t third-party bundles during Phase 2 ingest.\n100\t- **Why rejected**: The tooling-compatible middle (emit four, validate lenient,\n101\t warn locally, `--strict` opt-in) matches real bundles and the user's desire for\n102\t a standard without sacrificing interoperability.\n103\t\n104\t## Consequences\n105\t\n106\t### Positive\n107\t\n108\t- `promote` is deterministic, testable, and true to `kb`'s identity.\n109\t- Bundles align with Google's real samples and tooling, maximizing portability.\n110\t- The type vocabulary keeps the operator's catalog consistent over time.\n111\t- Lenient validation lets Phase 2 ingest accept real-world external bundles.\n112\t\n113\t### Negative\n114\t\n115\t- The operator (or an agent) still does the intelligent distillation; `promote`\n116\t alone does not condense content.\n117\t- Two documented spec contradictions must be encoded as deliberate, documented\n118\t deviations (emit four, emit relative links).\n119\t\n120\t### Risks\n121\t\n122\t- **Spec churn** (v0.1 draft) → keep emit/validate rules isolated and versioned.\n123\t- **Vocabulary friction** (warnings ignored) → `--strict` in CI keeps the\n124\t standard enforceable when it matters.\n125\t\n126\t## Implementation Notes\n127\t\n128\tDetail belongs in the TechSpec. Product-level notes: `promote` appends to\n129\t`log.md` and the bundle's `index.md` is auto-maintained (OKF index files are\n130\ttype-grouped bullet lists, which are mechanically generatable). Conformance rules\n131\ttarget OKF v0.1 and validate against the official sample bundles (GA4, Stack\n132\tOverflow, Bitcoin) as fixtures.\n133\t\n134\t## References\n135\t\n136\t- PRD: `../_prd.md`\n137\t- ADR-001: OKF as a first-class authoring mode\n138\t- OKF spec v0.1 §4 (frontmatter), §5 (links), §6 (index.md), §7 (log.md),\n139\t §9 (conformance): `GoogleCloudPlatform/knowledge-catalog` → `okf/SPEC.md`\n140\t- OKF reference producer (the stricter four-key reality):\n141\t `okf/src/reference_agent/bundle/document.py`\n142\t\n```"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-221610-502089000-797361526ed537e0","time":"2026-06-27T22:16:37.894466Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01LsvokgZ5xDbnTYGN6rpSi6","blocks":[{"type":"tool_use","id":"toolu_01LsvokgZ5xDbnTYGN6rpSi6","name":"Read","title":"Read .compozy/tasks/okf-dual-mode/adrs/adr-003.md","input":{"file_path":"/Users/pedronauck/Dev/compozy/kb/.compozy/tasks/okf-dual-mode/adrs/adr-003.md"},"raw_input":{"file_path":"/Users/pedronauck/Dev/compozy/kb/.compozy/tasks/okf-dual-mode/adrs/adr-003.md"}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-221610-502089000-797361526ed537e0","time":"2026-06-27T22:16:37.902784Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01LsvokgZ5xDbnTYGN6rpSi6","usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-221610-502089000-797361526ed537e0","time":"2026-06-27T22:16:37.902895Z","turn":1,"type":"session.update","update":{"kind":"tool_call_started","tool_call_id":"toolu_015QjvscLzexizXGnvkQgcRv","tool_call_state":"pending","blocks":[{"type":"tool_use","id":"toolu_015QjvscLzexizXGnvkQgcRv","name":"Read","title":"Read File","raw_input":{}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-221610-502089000-797361526ed537e0","time":"2026-06-27T22:16:37.914219Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01LsvokgZ5xDbnTYGN6rpSi6","tool_call_state":"completed","blocks":[{"type":"tool_result","tool_use_id":"toolu_01LsvokgZ5xDbnTYGN6rpSi6","content":"```\n1\t# ADR-003: A cohesive `internal/okf` package and a `mode` field on the topic model\n2\t\n3\t## Status\n4\t\n5\tAccepted\n6\t\n7\t## Date\n8\t\n9\t2026-06-27\n10\t\n11\t## Context\n12\t\n13\tOKF dual-mode (ADR-001) introduces a substantial new concern: an OKF frontmatter\n14\tcontract, a wikilink→markdown transform, `index.md`/`log.md` generation, a\n15\tconformance checker, and the `promote` orchestration. The existing packages own\n16\tthe LLM-Wiki pyramid:\n17\t\n18\t- `internal/topic` — scaffold + `topic.yaml` (`topicMetadataFile` at\n19\t `topic.go:104`; scaffold list `currentTopicDirectories` at `topic.go:38`).\n20\t- `internal/vault` — render/read/write of documents.\n21\t- `internal/lint` — path-prefix structural validation (`schemaForPath`).\n22\t- `internal/ingest` — frontmatter assembly (`buildFrontmatter` at `ingest.go:160`).\n23\t\n24\tTwo structural needs must be settled before any behavior is built: where OKF logic\n25\tlives, and how a topic signals it is an OKF bundle versus a wiki.\n26\t\n27\t## Decision\n28\t\n29\t**1. A new cohesive `internal/okf` package owns all OKF behavior.**\n30\t\n31\tIt contains: the OKF frontmatter contract (the four producer fields), the\n32\t`[[wikilink]]`→relative-markdown transform, `index.md`/`log.md` generation, the\n33\tconformance checker (ADR-005), and the `promote` orchestration. Dependency\n34\tdirection is one-way: `internal/okf` imports `internal/frontmatter`,\n35\t`internal/vault` (document read/write), `internal/config` (the type vocabulary),\n36\tand `internal/models`; none of those import `internal/okf`. This prevents import\n37\tcycles and keeps the wiki packages free of OKF concerns.\n38\t\n39\t**2. Add a `mode` field to the topic model, defaulting to wiki.**\n40\t\n41\t- `internal/models`: typed constants `TopicModeWiki = \"wiki\"` and\n42\t `TopicModeOKF = \"okf\"`, and a `Mode` field on `models.TopicMetadata`\n43\t (`models.go:271`) and `models.TopicInfo`.\n44\t- `internal/topic`: `Mode string \\`yaml:\"mode,omitempty\"\\`` on `topicMetadataFile`\n45\t (`topic.go:104`); written by `writeMetadataFile` and read by\n46\t `readTopicYAMLMetadata` (`topic.go:770`).\n47\t- An absent/empty `mode` is normalized to `wiki` on read, so existing topics and\n48\t existing `topic.yaml` files behave identically (zero regression).\n49\t\n50\t## Alternatives Considered\n51\t\n52\t### Alternative 1: Spread OKF logic across existing packages\n53\t\n54\t- **Description**: `mode` in topic, links in vault, conformance extending lint,\n55\t `promote` in ingest.\n56\t- **Pros**: Maximum reuse; no new package.\n57\t- **Cons**: Dilutes OKF across four packages; entangles it with the wiki pyramid;\n58\t harder to test and to grow in Phase 2/3.\n59\t- **Why rejected**: Cohesion and a clean phase-growth boundary outweigh avoiding a\n60\t new package.\n61\t\n62\t### Alternative 2: Hybrid (okf package + okf logic also in vault/topic)\n63\t\n64\t- **Description**: Core in `internal/okf`, but the link formatter and `mode`\n65\t scattered into vault/topic.\n66\t- **Pros**: Avoids some indirection.\n67\t- **Cons**: The boundary becomes ambiguous (OKF logic in two homes).\n68\t- **Why rejected**: Only the genuinely cross-cutting `LinkFormatter` belongs in\n69\t vault (ADR-004); everything else stays in `internal/okf`. The `mode` field is a\n70\t data-model fact, not OKF logic, so it correctly lives on the topic model.\n71\t\n72\t## Consequences\n73\t\n74\t### Positive\n75\t\n76\t- One obvious home for OKF; isolated unit/integration testing.\n77\t- Clean dependency graph; Phase 2/3 (export/ingest/codebase→OKF) extend one package.\n78\t- `mode` is a tiny, backward-compatible addition.\n79\t\n80\t### Negative\n81\t\n82\t- A new package and a field threaded through topic load/write.\n83\t\n84\t### Risks\n85\t\n86\t- **Mode drift** (a topic with an unknown mode) → normalize unknown/empty to\n87\t `wiki` and reject unknown explicit values at scaffold time.\n88\t\n89\t## Implementation Notes\n90\t\n91\t`topicMetadataFile` marshals via `yaml.Marshal`; adding `Mode` with `omitempty`\n92\tkeeps existing files byte-stable when mode is wiki. Topic resolution\n93\t(`topic.Resolve`/`Info`) must surface `Mode` so downstream commands (`promote`,\n94\t`okf check`) can branch.\n95\t\n96\t## References\n97\t\n98\t- ADR-001 (OKF as a first-class mode), ADR-004 (LinkFormatter), ADR-005 (checker)\n99\t- `internal/topic/topic.go:104`, `internal/models/models.go:271`\n100\t- TechSpec: `../_techspec.md`\n101\t\n```"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-221610-502089000-797361526ed537e0","time":"2026-06-27T22:16:38.407054Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_015QjvscLzexizXGnvkQgcRv","blocks":[{"type":"tool_use","id":"toolu_015QjvscLzexizXGnvkQgcRv","name":"Read","title":"Read .compozy/tasks/okf-dual-mode/adrs/adr-004.md","input":{"file_path":"/Users/pedronauck/Dev/compozy/kb/.compozy/tasks/okf-dual-mode/adrs/adr-004.md"},"raw_input":{"file_path":"/Users/pedronauck/Dev/compozy/kb/.compozy/tasks/okf-dual-mode/adrs/adr-004.md"}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-221610-502089000-797361526ed537e0","time":"2026-06-27T22:16:38.40706Z","turn":1,"type":"session.update","update":{"kind":"tool_call_started","tool_call_id":"toolu_01GMocwEWVpnVXUT7acDEa9s","tool_call_state":"pending","blocks":[{"type":"tool_use","id":"toolu_01GMocwEWVpnVXUT7acDEa9s","name":"Read","title":"Read File","raw_input":{}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-221610-502089000-797361526ed537e0","time":"2026-06-27T22:16:38.408115Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_015QjvscLzexizXGnvkQgcRv","usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-221610-502089000-797361526ed537e0","time":"2026-06-27T22:16:38.410954Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_015QjvscLzexizXGnvkQgcRv","tool_call_state":"completed","blocks":[{"type":"tool_result","tool_use_id":"toolu_015QjvscLzexizXGnvkQgcRv","content":"```\n1\t# ADR-004: A `LinkFormatter` abstraction and full migration of the link call sites\n2\t\n3\t## Status\n4\t\n5\tAccepted\n6\t\n7\t## Date\n8\t\n9\t2026-06-27\n10\t\n11\t## Context\n12\t\n13\tLink emission is hardcoded to Obsidian `[[wikilinks]]`:\n14\t\n15\t- `internal/vault/pathutils.go:244` `ToTopicWikiLink(topicSlug, documentPath,\n16\t label)` → `[[slug/path|label]]`, with a thin wrapper `toSourceWikiLink(topic,\n17\t relativePath, label)` at `render.go:186`.\n18\t- These are called from **~43 sites**, all in `internal/vault/render.go` and\n19\t `render_wiki.go` — the **codebase→wiki** render pipeline.\n20\t- There is **no `Renderer`/`Writer` struct** in `internal/vault`; render functions\n21\t receive `topic models.TopicMetadata` directly, so there is no constructor to\n22\t inject a formatter into.\n23\t\n24\tOKF requires standard markdown links instead of wikilinks (ADR-002: relative,\n25\tGitHub-safe). The question is how the link format becomes mode-aware, and how much\n26\tof the refactor lands in the MVP. The user chose to introduce the full abstraction\n27\tand migrate all call sites in the MVP, rather than defer the render-pipeline\n28\trefactor to Phase 3.\n29\t\n30\tA key technical subtlety: an OKF **relative** link needs the **source document's\n31\tdirectory** to compute the path to the target. The current call sites pass only\n32\tthe target path and a label.\n33\t\n34\t## Decision\n35\t\n36\t**1. Define a `LinkFormatter` interface in `internal/vault`** (rendering's home;\n37\tkeeps the `okf`→`vault` dependency one-way and cycle-free):\n38\t\n39\t```go\n40\t// LinkFormatter renders a link from the document being written (fromDir,\n41\t// bundle-relative) to a target document, with an optional label.\n42\ttype LinkFormatter interface {\n43\t Link(fromDir, targetPath, label string) string\n44\t}\n45\t```\n46\t\n47\t- `WikiLinkFormatter` — ignores `fromDir`; emits `[[slug/target|label]]` exactly\n48\t as `ToTopicWikiLink` does today (`ToTopicWikiLink` becomes its implementation).\n49\t- `OKFLinkFormatter` — emits a relative, GitHub-safe markdown link\n50\t (`[label](relpath)`), computing `relpath` from `fromDir` to `targetPath`.\n51\t\n52\t**2. Resolve the formatter from `topic.Mode`** via\n53\t`vault.LinkFormatterFor(topic) LinkFormatter`, and route every call site through a\n54\tsingle chokepoint helper `linkFor(topic, fromDir, targetPath, label)`. The ~12\n55\tdirect `ToTopicWikiLink(topic.Slug, …)` calls in `render_wiki.go` are converted to\n56\tuse this helper too.\n57\t\n58\t**3. Scope the MVP correctly.** The 43 render sites belong to the codebase→wiki\n59\tpipeline, which produces **wiki-mode** topics; codebase→OKF is Phase 3. Therefore\n60\tin the MVP these sites continue to resolve `WikiLinkFormatter` (mode = wiki) and\n61\temit byte-identical output. The **active** MVP consumer of `OKFLinkFormatter` is\n62\t`promote`'s body transform (which knows the concept's own directory) and\n63\t`index.md` generation. The OKF branch at the render sites is wired but dormant\n64\tuntil Phase 3.\n65\t\n66\t## Alternatives Considered\n67\t\n68\t### Alternative 1: Defer the render-site refactor; body-transform only in MVP\n69\t\n70\t- **Description**: MVP does only `[[wikilink]]`→markdown in `promote` body text and\n71\t index generation; the 43 sites are untouched until Phase 3.\n72\t- **Pros**: Smallest MVP; least risk.\n73\t- **Cons**: The abstraction lands later; two passes over the render code.\n74\t- **Why rejected**: The user chose to do the foundational abstraction now.\n75\t\n76\t### Alternative 2: Add a `fromDir`/formatter argument to each call site signature\n77\t\n78\t- **Description**: Thread source context explicitly through 43 signatures.\n79\t- **Pros**: Fully explicit.\n80\t- **Cons**: Churns 43 signatures for no MVP-exercised benefit.\n81\t- **Why rejected**: The chokepoint helper + `topic`-resolved formatter achieves the\n82\t same with far less churn.\n83\t\n84\t### Alternative 3: Package-global formatter strategy\n85\t\n86\t- **Description**: A mutable package-level formatter set per run.\n87\t- **Pros**: No threading.\n88\t- **Cons**: Not concurrency-safe; hidden global state; hard to test.\n89\t- **Why rejected**: Violates testability.\n90\t\n91\t## Consequences\n92\t\n93\t### Positive\n94\t\n95\t- One mode-aware seam for all link emission; Phase 3 only flips render topics to\n96\t OKF mode and supplies `fromDir`.\n97\t- `promote` and index generation reuse the same formatter.\n98\t\n99\t### Negative\n100\t\n101\t- A large mechanical refactor whose OKF branch is not exercised at render sites in\n102\t the MVP.\n103\t\n104\t### Risks\n105\t\n106\t- **Silent regression** in wiki output during the refactor → mitigate with golden\n107\t tests asserting byte-identical wiki render output before/after, and keep the\n108\t existing `vault` test suite green.\n109\t- **Relative-path edge cases** (siblings, parents, root) in `OKFLinkFormatter` →\n110\t cover with table-driven unit tests including `fromDir == targetDir` and nested\n111\t cases.\n112\t\n113\t## Implementation Notes\n114\t\n115\t`OKFLinkFormatter.Link` uses `filepath.Rel(fromDir, targetPath)` semantics on\n116\tforward-slash bundle paths, never emitting a leading `/` (GitHub-safe, per\n117\tADR-002). `WikiLinkFormatter` preserves `StripMarkdownExtension` behavior. The\n118\tformatter is selected once per document render from `topic.Mode`.\n119\t\n120\t## References\n121\t\n122\t- ADR-002 (relative links), ADR-003 (okf package boundary)\n123\t- `internal/vault/pathutils.go:244`, `render.go:186`, `render_wiki.go`\n124\t- TechSpec: `../_techspec.md`\n125\t\n```"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-221610-502089000-797361526ed537e0","time":"2026-06-27T22:16:38.806174Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01GMocwEWVpnVXUT7acDEa9s","blocks":[{"type":"tool_use","id":"toolu_01GMocwEWVpnVXUT7acDEa9s","name":"Read","title":"Read .compozy/tasks/okf-dual-mode/adrs/adr-005.md","input":{"file_path":"/Users/pedronauck/Dev/compozy/kb/.compozy/tasks/okf-dual-mode/adrs/adr-005.md"},"raw_input":{"file_path":"/Users/pedronauck/Dev/compozy/kb/.compozy/tasks/okf-dual-mode/adrs/adr-005.md"}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-221610-502089000-797361526ed537e0","time":"2026-06-27T22:16:38.807478Z","turn":1,"type":"session.update","update":{"kind":"tool_call_started","tool_call_id":"toolu_018BAGoUo4rRU4whF3LEL5hQ","tool_call_state":"pending","blocks":[{"type":"tool_use","id":"toolu_018BAGoUo4rRU4whF3LEL5hQ","name":"Read","title":"Read File","raw_input":{}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-221610-502089000-797361526ed537e0","time":"2026-06-27T22:16:38.808854Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01GMocwEWVpnVXUT7acDEa9s","usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-221610-502089000-797361526ed537e0","time":"2026-06-27T22:16:38.812701Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01GMocwEWVpnVXUT7acDEa9s","tool_call_state":"completed","blocks":[{"type":"tool_result","tool_use_id":"toolu_01GMocwEWVpnVXUT7acDEa9s","content":"```\n1\t# ADR-005: A dedicated OKF conformance checker with vendored official fixtures\n2\t\n3\t## Status\n4\t\n5\tAccepted\n6\t\n7\t## Date\n8\t\n9\t2026-06-27\n10\t\n11\t## Context\n12\t\n13\tOKF v0.1 conformance (§9) is small and deliberately permissive: every non-reserved\n14\t`.md` must have parseable frontmatter with a non-empty `type`; `index.md`/`log.md`\n15\tmust follow their shapes when present; consumers MUST tolerate broken cross-links,\n16\tunknown `type` values, and missing optional fields. ADR-002 set the product\n17\tposture: emit four producer fields and relative links, validate lenient, warn on\n18\tlocal standards (four fields present + `type` in the `kb.toml` vocabulary), and\n19\toffer `--strict`.\n20\t\n21\tThe existing lint engine (`internal/lint/lint.go:337` `schemaForPath`) is a\n22\tpath-prefix switch tuned to the wiki pyramid (`wiki/concepts/`, `raw/github/`,\n23\tetc.) and parses `[[wikilinks]]` specifically (`wikilinkPattern` regex). OKF is\n24\tstructurally different: a recursive tree of freeform directories where `type` is\n25\tthe only hard requirement and links are markdown.\n26\t\n27\t## Decision\n28\t\n29\t**Implement a dedicated conformance checker in `internal/okf`**, not an extension\n30\tof `schemaForPath`.\n31\t\n32\t- Recursively walk the bundle; for every non-reserved `.md`, parse frontmatter via\n33\t `internal/frontmatter` and require a non-empty `type` (§9.2). Validate `index.md`\n34\t (no frontmatter except an optional bundle-root `okf_version`) and `log.md`\n35\t (ISO-date headings) shapes when present (§6/§7).\n36\t- **Lenient by default** (§9): broken links, unknown `type`, and missing optional\n37\t fields are tolerated (not errors) — so externally produced bundles pass, which\n38\t Phase 2 `ingest okf` depends on.\n39\t- **Local-standard warnings** on the operator's own bundle: the four producer\n40\t fields present, and `type` ∈ `config.OKF.Types`.\n41\t- `--strict` promotes warnings to errors (CI gate).\n42\t- **Reuse `models.LintIssue` and the `internal/output` formatters** (table/json/\n43\t tsv) for a consistent UX; a severity distinguishes error from warning.\n44\t\n45\t**Fixtures (per the testing decision):** vendor the three official sample bundles\n46\t(GA4, Stack Overflow, crypto_bitcoin; Apache-2.0) into `internal/okf/testdata/` as\n47\tpositive fixtures, plus synthetic negatives (missing `type`, unterminated\n48\tfrontmatter, list-not-mapping frontmatter) and must-tolerate cases (broken link,\n49\tunknown `type`, frontmatter-less `index.md`). Conformance tests run behind\n50\t`//go:build integration`.\n51\t\n52\t## Alternatives Considered\n53\t\n54\t### Alternative 1: Extend the existing lint engine (`schemaForPath`)\n55\t\n56\t- **Description**: Add OKF cases to the path-prefix switch.\n57\t- **Pros**: One lint entry point.\n58\t- **Cons**: Conflates two structurally different models (path-pyramid wiki vs\n59\t recursive type-required OKF); the wikilink parser does not apply; a single engine\n60\t grows two divergent rule systems.\n61\t- **Why rejected**: Separation keeps both engines simple and correct.\n62\t\n63\t### Alternative 2: Maximum-rigor default (all rules hard errors)\n64\t\n65\t- **Description**: Make four-field + vocabulary + link rules errors without\n66\t `--strict`.\n67\t- **Pros**: Iron standard.\n68\t- **Cons**: Fights OKF's permissive model; rejects third-party bundles on ingest.\n69\t- **Why rejected**: Superseded by ADR-002's lenient-with-warnings posture.\n70\t\n71\t### Alternative 3: Synthetic-only fixtures\n72\t\n73\t- **Description**: Hand-write minimal bundles only.\n74\t- **Pros**: Smaller repo; faster tests.\n75\t- **Cons**: No validation against the bundles real OKF consumers produce/expect.\n76\t- **Why rejected**: The user chose to vendor the official bundles for maximum\n77\t signal.\n78\t\n79\t## Consequences\n80\t\n81\t### Positive\n82\t\n83\t- Spec-correct and tooling-compatible; high-signal conformance tests.\n84\t- Consistent CLI output via shared `LintIssue`/`output` types.\n85\t- Lenient validation unblocks Phase 2 ingest.\n86\t\n87\t### Negative\n88\t\n89\t- The repo grows by the vendored bundles (dozens of `.md` files).\n90\t- Two checking engines coexist (wiki lint and OKF check).\n91\t\n92\t### Risks\n93\t\n94\t- **Spec churn (v0.1 draft)** → isolate rules behind a versioned ruleset in\n95\t `internal/okf`.\n96\t- **Third-party license** → keep the bundles' Apache-2.0 `LICENSE`/attribution in\n97\t `testdata/` and document provenance.\n98\t\n99\t## Implementation Notes\n100\t\n101\t`okf check` returns `[]models.LintIssue` with a severity field; the CLI renders via\n102\t`internal/output`. Reserved filenames (`index.md`, `log.md`) are excluded from the\n103\t`type` requirement. The checker takes the resolved bundle (topic) path; it does not\n104\trequire `mode: okf` so it can validate arbitrary directories and external bundles.\n105\t\n106\t## References\n107\t\n108\t- ADR-002 (conformance posture), ADR-003 (okf package)\n109\t- OKF spec §6/§7/§9; official bundles `okf/bundles/{ga4,stackoverflow,crypto_bitcoin}`\n110\t- `internal/lint/lint.go:337`, `internal/frontmatter`, `internal/output`\n111\t- TechSpec: `../_techspec.md`\n112\t\n```"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-221610-502089000-797361526ed537e0","time":"2026-06-27T22:16:39.75004Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_018BAGoUo4rRU4whF3LEL5hQ","blocks":[{"type":"tool_use","id":"toolu_018BAGoUo4rRU4whF3LEL5hQ","name":"Read","title":"Read .compozy/tasks/okf-dual-mode/adrs/adr-006.md","input":{"file_path":"/Users/pedronauck/Dev/compozy/kb/.compozy/tasks/okf-dual-mode/adrs/adr-006.md"},"raw_input":{"file_path":"/Users/pedronauck/Dev/compozy/kb/.compozy/tasks/okf-dual-mode/adrs/adr-006.md"}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-221610-502089000-797361526ed537e0","time":"2026-06-27T22:16:39.751254Z","turn":1,"type":"session.update","update":{"kind":"tool_call_started","tool_call_id":"toolu_016PgT5sWA4LZhKoQpzDkoNz","tool_call_state":"pending","blocks":[{"type":"tool_use","id":"toolu_016PgT5sWA4LZhKoQpzDkoNz","name":"Read","title":"Read File","raw_input":{}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-221610-502089000-797361526ed537e0","time":"2026-06-27T22:16:39.752325Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_018BAGoUo4rRU4whF3LEL5hQ","usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-221610-502089000-797361526ed537e0","time":"2026-06-27T22:16:39.756107Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_018BAGoUo4rRU4whF3LEL5hQ","tool_call_state":"completed","blocks":[{"type":"tool_result","tool_use_id":"toolu_018BAGoUo4rRU4whF3LEL5hQ","content":"```\n1\t# ADR-006: CLI surface — `kb promote` top-level and a `kb okf` command group\n2\t\n3\t## Status\n4\t\n5\tAccepted\n6\t\n7\t## Date\n8\t\n9\t2026-06-27\n10\t\n11\t## Context\n12\t\n13\tThe MVP adds three user-facing operations now (`promote`, conformance `check`, and\n14\ta `--mode` choice on topic creation), and Phase 2 adds `export okf` and\n15\t`ingest okf`. The existing CLI (`internal/cli/root.go:14`) registers top-level\n16\tcommands (`topic`, `ingest`, `lint`, `inspect`, `index`, `search`, `generate`,\n17\t`migrate`, `version`) via `command.AddCommand(...)`, shares a `--vault` persistent\n18\tflag, resolves the vault with `resolveCommandVaultPath`, and renders output through\n19\t`internal/output`. `topic new` (`topic.go`) takes positional args and has no\n20\t`--mode` flag yet.\n21\t\n22\t`promote` is the daily distill verb (1 wiki doc → 1 OKF concept); `check`,\n23\t`export`, and `ingest` are OKF-ecosystem operations.\n24\t\n25\t## Decision\n26\t\n27\t- **`kb promote \u003cwiki-doc\u003e --to \u003ctopic\u003e --type \u003cType\u003e`** as a **top-level**\n28\t command — it is the daily verb and deserves prominence.\n29\t- **`kb okf`** as a parent command **group**:\n30\t - MVP: `kb okf check \u003ctopic\u003e [--strict] [--format table|json|tsv]`.\n31\t - Phase 2: `kb okf export \u003ctopic\u003e [...]`, `kb okf ingest \u003cbundle\u003e --to \u003ctopic\u003e`.\n32\t- **`kb topic new`** gains `--mode wiki|okf` (default `wiki`).\n33\t\n34\tAll new commands follow the existing cobra patterns: built in `internal/cli`,\n35\tregistered via `AddCommand`, resolving the vault through `resolveCommandVaultPath`,\n36\tdelegating business logic to `internal/okf`, and rendering via `internal/output`.\n37\t\n38\t## Alternatives Considered\n39\t\n40\t### Alternative 1: Everything under `kb okf`\n41\t\n42\t- **Description**: `kb okf promote|check|export|ingest`.\n43\t- **Pros**: Maximum namespace cohesion.\n44\t- **Cons**: Buries `promote` — the most-used verb — one level deeper.\n45\t- **Why rejected**: Daily ergonomics beat namespace purity for the hot verb.\n46\t\n47\t### Alternative 2: `kb promote` + `kb lint --okf`\n48\t\n49\t- **Description**: Conformance as a flag on the existing `lint`.\n50\t- **Pros**: Reuses `lint`.\n51\t- **Cons**: Mixes two engines (wiki pyramid vs OKF conformance) in one command and\n52\t gives no natural home to Phase 2's `export`/`ingest`.\n53\t- **Why rejected**: ADR-005 keeps the engines separate; the `okf` group hosts the\n54\t whole ecosystem.\n55\t\n56\t## Consequences\n57\t\n58\t### Positive\n59\t\n60\t- Discoverable `kb okf …` namespace that grows cleanly across phases.\n61\t- The daily verb stays one keystroke deep.\n62\t- Reuses vault resolution, flags, and output formatting conventions.\n63\t\n64\t### Negative\n65\t\n66\t- Two entry points for OKF (`kb promote` and `kb okf …`) — mitigated by docs and\n67\t `--help` cross-references.\n68\t\n69\t### Risks\n70\t\n71\t- **`--mode` typos / invalid values** → validate against `{wiki, okf}` at parse\n72\t time with a clear error.\n73\t\n74\t## Implementation Notes\n75\t\n76\t`newPromoteCommand` and `newOKFCommand` (with a `check` subcommand) are added to\n77\t`newRootCommand`. `topic new` adds `command.Flags().StringVar(\u0026mode, \"mode\",\n78\t\"wiki\", …)` and passes it into topic creation (ADR-003). Output uses\n79\t`output.OutputFormat*` like `lint`.\n80\t\n81\t## References\n82\t\n83\t- ADR-003 (mode field), ADR-005 (conformance engine)\n84\t- `internal/cli/root.go:14`, `internal/cli/topic.go`, `internal/cli/lint.go`\n85\t- TechSpec: `../_techspec.md`\n86\t\n```"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-221610-502089000-797361526ed537e0","time":"2026-06-27T22:16:39.968178Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_016PgT5sWA4LZhKoQpzDkoNz","blocks":[{"type":"tool_use","id":"toolu_016PgT5sWA4LZhKoQpzDkoNz","name":"Read","title":"Read CLAUDE.md","input":{"file_path":"/Users/pedronauck/Dev/compozy/kb/CLAUDE.md"},"raw_input":{"file_path":"/Users/pedronauck/Dev/compozy/kb/CLAUDE.md"}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-221610-502089000-797361526ed537e0","time":"2026-06-27T22:16:39.969492Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_016PgT5sWA4LZhKoQpzDkoNz","usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-221610-502089000-797361526ed537e0","time":"2026-06-27T22:16:39.976806Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_016PgT5sWA4LZhKoQpzDkoNz","tool_call_state":"completed","blocks":[{"type":"tool_result","tool_use_id":"toolu_016PgT5sWA4LZhKoQpzDkoNz","content":"```\n1\t# CLAUDE.md\n2\t\n3\t## Project Overview\n4\t\n5\t`kb` is a single-binary Go CLI for building and maintaining topic-based knowledge bases in the Karpathy KB pattern. It handles the non-LLM workflow: topic scaffolding, multi-source ingestion, structural linting, codebase analysis, QMD indexing/search, and KB-oriented inspection commands.\n6\t\n7\t**Reference TypeScript source:** `~/dev/projects/kodebase`\n8\t\n9\t## Source of Truth\n10\t\n11\t- KB pivot tech spec: `.compozy/tasks/kb-pivot/_techspec.md`\n12\t- KB pivot task tracker: `.compozy/tasks/kb-pivot/_tasks.md`\n13\t- KB pivot workflow memory: `.compozy/tasks/kb-pivot/memory/`\n14\t\n15\t## Critical Rules\n16\t\n17\t- `make verify` is the non-negotiable completion gate: `fmt -\u003e lint -\u003e test -\u003e build -\u003e boundaries`.\n18\t- `make lint` must report zero findings.\n19\t- Use `go get` for dependency changes.\n20\t- Never use destructive git restore/reset/checkout/clean/rm commands without explicit approval.\n21\t- Prefer local repository inspection over web search for codebase questions.\n22\t\n23\t## Active Build Surface\n24\t\n25\t```bash\n26\tmake verify\n27\tmake fmt\n28\tmake lint\n29\tmake test\n30\tmake test-integration\n31\tmake build\n32\tmake deps\n33\tmake help\n34\t```\n35\t\n36\t- `make build` compiles `./...` and writes `bin/kb` from `cmd/kb`.\n37\t\n38\t## Active Package Layout\n39\t\n40\t| Path | Responsibility |\n41\t| --- | --- |\n42\t| `cmd/kb` | Program entrypoint for the `kb` binary |\n43\t| `internal/cli` | Cobra root, subcommands, flag resolution, and command I/O |\n44\t| `internal/topic` | Topic scaffolding, listing, and topic metadata lookup |\n45\t| `internal/ingest` | Ingest orchestration, frontmatter assembly, raw writes, and log entries |\n46\t| `internal/convert` | Converter registry and format-specific file converters |\n47\t| `internal/firecrawl` | Firecrawl REST client for `kb ingest url` |\n48\t| `internal/mediadl` | Shared `yt-dlp` + STT engine: audio/caption download, ffmpeg segmentation, and transcript extraction reused by `youtube` and `instagram` |\n49\t| `internal/youtube` | YouTube URL parsing, channel/bulk ingestion, and frontmatter over the `mediadl` core |\n50\t| `internal/instagram` | Instagram reel/video URL parsing and caption+transcript composition over the `mediadl` core |\n51\t| `internal/frontmatter` | Shared frontmatter parsing and generation helpers |\n52\t| `internal/lint` | KB structural lint engine and report rendering |\n53\t| `internal/generate` | Codebase-to-KB pipeline used by `kb ingest codebase` and the hidden legacy `generate` alias |\n54\t| `internal/scanner` | Source discovery and ignore handling for codebase ingest |\n55\t| `internal/adapter` | Tree-sitter parsing adapters |\n56\t| `internal/graph` | Graph normalization for codebase snapshots |\n57\t| `internal/metrics` | File, symbol, directory, and smell metrics |\n58\t| `internal/vault` | Topic path helpers, render/write logic, vault reads, and inspect snapshot loading |\n59\t| `internal/qmd` | QMD subprocess integration for index and search |\n60\t| `internal/output` | Table, JSON, and TSV formatting |\n61\t| `internal/models` | Shared domain models and interfaces |\n62\t| `internal/config` | TOML config plus env-backed runtime overrides |\n63\t| `internal/logger` | Slog logger setup |\n64\t| `internal/version` | Build metadata surfaced by `kb version` |\n65\t\n66\t## Implementation Conventions\n67\t\n68\t- Keep Cobra commands thin. Business logic belongs in packages like `internal/topic`, `internal/ingest`, `internal/lint`, `internal/generate`, `internal/vault`, and `internal/qmd`.\n69\t- `kb topic new` owns the topic skeleton under the selected vault root, including `raw/`, `wiki/`, `outputs/`, `bases/`, `CLAUDE.md`, `AGENTS.md`, and `log.md`.\n70\t- `kb ingest file`, `url`, `youtube`, `instagram`, and `bookmarks` should route through `internal/ingest` and the converter/client surfaces instead of duplicating write logic in CLI code.\n71\t- `kb ingest youtube` and `kb ingest instagram` are thin shims over `internal/mediadl`: each parses its platform URL and frontmatter, then delegates yt-dlp/STT extraction to the shared core. Add new yt-dlp-backed sources as `mediadl` consumers rather than duplicating the engine.\n72\t- `kb ingest codebase` is the supported codebase entrypoint. The hidden `kb generate` command remains as a compatibility wrapper and should stay thin.\n73\t- Codebase inspection commands operate on `raw/codebase/` beneath the resolved topic. Keep inspect behavior topic-aware rather than vault-global.\n74\t- Raw KB documents must include frontmatter before being written; use `internal/frontmatter` helpers instead of hand-assembling YAML.\n75\t- CLI commands share the root `--vault` flag from `internal/cli/root.go`. Reuse the vault helpers instead of defining duplicate per-command vault flags.\n76\t- Prefer native Go integrations for converters and clients. Optional OCR or remote-provider fallbacks must degrade cleanly when prerequisites are missing.\n77\t- QMD integrations parse JSON from stdout and treat stderr as progress or diagnostics.\n78\t\n79\t## CLI Surface\n80\t\n81\t- `kb topic {new,list,info}`\n82\t- `kb ingest {url,file,youtube,channel,instagram,codebase,bookmarks}`\n83\t- `kb lint [\u003cslug\u003e]`\n84\t- `kb inspect {smells|dead-code|complexity|blast-radius|coupling|symbol|file|backlinks|deps|circular-deps}`\n85\t- `kb index`\n86\t- `kb search \u003cquery\u003e`\n87\t- `kb version`\n88\t- Hidden compatibility alias: `kb generate \u003cpath\u003e`\n89\t\n90\t## Runtime Config Notes\n91\t\n92\t- `config.example.toml` documents every TOML section currently accepted by `internal/config`: `[app]`, `[log]`, `[vault]`, `[firecrawl]`, `[openrouter]`, `[stt]`, `[youtube]`, and `[instagram]`.\n93\t- `APP_CONFIG` selects the TOML file path.\n94\t- `kb ingest youtube` requires `yt-dlp` for metadata, captions, and audio extraction. `[youtube].yt_dlp_path`, `[youtube].proxy`, `[youtube].cookies_file`, `[youtube].user_agent`, `[youtube].retry_attempts`, `[youtube].retry_backoff`, and `[youtube].transcription` configure that path.\n95\t- YouTube transcription policy is `captions|auto|stt`: `captions` uses YouTube captions only, `auto` uses manual captions when present and STT when only automatic captions or no captions are available, and `stt` forces audio transcription.\n96\t- `kb ingest instagram \u003curl\u003e` ingests reels, video posts, and IGTV through the same `yt-dlp` + STT engine. `[instagram]` mirrors the `[youtube]` yt-dlp knobs (`yt_dlp_path`, `proxy`, `cookies_file`, `user_agent`, `retry_attempts`, `retry_backoff`, `transcription`) with cookies kept separate because Instagram needs its own session. The default `[instagram].transcription` is `auto` since reels rarely carry caption tracks; the document body is the post caption followed by the transcript, degrading to caption-only when audio yields no transcript. Whole-profile bulk ingestion is out of scope (yt-dlp's `instagram:user` extractor is currently broken).\n97\t- OpenAI is the default STT provider through `/v1/audio/transcriptions`. Configure it with `[stt]` plus `OPENAI_API_KEY`, `OPENAI_API_URL`, `STT_PROVIDER`, and `STT_MODEL`.\n98\t- OpenRouter is an optional STT provider when `stt.provider = \"openrouter\"`; configure it with `OPENROUTER_API_KEY`, `OPENROUTER_API_URL`, and `openrouter.stt_model`.\n99\t- Long STT audio is segmented with `ffmpeg`; keep `[stt].ffmpeg_path`, `[stt].chunk_duration`, `[stt].max_chunk_bytes`, and `[stt].concurrency` aligned with provider upload limits.\n100\t\n101\t## Testing Conventions\n102\t\n103\t- Default to table-driven tests with focused helpers and `t.TempDir()` for filesystem isolation.\n104\t- Keep integration tests co-located with the package under test behind `//go:build integration`.\n105\t- CLI integration tests should exercise real topic/ingest/lint/inspect flows instead of mocking Cobra wiring when the workflow itself is the behavior under test.\n106\t- QMD-related integration tests must isolate `HOME`, `XDG_CACHE_HOME`, and `XDG_CONFIG_HOME`.\n107\t- Treat test failures as behavior bugs first; do not weaken assertions to fit broken behavior.\n108\t\n```"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-221610-502089000-797361526ed537e0","time":"2026-06-27T22:16:44.123548Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":""}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-221610-502089000-797361526ed537e0","time":"2026-06-27T22:16:44.123779Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":"Now let me check for the"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-221610-502089000-797361526ed537e0","time":"2026-06-27T22:16:44.569708Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":" other project rule files and verify the spec's key claims against the actual codebase."}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-221610-502089000-797361526ed537e0","time":"2026-06-27T22:16:45.036469Z","turn":1,"type":"session.update","update":{"kind":"tool_call_started","tool_call_id":"toolu_01C96ykC7ib53vs2oYerbZ5J","tool_call_state":"pending","blocks":[{"type":"tool_use","id":"toolu_01C96ykC7ib53vs2oYerbZ5J","name":"Bash","title":"Terminal","raw_input":{}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-221610-502089000-797361526ed537e0","time":"2026-06-27T22:16:48.456117Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01C96ykC7ib53vs2oYerbZ5J","blocks":[{"type":"tool_use","id":"toolu_01C96ykC7ib53vs2oYerbZ5J","name":"Task","title":"for f in AGENTS.md CONTRIBUTING.md; do echo \"=== $f ===\"; if [ -f \"$f\" ]; then echo \"EXISTS\"; else echo \"MISSING\"; fi; done; echo \"=== config.example.toml ===\"; ls -la config.example.toml 2\u003e/dev/null || echo MISSING","input":{"prompt":"Check for project rule files"},"raw_input":{"command":"for f in AGENTS.md CONTRIBUTING.md; do echo \"=== $f ===\"; if [ -f \"$f\" ]; then echo \"EXISTS\"; else echo \"MISSING\"; fi; done; echo \"=== config.example.toml ===\"; ls -la config.example.toml 2\u003e/dev/null || echo MISSING","description":"Check for project rule files"}},{"type":"tool_result","tool_use_id":"toolu_01C96ykC7ib53vs2oYerbZ5J","content":"Check for project rule files"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-221610-502089000-797361526ed537e0","time":"2026-06-27T22:16:48.459274Z","turn":1,"type":"session.update","update":{"kind":"tool_call_started","tool_call_id":"toolu_015eYYWK3C6x55DHYfi5y47t","tool_call_state":"pending","blocks":[{"type":"tool_use","id":"toolu_015eYYWK3C6x55DHYfi5y47t","name":"Bash","title":"Terminal","raw_input":{}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-221610-502089000-797361526ed537e0","time":"2026-06-27T22:16:48.652169Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01C96ykC7ib53vs2oYerbZ5J","usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-221610-502089000-797361526ed537e0","time":"2026-06-27T22:16:48.65425Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01C96ykC7ib53vs2oYerbZ5J","tool_call_state":"completed","blocks":[{"type":"tool_result","tool_use_id":"toolu_01C96ykC7ib53vs2oYerbZ5J","content":"```console\n=== AGENTS.md ===\nEXISTS\n=== CONTRIBUTING.md ===\nEXISTS\n=== config.example.toml ===\n644 config.example.toml 2.9K\n```"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-221610-502089000-797361526ed537e0","time":"2026-06-27T22:16:49.322514Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_015eYYWK3C6x55DHYfi5y47t","blocks":[{"type":"tool_use","id":"toolu_015eYYWK3C6x55DHYfi5y47t","name":"Task","title":"echo \"=== ToTopicWikiLink in pathutils.go ===\" \u0026\u0026 grep -n \"ToTopicWikiLink\\|func toSourceWikiLink\\|StripMarkdownExtension\" internal/vault/pathutils.go internal/vault/render.go 2\u003e/dev/null; echo \"=== count ToTopicWikiLink call sites ===\" \u0026\u0026 grep -rn \"ToTopicWikiLink\\|toSourceWikiLink\" internal/vault/ | wc -l; echo \"=== where called ===\" \u0026\u0026 grep -rln \"ToTopicWikiLink\\|toSourceWikiLink\" internal/","input":{"prompt":"Verify link call sites count"},"raw_input":{"command":"echo \"=== ToTopicWikiLink in pathutils.go ===\" \u0026\u0026 grep -n \"ToTopicWikiLink\\|func toSourceWikiLink\\|StripMarkdownExtension\" internal/vault/pathutils.go internal/vault/render.go 2\u003e/dev/null; echo \"=== count ToTopicWikiLink call sites ===\" \u0026\u0026 grep -rn \"ToTopicWikiLink\\|toSourceWikiLink\" internal/vault/ | wc -l; echo \"=== where called ===\" \u0026\u0026 grep -rln \"ToTopicWikiLink\\|toSourceWikiLink\" internal/","description":"Verify link call sites count"}},{"type":"tool_result","tool_use_id":"toolu_015eYYWK3C6x55DHYfi5y47t","content":"Verify link call sites count"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-221610-502089000-797361526ed537e0","time":"2026-06-27T22:16:49.525726Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_015eYYWK3C6x55DHYfi5y47t","usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-221610-502089000-797361526ed537e0","time":"2026-06-27T22:16:49.532288Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_015eYYWK3C6x55DHYfi5y47t","tool_call_state":"completed","blocks":[{"type":"tool_result","tool_use_id":"toolu_015eYYWK3C6x55DHYfi5y47t","content":"```console\n=== ToTopicWikiLink in pathutils.go ===\n7 matches in 2 files:\n\ninternal/vault/pathutils.go:239:// StripMarkdownExtension removes a trailing .md extension from a document path.\ninternal/vault/pathutils.go:240:func StripMarkdownExtension(documentPath string) string {\ninternal/vault/pathutils.go:244:// ToTopicWikiLink formats a topic-scoped Obsidian wiki-link.\ninternal/vault/pathutils.go:245:func ToTopicWikiLink(topicSlug, documentPath, label string) string {\ninternal/vault/pathutils.go:246:target := fmt.Sprintf(\"%s/%s\", topicSlug, StripMarkdownExtension(documentPath))\ninternal/vault/render.go:186:func toSourceWikiLink(topic models.TopicMetadata, relativePath, label string)...\ninternal/vault/render.go:187:return ToTopicWikiLink(topic.Slug, relativePath, label)\n=== count ToTopicWikiLink call sites ===\n 66\n=== where called ===\ninternal/cli/workflow_integration_test.go\ninternal/vault/render.go\ninternal/vault/writer_test.go\ninternal/vault/pathutils.go\ninternal/vault/render_wiki.go\ninternal/vault/writer.go\ninternal/vault/pathutils_test.go\ninternal/vault/render_test.go\n```"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-221610-502089000-797361526ed537e0","time":"2026-06-27T22:16:54.789159Z","turn":1,"type":"session.update","update":{"kind":"tool_call_started","tool_call_id":"toolu_01YHATXbxEBwTkJnhf2afwzj","tool_call_state":"pending","blocks":[{"type":"tool_use","id":"toolu_01YHATXbxEBwTkJnhf2afwzj","name":"Read","title":"Read File","raw_input":{}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-221610-502089000-797361526ed537e0","time":"2026-06-27T22:16:55.743981Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01YHATXbxEBwTkJnhf2afwzj","blocks":[{"type":"tool_use","id":"toolu_01YHATXbxEBwTkJnhf2afwzj","name":"Read","title":"Read AGENTS.md","input":{"file_path":"/Users/pedronauck/Dev/compozy/kb/AGENTS.md"},"raw_input":{"file_path":"/Users/pedronauck/Dev/compozy/kb/AGENTS.md"}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-221610-502089000-797361526ed537e0","time":"2026-06-27T22:16:55.745061Z","turn":1,"type":"session.update","update":{"kind":"tool_call_started","tool_call_id":"toolu_01KYNAavSDZVShqUwcwqGPKi","tool_call_state":"pending","blocks":[{"type":"tool_use","id":"toolu_01KYNAavSDZVShqUwcwqGPKi","name":"Read","title":"Read File","raw_input":{}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-221610-502089000-797361526ed537e0","time":"2026-06-27T22:16:55.746337Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01YHATXbxEBwTkJnhf2afwzj","usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-221610-502089000-797361526ed537e0","time":"2026-06-27T22:16:55.752233Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01YHATXbxEBwTkJnhf2afwzj","tool_call_state":"completed","blocks":[{"type":"tool_result","tool_use_id":"toolu_01YHATXbxEBwTkJnhf2afwzj","content":"```\n1\t# AGENTS.md\n2\t\n3\t## Project Overview\n4\t\n5\t`kb` is a single-binary Go CLI for building and maintaining topic-based knowledge bases in the Karpathy KB pattern. It handles the non-LLM workflow: topic scaffolding, multi-source ingestion, structural linting, codebase analysis, QMD indexing/search, and KB-oriented inspection commands.\n6\t\n7\t**Reference TypeScript source:** `~/dev/projects/kodebase`\n8\t\n9\t## Source of Truth\n10\t\n11\t- KB pivot tech spec: `.compozy/tasks/kb-pivot/_techspec.md`\n12\t- KB pivot task tracker: `.compozy/tasks/kb-pivot/_tasks.md`\n13\t- KB pivot workflow memory: `.compozy/tasks/kb-pivot/memory/`\n14\t\n15\t## Critical Rules\n16\t\n17\t- `make verify` is the non-negotiable completion gate: `fmt -\u003e lint -\u003e test -\u003e build -\u003e boundaries`.\n18\t- `make lint` must report zero findings.\n19\t- Use `go get` for dependency changes.\n20\t- Never use destructive git restore/reset/checkout/clean/rm commands without explicit approval.\n21\t- Prefer local repository inspection over web search for codebase questions.\n22\t\n23\t## Build Commands\n24\t\n25\t```bash\n26\tmake verify # fmt -\u003e lint -\u003e test -\u003e build -\u003e boundaries\n27\tmake fmt # gofmt over repository Go files\n28\tmake lint # golangci-lint v2\n29\tmake test # unit tests with -race via gotestsum\n30\tmake test-integration # unit + integration tests with -race and -tags integration\n31\tmake build # build ./... and bin/kb with ldflags\n32\tmake deps # go mod tidy\n33\tmake help # mage target list\n34\t```\n35\t\n36\t## Package Layout\n37\t\n38\t| Path | Responsibility |\n39\t| --- | --- |\n40\t| `cmd/kb` | Program entrypoint for the `kb` binary |\n41\t| `internal/cli` | Cobra root, subcommands, flag resolution, and command I/O |\n42\t| `internal/topic` | Topic scaffolding, listing, and topic metadata lookup |\n43\t| `internal/ingest` | Ingest orchestration, frontmatter assembly, raw writes, and log entries |\n44\t| `internal/convert` | Converter registry and format-specific file converters |\n45\t| `internal/firecrawl` | Firecrawl REST client for `kb ingest url` |\n46\t| `internal/youtube` | YouTube caption extraction through `yt-dlp` and STT providers |\n47\t| `internal/frontmatter` | Shared frontmatter parsing and generation helpers |\n48\t| `internal/lint` | KB structural lint engine and report rendering |\n49\t| `internal/generate` | Codebase-to-KB pipeline used by `kb ingest codebase` and the hidden legacy `generate` alias |\n50\t| `internal/scanner` | Source discovery and ignore handling for codebase ingest |\n51\t| `internal/adapter` | Tree-sitter parsing adapters |\n52\t| `internal/graph` | Graph normalization for codebase snapshots |\n53\t| `internal/metrics` | File, symbol, directory, and smell metrics |\n54\t| `internal/vault` | Topic path helpers, render/write logic, vault reads, and inspect snapshot loading |\n55\t| `internal/qmd` | QMD subprocess integration for index and search |\n56\t| `internal/output` | Table, JSON, and TSV formatting |\n57\t| `internal/models` | Shared domain models and interfaces |\n58\t| `internal/config` | TOML config plus env-backed runtime overrides |\n59\t| `internal/logger` | Slog logger setup |\n60\t| `internal/version` | Build metadata surfaced by `kb version` |\n61\t\n62\t## CLI Commands\n63\t\n64\t| Command | Purpose |\n65\t| --- | --- |\n66\t| `kb topic new \u003cslug\u003e \u003ctitle\u003e \u003cdomain\u003e` | Scaffold a new knowledge base topic |\n67\t| `kb topic list` | List scaffolded topics |\n68\t| `kb topic info \u003cslug\u003e` | Show metadata for one topic |\n69\t| `kb ingest url \u003curl\u003e --topic \u003cslug\u003e` | Scrape a web URL and ingest into a topic |\n70\t| `kb ingest file \u003cpath\u003e --topic \u003cslug\u003e` | Convert a local file and ingest into a topic |\n71\t| `kb ingest youtube \u003curl\u003e --topic \u003cslug\u003e` | Extract a YouTube transcript and ingest into a topic |\n72\t| `kb ingest codebase \u003cpath\u003e --topic \u003cslug\u003e` | Analyze a codebase and ingest artifacts into a topic |\n73\t| `kb ingest bookmarks \u003cpath\u003e --topic \u003cslug\u003e` | Ingest a bookmark-cluster markdown file into a topic |\n74\t| `kb lint [\u003cslug\u003e]` | Check one topic for structural KB issues |\n75\t| `kb inspect smells` | List smell signals for symbols and files |\n76\t| `kb inspect dead-code` | List dead exports and orphan files |\n77\t| `kb inspect complexity` | Rank functions by cyclomatic complexity |\n78\t| `kb inspect blast-radius` | Rank symbols by blast radius |\n79\t| `kb inspect coupling` | Rank files by instability |\n80\t| `kb inspect symbol \u003cname\u003e` | Find symbols by case-insensitive substring |\n81\t| `kb inspect file \u003cpath\u003e` | Resolve one source file by exact path |\n82\t| `kb inspect backlinks \u003cname-or-path\u003e` | Show inbound relations for a file or symbol |\n83\t| `kb inspect deps \u003cname-or-path\u003e` | Show outgoing relations for a file or symbol |\n84\t| `kb inspect circular-deps` | List detected circular dependency cycles |\n85\t| `kb search \u003cquery\u003e` | Query a vault through QMD hybrid, lexical, or vector modes |\n86\t| `kb index` | Create or update a QMD collection for a topic |\n87\t| `kb version` | Print build version metadata |\n88\t\n89\t### Command Notes\n90\t\n91\t- `topic` subcommands share the root `--vault` flag for vault path resolution.\n92\t- `ingest` subcommands require `--topic \u003cslug\u003e` to identify the target topic.\n93\t- `ingest codebase` accepts `--include`, `--exclude`, `--semantic`, `--progress`, and `--log-format`.\n94\t- `ingest youtube` accepts `--transcribe captions|auto|stt`; `yt-dlp` is required for YouTube metadata, captions, and audio extraction.\n95\t- `inspect` subcommands share `--vault`, `--topic`, and `--format` (`table`, `json`, `tsv`).\n96\t- `lint` accepts `--format`, `--save`, and optional positional `\u003cslug\u003e` or `--topic`.\n97\t- `search` supports `--lex`, `--vec`, `--limit`, `--min-score`, `--full`, `--all`, `--collection`, `--vault`, `--topic`, and `--format`.\n98\t- `index` supports `--vault`, `--topic`, `--name`, `--embed`, `--context`, and `--force-embed`.\n99\t- Hidden compatibility alias: `kb generate \u003cpath\u003e` (delegates to codebase pipeline).\n100\t\n101\t## Runtime Config\n102\t\n103\t- `config.example.toml` documents the TOML keys currently supported by `internal/config`.\n104\t- `APP_CONFIG` overrides the config file path.\n105\t- `.env` is loaded automatically when present.\n106\t- `FIRECRAWL_API_KEY` and `FIRECRAWL_API_URL` configure the Firecrawl client for `ingest url`.\n107\t- `OPENAI_API_KEY`, `OPENAI_API_URL`, `STT_PROVIDER`, and `STT_MODEL` configure the default OpenAI STT provider for `ingest youtube --transcribe auto|stt`.\n108\t- `OPENROUTER_API_KEY`, `OPENROUTER_API_URL`, and `openrouter.stt_model` (TOML-only) configure the optional OpenRouter STT provider when `stt.provider = \"openrouter\"`.\n109\t- Generation, inspect, search, and index behavior is configured by CLI flags rather than TOML keys.\n110\t\n111\t## Architecture Notes\n112\t\n113\t- `internal/generate` is the codebase pipeline orchestration layer. Keep Cobra commands thin and push behavior into internal packages.\n114\t- The codebase pipeline is: scan -\u003e adapter parse -\u003e graph normalize -\u003e metrics compute -\u003e vault render -\u003e vault write -\u003e inspect/search/index read paths.\n115\t- `kb ingest file` routes through a converter registry (`internal/convert`) that matches file extensions to format-specific converters (PDF, DOCX, XLSX, PPTX, EPUB, HTML, CSV, JSON, XML, text, images with OCR).\n116\t- `kb ingest url` uses `internal/firecrawl` for web scraping, then writes through `internal/ingest`.\n117\t- `kb ingest youtube` uses `internal/youtube` for `yt-dlp` caption/audio extraction, with OpenAI STT by default and OpenRouter as an optional provider.\n118\t- Raw KB documents must include frontmatter before being written; use `internal/frontmatter` helpers instead of hand-assembling YAML.\n119\t- `vault.RenderDocuments` returns markdown bodies that already include frontmatter. Base definitions are rendered separately and written as YAML `.base` files.\n120\t- `kb topic new` owns the topic skeleton under the vault root, including `raw/`, `wiki/`, `outputs/`, `bases/`, `CLAUDE.md`, `AGENTS.md`, and `log.md`.\n121\t- Codebase inspection commands operate on `raw/codebase/` beneath the resolved topic. Keep inspect behavior topic-aware rather than vault-global.\n122\t\n123\t## Testing Notes\n124\t\n125\t- Default to table-driven tests with focused helpers and `t.TempDir()` for filesystem isolation.\n126\t- Integration tests use the `integration` build tag and live next to the packages they exercise.\n127\t- CLI integration tests exercise real topic/ingest/lint/inspect flows instead of mocking Cobra wiring when the workflow itself is the behavior under test.\n128\t- QMD-related integration tests must isolate `HOME`, `XDG_CACHE_HOME`, and `XDG_CONFIG_HOME`.\n129\t- Treat failing tests as product bugs first. Fix production behavior instead of weakening assertions.\n130\t\n```"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-221610-502089000-797361526ed537e0","time":"2026-06-27T22:16:55.889694Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01KYNAavSDZVShqUwcwqGPKi","blocks":[{"type":"tool_use","id":"toolu_01KYNAavSDZVShqUwcwqGPKi","name":"Read","title":"Read CONTRIBUTING.md","input":{"file_path":"/Users/pedronauck/Dev/compozy/kb/CONTRIBUTING.md"},"raw_input":{"file_path":"/Users/pedronauck/Dev/compozy/kb/CONTRIBUTING.md"}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-221610-502089000-797361526ed537e0","time":"2026-06-27T22:16:55.895219Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01KYNAavSDZVShqUwcwqGPKi","usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-221610-502089000-797361526ed537e0","time":"2026-06-27T22:16:55.901383Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01KYNAavSDZVShqUwcwqGPKi","tool_call_state":"completed","blocks":[{"type":"tool_result","tool_use_id":"toolu_01KYNAavSDZVShqUwcwqGPKi","content":"```\n1\t# Contributing to kb\n2\t\n3\tThanks for your interest in contributing. Whether it's a bug report, a new language adapter, a file converter, or a documentation fix, every contribution helps.\n4\t\n5\t---\n6\t\n7\t## Prerequisites\n8\t\n9\t- [Go](https://go.dev) \u003e= 1.24\n10\t- [Git](https://git-scm.com)\n11\t\n12\tOptional (auto-downloaded by the build system if missing):\n13\t\n14\t- [golangci-lint](https://golangci-lint.run) v2\n15\t- [gotestsum](https://github.com/gotestyourself/gotestsum)\n16\t\n17\t---\n18\t\n19\t## Getting Started\n20\t\n21\t```bash\n22\tgit clone https://github.com/pedronauck/kodebase-go.git\n23\tcd kodebase-go\n24\tmake verify # Verify everything passes before making changes\n25\t```\n26\t\n27\t### Project Structure\n28\t\n29\t```text\n30\tcmd/\n31\t kb/\n32\t main.go # Program entrypoint\n33\tinternal/\n34\t cli/ # Cobra command tree and command adapters\n35\t root.go\n36\t topic.go # kb topic {new,list,info}\n37\t ingest.go # kb ingest parent command + shared helpers\n38\t ingest_url.go # kb ingest url\n39\t ingest_file.go # kb ingest file\n40\t ingest_youtube.go # kb ingest youtube\n41\t ingest_codebase.go # kb ingest codebase\n42\t ingest_bookmarks.go # kb ingest bookmarks\n43\t lint.go # kb lint\n44\t generate.go # Hidden compatibility alias\n45\t inspect.go # Router for inspect subcommands\n46\t inspect_*.go # Inspect subcommand implementations\n47\t search.go\n48\t index.go\n49\t version.go\n50\t topic/ # Topic scaffolding and metadata\n51\t ingest/ # Ingest orchestration and vault writes\n52\t convert/ # Converter registry and format-specific converters\n53\t firecrawl/ # Firecrawl REST API client\n54\t youtube/ # YouTube transcript extraction + STT fallback\n55\t frontmatter/ # YAML frontmatter parsing and generation\n56\t lint/ # KB structural lint engine\n57\t generate/ # Codebase-to-vault pipeline orchestration\n58\t models/ # Domain types, snapshots, and interfaces\n59\t scanner/ # Workspace discovery and ignore filtering\n60\t adapter/ # Tree-sitter parsing adapters\n61\t go_adapter.go\n62\t ts_adapter.go\n63\t treesitter.go\n64\t graph/ # Graph normalization\n65\t metrics/ # File, symbol, and directory metrics\n66\t vault/ # Rendering, writing, reading, query helpers\n67\t qmd/ # QMD shell client integration\n68\t output/ # Table / JSON / TSV output rendering\n69\t config/ # TOML config and env-backed secrets\n70\t logger/ # Structured slog logger\n71\t version/ # Build metadata\n72\tmagefile.go # Mage build tasks (wrapped by Makefile)\n73\t```\n74\t\n75\t---\n76\t\n77\t## Development Workflow\n78\t\n79\t| Command | Description |\n80\t| ----------------------- | ----------------------------------------- |\n81\t| `make fmt` | Format all Go files with gofmt |\n82\t| `make lint` | Run golangci-lint v2 with zero tolerance |\n83\t| `make test` | Unit tests with race detector |\n84\t| `make test-integration` | Unit + integration tests |\n85\t| `make build` | Build binary to `bin/kb` with ldflags |\n86\t| `make verify` | fmt -\u003e lint -\u003e test -\u003e build -\u003e boundaries|\n87\t| `make deps` | Run `go mod tidy` |\n88\t\n89\t**`make verify` must pass before submitting a PR.** The CI pipeline runs the same command.\n90\t\n91\t---\n92\t\n93\t## Code Style\n94\t\n95\t- **File naming:** `snake_case.go` for all Go files\n96\t- **Exports:** Capitalize public symbols; keep internal logic unexported\n97\t- **Formatting:** `gofmt` (standard Go formatting, enforced by `make fmt`)\n98\t- **Linting:** golangci-lint v2 with zero warnings -- warnings are treated as errors\n99\t- **Imports:** Group in order: stdlib, third-party, internal\n100\t- **Dependencies:** Use `go get` for dependency changes, never hand-edit `go.mod`\n101\t- **CLI commands:** Use [Cobra](https://github.com/spf13/cobra). Keep commands thin -- delegate to packages like `internal/topic`, `internal/ingest`, `internal/lint`, `internal/generate`, `internal/vault`, and `internal/qmd`\n102\t\n103\t---\n104\t\n105\t## Commit Messages\n106\t\n107\tWe use [Conventional Commits](https://www.conventionalcommits.org) enforced by CI.\n108\t\n109\t```text\n110\tfeat(cli): add python language adapter\n111\tfix(cli): correct cyclomatic complexity for method receivers\n112\trefactor(cli): extract shared metric computation\n113\ttest(cli): add coverage for blast-radius edge cases\n114\tdocs(repo): update README with search command examples\n115\tchore(repo): update dependencies\n116\t```\n117\t\n118\t**Valid types:** `feat`, `fix`, `refactor`, `test`, `docs`, `chore`, `build`, `ci`\n119\t\n120\t**Valid scopes:** `cli`, `repo`, `docs`, `test`, `ci`\n121\t\n122\tPR titles must follow the same format (enforced by CI).\n123\t\n124\t---\n125\t\n126\t## Testing\n127\t\n128\t- **Framework:** Standard `testing` package with [gotestsum](https://github.com/gotestyourself/gotestsum) runner\n129\t- **Run tests:** `make test` or `make verify`\n130\t- **Test location:** Co-located with source (`*_test.go` next to the file under test)\n131\t- **File pattern:** `\u003cmodule\u003e_test.go`\n132\t\n133\t### Conventions\n134\t\n135\t- Table-driven tests with `t.Run` subtests\n136\t- `t.TempDir()` for filesystem isolation\n137\t- Focused test helpers with `t.Helper()`\n138\t- Integration tests use the `//go:build integration` build tag and run via `make test-integration`\n139\t- Race detector is always enabled (`-race`)\n140\t\n141\t---\n142\t\n143\t## Adding a New Language Adapter\n144\t\n145\tThis is one of the most impactful contributions you can make. `kb` uses a clean adapter interface that makes adding new languages straightforward.\n146\t\n147\t### Step 1: Create the Adapter File\n148\t\n149\t```text\n150\tinternal/adapter/\u003clanguage\u003e_adapter.go\n151\t```\n152\t\n153\t### Step 2: Implement the LanguageAdapter Interface\n154\t\n155\tThe interface is defined in `internal/models/models.go`:\n156\t\n157\t```go\n158\ttype LanguageAdapter interface {\n159\t Supports(lang SupportedLanguage) bool\n160\t ParseFiles(files []ScannedSourceFile, rootPath string) ([]ParsedFile, error)\n161\t}\n162\t```\n163\t\n164\tYour `ParseFiles` method must return, for each file:\n165\t\n166\t- **`File`** -- A `GraphFile` node with the file's path, language, and symbol IDs\n167\t- **`Symbols`** -- Slice of `SymbolNode` entries (functions, classes, interfaces, types, variables, methods)\n168\t- **`ExternalNodes`** -- External module references\n169\t- **`Relations`** -- Edges connecting files and symbols (`imports`, `exports`, `calls`, `references`, `declares`, `contains`)\n170\t- **`Diagnostics`** -- Any parse warnings or errors\n171\t\n172\t### Step 3: Register Language Extensions\n173\t\n174\tAdd your language's file extensions to the `SupportedLanguage` constants in `internal/models/models.go` and extend `SupportedLanguages()`.\n175\t\n176\t### Step 4: Add Tree-sitter Bindings\n177\t\n178\tAdd the tree-sitter grammar dependency and wire it in `internal/adapter/treesitter.go`.\n179\t\n180\t### Step 5: Wire the Adapter\n181\t\n182\tAdd adapter instantiation and file dispatch in `internal/generate/generate.go`, following the pattern of the existing Go and TypeScript adapters.\n183\t\n184\t### Step 6: Write Tests\n185\t\n186\tAdd `\u003clanguage\u003e_adapter_test.go` in `internal/adapter/` with fixture files in `internal/adapter/testdata/`.\n187\t\n188\t### Step 7: Update Documentation\n189\t\n190\tAdd the language to the \"Supported Languages\" table in `README.md`.\n191\t\n192\t### Reference Implementations\n193\t\n194\t- **`go_adapter.go`** -- Tree-sitter-based Go parser. Relation confidence: `syntactic`.\n195\t- **`ts_adapter.go`** -- Tree-sitter-based TypeScript/JavaScript parser. Relation confidence: `syntactic`.\n196\t\n197\t---\n198\t\n199\t## Adding a New File Converter\n200\t\n201\tThe converter registry (`internal/convert`) makes adding new file format support straightforward.\n202\t\n203\t### Step 1: Create the Converter File\n204\t\n205\t```text\n206\tinternal/convert/\u003cformat\u003e.go\n207\t```\n208\t\n209\t### Step 2: Implement the Converter Interface\n210\t\n211\tThe interface is defined in `internal/models/models.go`:\n212\t\n213\t```go\n214\ttype Converter interface {\n215\t Extensions() []string\n216\t Convert(ctx context.Context, input ConvertInput) (*ConvertResult, error)\n217\t}\n218\t```\n219\t\n220\t### Step 3: Register the Converter\n221\t\n222\tAdd your converter to `NewRegistry()` in `internal/convert/registry.go`.\n223\t\n224\t### Step 4: Write Tests\n225\t\n226\tAdd `\u003cformat\u003e_test.go` in `internal/convert/` with fixture files in `internal/convert/testdata/`.\n227\t\n228\t### Reference Implementations\n229\t\n230\t- **`pdf.go`** -- PDF text extraction via pdfcpu\n231\t- **`docx.go`** -- DOCX XML extraction\n232\t- **`html.go`** -- HTML-to-markdown conversion\n233\t\n234\t---\n235\t\n236\t## Pull Request Process\n237\t\n238\t1. Fork the repository and create a feature branch from `main`\n239\t2. Make your changes following the code style guidelines above\n240\t3. Run `make verify` and ensure it passes with zero warnings\n241\t4. Write a clear PR description explaining what changed and why\n242\t5. PR titles must follow Conventional Commits format with a scope\n243\t6. Keep PRs focused -- one feature or fix per PR\n244\t\n245\t---\n246\t\n247\t## Reporting Issues\n248\t\n249\t- **Bugs:** Use the [bug report template](https://github.com/pedronauck/kodebase-go/issues/new?template=bug-report.yml)\n250\t- **Features:** Use the [feature request template](https://github.com/pedronauck/kodebase-go/issues/new?template=feature-request.yml)\n251\t\n252\tInclude the command you ran, the output you got, and the output you expected. `kb version` output and Go version help us reproduce faster.\n253\t\n254\t---\n255\t\n256\t## Code of Conduct\n257\t\n258\tBe respectful and constructive. We follow the [Contributor Covenant](https://www.contributor-covenant.org/version/2/1/code_of_conduct/).\n259\t\n```"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-221610-502089000-797361526ed537e0","time":"2026-06-27T22:17:00.223029Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":""}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-221610-502089000-797361526ed537e0","time":"2026-06-27T22:17:00.223357Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":"Let me verify the spec's specific code claims against the actual source"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-221610-502089000-797361526ed537e0","time":"2026-06-27T22:17:00.699474Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":"."}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-221610-502089000-797361526ed537e0","time":"2026-06-27T22:17:00.69978Z","turn":1,"type":"session.update","update":{"kind":"tool_call_started","tool_call_id":"toolu_01LzuuVD551Ji2UQ4T6aVB6k","tool_call_state":"pending","blocks":[{"type":"tool_use","id":"toolu_01LzuuVD551Ji2UQ4T6aVB6k","name":"Bash","title":"Terminal","raw_input":{}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-221610-502089000-797361526ed537e0","time":"2026-06-27T22:17:03.214119Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01LzuuVD551Ji2UQ4T6aVB6k","blocks":[{"type":"tool_use","id":"toolu_01LzuuVD551Ji2UQ4T6aVB6k","name":"Task","title":"echo \"=== actual call sites in render.go + render_wiki.go (non-test) ===\" \u0026\u0026 grep -cn \"ToTopicWikiLink\\|toSourceWikiLink\" internal/vault/render.go internal/vault/render_wiki.go; echo \"=== total across the two ===\" \u0026\u0026 grep -hn \"ToTopicWikiLink\\|toSourceWikiLink\" internal/vault/render.go internal/vault/render_wiki.go | wc -l; echo \"=== render function signatures (how topic flows in) ===\" \u0026\u0026 grep -n \"topic models.TopicMetadata\\|topic models.TopicInfo\\|func render\\|func Render\" internal/vault/render.go internal/vault/render_wiki.go | head -50","input":{"prompt":"Count actual render call sites"},"raw_input":{"command":"echo \"=== actual call sites in render.go + render_wiki.go (non-test) ===\" \u0026\u0026 grep -cn \"ToTopicWikiLink\\|toSourceWikiLink\" internal/vault/render.go internal/vault/render_wiki.go; echo \"=== total across the two ===\" \u0026\u0026 grep -hn \"ToTopicWikiLink\\|toSourceWikiLink\" internal/vault/render.go internal/vault/render_wiki.go | wc -l; echo \"=== render function signatures (how topic flows in) ===\" \u0026\u0026 grep -n \"topic models.TopicMetadata\\|topic models.TopicInfo\\|func render\\|func Render\" internal/vault/render.go internal/vault/render_wiki.go | head -50","description":"Count actual render call sites"}},{"type":"tool_result","tool_use_id":"toolu_01LzuuVD551Ji2UQ4T6aVB6k","content":"Count actual render call sites"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-221610-502089000-797361526ed537e0","time":"2026-06-27T22:17:03.215634Z","turn":1,"type":"session.update","update":{"kind":"tool_call_started","tool_call_id":"toolu_01DDk47StG5PX38r3PeMFrWi","tool_call_state":"pending","blocks":[{"type":"tool_use","id":"toolu_01DDk47StG5PX38r3PeMFrWi","name":"Bash","title":"Terminal","raw_input":{}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-221610-502089000-797361526ed537e0","time":"2026-06-27T22:17:03.31847Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01LzuuVD551Ji2UQ4T6aVB6k","usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-221610-502089000-797361526ed537e0","time":"2026-06-27T22:17:03.322958Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01LzuuVD551Ji2UQ4T6aVB6k","tool_call_state":"completed","blocks":[{"type":"tool_result","tool_use_id":"toolu_01LzuuVD551Ji2UQ4T6aVB6k","content":"```console\n=== actual call sites in render.go + render_wiki.go (non-test) ===\ninternal/vault/render.go:9\ninternal/vault/render_wiki.go:34\n=== total across the two ===\n 19\n=== render function signatures (how topic flows in) ===\n40 matches in 2 files:\n\ninternal/vault/render.go:31:func RenderDocuments(\ninternal/vault/render.go:34:topic models.TopicMetadata,\ninternal/vault/render.go:143:func renderFrontmatter(frontmatter map[string]any) string {\ninternal/vault/render.go:177:func renderMarkdownDocument(document models.RenderedDocument) string {\ninternal/vault/render.go:186:func toSourceWikiLink(topic models.TopicMetadata, relativePath, label string)...\ninternal/vault/render.go:213:topic models.TopicMetadata,\ninternal/vault/render.go:234:func renderRelationList(\ninternal/vault/render.go:235:topic models.TopicMetadata,\ninternal/vault/render.go:275:func renderBacklinkList(\ninternal/vault/render.go:276:topic models.TopicMetadata,\ninternal/vault/render.go:313:func renderSmellList(smells []string) string {\ninternal/vault/render.go:325:func renderRawFileDocument(\ninternal/vault/render.go:326:topic models.TopicMetadata,\ninternal/vault/render.go:417:topic models.TopicMetadata,\ninternal/vault/render.go:464:func renderRawSymbolDocument(\ninternal/vault/render.go:465:topic models.TopicMetadata,\ninternal/vault/render.go:539:func renderRawDirectoryIndex(\ninternal/vault/render.go:540:topic models.TopicMetadata,\ninternal/vault/render.go:616:func renderRawLanguageIndex(\ninternal/vault/render.go:617:topic models.TopicMetadata,\ninternal/vault/render_wiki.go:20:topic models.TopicMetadata,\ninternal/vault/render_wiki.go:44:topic models.TopicMetadata,\ninternal/vault/render_wiki.go:65:func renderWikiArticle(topic models.TopicMetadata, article starterWikiArticle...\ninternal/vault/render_wiki.go:75:func renderDashboard(\ninternal/vault/render_wiki.go:76:topic models.TopicMetadata,\ninternal/vault/render_wiki.go:134:func renderConceptIndex(\ninternal/vault/render_wiki.go:135:topic models.TopicMetadata,\ninternal/vault/render_wiki.go:178:func renderSourceIndex(topic models.TopicMetadata, articles []starterWikiArti...\ninternal/vault/render_wiki.go:231:topic models.TopicMetadata,\ninternal/vault/render_wiki.go:350:topic models.TopicMetadata,\ninternal/vault/render_wiki.go:406:topic models.TopicMetadata,\ninternal/vault/render_wiki.go:472:topic models.TopicMetadata,\ninternal/vault/render_wiki.go:548:topic models.TopicMetadata,\ninternal/vault/render_wiki.go:623:topic models.TopicMetadata,\ninternal/vault/render_wiki.go:693:topic models.TopicMetadata,\ninternal/vault/render_wiki.go:785:topic models.TopicMetadata,\ninternal/vault/render_wiki.go:866:topic models.TopicMetadata,\ninternal/vault/render_wiki.go:900:topic models.TopicMetadata,\ninternal/vault/render_wiki.go:971:func renderGroupedLinks(groups map[string][]string, emptyMessage string) []st...\ninternal/vault/render_wiki.go:985:func renderSourceBulletList(topic models.TopicMetadata, sources []string, emp...\n```"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-221610-502089000-797361526ed537e0","time":"2026-06-27T22:17:04.62293Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01DDk47StG5PX38r3PeMFrWi","blocks":[{"type":"tool_use","id":"toolu_01DDk47StG5PX38r3PeMFrWi","name":"Task","title":"echo \"=== frontmatter public API ===\" \u0026\u0026 grep -n \"^func \\|^type \" internal/frontmatter/*.go | grep -v _test; echo; echo \"=== TopicMetadata + TopicInfo structs ===\" \u0026\u0026 grep -n \"type TopicMetadata\\|type TopicInfo\\|type LintIssue\" internal/models/models.go","input":{"prompt":"Inspect frontmatter API and models"},"raw_input":{"command":"echo \"=== frontmatter public API ===\" \u0026\u0026 grep -n \"^func \\|^type \" internal/frontmatter/*.go | grep -v _test; echo; echo \"=== TopicMetadata + TopicInfo structs ===\" \u0026\u0026 grep -n \"type TopicMetadata\\|type TopicInfo\\|type LintIssue\" internal/models/models.go","description":"Inspect frontmatter API and models"}},{"type":"tool_result","tool_use_id":"toolu_01DDk47StG5PX38r3PeMFrWi","content":"Inspect frontmatter API and models"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-221610-502089000-797361526ed537e0","time":"2026-06-27T22:17:04.713195Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01DDk47StG5PX38r3PeMFrWi","usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-221610-502089000-797361526ed537e0","time":"2026-06-27T22:17:04.715913Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01DDk47StG5PX38r3PeMFrWi","tool_call_state":"completed","blocks":[{"type":"tool_result","tool_use_id":"toolu_01DDk47StG5PX38r3PeMFrWi","content":"```console\n=== frontmatter public API ===\n40 matches in 2 files:\n\ninternal/frontmatter/frontmatter.go:22:type ErrorKind string\ninternal/frontmatter/frontmatter.go:33:type Error struct {\ninternal/frontmatter/frontmatter.go:40:func (e *Error) Error() string {\ninternal/frontmatter/frontmatter.go:60:func (e *Error) Unwrap() error {\ninternal/frontmatter/frontmatter.go:71:func Parse(markdown string) (map[string]any, string, error) {\ninternal/frontmatter/frontmatter.go:102:func Generate(values map[string]any, body string) (string, error) {\ninternal/frontmatter/frontmatter.go:121:func GetString(values map[string]any, key string) string {\ninternal/frontmatter/frontmatter.go:138:func GetStringSlice(values map[string]any, key string) []string {\ninternal/frontmatter/frontmatter.go:163:func GetTime(values map[string]any, key string) time.Time {\ninternal/frontmatter/frontmatter.go:186:func GetBool(values map[string]any, key string) bool {\ninternal/frontmatter/frontmatter.go:206:func hasOpeningDelimiter(markdown string) bool {\ninternal/frontmatter/frontmatter.go:210:func splitFrontmatter(markdown string) (string, string, bool) {\ninternal/frontmatter/frontmatter.go:231:func firstLineEnd(markdown string) int {\ninternal/frontmatter/frontmatter.go:241:func lineBounds(markdown string, start int) (int, int) {\ninternal/frontmatter/frontmatter.go:255:func normalizeMap(values map[string]any) map[string]any {\ninternal/frontmatter/frontmatter.go:268:func normalizeValue(value any) any {\ninternal/frontmatter/frontmatter.go:305:func buildNode(value any, key string) (*yaml.Node, error) {\ninternal/frontmatter/frontmatter.go:390:func buildMappingNode(values map[string]any) (*yaml.Node, error) {\ninternal/frontmatter/frontmatter.go:412:func formatTime(value time.Time) string {\ninternal/frontmatter/frontmatter.go:420:func extractYAMLLine(err error) int {\n\n=== TopicMetadata + TopicInfo structs ===\n1 matches in 1 files:\n\ninternal/models/models.go:271:type TopicMetadata struct {\n```"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-221610-502089000-797361526ed537e0","time":"2026-06-27T22:17:19.673329Z","turn":1,"type":"session.update","update":{"kind":"tool_call_started","tool_call_id":"toolu_01KDZ7zKUiNuVWYZkuKVin7h","tool_call_state":"pending","blocks":[{"type":"tool_use","id":"toolu_01KDZ7zKUiNuVWYZkuKVin7h","name":"Bash","title":"Terminal","raw_input":{}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-221610-502089000-797361526ed537e0","time":"2026-06-27T22:17:25.080432Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01KDZ7zKUiNuVWYZkuKVin7h","blocks":[{"type":"tool_use","id":"toolu_01KDZ7zKUiNuVWYZkuKVin7h","name":"Task","title":"cd /Users/pedronauck/Dev/compozy/kb\necho \"=== render.go matching lines ===\" \u0026\u0026 grep -nc \"ToTopicWikiLink\\|toSourceWikiLink\" internal/vault/render.go\necho \"=== render_wiki.go matching lines ===\" \u0026\u0026 grep -nc \"ToTopicWikiLink\\|toSourceWikiLink\" internal/vault/render_wiki.go\necho \"=== total matches (-o) across both ===\" \u0026\u0026 grep -ho \"ToTopicWikiLink\\|toSourceWikiLink\" internal/vault/render.go internal/vault/render_wiki.go | wc -l\necho \"=== only the call expressions (exclude def lines) ===\" \ngrep -n \"toSourceWikiLink\\|ToTopicWikiLink\" internal/vault/render_wiki.go | grep -v \"func toSourceWikiLink\\|func ToTopicWikiLink\" | wc -l\necho \"=== Does models.TopicInfo exist? ===\" \u0026\u0026 grep -rn \"type TopicInfo\\b\\|TopicInfo struct\\|TopicInfo \" internal/models/ | head\necho \"=== Anything named TopicInfo anywhere? ===\" \u0026\u0026 grep -rn \"TopicInfo\" internal/ | grep -v _test | head","input":{"prompt":"Reconcile call-site count and check TopicInfo"},"raw_input":{"command":"cd /Users/pedronauck/Dev/compozy/kb\necho \"=== render.go matching lines ===\" \u0026\u0026 grep -nc \"ToTopicWikiLink\\|toSourceWikiLink\" internal/vault/render.go\necho \"=== render_wiki.go matching lines ===\" \u0026\u0026 grep -nc \"ToTopicWikiLink\\|toSourceWikiLink\" internal/vault/render_wiki.go\necho \"=== total matches (-o) across both ===\" \u0026\u0026 grep -ho \"ToTopicWikiLink\\|toSourceWikiLink\" internal/vault/render.go internal/vault/render_wiki.go | wc -l\necho \"=== only the call expressions (exclude def lines) ===\" \ngrep -n \"toSourceWikiLink\\|ToTopicWikiLink\" internal/vault/render_wiki.go | grep -v \"func toSourceWikiLink\\|func ToTopicWikiLink\" | wc -l\necho \"=== Does models.TopicInfo exist? ===\" \u0026\u0026 grep -rn \"type TopicInfo\\b\\|TopicInfo struct\\|TopicInfo \" internal/models/ | head\necho \"=== Anything named TopicInfo anywhere? ===\" \u0026\u0026 grep -rn \"TopicInfo\" internal/ | grep -v _test | head","description":"Reconcile call-site count and check TopicInfo"}},{"type":"tool_result","tool_use_id":"toolu_01KDZ7zKUiNuVWYZkuKVin7h","content":"Reconcile call-site count and check TopicInfo"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-221610-502089000-797361526ed537e0","time":"2026-06-27T22:17:25.263341Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01KDZ7zKUiNuVWYZkuKVin7h","usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-221610-502089000-797361526ed537e0","time":"2026-06-27T22:17:25.266087Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01KDZ7zKUiNuVWYZkuKVin7h","tool_call_state":"completed","blocks":[{"type":"tool_result","tool_use_id":"toolu_01KDZ7zKUiNuVWYZkuKVin7h","content":"```console\n=== render.go matching lines ===\n9\n=== render_wiki.go matching lines ===\n34\n=== total matches (-o) across both ===\n 19\n=== only the call expressions (exclude def lines) ===\n 34\n=== Does models.TopicInfo exist? ===\ninternal/models/kb_models.go:115:// TopicInfo captures topic metadata for list and info operations.\ninternal/models/kb_models.go:116:type TopicInfo struct {\n=== Anything named TopicInfo anywhere? ===\ninternal/ingest/ingest.go:161:\ttopicInfo models.TopicInfo,\ninternal/models/kb_models.go:115:// TopicInfo captures topic metadata for list and info operations.\ninternal/models/kb_models.go:116:type TopicInfo struct {\ninternal/topic/topic.go:114:func New(vaultPath, slug, title, domain string) (models.TopicInfo, error) {\ninternal/topic/topic.go:119:func List(vaultPath string) ([]models.TopicInfo, error) {\ninternal/topic/topic.go:130:\ttopics := make([]models.TopicInfo, 0, len(topicSlugs))\ninternal/topic/topic.go:148:func Resolve(vaultPath, topicRef string) (models.TopicInfo, error) {\ninternal/topic/topic.go:151:\t\treturn models.TopicInfo{}, fmt.Errorf(\"resolve topic: %w\", err)\ninternal/topic/topic.go:156:\t\treturn models.TopicInfo{}, fmt.Errorf(\"resolve topic: %w\", err)\ninternal/topic/topic.go:162:\t\treturn models.TopicInfo{}, fmt.Errorf(\"resolve topic: inspect %q: %w\", topicPath, err)\n```"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-221610-502089000-797361526ed537e0","time":"2026-06-27T22:17:46.7329Z","turn":1,"type":"session.update","update":{"kind":"tool_call_started","tool_call_id":"toolu_01HtgoUkMnUkutm2JEGA2hjE","tool_call_state":"pending","blocks":[{"type":"tool_use","id":"toolu_01HtgoUkMnUkutm2JEGA2hjE","name":"Read","title":"Read File","raw_input":{}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-221610-502089000-797361526ed537e0","time":"2026-06-27T22:17:48.662075Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01HtgoUkMnUkutm2JEGA2hjE","blocks":[{"type":"tool_use","id":"toolu_01HtgoUkMnUkutm2JEGA2hjE","name":"Read","title":"Read internal/frontmatter/frontmatter.go (102 - 161)","input":{"file_path":"/Users/pedronauck/Dev/compozy/kb/internal/frontmatter/frontmatter.go"},"raw_input":{"file_path":"/Users/pedronauck/Dev/compozy/kb/internal/frontmatter/frontmatter.go","limit":60,"offset":102}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-221610-502089000-797361526ed537e0","time":"2026-06-27T22:17:48.662574Z","turn":1,"type":"session.update","update":{"kind":"tool_call_started","tool_call_id":"toolu_01SBSy9AmmTkgcGRZN3K2dqT","tool_call_state":"pending","blocks":[{"type":"tool_use","id":"toolu_01SBSy9AmmTkgcGRZN3K2dqT","name":"Read","title":"Read File","raw_input":{}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-221610-502089000-797361526ed537e0","time":"2026-06-27T22:17:48.662863Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01HtgoUkMnUkutm2JEGA2hjE","usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-221610-502089000-797361526ed537e0","time":"2026-06-27T22:17:48.664208Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01HtgoUkMnUkutm2JEGA2hjE","tool_call_state":"completed","blocks":[{"type":"tool_result","tool_use_id":"toolu_01HtgoUkMnUkutm2JEGA2hjE","content":"```\n102\tfunc Generate(values map[string]any, body string) (string, error) {\n103\t\tif len(values) == 0 {\n104\t\t\treturn body, nil\n105\t\t}\n106\t\n107\t\tnode, err := buildNode(values, \"\")\n108\t\tif err != nil {\n109\t\t\treturn \"\", err\n110\t\t}\n111\t\n112\t\tencoded, err := yaml.Marshal(node)\n113\t\tif err != nil {\n114\t\t\treturn \"\", fmt.Errorf(\"marshal frontmatter: %w\", err)\n115\t\t}\n116\t\n117\t\treturn \"---\\n\" + strings.TrimRight(string(encoded), \"\\n\") + \"\\n---\\n\" + body, nil\n118\t}\n119\t\n120\t// GetString returns the string value for key, or the zero value when missing.\n121\tfunc GetString(values map[string]any, key string) string {\n122\t\tvalue, exists := values[key]\n123\t\tif !exists {\n124\t\t\treturn \"\"\n125\t\t}\n126\t\n127\t\tswitch typed := value.(type) {\n128\t\tcase string:\n129\t\t\treturn typed\n130\t\tcase fmt.Stringer:\n131\t\t\treturn typed.String()\n132\t\tdefault:\n133\t\t\treturn fmt.Sprint(typed)\n134\t\t}\n135\t}\n136\t\n137\t// GetStringSlice returns the string slice value for key, or nil when missing.\n138\tfunc GetStringSlice(values map[string]any, key string) []string {\n139\t\tvalue, exists := values[key]\n140\t\tif !exists {\n141\t\t\treturn nil\n142\t\t}\n143\t\n144\t\tswitch typed := value.(type) {\n145\t\tcase []string:\n146\t\t\treturn append([]string(nil), typed...)\n147\t\tcase []any:\n148\t\t\titems := make([]string, 0, len(typed))\n149\t\t\tfor _, item := range typed {\n150\t\t\t\ttext, ok := item.(string)\n151\t\t\t\tif !ok {\n152\t\t\t\t\treturn nil\n153\t\t\t\t}\n154\t\t\t\titems = append(items, text)\n155\t\t\t}\n156\t\t\treturn items\n157\t\tdefault:\n158\t\t\treturn nil\n159\t\t}\n160\t}\n161\t\n```"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-221610-502089000-797361526ed537e0","time":"2026-06-27T22:17:49.263833Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01SBSy9AmmTkgcGRZN3K2dqT","blocks":[{"type":"tool_use","id":"toolu_01SBSy9AmmTkgcGRZN3K2dqT","name":"Read","title":"Read internal/frontmatter/frontmatter.go (385 - 419)","input":{"file_path":"/Users/pedronauck/Dev/compozy/kb/internal/frontmatter/frontmatter.go"},"raw_input":{"file_path":"/Users/pedronauck/Dev/compozy/kb/internal/frontmatter/frontmatter.go","limit":35,"offset":385}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-221610-502089000-797361526ed537e0","time":"2026-06-27T22:17:49.26465Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01SBSy9AmmTkgcGRZN3K2dqT","usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-221610-502089000-797361526ed537e0","time":"2026-06-27T22:17:49.266168Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01SBSy9AmmTkgcGRZN3K2dqT","tool_call_state":"completed","blocks":[{"type":"tool_result","tool_use_id":"toolu_01SBSy9AmmTkgcGRZN3K2dqT","content":"```\n385\t\t\t\tErr: fmt.Errorf(\"unsupported value type %T\", value),\n386\t\t\t}\n387\t\t}\n388\t}\n389\t\n390\tfunc buildMappingNode(values map[string]any) (*yaml.Node, error) {\n391\t\tnode := \u0026yaml.Node{Kind: yaml.MappingNode, Tag: \"!!map\"}\n392\t\tkeys := make([]string, 0, len(values))\n393\t\tfor key := range values {\n394\t\t\tkeys = append(keys, key)\n395\t\t}\n396\t\tsort.Strings(keys)\n397\t\n398\t\tfor _, key := range keys {\n399\t\t\tvalueNode, err := buildNode(values[key], key)\n400\t\t\tif err != nil {\n401\t\t\t\treturn nil, err\n402\t\t\t}\n403\t\t\tnode.Content = append(node.Content,\n404\t\t\t\t\u0026yaml.Node{Kind: yaml.ScalarNode, Tag: \"!!str\", Value: key},\n405\t\t\t\tvalueNode,\n406\t\t\t)\n407\t\t}\n408\t\n409\t\treturn node, nil\n410\t}\n411\t\n412\tfunc formatTime(value time.Time) string {\n413\t\tif value.Hour() == 0 \u0026\u0026 value.Minute() == 0 \u0026\u0026 value.Second() == 0 \u0026\u0026 value.Nanosecond() == 0 {\n414\t\t\treturn value.Format(DateLayout)\n415\t\t}\n416\t\n417\t\treturn value.Format(time.RFC3339Nano)\n418\t}\n419\t\n```"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-221610-502089000-797361526ed537e0","time":"2026-06-27T22:19:02.854859Z","turn":1,"type":"session.update","update":{"kind":"tool_call_started","tool_call_id":"toolu_01JcpcLsXAsLQJq1BdScwvwf","tool_call_state":"pending","blocks":[{"type":"tool_use","id":"toolu_01JcpcLsXAsLQJq1BdScwvwf","name":"Read","title":"Read File","raw_input":{}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-221610-502089000-797361526ed537e0","time":"2026-06-27T22:19:04.109025Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01JcpcLsXAsLQJq1BdScwvwf","blocks":[{"type":"tool_use","id":"toolu_01JcpcLsXAsLQJq1BdScwvwf","name":"Read","title":"Read internal/vault/pathutils.go (239 - 258)","input":{"file_path":"/Users/pedronauck/Dev/compozy/kb/internal/vault/pathutils.go"},"raw_input":{"file_path":"/Users/pedronauck/Dev/compozy/kb/internal/vault/pathutils.go","limit":20,"offset":239}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-221610-502089000-797361526ed537e0","time":"2026-06-27T22:19:04.109641Z","turn":1,"type":"session.update","update":{"kind":"tool_call_started","tool_call_id":"toolu_01DkMmobPhvwFxFdee1MGNLy","tool_call_state":"pending","blocks":[{"type":"tool_use","id":"toolu_01DkMmobPhvwFxFdee1MGNLy","name":"Bash","title":"Terminal","raw_input":{}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-221610-502089000-797361526ed537e0","time":"2026-06-27T22:19:04.109943Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01JcpcLsXAsLQJq1BdScwvwf","usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-221610-502089000-797361526ed537e0","time":"2026-06-27T22:19:04.111217Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01JcpcLsXAsLQJq1BdScwvwf","tool_call_state":"completed","blocks":[{"type":"tool_result","tool_use_id":"toolu_01JcpcLsXAsLQJq1BdScwvwf","content":"```\n239\t// StripMarkdownExtension removes a trailing .md extension from a document path.\n240\tfunc StripMarkdownExtension(documentPath string) string {\n241\t\treturn strings.TrimSuffix(documentPath, \".md\")\n242\t}\n243\t\n244\t// ToTopicWikiLink formats a topic-scoped Obsidian wiki-link.\n245\tfunc ToTopicWikiLink(topicSlug, documentPath, label string) string {\n246\t\ttarget := fmt.Sprintf(\"%s/%s\", topicSlug, StripMarkdownExtension(documentPath))\n247\t\tif label != \"\" {\n248\t\t\treturn fmt.Sprintf(\"[[%s|%s]]\", target, label)\n249\t\t}\n250\t\n251\t\treturn fmt.Sprintf(\"[[%s]]\", target)\n252\t}\n253\t\n254\tfunc stripWikiConceptFilePrefix(articleTitle string) string {\n255\t\tnormalizedTitle := strings.TrimSpace(articleTitle)\n256\t\treturn strings.TrimSpace(strings.TrimPrefix(normalizedTitle, legacyWikiConceptPrefix))\n257\t}\n258\t\n```"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-221610-502089000-797361526ed537e0","time":"2026-06-27T22:19:06.355895Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01DkMmobPhvwFxFdee1MGNLy","blocks":[{"type":"tool_use","id":"toolu_01DkMmobPhvwFxFdee1MGNLy","name":"Task","title":"cd /Users/pedronauck/Dev/compozy/kb\necho \"=== TopicMetadata struct body ===\" \u0026\u0026 sed -n '271,300p' internal/models/models.go\necho \"=== TopicInfo struct body ===\" \u0026\u0026 sed -n '115,140p' internal/models/kb_models.go\necho \"=== wikilink pattern in lint ===\" \u0026\u0026 grep -n \"wikilinkPattern\\|schemaForPath\" internal/lint/lint.go | head","input":{"prompt":"Inspect structs and wikilink pattern"},"raw_input":{"command":"cd /Users/pedronauck/Dev/compozy/kb\necho \"=== TopicMetadata struct body ===\" \u0026\u0026 sed -n '271,300p' internal/models/models.go\necho \"=== TopicInfo struct body ===\" \u0026\u0026 sed -n '115,140p' internal/models/kb_models.go\necho \"=== wikilink pattern in lint ===\" \u0026\u0026 grep -n \"wikilinkPattern\\|schemaForPath\" internal/lint/lint.go | head","description":"Inspect structs and wikilink pattern"}},{"type":"tool_result","tool_use_id":"toolu_01DkMmobPhvwFxFdee1MGNLy","content":"Inspect structs and wikilink pattern"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-221610-502089000-797361526ed537e0","time":"2026-06-27T22:19:06.40259Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01DkMmobPhvwFxFdee1MGNLy","usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-221610-502089000-797361526ed537e0","time":"2026-06-27T22:19:06.40428Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01DkMmobPhvwFxFdee1MGNLy","tool_call_state":"completed","blocks":[{"type":"tool_result","tool_use_id":"toolu_01DkMmobPhvwFxFdee1MGNLy","content":"```console\n=== TopicMetadata struct body ===\ntype TopicMetadata struct {\n\tRootPath string `json:\"rootPath\"`\n\tTitle string `json:\"title\"`\n\tSlug string `json:\"slug\"`\n\tDomain string `json:\"domain\"`\n\tToday string `json:\"today\"`\n\tVaultPath string `json:\"vaultPath\"`\n\tTopicPath string `json:\"topicPath\"`\n}\n\n// GenerateOptions configures a full knowledge-base generation run.\ntype GenerateOptions struct {\n\tRootPath string `json:\"rootPath\"`\n\tVaultPath string `json:\"vaultPath,omitempty\"`\n\tTopicSlug string `json:\"topicSlug,omitempty\"`\n\tTitle string `json:\"title,omitempty\"`\n\tDomain string `json:\"domain,omitempty\"`\n\tIncludePatterns []string `json:\"includePatterns,omitempty\"`\n\tExcludePatterns []string `json:\"excludePatterns,omitempty\"`\n\tDryRun bool `json:\"dryRun,omitempty\"`\n\tSemantic bool `json:\"semantic,omitempty\"`\n}\n\n// GenerationTimings reports the elapsed wall-clock time for each pipeline stage.\ntype GenerationTimings struct {\n\tScanMillis int64 `json:\"scanMillis\"`\n\tSelectAdaptersMillis int64 `json:\"selectAdaptersMillis\"`\n\tParseMillis int64 `json:\"parseMillis\"`\n\tNormalizeMillis int64 `json:\"normalizeMillis\"`\n\tMetricsMillis int64 `json:\"metricsMillis\"`\n=== TopicInfo struct body ===\n// TopicInfo captures topic metadata for list and info operations.\ntype TopicInfo struct {\n\tSlug string `json:\"slug\"`\n\tTitle string `json:\"title\"`\n\tDomain string `json:\"domain\"`\n\tRootPath string `json:\"rootPath\"`\n\tArticleCount int `json:\"articleCount\"`\n\tSourceCount int `json:\"sourceCount\"`\n\tLastLogEntry string `json:\"lastLogEntry,omitempty\"`\n}\n=== wikilink pattern in lint ===\n4 matches in 1 files:\n\ninternal/lint/lint.go:25:wikilinkPattern = regexp.MustCompile(`\\[\\[([^\\[\\]|#]+?)(?:\\|[^\\[\\]]*?)...\ninternal/lint/lint.go:338:spec, ok := schemaForPath(file.relativePath)\ninternal/lint/lint.go:703:func schemaForPath(relativePath string) (schemaSpec, bool) {\ninternal/lint/lint.go:904:matches := wikilinkPattern.FindAllStringSubmatch(clean, -1)\n```"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-221610-502089000-797361526ed537e0","time":"2026-06-27T22:19:16.976608Z","turn":1,"type":"session.update","update":{"kind":"tool_call_started","tool_call_id":"toolu_01G62ENsaHcR5vX9XTZDoh8Z","tool_call_state":"pending","blocks":[{"type":"tool_use","id":"toolu_01G62ENsaHcR5vX9XTZDoh8Z","name":"Bash","title":"Terminal","raw_input":{}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-221610-502089000-797361526ed537e0","time":"2026-06-27T22:19:20.390349Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01G62ENsaHcR5vX9XTZDoh8Z","blocks":[{"type":"tool_use","id":"toolu_01G62ENsaHcR5vX9XTZDoh8Z","name":"Task","title":"cd /Users/pedronauck/Dev/compozy/kb\necho \"=== topic.go: New, scaffold dirs, metadata read/write, AGENTS handling ===\" \ngrep -n \"func New\\|currentTopicDirectories\\|func writeMetadataFile\\|func readTopicYAMLMetadata\\|AGENTS\\|Symlink\\|func scaffold\\|topicMetadataFile\\b\" internal/topic/topic.go | head -40\necho\necho \"=== how AGENTS.md is created in scaffold ===\"\ngrep -rn \"AGENTS\" internal/topic/*.go | grep -v _test | head\necho\necho \"=== topicMetadataFile struct ===\"\nsed -n '100,115p' internal/topic/topic.go","input":{"prompt":"Inspect topic scaffold internals"},"raw_input":{"command":"cd /Users/pedronauck/Dev/compozy/kb\necho \"=== topic.go: New, scaffold dirs, metadata read/write, AGENTS handling ===\" \ngrep -n \"func New\\|currentTopicDirectories\\|func writeMetadataFile\\|func readTopicYAMLMetadata\\|AGENTS\\|Symlink\\|func scaffold\\|topicMetadataFile\\b\" internal/topic/topic.go | head -40\necho\necho \"=== how AGENTS.md is created in scaffold ===\"\ngrep -rn \"AGENTS\" internal/topic/*.go | grep -v _test | head\necho\necho \"=== topicMetadataFile struct ===\"\nsed -n '100,115p' internal/topic/topic.go","description":"Inspect topic scaffold internals"}},{"type":"tool_result","tool_use_id":"toolu_01G62ENsaHcR5vX9XTZDoh8Z","content":"Inspect topic scaffold internals"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-221610-502089000-797361526ed537e0","time":"2026-06-27T22:19:20.425661Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01G62ENsaHcR5vX9XTZDoh8Z","usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-221610-502089000-797361526ed537e0","time":"2026-06-27T22:19:20.427509Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01G62ENsaHcR5vX9XTZDoh8Z","tool_call_state":"completed","blocks":[{"type":"tool_result","tool_use_id":"toolu_01G62ENsaHcR5vX9XTZDoh8Z","content":"```console\n=== topic.go: New, scaffold dirs, metadata read/write, AGENTS handling ===\n38:\tcurrentTopicDirectories = []string{\n104:type topicMetadataFile struct {\n114:func New(vaultPath, slug, title, domain string) (models.TopicInfo, error) {\n234:\tif err := ensureAgentsSymlink(topicPath); err != nil {\n235:\t\treturn models.TopicInfo{}, fmt.Errorf(\"new topic: ensure AGENTS.md symlink: %w\", err)\n416:\tfor _, relativePath := range currentTopicDirectories {\n512:func topicMetadataForRef(topicRef TopicRef, title, domain string) topicMetadataFile {\n513:\tmetadata := topicMetadataFile{\n526:func writeMetadataFile(topicPath string, metadata topicMetadataFile) error {\n607:func ensureAgentsSymlink(topicPath string) error {\n608:\tagentsPath := filepath.Join(topicPath, \"AGENTS.md\")\n610:\t\treturn fmt.Errorf(\"remove existing AGENTS.md: %w\", err)\n612:\tif err := os.Symlink(\"CLAUDE.md\", agentsPath); err != nil {\n613:\t\treturn fmt.Errorf(\"create AGENTS.md symlink: %w\", err)\n620:\tagentsPath := filepath.Join(topicPath, \"AGENTS.md\")\n627:\tif err := os.Symlink(\"CLAUDE.md\", agentsPath); err != nil {\n628:\t\treturn fmt.Errorf(\"create AGENTS.md symlink: %w\", err)\n770:func readTopicYAMLMetadata(metadataPath string) (topicMetadataFile, error) {\n773:\t\treturn topicMetadataFile{}, nil\n776:\t\treturn topicMetadataFile{}, fmt.Errorf(\"read %q: %w\", metadataPath, err)\n779:\tvar metadata topicMetadataFile\n781:\t\treturn topicMetadataFile{}, fmt.Errorf(\"parse %q: %w\", metadataPath, err)\n\n=== how AGENTS.md is created in scaffold ===\ninternal/topic/topic.go:235:\t\treturn models.TopicInfo{}, fmt.Errorf(\"new topic: ensure AGENTS.md symlink: %w\", err)\ninternal/topic/topic.go:608:\tagentsPath := filepath.Join(topicPath, \"AGENTS.md\")\ninternal/topic/topic.go:610:\t\treturn fmt.Errorf(\"remove existing AGENTS.md: %w\", err)\ninternal/topic/topic.go:613:\t\treturn fmt.Errorf(\"create AGENTS.md symlink: %w\", err)\ninternal/topic/topic.go:620:\tagentsPath := filepath.Join(topicPath, \"AGENTS.md\")\ninternal/topic/topic.go:628:\t\treturn fmt.Errorf(\"create AGENTS.md symlink: %w\", err)\n\n=== topicMetadataFile struct ===\n\tCategory string\n\tLeaf string\n}\n\ntype topicMetadataFile struct {\n\tSlug string `yaml:\"slug\"`\n\tTitle string `yaml:\"title\"`\n\tDomain string `yaml:\"domain\"`\n\tCategory string `yaml:\"category,omitempty\"`\n\tPath string `yaml:\"path,omitempty\"`\n\tQMDCollection string `yaml:\"qmd_collection,omitempty\"`\n}\n\n// New scaffolds a new topic underneath the provided vault root.\nfunc New(vaultPath, slug, title, domain string) (models.TopicInfo, error) {\n\treturn newWithDate(vaultPath, slug, title, domain, time.Now())\n```"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-221610-502089000-797361526ed537e0","time":"2026-06-27T22:20:36.848981Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":""}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-221610-502089000-797361526ed537e0","time":"2026-06-27T22:20:36.849484Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":"I have enough verified evidence. Let me get a timestamp and confir"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-221610-502089000-797361526ed537e0","time":"2026-06-27T22:20:37.214891Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":"m the target directory is writable."}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-221610-502089000-797361526ed537e0","time":"2026-06-27T22:20:37.215256Z","turn":1,"type":"session.update","update":{"kind":"tool_call_started","tool_call_id":"toolu_011BZVjpraWupGgJfHmdzcYQ","tool_call_state":"pending","blocks":[{"type":"tool_use","id":"toolu_011BZVjpraWupGgJfHmdzcYQ","name":"Bash","title":"Terminal","raw_input":{}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-221610-502089000-797361526ed537e0","time":"2026-06-27T22:20:41.046359Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_011BZVjpraWupGgJfHmdzcYQ","blocks":[{"type":"tool_use","id":"toolu_011BZVjpraWupGgJfHmdzcYQ","name":"Task","title":"cd /Users/pedronauck/Dev/compozy/kb\ndate -u +\"%Y-%m-%dT%H:%M:%SZ\"\nmkdir -p .compozy/tasks/okf-dual-mode/qa \u0026\u0026 echo \"qa dir ready\"\necho \"=== confirm count: render_wiki call lines minus none-def ===\"\ngrep -c \"toSourceWikiLink\\|ToTopicWikiLink\" internal/vault/render_wiki.go\necho \"=== render.go call lines (incl 2 def/impl lines at 186-187) ===\"\ngrep -n \"toSourceWikiLink\\|ToTopicWikiLink\" internal/vault/render.go","input":{"prompt":"Timestamp, ensure qa dir, finalize counts"},"raw_input":{"command":"cd /Users/pedronauck/Dev/compozy/kb\ndate -u +\"%Y-%m-%dT%H:%M:%SZ\"\nmkdir -p .compozy/tasks/okf-dual-mode/qa \u0026\u0026 echo \"qa dir ready\"\necho \"=== confirm count: render_wiki call lines minus none-def ===\"\ngrep -c \"toSourceWikiLink\\|ToTopicWikiLink\" internal/vault/render_wiki.go\necho \"=== render.go call lines (incl 2 def/impl lines at 186-187) ===\"\ngrep -n \"toSourceWikiLink\\|ToTopicWikiLink\" internal/vault/render.go","description":"Timestamp, ensure qa dir, finalize counts"}},{"type":"tool_result","tool_use_id":"toolu_011BZVjpraWupGgJfHmdzcYQ","content":"Timestamp, ensure qa dir, finalize counts"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-221610-502089000-797361526ed537e0","time":"2026-06-27T22:20:41.084248Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_011BZVjpraWupGgJfHmdzcYQ","usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-221610-502089000-797361526ed537e0","time":"2026-06-27T22:20:41.085955Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_011BZVjpraWupGgJfHmdzcYQ","tool_call_state":"completed","blocks":[{"type":"tool_result","tool_use_id":"toolu_011BZVjpraWupGgJfHmdzcYQ","content":"```console\n2026-06-27T22:20:41Z\nqa dir ready\n=== confirm count: render_wiki call lines minus none-def ===\n34\n=== render.go call lines (incl 2 def/impl lines at 186-187) ===\n186:func toSourceWikiLink(topic models.TopicMetadata, relativePath, label string) string {\n187:\treturn ToTopicWikiLink(topic.Slug, relativePath, label)\n220:\t\treturn toSourceWikiLink(topic, documentPath, fallbackLabel)\n339:\t\t\tsymbolLink := toSourceWikiLink(\n473:\tfileLink := toSourceWikiLink(topic, GetRawFileDocumentPath(symbol.FilePath), symbol.FilePath)\n555:\t\t\tfileLinks = append(fileLinks, \"- \"+toSourceWikiLink(topic, GetRawFileDocumentPath(file.FilePath), file.FilePath))\n566:\t\t\tsymbolLinks = append(symbolLinks, \"- \"+toSourceWikiLink(\n632:\t\t\tfileLinks = append(fileLinks, \"- \"+toSourceWikiLink(topic, GetRawFileDocumentPath(file.FilePath), file.FilePath))\n643:\t\t\tsymbolLinks = append(symbolLinks, \"- \"+toSourceWikiLink(\n```"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-221610-502089000-797361526ed537e0","time":"2026-06-27T22:20:49.074294Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":""}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-221610-502089000-797361526ed537e0","time":"2026-06-27T22:20:49.083626Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":"Evidence gathered and verified. Writing the findings file."}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-221610-502089000-797361526ed537e0","time":"2026-06-27T22:20:49.101733Z","turn":1,"type":"session.update","update":{"kind":"tool_call_started","tool_call_id":"toolu_01NijSS2kxxLBz1EgaSCr3nV","tool_call_state":"pending","blocks":[{"type":"tool_use","id":"toolu_01NijSS2kxxLBz1EgaSCr3nV","name":"Edit","title":"Write","raw_input":{}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-221610-502089000-797361526ed537e0","time":"2026-06-27T22:21:39.263917Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01NijSS2kxxLBz1EgaSCr3nV","blocks":[{"type":"tool_use","id":"toolu_01NijSS2kxxLBz1EgaSCr3nV","name":"Edit","title":"Write .compozy/tasks/okf-dual-mode/qa/peer-review-findings-round1.md","input":{"content":"---\nschema_version: 1\nreview_kind: techspec\nround: 1\nreadiness: NEEDS_REWORK\nreviewer_runtime: claude\nreviewer_model: opus\ngenerated_at: 2026-06-27T22:20:41Z\n---\n\n# Summary\n\nThe design is sound and well-bounded — the `mode` field is a clean additive,\nbackward-compatible change; the `internal/okf` package boundary is one-way and\ncycle-free; and the conformance posture is correctly reasoned. It is held back\nfrom approval because the MVP's central verb, `promote`, has two undefined\ncontracts on its critical path: where a promoted concept file lands in the bundle,\nand how a `[[wikilink]]` target (a *source-topic* path) is resolved to a relative\npath *inside the OKF bundle*. Both must be specified before implementation.\n\n# Blockers\n\n## B-001 — `promote` output contract is undefined (write location, filename, `ConceptResult`)\n\n- Section: `_techspec.md` \"Data Flow → Promote\", \"Core Interfaces\" (`PromoteInput`/`Promoter`/`ConceptResult`)\n- Issue: `PromoteInput` carries `SourceDocPath`, `TargetTopic`, and `Type` but no\n output location. The data flow says promote \"writes the new concept via\n `frontmatter.Generate` + `vault`\" without defining the target path or filename\n inside the bundle: is it the source basename, a slug of `title`, or a\n `type`-grouped subdirectory? `Promoter.Promote` returns `ConceptResult`, which is\n referenced but never defined. The PRD's own open question — \"Catalog granularity …\n sub-areas map to one OKF topic with nested concept directories, or multiple OKF\n topics?\" — is explicitly deferred \"to TechSpec\" and remains unanswered here, yet\n it directly determines the concept path model.\n- Rationale: This is an interface/contract gap on the primary MVP path. The\n scoped-write contract for this review calls out \"payloads, types, or schemas waved\n at instead of defined\" as a blocker, and `CLAUDE.md` requires business logic\n (concept placement) to be concretely specified before Cobra wiring. Without a\n defined path model, the end-to-end promote integration test (\"concept written\n with four fields + relative links … `index.md` updated\") cannot assert a concrete\n location, and `index.md` type-grouping (which must enumerate concept files) has no\n defined tree to walk.\n- Suggested fix: Define the bundle's internal concept-path model explicitly: the\n concept filename derivation (recommend deterministic slug of `title`, collision\n rule stated), whether concepts sit at bundle root or under a `type`/area\n subdirectory, and the full `ConceptResult` struct (at minimum: written path,\n transformed-link count, broken/unresolved-link list). Resolve the PRD \"catalog\n granularity\" open question in the spec (flat root for MVP is the simplest,\n deletable choice) rather than leaving it to implementation.\n\n## B-002 — wikilink→relative-markdown target resolution is unspecified\n\n- Section: `_techspec.md` \"Data Flow → Promote\", \"Implementation Design → Wikilink→markdown body transform\"; `adr-004.md` \"Decision\"\n- Issue: `OKFLinkFormatter.Link(fromDir, targetPath, label)` computes a relative\n path from the new concept's directory to `targetPath`. During promote, the source\n body contains `[[slug/path|label]]`, where `slug/path` is a target in the *source\n wiki topic's* namespace (verified: `ToTopicWikiLink` emits `[[topicSlug/docPath]]`\n at `internal/vault/pathutils.go:245`). The promoted concept lives in a *different*\n topic (the OKF bundle) and the target concept usually is not promoted yet. The\n spec never defines the rule that maps a wiki-namespace target to a bundle-relative\n `targetPath` — so `OKFLinkFormatter.Link` cannot be called deterministically, and\n \"converts `[[wikilinks]]` to relative markdown links\" is not implementable as\n written.\n- Rationale: This is a second interface gap on the critical path, and it is the\n mechanical core that ADR-002 promises is \"deterministic, testable.\" The PRD\n success metric \"links resolved and the four producer fields present\" and the unit\n test \"links to not-yet-promoted concepts (tolerated/flagged)\" both presuppose a\n defined target-derivation rule that does not exist. It is coupled to B-001: the\n link target is whatever path model B-001 settles.\n- Suggested fix: Specify the deterministic mapping from a `[[slug/path|label]]`\n target to a bundle-relative path (e.g., strip the source slug, map to the same\n concept-filename derivation chosen in B-001, emit `[label](relpath)` from the\n concept's `fromDir`), and state explicitly that targets with no promoted\n counterpart still emit a relative link to the *would-be* path and are reported as\n tolerated/unresolved (per §9). Add a unit case asserting the exact emitted\n relative path for a cross-concept link.\n\n# Nits\n\n## N-001 — Dormant OKF render branch ships untested in the MVP\n\n- Section: `_techspec.md` \"Executive Summary\"/\"Primary trade-off\"; `adr-004.md` Decision §3\n- Issue: The ~40+ render call sites (verified: 7 in `render.go`, 34 lines in\n `render_wiki.go`) gain a mode-aware seam whose OKF branch is \"wired but dormant\n until Phase 3\" and never exercised at render sites in the MVP, i.e. shipped dead\n code; ADR-004 Alternative 1 (body-transform only now) is the simpler, deletable\n option the project's bias favors.\n- Suggested fix: Either defer the 43-site migration per ADR-004 Alt 1, or add one\n render-site-in-OKF-mode smoke test so the shipped branch (and its `fromDir`\n plumbing) is not dead and untested.\n\n## N-002 — `frontmatter.Generate` sorts keys; producer field order is alphabetical\n\n- Section: `_techspec.md` \"OKF frontmatter contract\"; `internal/frontmatter/frontmatter.go:396`\n- Issue: `buildMappingNode` does `sort.Strings(keys)`, so the four fields emit as\n `description, timestamp, title, type` (plus `tags`), not Google's `type`-first\n producer order — harmless for conformance (YAML maps are unordered) but worth\n stating so no test freezes a producer order the helper can't honor.\n- Suggested fix: Note in the spec that emitted field order is alphabetical and that\n conformance/tests must not assert producer ordering (or add a dedicated ordered\n encoder if byte-matching the samples is actually required).\n\n## N-003 — `index.md` regeneration may drop root `okf_version` and clobber hand edits\n\n- Section: `_techspec.md` \"Data Flow → Promote\" (\"regenerates `index.md`\"); `adr-005.md`\n- Issue: The scaffold writes a root `index.md` with `okf_version: \"0.1\"`\n frontmatter, but promote \"regenerates `index.md`\" as type-grouped bullet lists;\n the spec does not say the regeneration preserves the root `okf_version`, and the\n PRD open question \"index.md / log.md ownership … whether operators ever hand-edit\n them\" is unresolved.\n- Suggested fix: Specify that regeneration preserves the bundle-root `okf_version`\n and state the hand-edit ownership contract (fully `kb`-owned vs merge-preserving).\n\n## N-004 — Scaffold `mode: wiki` vs `omitempty` \"byte-stable\" claim\n\n- Section: `_techspec.md` Build Order step 1; `adr-003.md` Implementation Notes\n- Issue: ADR-003 argues `omitempty` keeps files \"byte-stable when mode is wiki,\" but\n that only covers *not rewriting* existing files; a newly scaffolded wiki topic\n will gain a visible `mode: wiki` line unless wiki is written as empty — a\n scaffold-output change the zero-regression story should acknowledge.\n- Suggested fix: State whether wiki scaffolds emit `mode: wiki` (recommended for\n clarity) and update the scaffold golden expectation, separating \"old files\n unchanged\" from \"new scaffold output.\"\n\n## N-005 — `[okf].types` default vocabulary contents unspecified\n\n- Section: `_techspec.md` \"Data Models\" (`OKFConfig`); Build Order step 2\n- Issue: The spec adds `[okf].types` \"defaults\" and a `config.example.toml` entry but\n never lists the default concept-type vocabulary.\n- Suggested fix: Enumerate the default `types` list (or state it ships empty) so the\n example file and out-of-vocabulary warning tests have a concrete baseline.\n\n## N-006 — `topic.New` signature change for `--mode` not called out\n\n- Section: `_techspec.md` Build Order steps 5/9; `internal/topic/topic.go:114`\n- Issue: `New(vaultPath, slug, title, domain string)` has no `mode` parameter; the\n spec implies but never states the signature/constructor change needed to thread\n mode from CLI into scaffold.\n- Suggested fix: Name the `New`/`newWithDate` signature change (or a `NewWithMode`\n variant) in the build order so the CLI→topic plumbing is explicit.\n\n# Evidence\n\n- Read in full: `_techspec.md`, `_prd.md`, `adrs/adr-001.md`..`adr-006.md`,\n `CLAUDE.md`, `AGENTS.md`, `CONTRIBUTING.md`.\n- Verified against source: `internal/vault/pathutils.go:239-252` (`ToTopicWikiLink`\n emits `[[slug/path|label]]`); `internal/vault/render.go:186-187` and call sites at\n 220/339/473/555/566/632/643; `internal/vault/render_wiki.go` (34 matching lines);\n `internal/frontmatter/frontmatter.go:102-118` (`Generate`) and `:390-410`\n (`buildMappingNode` alphabetical `sort.Strings`); `internal/models/models.go:271`\n (`TopicMetadata`, has `Slug`, no `Mode`); `internal/models/kb_models.go:115-123`\n (`TopicInfo` exists, no `Mode`); `internal/topic/topic.go:104-110`\n (`topicMetadataFile`, no `Mode`), `:114` (`New` signature), `:607-628`\n (`ensureAgentsSymlink` — AGENTS.md is a symlink to CLAUDE.md), `:770-781`\n (`readTopicYAMLMetadata` returns empty struct when file absent);\n `internal/lint/lint.go:25/703/904` (`wikilinkPattern`, `schemaForPath`).\n- Limitation: I could not read the OKF v0.1 `SPEC.md` or the vendored sample bundles\n (not present in this repo); §9/§6/§7 claims and the \"four producer fields\" /\n field-order behavior are taken from the ADRs, not independently verified against\n the upstream spec.\n\n# Deferred Or Follow-Up\n\n- B-001/B-002 share a root cause (the bundle's internal concept-path model); resolve\n the PRD \"catalog granularity\" open question once and both follow.\n- Confirm whether matching Google's sample bundles requires byte-identical\n producer-field ordering (N-002) or only structural conformance.\n- ADR-004 estimates \"~12 direct `ToTopicWikiLink` calls in `render_wiki.go`\" but the\n file has ~34 matching lines; reconcile the migration-effort estimate (non-blocking).","file_path":"/Users/pedronauck/Dev/compozy/kb/.compozy/tasks/okf-dual-mode/qa/peer-review-findings-round1.md"},"raw_input":{"content":"---\nschema_version: 1\nreview_kind: techspec\nround: 1\nreadiness: NEEDS_REWORK\nreviewer_runtime: claude\nreviewer_model: opus\ngenerated_at: 2026-06-27T22:20:41Z\n---\n\n# Summary\n\nThe design is sound and well-bounded — the `mode` field is a clean additive,\nbackward-compatible change; the `internal/okf` package boundary is one-way and\ncycle-free; and the conformance posture is correctly reasoned. It is held back\nfrom approval because the MVP's central verb, `promote`, has two undefined\ncontracts on its critical path: where a promoted concept file lands in the bundle,\nand how a `[[wikilink]]` target (a *source-topic* path) is resolved to a relative\npath *inside the OKF bundle*. Both must be specified before implementation.\n\n# Blockers\n\n## B-001 — `promote` output contract is undefined (write location, filename, `ConceptResult`)\n\n- Section: `_techspec.md` \"Data Flow → Promote\", \"Core Interfaces\" (`PromoteInput`/`Promoter`/`ConceptResult`)\n- Issue: `PromoteInput` carries `SourceDocPath`, `TargetTopic`, and `Type` but no\n output location. The data flow says promote \"writes the new concept via\n `frontmatter.Generate` + `vault`\" without defining the target path or filename\n inside the bundle: is it the source basename, a slug of `title`, or a\n `type`-grouped subdirectory? `Promoter.Promote` returns `ConceptResult`, which is\n referenced but never defined. The PRD's own open question — \"Catalog granularity …\n sub-areas map to one OKF topic with nested concept directories, or multiple OKF\n topics?\" — is explicitly deferred \"to TechSpec\" and remains unanswered here, yet\n it directly determines the concept path model.\n- Rationale: This is an interface/contract gap on the primary MVP path. The\n scoped-write contract for this review calls out \"payloads, types, or schemas waved\n at instead of defined\" as a blocker, and `CLAUDE.md` requires business logic\n (concept placement) to be concretely specified before Cobra wiring. Without a\n defined path model, the end-to-end promote integration test (\"concept written\n with four fields + relative links … `index.md` updated\") cannot assert a concrete\n location, and `index.md` type-grouping (which must enumerate concept files) has no\n defined tree to walk.\n- Suggested fix: Define the bundle's internal concept-path model explicitly: the\n concept filename derivation (recommend deterministic slug of `title`, collision\n rule stated), whether concepts sit at bundle root or under a `type`/area\n subdirectory, and the full `ConceptResult` struct (at minimum: written path,\n transformed-link count, broken/unresolved-link list). Resolve the PRD \"catalog\n granularity\" open question in the spec (flat root for MVP is the simplest,\n deletable choice) rather than leaving it to implementation.\n\n## B-002 — wikilink→relative-markdown target resolution is unspecified\n\n- Section: `_techspec.md` \"Data Flow → Promote\", \"Implementation Design → Wikilink→markdown body transform\"; `adr-004.md` \"Decision\"\n- Issue: `OKFLinkFormatter.Link(fromDir, targetPath, label)` computes a relative\n path from the new concept's directory to `targetPath`. During promote, the source\n body contains `[[slug/path|label]]`, where `slug/path` is a target in the *source\n wiki topic's* namespace (verified: `ToTopicWikiLink` emits `[[topicSlug/docPath]]`\n at `internal/vault/pathutils.go:245`). The promoted concept lives in a *different*\n topic (the OKF bundle) and the target concept usually is not promoted yet. The\n spec never defines the rule that maps a wiki-namespace target to a bundle-relative\n `targetPath` — so `OKFLinkFormatter.Link` cannot be called deterministically, and\n \"converts `[[wikilinks]]` to relative markdown links\" is not implementable as\n written.\n- Rationale: This is a second interface gap on the critical path, and it is the\n mechanical core that ADR-002 promises is \"deterministic, testable.\" The PRD\n success metric \"links resolved and the four producer fields present\" and the unit\n test \"links to not-yet-promoted concepts (tolerated/flagged)\" both presuppose a\n defined target-derivation rule that does not exist. It is coupled to B-001: the\n link target is whatever path model B-001 settles.\n- Suggested fix: Specify the deterministic mapping from a `[[slug/path|label]]`\n target to a bundle-relative path (e.g., strip the source slug, map to the same\n concept-filename derivation chosen in B-001, emit `[label](relpath)` from the\n concept's `fromDir`), and state explicitly that targets with no promoted\n counterpart still emit a relative link to the *would-be* path and are reported as\n tolerated/unresolved (per §9). Add a unit case asserting the exact emitted\n relative path for a cross-concept link.\n\n# Nits\n\n## N-001 — Dormant OKF render branch ships untested in the MVP\n\n- Section: `_techspec.md` \"Executive Summary\"/\"Primary trade-off\"; `adr-004.md` Decision §3\n- Issue: The ~40+ render call sites (verified: 7 in `render.go`, 34 lines in\n `render_wiki.go`) gain a mode-aware seam whose OKF branch is \"wired but dormant\n until Phase 3\" and never exercised at render sites in the MVP, i.e. shipped dead\n code; ADR-004 Alternative 1 (body-transform only now) is the simpler, deletable\n option the project's bias favors.\n- Suggested fix: Either defer the 43-site migration per ADR-004 Alt 1, or add one\n render-site-in-OKF-mode smoke test so the shipped branch (and its `fromDir`\n plumbing) is not dead and untested.\n\n## N-002 — `frontmatter.Generate` sorts keys; producer field order is alphabetical\n\n- Section: `_techspec.md` \"OKF frontmatter contract\"; `internal/frontmatter/frontmatter.go:396`\n- Issue: `buildMappingNode` does `sort.Strings(keys)`, so the four fields emit as\n `description, timestamp, title, type` (plus `tags`), not Google's `type`-first\n producer order — harmless for conformance (YAML maps are unordered) but worth\n stating so no test freezes a producer order the helper can't honor.\n- Suggested fix: Note in the spec that emitted field order is alphabetical and that\n conformance/tests must not assert producer ordering (or add a dedicated ordered\n encoder if byte-matching the samples is actually required).\n\n## N-003 — `index.md` regeneration may drop root `okf_version` and clobber hand edits\n\n- Section: `_techspec.md` \"Data Flow → Promote\" (\"regenerates `index.md`\"); `adr-005.md`\n- Issue: The scaffold writes a root `index.md` with `okf_version: \"0.1\"`\n frontmatter, but promote \"regenerates `index.md`\" as type-grouped bullet lists;\n the spec does not say the regeneration preserves the root `okf_version`, and the\n PRD open question \"index.md / log.md ownership … whether operators ever hand-edit\n them\" is unresolved.\n- Suggested fix: Specify that regeneration preserves the bundle-root `okf_version`\n and state the hand-edit ownership contract (fully `kb`-owned vs merge-preserving).\n\n## N-004 — Scaffold `mode: wiki` vs `omitempty` \"byte-stable\" claim\n\n- Section: `_techspec.md` Build Order step 1; `adr-003.md` Implementation Notes\n- Issue: ADR-003 argues `omitempty` keeps files \"byte-stable when mode is wiki,\" but\n that only covers *not rewriting* existing files; a newly scaffolded wiki topic\n will gain a visible `mode: wiki` line unless wiki is written as empty — a\n scaffold-output change the zero-regression story should acknowledge.\n- Suggested fix: State whether wiki scaffolds emit `mode: wiki` (recommended for\n clarity) and update the scaffold golden expectation, separating \"old files\n unchanged\" from \"new scaffold output.\"\n\n## N-005 — `[okf].types` default vocabulary contents unspecified\n\n- Section: `_techspec.md` \"Data Models\" (`OKFConfig`); Build Order step 2\n- Issue: The spec adds `[okf].types` \"defaults\" and a `config.example.toml` entry but\n never lists the default concept-type vocabulary.\n- Suggested fix: Enumerate the default `types` list (or state it ships empty) so the\n example file and out-of-vocabulary warning tests have a concrete baseline.\n\n## N-006 — `topic.New` signature change for `--mode` not called out\n\n- Section: `_techspec.md` Build Order steps 5/9; `internal/topic/topic.go:114`\n- Issue: `New(vaultPath, slug, title, domain string)` has no `mode` parameter; the\n spec implies but never states the signature/constructor change needed to thread\n mode from CLI into scaffold.\n- Suggested fix: Name the `New`/`newWithDate` signature change (or a `NewWithMode`\n variant) in the build order so the CLI→topic plumbing is explicit.\n\n# Evidence\n\n- Read in full: `_techspec.md`, `_prd.md`, `adrs/adr-001.md`..`adr-006.md`,\n `CLAUDE.md`, `AGENTS.md`, `CONTRIBUTING.md`.\n- Verified against source: `internal/vault/pathutils.go:239-252` (`ToTopicWikiLink`\n emits `[[slug/path|label]]`); `internal/vault/render.go:186-187` and call sites at\n 220/339/473/555/566/632/643; `internal/vault/render_wiki.go` (34 matching lines);\n `internal/frontmatter/frontmatter.go:102-118` (`Generate`) and `:390-410`\n (`buildMappingNode` alphabetical `sort.Strings`); `internal/models/models.go:271`\n (`TopicMetadata`, has `Slug`, no `Mode`); `internal/models/kb_models.go:115-123`\n (`TopicInfo` exists, no `Mode`); `internal/topic/topic.go:104-110`\n (`topicMetadataFile`, no `Mode`), `:114` (`New` signature), `:607-628`\n (`ensureAgentsSymlink` — AGENTS.md is a symlink to CLAUDE.md), `:770-781`\n (`readTopicYAMLMetadata` returns empty struct when file absent);\n `internal/lint/lint.go:25/703/904` (`wikilinkPattern`, `schemaForPath`).\n- Limitation: I could not read the OKF v0.1 `SPEC.md` or the vendored sample bundles\n (not present in this repo); §9/§6/§7 claims and the \"four producer fields\" /\n field-order behavior are taken from the ADRs, not independently verified against\n the upstream spec.\n\n# Deferred Or Follow-Up\n\n- B-001/B-002 share a root cause (the bundle's internal concept-path model); resolve\n the PRD \"catalog granularity\" open question once and both follow.\n- Confirm whether matching Google's sample bundles requires byte-identical\n producer-field ordering (N-002) or only structural conformance.\n- ADR-004 estimates \"~12 direct `ToTopicWikiLink` calls in `render_wiki.go`\" but the\n file has ~34 matching lines; reconcile the migration-effort estimate (non-blocking).\n","file_path":"/Users/pedronauck/Dev/compozy/kb/.compozy/tasks/okf-dual-mode/qa/peer-review-findings-round1.md"}},{"type":"diff","file_path":"/Users/pedronauck/Dev/compozy/kb/.compozy/tasks/okf-dual-mode/qa/peer-review-findings-round1.md","diff":"+++ /Users/pedronauck/Dev/compozy/kb/.compozy/tasks/okf-dual-mode/qa/peer-review-findings-round1.md\n---\nschema_version: 1\nreview_kind: techspec\nround: 1\nreadiness: NEEDS_REWORK\nreviewer_runtime: claude\nreviewer_model: opus\ngenerated_at: 2026-06-27T22:20:41Z\n---\n\n# Summary\n\nThe design is sound and well-bounded — the `mode` field is a clean additive,\nbackward-compatible change; the `internal/okf` package boundary is one-way and\ncycle-free; and the conformance posture is correctly reasoned. It is held back\nfrom approval because the MVP's central verb, `promote`, has two undefined\ncontracts on its critical path: where a promoted concept file lands in the bundle,\nand how a `[[wikilink]]` target (a *source-topic* path) is resolved to a relative\npath *inside the OKF bundle*. Both must be specified before implementation.\n\n# Blockers\n\n## B-001 — `promote` output contract is undefined (write location, filename, `ConceptResult`)\n\n- Section: `_techspec.md` \"Data Flow → Promote\", \"Core Interfaces\" (`PromoteInput`/`Promoter`/`ConceptResult`)\n- Issue: `PromoteInput` carries `SourceDocPath`, `TargetTopic`, and `Type` but no\n output location. The data flow says promote \"writes the new concept via\n `frontmatter.Generate` + `vault`\" without defining the target path or filename\n inside the bundle: is it the source basename, a slug of `title`, or a\n `type`-grouped subdirectory? `Promoter.Promote` returns `ConceptResult`, which is\n referenced but never defined. The PRD's own open question — \"Catalog granularity …\n sub-areas map to one OKF topic with nested concept directories, or multiple OKF\n topics?\" — is explicitly deferred \"to TechSpec\" and remains unanswered here, yet\n it directly determines the concept path model.\n- Rationale: This is an interface/contract gap on the primary MVP path. The\n scoped-write contract for this review calls out \"payloads, types, or schemas waved\n at instead of defined\" as a blocker, and `CLAUDE.md` requires business logic\n (concept placement) to be concretely specified before Cobra wiring. Without a\n defined path model, the end-to-end promote integration test (\"concept written\n with four fields + relative links … `index.md` updated\") cannot assert a concrete\n location, and `index.md` type-grouping (which must enumerate concept files) has no\n defined tree to walk.\n- Suggested fix: Define the bundle's internal concept-path model explicitly: the\n concept filename derivation (recommend deterministic slug of `title`, collision\n rule stated), whether concepts sit at bundle root or under a `type`/area\n subdirectory, and the full `ConceptResult` struct (at minimum: written path,\n transformed-link count, broken/unresolved-link list). Resolve the PRD \"catalog\n granularity\" open question in the spec (flat root for MVP is the simplest,\n deletable choice) rather than leaving it to implementation.\n\n## B-002 — wikilink→relative-markdown target resolution is unspecified\n\n- Section: `_techspec.md` \"Data Flow → Promote\", \"Implementation Design → Wikilink→markdown body transform\"; `adr-004.md` \"Decision\"\n- Issue: `OKFLinkFormatter.Link(fromDir, targetPath, label)` computes a relative\n path from the new concept's directory to `targetPath`. During promote, the source\n body contains `[[slug/path|label]]`, where `slug/path` is a target in the *source\n wiki topic's* namespace (verified: `ToTopicWikiLink` emits `[[topicSlug/docPath]]`\n at `internal/vault/pathutils.go:245`). The promoted concept lives in a *different*\n topic (the OKF bundle) and the target concept usually is not promoted yet. The\n spec never defines the rule that maps a wiki-namespace target to a bundle-relative\n `targetPath` — so `OKFLinkFormatter.Link` cannot be called deterministically, and\n \"converts `[[wikilinks]]` to relative markdown links\" is not implementable as\n written.\n- Rationale: This is a second interface gap on the critical path, and it is the\n mechanical core that ADR-002 promises is \"deterministic, testable.\" The PRD\n success metric \"links resolved and the four producer fields present\" and the unit\n test \"links to not-yet-promoted concepts (tolerated/flagged)\" both presuppose a\n defined target-derivation rule that does not exist. It is coupled to B-001: the\n link target is whatever path model B-001 settles.\n- Suggested fix: Specify the deterministic mapping from a `[[slug/path|label]]`\n target to a bundle-relative path (e.g., strip the source slug, map to the same\n concept-filename derivation chosen in B-001, emit `[label](relpath)` from the\n concept's `fromDir`), and state explicitly that targets with no promoted\n counterpart still emit a relative link to the *would-be* path and are reported as\n tolerated/unresolved (per §9). Add a unit case asserting the exact emitted\n relative path for a cross-concept link.\n\n# Nits\n\n## N-001 — Dormant OKF render branch ships untested in the MVP\n\n- Section: `_techspec.md` \"Executive Summary\"/\"Primary trade-off\"; `adr-004.md` Decision §3\n- Issue: The ~40+ render call sites (verified: 7 in `render.go`, 34 lines in\n `render_wiki.go`) gain a mode-aware seam whose OKF branch is \"wired but dormant\n until Phase 3\" and never exercised at render sites in the MVP, i.e. shipped dead\n code; ADR-004 Alternative 1 (body-transform only now) is the simpler, deletable\n option the project's bias favors.\n- Suggested fix: Either defer the 43-site migration per ADR-004 Alt 1, or add one\n render-site-in-OKF-mode smoke test so the shipped branch (and its `fromDir`\n plumbing) is not dead and untested.\n\n## N-002 — `frontmatter.Generate` sorts keys; producer field order is alphabetical\n\n- Section: `_techspec.md` \"OKF frontmatter contract\"; `internal/frontmatter/frontmatter.go:396`\n- Issue: `buildMappingNode` does `sort.Strings(keys)`, so the four fields emit as\n `description, timestamp, title, type` (plus `tags`), not Google's `type`-first\n producer order — harmless for conformance (YAML maps are unordered) but worth\n stating so no test freezes a producer order the helper can't honor.\n- Suggested fix: Note in the spec that emitted field order is alphabetical and that\n conformance/tests must not assert producer ordering (or add a dedicated ordered\n encoder if byte-matching the samples is actually required).\n\n## N-003 — `index.md` regeneration may drop root `okf_version` and clobber hand edits\n\n- Section: `_techspec.md` \"Data Flow → Promote\" (\"regenerates `index.md`\"); `adr-005.md`\n- Issue: The scaffold writes a root `index.md` with `okf_version: \"0.1\"`\n frontmatter, but promote \"regenerates `index.md`\" as type-grouped bullet lists;\n the spec does not say the regeneration preserves the root `okf_version`, and the\n PRD open question \"index.md / log.md ownership … whether operators ever hand-edit\n them\" is unresolved.\n- Suggested fix: Specify that regeneration preserves the bundle-root `okf_version`\n and state the hand-edit ownership contract (fully `kb`-owned vs merge-preserving).\n\n## N-004 — Scaffold `mode: wiki` vs `omitempty` \"byte-stable\" claim\n\n- Section: `_techspec.md` Build Order step 1; `adr-003.md` Implementation Notes\n- Issue: ADR-003 argues `omitempty` keeps files \"byte-stable when mode is wiki,\" but\n that only covers *not rewriting* existing files; a newly scaffolded wiki topic\n will gain a visible `mode: wiki` line unless wiki is written as empty — a\n scaffold-output change the zero-regression story should acknowledge.\n- Suggested fix: State whether wiki scaffolds emit `mode: wiki` (recommended for\n clarity) and update the scaffold golden expectation, separating \"old files\n unchanged\" from \"new scaffold output.\"\n\n## N-005 — `[okf].types` default vocabulary contents unspecified\n\n- Section: `_techspec.md` \"Data Models\" (`OKFConfig`); Build Order step 2\n- Issue: The spec adds `[okf].types` \"defaults\" and a `config.example.toml` entry but\n never lists the default concept-type vocabulary.\n- Suggested fix: Enumerate the default `types` list (or state it ships empty) so the\n example file and out-of-vocabulary warning tests have a concrete baseline.\n\n## N-006 — `topic.New` signature change for `--mode` not called out\n\n- Section: `_techspec.md` Build Order steps 5/9; `internal/topic/topic.go:114`\n- Issue: `New(vaultPath, slug, title, domain string)` has no `mode` parameter; the\n spec implies but never states the signature/constructor change needed to thread\n mode from CLI into scaffold.\n- Suggested fix: Name the `New`/`newWithDate` signature change (or a `NewWithMode`\n variant) in the build order so the CLI→topic plumbing is explicit.\n\n# Evidence\n\n- Read in full: `_techspec.md`, `_prd.md`, `adrs/adr-001.md`..`adr-006.md`,\n `CLAUDE.md`, `AGENTS.md`, `CONTRIBUTING.md`.\n- Verified against source: `internal/vault/pathutils.go:239-252` (`ToTopicWikiLink`\n emits `[[slug/path|label]]`); `internal/vault/render.go:186-187` and call sites at\n 220/339/473/555/566/632/643; `internal/vault/render_wiki.go` (34 matching lines);\n `internal/frontmatter/frontmatter.go:102-118` (`Generate`) and `:390-410`\n (`buildMappingNode` alphabetical `sort.Strings`); `internal/models/models.go:271`\n (`TopicMetadata`, has `Slug`, no `Mode`); `internal/models/kb_models.go:115-123`\n (`TopicInfo` exists, no `Mode`); `internal/topic/topic.go:104-110`\n (`topicMetadataFile`, no `Mode`), `:114` (`New` signature), `:607-628`\n (`ensureAgentsSymlink` — AGENTS.md is a symlink to CLAUDE.md), `:770-781`\n (`readTopicYAMLMetadata` returns empty struct when file absent);\n `internal/lint/lint.go:25/703/904` (`wikilinkPattern`, `schemaForPath`).\n- Limitation: I could not read the OKF v0.1 `SPEC.md` or the vendored sample bundles\n (not present in this repo); §9/§6/§7 claims and the \"four producer fields\" /\n field-order behavior are taken from the ADRs, not independently verified against\n the upstream spec.\n\n# Deferred Or Follow-Up\n\n- B-001/B-002 share a root cause (the bundle's internal concept-path model); resolve\n the PRD \"catalog granularity\" open question once and both follow.\n- Confirm whether matching Google's sample bundles requires byte-identical\n producer-field ordering (N-002) or only structural conformance.\n- ADR-004 estimates \"~12 direct `ToTopicWikiLink` calls in `render_wiki.go`\" but the\n file has ~34 matching lines; reconcile the migration-effort estimate (non-blocking).\n","new_text":"---\nschema_version: 1\nreview_kind: techspec\nround: 1\nreadiness: NEEDS_REWORK\nreviewer_runtime: claude\nreviewer_model: opus\ngenerated_at: 2026-06-27T22:20:41Z\n---\n\n# Summary\n\nThe design is sound and well-bounded — the `mode` field is a clean additive,\nbackward-compatible change; the `internal/okf` package boundary is one-way and\ncycle-free; and the conformance posture is correctly reasoned. It is held back\nfrom approval because the MVP's central verb, `promote`, has two undefined\ncontracts on its critical path: where a promoted concept file lands in the bundle,\nand how a `[[wikilink]]` target (a *source-topic* path) is resolved to a relative\npath *inside the OKF bundle*. Both must be specified before implementation.\n\n# Blockers\n\n## B-001 — `promote` output contract is undefined (write location, filename, `ConceptResult`)\n\n- Section: `_techspec.md` \"Data Flow → Promote\", \"Core Interfaces\" (`PromoteInput`/`Promoter`/`ConceptResult`)\n- Issue: `PromoteInput` carries `SourceDocPath`, `TargetTopic`, and `Type` but no\n output location. The data flow says promote \"writes the new concept via\n `frontmatter.Generate` + `vault`\" without defining the target path or filename\n inside the bundle: is it the source basename, a slug of `title`, or a\n `type`-grouped subdirectory? `Promoter.Promote` returns `ConceptResult`, which is\n referenced but never defined. The PRD's own open question — \"Catalog granularity …\n sub-areas map to one OKF topic with nested concept directories, or multiple OKF\n topics?\" — is explicitly deferred \"to TechSpec\" and remains unanswered here, yet\n it directly determines the concept path model.\n- Rationale: This is an interface/contract gap on the primary MVP path. The\n scoped-write contract for this review calls out \"payloads, types, or schemas waved\n at instead of defined\" as a blocker, and `CLAUDE.md` requires business logic\n (concept placement) to be concretely specified before Cobra wiring. Without a\n defined path model, the end-to-end promote integration test (\"concept written\n with four fields + relative links … `index.md` updated\") cannot assert a concrete\n location, and `index.md` type-grouping (which must enumerate concept files) has no\n defined tree to walk.\n- Suggested fix: Define the bundle's internal concept-path model explicitly: the\n concept filename derivation (recommend deterministic slug of `title`, collision\n rule stated), whether concepts sit at bundle root or under a `type`/area\n subdirectory, and the full `ConceptResult` struct (at minimum: written path,\n transformed-link count, broken/unresolved-link list). Resolve the PRD \"catalog\n granularity\" open question in the spec (flat root for MVP is the simplest,\n deletable choice) rather than leaving it to implementation.\n\n## B-002 — wikilink→relative-markdown target resolution is unspecified\n\n- Section: `_techspec.md` \"Data Flow → Promote\", \"Implementation Design → Wikilink→markdown body transform\"; `adr-004.md` \"Decision\"\n- Issue: `OKFLinkFormatter.Link(fromDir, targetPath, label)` computes a relative\n path from the new concept's directory to `targetPath`. During promote, the source\n body contains `[[slug/path|label]]`, where `slug/path` is a target in the *source\n wiki topic's* namespace (verified: `ToTopicWikiLink` emits `[[topicSlug/docPath]]`\n at `internal/vault/pathutils.go:245`). The promoted concept lives in a *different*\n topic (the OKF bundle) and the target concept usually is not promoted yet. The\n spec never defines the rule that maps a wiki-namespace target to a bundle-relative\n `targetPath` — so `OKFLinkFormatter.Link` cannot be called deterministically, and\n \"converts `[[wikilinks]]` to relative markdown links\" is not implementable as\n written.\n- Rationale: This is a second interface gap on the critical path, and it is the\n mechanical core that ADR-002 promises is \"deterministic, testable.\" The PRD\n success metric \"links resolved and the four producer fields present\" and the unit\n test \"links to not-yet-promoted concepts (tolerated/flagged)\" both presuppose a\n defined target-derivation rule that does not exist. It is coupled to B-001: the\n link target is whatever path model B-001 settles.\n- Suggested fix: Specify the deterministic mapping from a `[[slug/path|label]]`\n target to a bundle-relative path (e.g., strip the source slug, map to the same\n concept-filename derivation chosen in B-001, emit `[label](relpath)` from the\n concept's `fromDir`), and state explicitly that targets with no promoted\n counterpart still emit a relative link to the *would-be* path and are reported as\n tolerated/unresolved (per §9). Add a unit case asserting the exact emitted\n relative path for a cross-concept link.\n\n# Nits\n\n## N-001 — Dormant OKF render branch ships untested in the MVP\n\n- Section: `_techspec.md` \"Executive Summary\"/\"Primary trade-off\"; `adr-004.md` Decision §3\n- Issue: The ~40+ render call sites (verified: 7 in `render.go`, 34 lines in\n `render_wiki.go`) gain a mode-aware seam whose OKF branch is \"wired but dormant\n until Phase 3\" and never exercised at render sites in the MVP, i.e. shipped dead\n code; ADR-004 Alternative 1 (body-transform only now) is the simpler, deletable\n option the project's bias favors.\n- Suggested fix: Either defer the 43-site migration per ADR-004 Alt 1, or add one\n render-site-in-OKF-mode smoke test so the shipped branch (and its `fromDir`\n plumbing) is not dead and untested.\n\n## N-002 — `frontmatter.Generate` sorts keys; producer field order is alphabetical\n\n- Section: `_techspec.md` \"OKF frontmatter contract\"; `internal/frontmatter/frontmatter.go:396`\n- Issue: `buildMappingNode` does `sort.Strings(keys)`, so the four fields emit as\n `description, timestamp, title, type` (plus `tags`), not Google's `type`-first\n producer order — harmless for conformance (YAML maps are unordered) but worth\n stating so no test freezes a producer order the helper can't honor.\n- Suggested fix: Note in the spec that emitted field order is alphabetical and that\n conformance/tests must not assert producer ordering (or add a dedicated ordered\n encoder if byte-matching the samples is actually required).\n\n## N-003 — `index.md` regeneration may drop root `okf_version` and clobber hand edits\n\n- Section: `_techspec.md` \"Data Flow → Promote\" (\"regenerates `index.md`\"); `adr-005.md`\n- Issue: The scaffold writes a root `index.md` with `okf_version: \"0.1\"`\n frontmatter, but promote \"regenerates `index.md`\" as type-grouped bullet lists;\n the spec does not say the regeneration preserves the root `okf_version`, and the\n PRD open question \"index.md / log.md ownership … whether operators ever hand-edit\n them\" is unresolved.\n- Suggested fix: Specify that regeneration preserves the bundle-root `okf_version`\n and state the hand-edit ownership contract (fully `kb`-owned vs merge-preserving).\n\n## N-004 — Scaffold `mode: wiki` vs `omitempty` \"byte-stable\" claim\n\n- Section: `_techspec.md` Build Order step 1; `adr-003.md` Implementation Notes\n- Issue: ADR-003 argues `omitempty` keeps files \"byte-stable when mode is wiki,\" but\n that only covers *not rewriting* existing files; a newly scaffolded wiki topic\n will gain a visible `mode: wiki` line unless wiki is written as empty — a\n scaffold-output change the zero-regression story should acknowledge.\n- Suggested fix: State whether wiki scaffolds emit `mode: wiki` (recommended for\n clarity) and update the scaffold golden expectation, separating \"old files\n unchanged\" from \"new scaffold output.\"\n\n## N-005 — `[okf].types` default vocabulary contents unspecified\n\n- Section: `_techspec.md` \"Data Models\" (`OKFConfig`); Build Order step 2\n- Issue: The spec adds `[okf].types` \"defaults\" and a `config.example.toml` entry but\n never lists the default concept-type vocabulary.\n- Suggested fix: Enumerate the default `types` list (or state it ships empty) so the\n example file and out-of-vocabulary warning tests have a concrete baseline.\n\n## N-006 — `topic.New` signature change for `--mode` not called out\n\n- Section: `_techspec.md` Build Order steps 5/9; `internal/topic/topic.go:114`\n- Issue: `New(vaultPath, slug, title, domain string)` has no `mode` parameter; the\n spec implies but never states the signature/constructor change needed to thread\n mode from CLI into scaffold.\n- Suggested fix: Name the `New`/`newWithDate` signature change (or a `NewWithMode`\n variant) in the build order so the CLI→topic plumbing is explicit.\n\n# Evidence\n\n- Read in full: `_techspec.md`, `_prd.md`, `adrs/adr-001.md`..`adr-006.md`,\n `CLAUDE.md`, `AGENTS.md`, `CONTRIBUTING.md`.\n- Verified against source: `internal/vault/pathutils.go:239-252` (`ToTopicWikiLink`\n emits `[[slug/path|label]]`); `internal/vault/render.go:186-187` and call sites at\n 220/339/473/555/566/632/643; `internal/vault/render_wiki.go` (34 matching lines);\n `internal/frontmatter/frontmatter.go:102-118` (`Generate`) and `:390-410`\n (`buildMappingNode` alphabetical `sort.Strings`); `internal/models/models.go:271`\n (`TopicMetadata`, has `Slug`, no `Mode`); `internal/models/kb_models.go:115-123`\n (`TopicInfo` exists, no `Mode`); `internal/topic/topic.go:104-110`\n (`topicMetadataFile`, no `Mode`), `:114` (`New` signature), `:607-628`\n (`ensureAgentsSymlink` — AGENTS.md is a symlink to CLAUDE.md), `:770-781`\n (`readTopicYAMLMetadata` returns empty struct when file absent);\n `internal/lint/lint.go:25/703/904` (`wikilinkPattern`, `schemaForPath`).\n- Limitation: I could not read the OKF v0.1 `SPEC.md` or the vendored sample bundles\n (not present in this repo); §9/§6/§7 claims and the \"four producer fields\" /\n field-order behavior are taken from the ADRs, not independently verified against\n the upstream spec.\n\n# Deferred Or Follow-Up\n\n- B-001/B-002 share a root cause (the bundle's internal concept-path model); resolve\n the PRD \"catalog granularity\" open question once and both follow.\n- Confirm whether matching Google's sample bundles requires byte-identical\n producer-field ordering (N-002) or only structural conformance.\n- ADR-004 estimates \"~12 direct `ToTopicWikiLink` calls in `render_wiki.go`\" but the\n file has ~34 matching lines; reconcile the migration-effort estimate (non-blocking).\n"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-221610-502089000-797361526ed537e0","time":"2026-06-27T22:21:39.268038Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01NijSS2kxxLBz1EgaSCr3nV","usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-221610-502089000-797361526ed537e0","time":"2026-06-27T22:21:39.270685Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01NijSS2kxxLBz1EgaSCr3nV","tool_call_state":"completed","blocks":[{"type":"tool_result","tool_use_id":"toolu_01NijSS2kxxLBz1EgaSCr3nV","content":"File created successfully at: /Users/pedronauck/Dev/compozy/kb/.compozy/tasks/okf-dual-mode/qa/peer-review-findings-round1.md (file state is current in your context — no need to Read it back)"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-221610-502089000-797361526ed537e0","time":"2026-06-27T22:21:41.296559Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":""}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-221610-502089000-797361526ed537e0","time":"2026-06-27T22:21:41.297153Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":"W"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-221610-502089000-797361526ed537e0","time":"2026-06-27T22:21:41.297698Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":"rote /Users/pedronauck/Dev/compozy/kb/.compozy/tasks/okf-d"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-221610-502089000-797361526ed537e0","time":"2026-06-27T22:21:41.467602Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":"ual-mode/qa/peer-review-findings-round1.md"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-221610-502089000-797361526ed537e0","time":"2026-06-27T22:21:41.58476Z","turn":1,"type":"session.update","update":{"usage":{},"status":"completed"},"usage":{}} +{"output":"I'll start by reading all the context files in full. Let me read them in parallel.\n\nNow let me check for the other project rule files and verify the spec's key claims against the actual codebase.\n\nLet me verify the spec's specific code claims against the actual source.\n\nI have enough verified evidence. Let me get a timestamp and confirm the target directory is writable.\n\nEvidence gathered and verified. Writing the findings file.\n\nWrote /Users/pedronauck/Dev/compozy/kb/.compozy/tasks/okf-dual-mode/qa/peer-review-findings-round1.md","run_id":"exec-20260627-221610-502089000-797361526ed537e0","status":"succeeded","time":"2026-06-27T22:21:41.600717Z","type":"run.succeeded"} diff --git a/.compozy/tasks/okf-dual-mode/qa/peer-review-events-round2.jsonl b/.compozy/tasks/okf-dual-mode/qa/peer-review-events-round2.jsonl new file mode 100644 index 0000000..0e8efa0 --- /dev/null +++ b/.compozy/tasks/okf-dual-mode/qa/peer-review-events-round2.jsonl @@ -0,0 +1,141 @@ +{"dry_run":false,"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","status":"running","time":"2026-06-27T22:35:42.183427Z","type":"run.started"} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","session":{"acp_session_id":"e790e2b5-21a1-4284-82fd-e7871e641848","agent_session_id":"","resumed":false},"time":"2026-06-27T22:35:43.008464Z","turn":1,"type":"session.attached"} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:35:44.667135Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":""}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:35:44.667406Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":"I'll read all the context files in full before re"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:35:45.152701Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":"asoning. Let me start with the spec under review and supporting documents."}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:35:45.153638Z","turn":1,"type":"session.update","update":{"kind":"tool_call_started","tool_call_id":"toolu_01QfziaN9UviCHux63ALpNgC","tool_call_state":"pending","blocks":[{"type":"tool_use","id":"toolu_01QfziaN9UviCHux63ALpNgC","name":"Read","title":"Read File","raw_input":{}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:35:46.781951Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01QfziaN9UviCHux63ALpNgC","blocks":[{"type":"tool_use","id":"toolu_01QfziaN9UviCHux63ALpNgC","name":"Read","title":"Read .compozy/tasks/okf-dual-mode/_techspec.md","input":{"file_path":"/Users/pedronauck/Dev/compozy/kb/.compozy/tasks/okf-dual-mode/_techspec.md"},"raw_input":{"file_path":"/Users/pedronauck/Dev/compozy/kb/.compozy/tasks/okf-dual-mode/_techspec.md"}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:35:46.787027Z","turn":1,"type":"session.update","update":{"kind":"tool_call_started","tool_call_id":"toolu_01HtQw4Eh7coNiEiFSiiWfDM","tool_call_state":"pending","blocks":[{"type":"tool_use","id":"toolu_01HtQw4Eh7coNiEiFSiiWfDM","name":"Read","title":"Read File","raw_input":{}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:35:46.792933Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01QfziaN9UviCHux63ALpNgC","usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:35:46.799825Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01QfziaN9UviCHux63ALpNgC","tool_call_state":"completed","blocks":[{"type":"tool_result","tool_use_id":"toolu_01QfziaN9UviCHux63ALpNgC","content":"```\n1\t# TechSpec: OKF Dual-Mode for `kb`\n2\t\n3\t\u003e Implements `_prd.md`. Business context (WHAT/WHY/WHO) lives there; this document\n4\t\u003e covers HOW. Phasing follows the PRD: **MVP** = `mode` + OKF scaffold +\n5\t\u003e `kb promote` + `kb okf check`; Phase 2 = export/ingest; Phase 3 = codebase→OKF.\n6\t\n7\t## Executive Summary\n8\t\n9\tAdd OKF as a first-class topic mode to `kb` by (1) introducing a `mode: wiki|okf`\n10\tfield on the topic model, (2) abstracting link emission behind a mode-aware\n11\t`LinkFormatter`, (3) housing all OKF behavior in a new cohesive `internal/okf`\n12\tpackage (frontmatter contract, wikilink→markdown transform, `index.md`/`log.md`\n13\tgeneration, conformance checker, and the `promote` orchestration), and (4) exposing\n14\ta top-level `kb promote` plus a `kb okf` command group. OKF emission stays\n15\t**mechanical and non-LLM**, matching `kb`'s identity; conformance is **lenient per\n16\tOKF §9** with local-standard **warnings** and a `--strict` gate, emitting the four\n17\tproducer fields and relative, GitHub-safe links to match Google's real tooling.\n18\t\n19\t**Primary trade-off:** per the user's decision (ADR-004), the MVP introduces the\n20\tfull `LinkFormatter` abstraction and migrates **all ~43 link call sites** in the\n21\tcodebase→wiki render pipeline now, even though that pipeline produces wiki-mode\n22\ttopics and its OKF branch stays **dormant until Phase 3**. We accept a large\n23\tmechanical refactor with no MVP-exercised OKF render path in exchange for a single,\n24\tfinished mode-aware link seam — guarded against regression by byte-identical golden\n25\ttests of wiki output. The MVP's *active* OKF link consumer is `promote` (body\n26\ttransform) and `index.md` generation.\n27\t\n28\t## System Architecture\n29\t\n30\t### Component Overview\n31\t\n32\t| Component | Home | Responsibility |\n33\t| --- | --- | --- |\n34\t| `mode` field | `internal/models`, `internal/topic` | Mark a topic as `wiki` (default) or `okf`; normalize empty→`wiki`. |\n35\t| `LinkFormatter` | `internal/vault` | Mode-aware link rendering; `WikiLinkFormatter` + `OKFLinkFormatter`. |\n36\t| OKF core | `internal/okf` (new) | Frontmatter contract, link transform, `index.md`/`log.md` gen, conformance checker, `promote`. |\n37\t| Type vocabulary | `internal/config` | `[okf].types` canonical list for local-standard validation. |\n38\t| OKF scaffold | `internal/topic` | `--mode okf` builds a minimal bundle (no wiki pyramid). |\n39\t| CLI | `internal/cli` | `kb promote`, `kb okf check`, `kb topic new --mode`. |\n40\t\n41\t### Data Flow\n42\t\n43\t- **Scaffold:** `kb topic new --mode okf` → `internal/topic` writes `topic.yaml`\n44\t (`mode: okf`), a root `index.md` (`okf_version: \"0.1\"`), `log.md`, an OKF-flavored\n45\t `CLAUDE.md`, and the `AGENTS.md` symlink. No `raw/`/`wiki/`/`outputs/`.\n46\t- **Promote:** `kb promote \u003cwiki-doc\u003e --to \u003ctopic\u003e --type \u003cT\u003e` → `internal/okf`\n47\t reads the source via `vault` + `frontmatter.Parse`, remaps frontmatter to the OKF\n48\t contract, transforms `[[wikilinks]]`→relative markdown links, writes the new\n49\t concept to `\u003cbundle-root\u003e/\u003cslug(title)\u003e.md` (see **Concept Path Model**) via\n50\t `frontmatter.Generate` + `vault`, appends to `log.md`, and regenerates `index.md`\n51\t (preserving the root `okf_version`). Source doc is untouched.\n52\t- **Check:** `kb okf check \u003ctopic\u003e` → `internal/okf` walks the bundle, validates\n53\t §9 leniently, emits `[]models.LintIssue` rendered by `internal/output`.\n54\t\n55\tDependency direction is one-way: `internal/okf` → {`frontmatter`, `vault`,\n56\t`config`, `models`}; nothing imports `okf` back (ADR-003).\n57\t\n58\t## Implementation Design\n59\t\n60\t### Core Interfaces\n61\t\n62\tMode-aware link rendering, defined in `internal/vault` (ADR-004):\n63\t\n64\t```go\n65\t// LinkFormatter renders a link from the document being written (fromDir,\n66\t// bundle-relative, forward-slash) to a target document, with an optional label.\n67\ttype LinkFormatter interface {\n68\t Link(fromDir, targetPath, label string) string\n69\t}\n70\t\n71\t// LinkFormatterFor selects the formatter from the topic's mode.\n72\tfunc LinkFormatterFor(topic models.TopicMetadata) LinkFormatter {\n73\t if topic.Mode == models.TopicModeOKF {\n74\t return OKFLinkFormatter{}\n75\t }\n76\t return WikiLinkFormatter{Slug: topic.Slug}\n77\t}\n78\t```\n79\t\n80\tOKF promotion and conformance, in `internal/okf`:\n81\t\n82\t```go\n83\ttype PromoteInput struct {\n84\t SourceDocPath string // wiki doc to read (vault-relative)\n85\t TargetTopic models.TopicInfo // an OKF-mode topic (the bundle)\n86\t Type string // OKF concept type (validated vs config.OKF.Types)\n87\t}\n88\t\n89\t// ConceptResult reports what promote wrote; UnresolvedLinks tolerated (§9).\n90\ttype ConceptResult struct {\n91\t WrittenPath string // bundle-relative path of the new concept\n92\t Type string\n93\t LinksRewritten int // [[wikilink]] occurrences converted\n94\t UnresolvedLinks []string // targets with no promoted counterpart yet\n95\t}\n96\t\n97\ttype Promoter interface {\n98\t Promote(ctx context.Context, in PromoteInput) (ConceptResult, error)\n99\t}\n100\t\n101\ttype Checker interface {\n102\t // Check walks an OKF bundle; strict promotes warnings to errors.\n103\t Check(ctx context.Context, bundlePath string, strict bool) ([]models.LintIssue, error)\n104\t}\n105\t```\n106\t\n107\t### Data Models\n108\t\n109\tAdditions only; existing structs are unchanged except for one field each.\n110\t\n111\t```go\n112\t// internal/models\n113\ttype TopicMode = string\n114\tconst (\n115\t TopicModeWiki TopicMode = \"wiki\"\n116\t TopicModeOKF TopicMode = \"okf\"\n117\t)\n118\t// TopicMetadata and TopicInfo each gain: Mode TopicMode `json:\"mode\"`\n119\t\n120\t// internal/topic topicMetadataFile gains:\n121\t// Mode string `yaml:\"mode,omitempty\"` // empty normalized to \"wiki\" on read\n122\t\n123\t// internal/config\n124\ttype OKFConfig struct {\n125\t Types []string `toml:\"types\"` // local vocabulary; ships EMPTY (no false warnings)\n126\t}\n127\t// Config gains: OKF OKFConfig `toml:\"okf\"`\n128\t// Empty Types ⇒ the type-vocabulary check is a no-op until the operator opts in.\n129\t```\n130\t\n131\t**OKF frontmatter contract** (the four producer fields written by `promote` and\n132\tauthoring): `type` (from `--type`), `title`, `description`, `timestamp`\n133\t(ISO 8601). Wiki stage markers (`stage`, wiki `type`) are dropped; `tags` carried\n134\tthrough when present.\n135\t\n136\t**Field order:** `frontmatter.Generate` sorts keys alphabetically\n137\t(`internal/frontmatter/frontmatter.go:396`), so concepts emit `description, tags,\n138\ttimestamp, title, type`. This is conformant (YAML maps are unordered); tests MUST\n139\tNOT assert a `type`-first producer order. If byte-matching the official samples is\n140\tever required, that needs a dedicated ordered encoder (out of scope).\n141\t\n142\t### Concept Path Model (resolves B-001 / B-002)\n143\t\n144\tFor the MVP an OKF bundle is **one topic**, and promoted concepts land **flat at the\n145\tbundle root** (`type`-grouped or nested subdirectories are deferred). Flat root\n146\tmakes both the write location and every cross-concept link deterministic:\n147\t\n148\t- **Filename derivation:** `slug(title)` — lowercase, ASCII, kebab-case. The concept\n149\t is written to `\u003ctopic-root\u003e/\u003cslug(title)\u003e.md`. Filename collisions get a numeric\n150\t suffix (`-2`, `-3`, …).\n151\t- **Wikilink target mapping:** a body link `[[srcSlug/docPath|label]]` resolves by\n152\t stripping the source slug, taking `base(docPath)`, and applying the same\n153\t derivation — the target's would-be path is `\u003ctopic-root\u003e/\u003cslug(base(docPath))\u003e.md`.\n154\t `OKFLinkFormatter.Link(fromDir, target, label)` then renders it; with a flat root\n155\t `fromDir == targetDir`, so the emitted link is simply `[label](\u003cslug\u003e.md)`.\n156\t- **Unresolved targets:** when no promoted counterpart exists yet, the link is still\n157\t emitted to the would-be path (tolerated per §9) and recorded in\n158\t `ConceptResult.UnresolvedLinks` (logged; a warning under `--strict`).\n159\t- **`--to`** names the target OKF topic. `--to \u003ctopic\u003e/\u003csubdir\u003e` writes under that\n160\t subdir (and `fromDir` updates accordingly); the default is the bundle root.\n161\t\n162\t\u003e Resolves the PRD open question \"catalog granularity\": for the MVP a bundle is one\n163\t\u003e OKF topic with concepts at its root. Splitting `second-brain/` sub-areas\n164\t\u003e (`identidade`, `estudos`, …) into separate OKF topics, or introducing nested\n165\t\u003e concept directories, is a deliberate Phase-2 follow-up, not MVP behavior.\n166\t\n167\t### Command Surface (CLI)\n168\t\n169\t| Command | Args / Flags | Behavior |\n170\t| --- | --- | --- |\n171\t| `kb topic new \u003cslug\u003e \u003ctitle\u003e \u003cdomain\u003e` | `--mode wiki\\|okf` (default `wiki`) | Wiki scaffold (unchanged) or minimal OKF scaffold. |\n172\t| `kb promote \u003cwiki-doc\u003e` | `--to \u003ctopic\u003e`, `--type \u003cT\u003e` | Mechanical, non-destructive wiki→OKF concept. |\n173\t| `kb okf check \u003ctopic\u003e` | `--strict`, `--format table\\|json\\|tsv` | OKF v0.1 conformance + local-standard warnings. |\n174\t\n175\tPhase 2 grows the group: `kb okf export`, `kb okf ingest` (out of MVP scope).\n176\t\n177\t## Integration Points\n178\t\n179\tNo external services. The only \"integration\" is the **OKF v0.1 file format**\n180\t(`GoogleCloudPlatform/knowledge-catalog` → `okf/SPEC.md`). Two deliberate,\n181\tdocumented deviations from the written spec, matching Google's reference tooling\n182\t(ADR-002): emit the four producer fields (spec mandates only `type`) and emit\n183\trelative links (spec recommends absolute `/path.md`, which breaks GitHub\n184\trendering). The official sample bundles are vendored as test fixtures (ADR-005),\n185\tnot a runtime dependency.\n186\t\n187\t## Impact Analysis\n188\t\n189\t| Component | Impact Type | Description and Risk | Required Action |\n190\t| --- | --- | --- | --- |\n191\t| `internal/okf` | new | Cohesive OKF package; isolated, low blast radius. | Build per ADR-003/004/005. |\n192\t| `internal/vault` (pathutils, render, render_wiki) | modified | `LinkFormatter` + route ~43 call sites; **regression risk** in wiki output. | Golden byte-identical wiki tests; keep suite green. |\n193\t| `internal/models` | modified | Add `Mode` to `TopicMetadata`/`TopicInfo` + mode consts; low risk. | Add fields/consts. |\n194\t| `internal/topic` | modified | `Mode` on `topicMetadataFile`; mode-conditional scaffold + OKF templates; medium risk (scaffold branch). | Normalize empty→wiki; add OKF assets. |\n195\t| `internal/config` | modified | Add `[okf].types`; low risk (additive). | Add struct + example + defaults. |\n196\t| `internal/cli` | modified | `kb promote`, `kb okf` group, `topic new --mode`; low risk. | Wire commands per existing patterns. |\n197\t| `internal/lint` | unchanged | OKF uses a separate checker (ADR-005); wiki lint untouched. | None. |\n198\t| existing wiki topics | unchanged | Default mode wiki ⇒ zero behavior change. | Verify via existing tests. |\n199\t\n200\t## Testing Approach\n201\t\n202\t### Unit Tests\n203\t\n204\t- **`OKFLinkFormatter.Link`** — table-driven relative-path cases: sibling, child,\n205\t parent (`../`), `fromDir == targetDir`, bundle root; never a leading `/`.\n206\t- **`WikiLinkFormatter.Link`** — asserts byte-identical output to today's\n207\t `ToTopicWikiLink` (with and without label).\n208\t- **Wikilink→markdown body transform** — `[[a]]`, `[[a|label]]`, `[[a#anchor]]`,\n209\t links to not-yet-promoted concepts (tolerated/flagged).\n210\t- **Frontmatter remap** — wiki frontmatter → four OKF producer fields; stage\n211\t markers dropped; `timestamp` ISO 8601.\n212\t- **`--type` vocabulary** — in-vocabulary (ok), out-of-vocabulary (warning),\n213\t empty (error).\n214\t- **mode normalization** — empty/absent `mode` → `wiki`; invalid explicit value\n215\t rejected at scaffold.\n216\t- **Conformance rules** — missing `type` (error), reserved files excluded,\n217\t `index.md`/`log.md` shape, `--strict` promotion.\n218\t- **Cross-concept wikilink mapping** — `[[srcSlug/Foo Bar|Foo]]` → `[Foo](foo-bar.md)`;\n219\t assert the exact emitted relative path; filename collision → `-2` suffix;\n220\t unresolved target recorded in `ConceptResult.UnresolvedLinks`.\n221\t- **OKF render smoke (N-001)** — render one document for a `mode: okf` topic and\n222\t assert the OKF link branch and its `fromDir` plumbing execute and emit a relative\n223\t markdown link, so the wired render branch is live, not shipped dead.\n224\t\n225\t### Integration Tests (`//go:build integration`)\n226\t\n227\t- **Conformance against vendored official bundles** (GA4, Stack Overflow,\n228\t crypto_bitcoin) — all pass leniently (ADR-005).\n229\t- **Negative fixtures** — missing `type`, unterminated frontmatter, list-not-\n230\t mapping frontmatter → expected errors.\n231\t- **Must-tolerate fixtures** — broken link, unknown `type`, frontmatter-less\n232\t `index.md` → no error under lenient mode.\n233\t- **End-to-end promote** — scaffold an OKF topic, ingest+compile a small wiki\n234\t topic, `kb promote` a concept, assert: source untouched, concept written with\n235\t four fields + relative links, `log.md` appended, `index.md` updated, and\n236\t `kb okf check` passes.\n237\t- **Zero-regression** — an existing wiki topic renders byte-identically and\n238\t `kb lint` output is unchanged.\n239\t\n240\tUse `t.TempDir()` for filesystem isolation; co-locate tests with their package.\n241\t\n242\t## Development Sequencing\n243\t\n244\t### Build Order\n245\t\n246\t1. **`mode` data model** — add `TopicMode` consts + `Mode` on\n247\t `models.TopicMetadata`/`TopicInfo`; add `Mode` to `topicMetadataFile` with\n248\t empty→wiki normalization in `readTopicYAMLMetadata`. *No dependencies.*\n249\t2. **`internal/config` `[okf].types`** — add `OKFConfig`, defaults, env/TOML load,\n250\t `config.example.toml`. *No dependencies.*\n251\t3. **`LinkFormatter` in `internal/vault`** — interface + `WikiLinkFormatter`\n252\t (wraps current behavior) + `OKFLinkFormatter` + `LinkFormatterFor`. *Depends on\n253\t step 1 (reads `topic.Mode`).*\n254\t4. **Migrate the ~43 call sites** through `linkFor(topic, fromDir, target, label)`;\n255\t add golden wiki-output tests. *Depends on step 3.*\n256\t5. **OKF scaffold** — `internal/topic` mode-conditional dirs + OKF `CLAUDE.md`/\n257\t `index.md`(`okf_version`)/`log.md` templates. Extend `topic.New`/`newWithDate`\n258\t with a `mode` parameter (or a `NewWithMode` variant) and write an explicit `mode`\n259\t value into `topic.yaml`. *Depends on step 1.*\n260\t6. **`internal/okf` emission primitives** — frontmatter contract, wikilink→markdown\n261\t transform (reusing step 3's `OKFLinkFormatter`), `index.md`/`log.md` generation.\n262\t *Depends on steps 2, 3.*\n263\t7. **`promote` orchestration** — read source, remap, transform, write concept,\n264\t append log, regenerate index. *Depends on steps 5, 6.*\n265\t8. **Conformance checker** — recursive walk, §9 lenient + local warnings +\n266\t `--strict`, emitting `models.LintIssue`. *Depends on step 2 (vocabulary).*\n267\t9. **CLI wiring** — `topic new --mode`, `kb promote`, `kb okf check`. *Depends on\n268\t steps 5, 7, 8.*\n269\t10. **Fixtures + integration tests** — vendor official bundles, negatives,\n270\t must-tolerate, end-to-end promote, zero-regression. *Depends on steps 7, 8, 9.*\n271\t\n272\t### Technical Dependencies\n273\t\n274\t- None external. Vendoring the official bundles requires keeping their Apache-2.0\n275\t license/attribution under `testdata/`.\n276\t\n277\t## Monitoring and Observability\n278\t\n279\tCLI tool — observability is structured logging via `internal/logger` (slog) and\n280\tcommand output:\n281\t\n282\t- **Log events** (debug/info): `promote` source/target/type, link-transform count,\n283\t broken-link count; `okf check` files-scanned, errors, warnings, strict flag.\n284\t- **Exit codes**: `okf check` returns non-zero on errors (and on warnings under\n285\t `--strict`) for CI gating.\n286\t- **Report output**: `okf check` renders via `internal/output` (table/json/tsv),\n287\t consistent with `kb lint`.\n288\t\n289\t## Technical Considerations\n290\t\n291\t### Key Decisions\n292\t\n293\t- **Cohesive `internal/okf` package** (ADR-003). *Rationale:* one testable home,\n294\t clean phase growth. *Trade-off:* a new package vs maximal reuse.\n295\t- **Full `LinkFormatter` migration in the MVP** (ADR-004). *Rationale:* one\n296\t finished mode-aware seam. *Trade-off:* large refactor whose OKF render branch is\n297\t dormant until Phase 3; mitigated by golden tests.\n298\t- **Dedicated conformance checker, lenient + `--strict`** (ADR-002/005).\n299\t *Rationale:* OKF is structurally unlike the wiki pyramid and is permissive by\n300\t spec. *Trade-off:* two checking engines coexist.\n301\t- **Non-LLM mechanical `promote`** (ADR-002). *Rationale:* preserves `kb`'s\n302\t identity and determinism. *Trade-off:* distillation stays a human/agent job.\n303\t- **`kb promote` top-level + `kb okf` group** (ADR-006). *Rationale:* prominence\n304\t for the daily verb, cohesive namespace for the ecosystem.\n305\t\n306\t### Known Risks\n307\t\n308\t- **Wiki render regression** during the 43-site migration (medium likelihood,\n309\t high impact) → byte-identical golden tests + keep the `vault` suite green;\n310\t default formatter is wiki when `mode != okf`.\n311\t- **OKF relative-path correctness** (medium) → exhaustive table-driven tests\n312\t including parent/sibling/root.\n313\t- **OKF v0.1 is a draft** (medium) → isolate the ruleset behind a versioned module;\n314\t track the spec repo.\n315\t- **Vocabulary ignored in practice** (low) → `--strict` in CI enforces it where it\n316\t matters.\n317\t- **Vendored-fixture drift / license** (low) → pin a commit, retain attribution.\n318\t\n319\t## Architecture Decision Records\n320\t\n321\t- [ADR-001: OKF as a first-class authoring mode, not an export-only projection](adrs/adr-001.md) — OKF is a first-class per-topic mode with phased delivery (product).\n322\t- [ADR-002: promote is a mechanical non-LLM transform; type governance and a spec-compatible conformance posture](adrs/adr-002.md) — mechanical/non-destructive promote, `--type` + `kb.toml` vocabulary, emit-four + relative links, lenient validation (product).\n323\t- [ADR-003: A cohesive `internal/okf` package and a `mode` field on the topic model](adrs/adr-003.md) — one-way-dependency OKF package + `mode` on the topic model (default wiki).\n324\t- [ADR-004: A `LinkFormatter` abstraction and full migration of the link call sites](adrs/adr-004.md) — mode-aware `LinkFormatter` in `vault`; migrate all ~43 sites in the MVP; OKF render branch dormant until Phase 3.\n325\t- [ADR-005: A dedicated OKF conformance checker with vendored official fixtures](adrs/adr-005.md) — separate checker in `internal/okf`, lenient §9 + local warnings + `--strict`, vendored official bundles as fixtures.\n326\t- [ADR-006: CLI surface — `kb promote` top-level and a `kb okf` command group](adrs/adr-006.md) — top-level `kb promote`, `kb okf check` group, `topic new --mode`.\n327\t\n```"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:35:46.824718Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01HtQw4Eh7coNiEiFSiiWfDM","blocks":[{"type":"tool_use","id":"toolu_01HtQw4Eh7coNiEiFSiiWfDM","name":"Read","title":"Read .compozy/tasks/okf-dual-mode/_prd.md","input":{"file_path":"/Users/pedronauck/Dev/compozy/kb/.compozy/tasks/okf-dual-mode/_prd.md"},"raw_input":{"file_path":"/Users/pedronauck/Dev/compozy/kb/.compozy/tasks/okf-dual-mode/_prd.md"}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:35:46.828683Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01HtQw4Eh7coNiEiFSiiWfDM","usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:35:46.832459Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01HtQw4Eh7coNiEiFSiiWfDM","tool_call_state":"completed","blocks":[{"type":"tool_result","tool_use_id":"toolu_01HtQw4Eh7coNiEiFSiiWfDM","content":"```\n1\t# PRD: OKF Dual-Mode for `kb`\n2\t\n3\t## Overview\n4\t\n5\t`kb` today implements one knowledge lifecycle: the Karpathy **LLM-Wiki** — a\n6\tpersonal research *lab* where raw sources are ingested, compiled into a wiki,\n7\tqueried, and linted, linked with Obsidian `[[wikilinks]]`.\n8\t\n9\tThe **Open Knowledge Format (OKF)** v0.1 (Google Cloud, 2026-06-12) standardizes\n10\tthe same markdown + YAML-frontmatter substrate for a different purpose: a\n11\tportable, vendor-neutral **catalog** that other people's agents and tools consume.\n12\tIt requires exactly one thing of every concept — a `type` field — uses plain\n13\tmarkdown links, and is meant to be *declared and shared*, not *explored*.\n14\t\n15\tThese are two lifecycles, not one derived from the other:\n16\t\n17\t- **LLM-Wiki** = a lab: `ingest → compile → query → lint`; for the operator to\n18\t research.\n19\t- **OKF** = a catalog/API: `declare → consume`; for others (humans and agents) to\n20\t consume; portable.\n21\t\n22\tThis duality already exists by hand in the operator's workspace: a `research/`\n23\tarea (LLM-wiki, where they research) and a `second-brain/` area (OKF, where they\n24\tdeclare \"what is mine\" for operations). The daily, highest-leverage move is\n25\t**distilling research into the operational catalog** — done manually today.\n26\t\n27\tThis feature makes both lifecycles first-class in `kb` via a per-topic\n28\t**`mode: wiki | okf`** (default `wiki`, so nothing regresses), and ships the\n29\tdistill loop as a one-command **`promote`** plus an OKF **conformance check**. It\n30\tis valuable because it turns a hand-maintained workflow into a tool-enforced one,\n31\tand positions `kb` as an OKF-conformant producer in a brand-new, vendor-neutral\n32\tecosystem.\n33\t\n34\t## Goals\n35\t\n36\t- Let an operator maintain LLM-Wiki research and OKF catalogs in the same tool,\n37\t with the research → catalog distill loop tool-enforced.\n38\t- Ship OKF as a first-class authoring mode (not an export-only projection).\n39\t- Produce bundles that are conformant with OKF v0.1 and aligned with Google's real\n40\t sample bundles and tooling.\n41\t- Preserve a consistent, drift-free local standard for concept types.\n42\t- Guarantee zero regression for existing wiki topics.\n43\t- Milestone: MVP delivers `mode` + OKF scaffold + `promote` + conformance check;\n44\t Phase 2 adds publish/consume interop; Phase 3 adds codebase → OKF.\n45\t\n46\t## User Stories\n47\t\n48\t**Primary persona — the Knowledge Operator** (researches in wiki mode, declares\n49\t\"what's mine\" in OKF mode, distills daily):\n50\t\n51\t- As an operator, I want to mark a topic as an OKF bundle so `kb` scaffolds and\n52\t lints it as a portable catalog instead of a research lab.\n53\t- As an operator, I want to promote a finished wiki concept into my OKF bundle\n54\t with one command so my research compounds into my operational catalog without\n55\t manual reformatting or re-linking.\n56\t- As an operator, I want my source research to stay untouched when I promote, so\n57\t the lab remains the immutable record and the catalog is the distilled view.\n58\t- As an operator, I want `kb` to validate my bundle against OKF conformance so my\n59\t catalog stays portable and consumable by agents.\n60\t- As an operator, I want a canonical list of concept types so my bundle stays\n61\t consistent and avoids type drift over time.\n62\t- As an operator, I want my existing wiki topics to keep working exactly as before\n63\t when OKF mode lands.\n64\t\n65\t**Secondary personas** (later phases):\n66\t\n67\t- As a knowledge publisher, I want to export a wiki topic as a portable OKF bundle\n68\t so others can consume it (Phase 2).\n69\t- As an AI agent / consuming tool, I want to read a `kb`-produced bundle as\n70\t standard OKF so I can use it without a custom integration (enabled throughout).\n71\t\n72\t## Core Features\n73\t\n74\tGrouped by priority. MVP features first.\n75\t\n76\t### 1. OKF mode for topics (MVP)\n77\t\n78\t- A per-topic **`mode: wiki | okf`** setting, defaulting to `wiki`.\n79\t- `kb topic new --mode okf` scaffolds an **OKF bundle** (flat typed concepts,\n80\t auto-maintained `index.md` and `log.md`) instead of the wiki pyramid\n81\t (`raw/` + `wiki/` + `outputs/`).\n82\t- An OKF bundle is a `kb` topic; it reuses the topic, vault, lint, and index\n83\t machinery. `mode` selects scaffold shape, link format, frontmatter contract, and\n84\t lint ruleset.\n85\t\n86\t### 2. `kb promote` — wiki → OKF distill (MVP)\n87\t\n88\t- `kb promote \u003cwiki-doc\u003e --to \u003cokf-topic\u003e --type \u003cConceptType\u003e` creates a new\n89\t typed OKF concept in the target bundle from a compiled wiki document.\n90\t- **Mechanical and non-LLM**: converts `[[wikilinks]]` to relative markdown links,\n91\t remaps wiki frontmatter to the OKF contract (`type`, `title`, `description`,\n92\t `timestamp`), and appends a dated entry to the bundle's `log.md`.\n93\t- **Non-destructive**: the source wiki document stays in research; a new concept is\n94\t born in the catalog.\n95\t- Any intelligent rewriting/condensing is left to the operator or an external\n96\t agent — `kb` does the structural work only.\n97\t\n98\t### 3. OKF conformance check (MVP)\n99\t\n100\t- A conformance command for an OKF bundle that validates OKF v0.1:\n101\t - every non-reserved `.md` has parseable frontmatter with a non-empty `type`;\n102\t - `index.md` / `log.md` follow their required shapes when present.\n103\t- **Lenient by default** (per spec §9): tolerates broken cross-links, unknown\n104\t `type` values, and missing optional fields (so externally produced bundles pass).\n105\t- **Local standards as warnings** on the operator's own bundles: all four producer\n106\t fields present, and `type` within the configured vocabulary.\n107\t- A `--strict` option promotes warnings to errors for CI gates.\n108\t\n109\t### 4. Concept-type governance (MVP)\n110\t\n111\t- A canonical **type vocabulary** in `kb.toml` (e.g. `[okf].types`).\n112\t- `promote` and authoring take `type` explicitly; a value outside the vocabulary\n113\t produces a lint warning. The vocabulary is extended by editing `kb.toml`.\n114\t- Prevents type drift (`Voice Profile` vs `voice-profile`) and keeps a consistent\n115\t local standard, while staying compatible with OKF's \"no global registry\" model.\n116\t\n117\t### 5. OKF-native emission (MVP, underpins 1–4)\n118\t\n119\t- Relative, GitHub-safe markdown links (not `[[wikilinks]]`, not absolute paths).\n120\t- The four producer fields on every concept; auto-maintained `index.md`\n121\t (type-grouped bullet lists) and `log.md` (ISO-date entries, newest first).\n122\t\n123\t### Later phases (not MVP)\n124\t\n125\t- **`kb export okf`** — publish an existing wiki topic as a portable bundle\n126\t (Phase 2).\n127\t- **`kb ingest okf`** — consume an external OKF bundle into research (Phase 2).\n128\t- **Codebase → OKF** — emit the typed symbol/relation graph as a bundle\n129\t (Phase 3, differentiator demo).\n130\t\n131\t## User Experience\n132\t\n133\t**Operator journey (MVP):**\n134\t\n135\t1. **Create a catalog.** `kb topic new \u003cname\u003e --mode okf` scaffolds an OKF bundle.\n136\t Existing topics are unaffected (they remain `mode: wiki`).\n137\t2. **Research as usual.** Ingest and compile in wiki-mode topics — unchanged.\n138\t3. **Distill.** When a wiki concept is ready, `kb promote \u003cwiki-doc\u003e --to \u003cbundle\u003e\n139\t --type \u003cConceptType\u003e`. The concept appears in the catalog with relative links,\n140\t the four producer fields, and a `log.md` entry; the source stays put.\n141\t4. **Verify.** Run the conformance check on the bundle; fix any warnings (missing\n142\t fields, off-vocabulary types). Use `--strict` in CI.\n143\t5. **Consume / share.** The bundle is plain markdown + frontmatter — readable in\n144\t Obsidian/GitHub and parseable by any OKF-aware agent.\n145\t\n146\t**UX considerations:**\n147\t\n148\t- `mode` is invisible to existing users; OKF behavior is opt-in per topic.\n149\t- `promote` is one command with a predictable, mechanical result (no surprises\n150\t from an LLM in the loop).\n151\t- Conformance output is actionable (what failed, where, how to fix) and\n152\t distinguishes hard errors from local-standard warnings.\n153\t- Discoverability: OKF options surface under existing `kb topic` / `kb lint`-style\n154\t help; `kb.toml` documents the type vocabulary.\n155\t\n156\t## High-Level Technical Constraints\n157\t\n158\tProduct boundaries (not implementation):\n159\t\n160\t- **Conform to OKF v0.1**, and remain aligned with Google's reference samples\n161\t (GA4, Stack Overflow, Bitcoin) and producer behavior.\n162\t- **Two deliberate, documented deviations** from the written spec, matching real\n163\t tooling: emit the four producer fields (not just `type`), and emit relative\n164\t links (not the spec-recommended absolute links, which break GitHub rendering).\n165\t- **Stay non-LLM.** The core workflow, including `promote`, performs no model\n166\t inference; `kb` remains \"the non-LLM workflow.\"\n167\t- **Zero regression.** Default `mode: wiki`; existing topics scaffold, lint, and\n168\t build identically; no migration required.\n169\t- **Portable output.** Bundles are usable without `kb`: plain files renderable in\n170\t Obsidian/GitHub and parseable by any OKF consumer.\n171\t- OKF is a v0.1 draft; conformance rules must be pinned to v0.1 and isolated so the\n172\t spec can evolve without disrupting the rest of the tool.\n173\t\n174\t## Non-Goals (Out of Scope)\n175\t\n176\t- **Export and ingest interop** (`kb export okf`, `kb ingest okf`) — Phase 2.\n177\t- **Codebase → OKF** emission — Phase 3.\n178\t- **LLM-assisted distillation** inside `promote` (no `--distill`) — possibly a\n179\t later flag, not now.\n180\t- **A global/shared type registry** — only a local per-vault vocabulary.\n181\t- **Whole-vault or cross-topic OKF operations** — OKF is per-topic/bundle.\n182\t- **An HTML/graph visualizer** for bundles.\n183\t- **Migrating existing wiki topics** to OKF automatically.\n184\t- **Changing the LLM-Wiki lifecycle** — wiki mode behavior is unchanged.\n185\t\n186\t## Phased Rollout Plan\n187\t\n188\t### MVP (Phase 1) — the distill loop\n189\t\n190\t- OKF mode (`mode: wiki | okf`) + `kb topic new --mode okf` scaffold.\n191\t- `kb promote` (mechanical, non-destructive, non-LLM).\n192\t- OKF conformance check (lenient + local warnings + `--strict`).\n193\t- Concept-type vocabulary in `kb.toml`.\n194\t- OKF-native emission (relative links, four producer fields, auto `index.md` /\n195\t `log.md`).\n196\t- **Success criteria to proceed:** the conformance check passes on all three\n197\t official sample bundles; the operator's catalog passes the check; a real wiki\n198\t concept promotes end-to-end and the result conforms; existing wiki topics show\n199\t zero behavior change.\n200\t\n201\t### Phase 2 — publish \u0026 consume interop\n202\t\n203\t- `kb export okf` (wiki topic → portable bundle).\n204\t- `kb ingest okf` (external bundle → research), accepting real-world bundles under\n205\t lenient conformance.\n206\t- **Success criteria to proceed:** a wiki topic round-trips through export and back\n207\t through ingest without loss of conceptual content; third-party sample bundles\n208\t ingest cleanly.\n209\t\n210\t### Phase 3 — codebase → OKF\n211\t\n212\t- Emit the existing typed symbol/relation graph as an OKF bundle (concepts =\n213\t files/symbols, links = relations).\n214\t- **Long-term success:** a real codebase produces a conformant, navigable OKF\n215\t bundle usable as agent context.\n216\t\n217\t## Success Metrics\n218\t\n219\t- **Spec correctness:** the conformance check passes on all three official OKF\n220\t sample bundles (GA4, Stack Overflow, Bitcoin).\n221\t- **Loop works end-to-end:** a real wiki concept promotes into a bundle and the\n222\t result conforms, with links resolved and the four producer fields present.\n223\t- **Operator catalog conformant:** the existing `second-brain/` catalog passes the\n224\t conformance check (with local standards) once adopted.\n225\t- **Zero regression:** existing wiki topics scaffold, lint, and build identically\n226\t to before (verified by the existing suite).\n227\t- **No type drift:** every concept type in the operator's bundle is within the\n228\t `kb.toml` vocabulary.\n229\t- **Adoption:** the operator stops distilling by hand — `promote` replaces the\n230\t manual copy/reformat step.\n231\t\n232\t## Risks and Mitigations\n233\t\n234\t- **Adoption risk** — if `promote` is not clearly lower-friction than copy/paste,\n235\t it won't be used. *Mitigation:* one command, mechanical, predictable output;\n236\t non-destructive so it's safe to run freely.\n237\t- **Spec-churn risk** — OKF is a v0.1 draft and may change. *Mitigation:* pin to\n238\t v0.1; isolate conformance rules; track the spec repo.\n239\t- **Scope risk** — the MVP already touches several surfaces. *Mitigation:* strict\n240\t phasing; export/ingest/codebase are explicit non-goals for the MVP.\n241\t- **Standard-drift risk** — free-text types fragment the catalog. *Mitigation:*\n242\t `kb.toml` vocabulary + lint warning + `--strict` in CI.\n243\t- **Identity-drift risk** — pressure to add LLM \"magic\" to `promote`.\n244\t *Mitigation:* keep the core non-LLM by decision (ADR-002); defer any\n245\t `--distill` flag.\n246\t- **Interop-expectation risk** — bundles must be consumable outside `kb`.\n247\t *Mitigation:* validate against official samples; emit what real tooling expects\n248\t (four fields, relative links).\n249\t\n250\t## Architecture Decision Records\n251\t\n252\t- [ADR-001: OKF as a first-class authoring mode, not an export-only projection](adrs/adr-001.md)\n253\t — OKF is a first-class per-topic `mode`, an OKF bundle is a `kb` topic, and the\n254\t MVP optimizes the research → operations distill loop with phased delivery.\n255\t- [ADR-002: promote is a mechanical non-LLM transform; type governance and a spec-compatible conformance posture](adrs/adr-002.md)\n256\t — `promote` is mechanical, non-destructive, and non-LLM; `type` is explicit and\n257\t governed by a local `kb.toml` vocabulary; conformance emits four fields + relative\n258\t links and validates lenient with local warnings and `--strict`.\n259\t\n260\t## Open Questions\n261\t\n262\t- **Conformance command surface** — `kb okf check \u003cbundle\u003e` vs `kb lint --okf`\n263\t (naming/placement). Defer to TechSpec.\n264\t- **Catalog granularity** — within the operator's `second-brain/`, do sub-areas\n265\t (`identidade/`, `estudos/`, `conteudo/`, `vendas/`) map to one OKF topic with\n266\t nested concept directories, or to multiple OKF topics? Needs confirmation; both\n267\t are valid OKF.\n268\t- **index.md / log.md ownership** — assumed auto-maintained by `kb`\n269\t (generated/updated on scaffold + promote). Confirm whether operators ever\n270\t hand-edit them.\n271\t- **Off-vocabulary type handling** — assumed \"warn + allow + extend via\n272\t `kb.toml`\". Confirm whether `promote` should suggest the closest existing type.\n273\t- **Broken-link reporting on promote** — promoting a concept whose wikilinks point\n274\t to not-yet-promoted concepts yields tolerated broken links (per §9). Confirm\n275\t whether the operator wants a warning listing them.\n276\t\n```"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:35:48.603461Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":""}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:35:48.603682Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":"Now let me read"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:35:48.603753Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":" the ADRs and the round-1 QA files."}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:35:48.604631Z","turn":1,"type":"session.update","update":{"kind":"tool_call_started","tool_call_id":"toolu_01HSQzp7FNbd1nQTXuaFDfUX","tool_call_state":"pending","blocks":[{"type":"tool_use","id":"toolu_01HSQzp7FNbd1nQTXuaFDfUX","name":"Read","title":"Read File","raw_input":{}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:35:50.021798Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01HSQzp7FNbd1nQTXuaFDfUX","blocks":[{"type":"tool_use","id":"toolu_01HSQzp7FNbd1nQTXuaFDfUX","name":"Read","title":"Read .compozy/tasks/okf-dual-mode/adrs/adr-001.md","input":{"file_path":"/Users/pedronauck/Dev/compozy/kb/.compozy/tasks/okf-dual-mode/adrs/adr-001.md"},"raw_input":{"file_path":"/Users/pedronauck/Dev/compozy/kb/.compozy/tasks/okf-dual-mode/adrs/adr-001.md"}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:35:50.022478Z","turn":1,"type":"session.update","update":{"kind":"tool_call_started","tool_call_id":"toolu_01M5VgFdXictFHLdNZKxzo21","tool_call_state":"pending","blocks":[{"type":"tool_use","id":"toolu_01M5VgFdXictFHLdNZKxzo21","name":"Read","title":"Read File","raw_input":{}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:35:50.023844Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01HSQzp7FNbd1nQTXuaFDfUX","usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:35:50.025609Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01HSQzp7FNbd1nQTXuaFDfUX","tool_call_state":"completed","blocks":[{"type":"tool_result","tool_use_id":"toolu_01HSQzp7FNbd1nQTXuaFDfUX","content":"```\n1\t# ADR-001: OKF as a first-class authoring mode, not an export-only projection\n2\t\n3\t## Status\n4\t\n5\tAccepted\n6\t\n7\t## Date\n8\t\n9\t2026-06-27\n10\t\n11\t## Context\n12\t\n13\tThe `kb` CLI implements the Karpathy \"LLM-wiki\" pattern: a research lab where raw\n14\tsources are ingested, compiled into a wiki, queried, and linted. Google Cloud\n15\treleased the Open Knowledge Format (OKF) v0.1 (2026-06-12), which formalizes the\n16\tsame markdown+YAML-frontmatter substrate but optimizes for a different purpose: a\n17\tportable, vendor-neutral **catalog/interchange** artifact that other people's\n18\tagents consume.\n19\t\n20\tThese are two distinct knowledge lifecycles on the same substrate:\n21\t\n22\t- **LLM-Wiki** = a *lab* (ingest → compile → query → lint; `[[wikilinks]]`;\n23\t `raw/` + `wiki/`; stage markers; for the operator to explore).\n24\t- **OKF** = a *catalog/API* (declare → consume; required `type` per concept;\n25\t markdown links; flat typed concepts; for others to consume; portable).\n26\t\n27\tThis mirrors the duality the user already maintains by hand in their workspace:\n28\t`research/` (LLM-wiki) where they research, and `second-brain/` (OKF) where they\n29\tdeclare \"what is mine\" for operations.\n30\t\n31\tAn earlier internal proposal treated OKF as an **export-only projection** of the\n32\twiki. The user found this too thin: it captures only the \"publish\" direction and\n33\tignores the daily, high-leverage flow of distilling research into the operational\n34\tcatalog. The question is whether OKF is a derived output of the wiki or a\n35\tfirst-class mode in its own right.\n36\t\n37\t## Decision\n38\t\n39\tMake OKF a **first-class authoring mode** of `kb`, selectable per topic, with\n40\tthe LLM-Wiki mode remaining the default.\n41\t\n42\t- Introduce a per-topic **`mode: wiki | okf`** (default `wiki`).\n43\t- An **OKF bundle is modeled as a `kb` topic with `mode: okf`** — it reuses the\n44\t existing topic, vault, lint, and index machinery rather than becoming a parallel\n45\t standalone concept. `mode` disambiguates scaffold shape, link format, frontmatter\n46\t contract, and lint ruleset.\n47\t- The MVP optimizes the **research → operations distill loop**: native OKF\n48\t authoring plus a `promote` interop verb (see ADR-002), not export.\n49\t- Deliver in phases:\n50\t - **MVP (Phase 1):** `mode` field, OKF scaffold (`kb topic new --mode okf`),\n51\t `kb promote` (wiki → OKF), `kb okf check` (conformance).\n52\t - **Phase 2:** `kb export okf` (publish a wiki topic as a portable bundle) and\n53\t `kb ingest okf` (consume an external bundle into research).\n54\t - **Phase 3:** codebase → OKF (the typed symbol/relation graph emitted as a\n55\t bundle) — the standout differentiator demo.\n56\t\n57\t## Alternatives Considered\n58\t\n59\t### Alternative 1: Export-only projection (OKF as a derived output)\n60\t\n61\t- **Description**: Add `kb export okf` that renders an existing wiki topic into an\n62\t OKF bundle; no OKF authoring, no `mode`, no interop loop.\n63\t- **Pros**: Smallest surface; no scaffold or link-format refactor; quick to ship.\n64\t- **Cons**: Captures only \"publish\"; cannot author or maintain operational\n65\t bundles; ignores the daily research→org distill the user lives; treats OKF as\n66\t second-class.\n67\t- **Why rejected**: Too thin; misses the highest-leverage use case.\n68\t\n69\t### Alternative 2: Standalone OKF concept (separate from topics)\n70\t\n71\t- **Description**: A new `kb okf init`/`kb okf …` surface independent of topics,\n72\t with its own metadata and storage.\n73\t- **Pros**: Clean conceptual separation between lab and catalog.\n74\t- **Cons**: Duplicates topic, vault, lint, and index machinery; two parallel\n75\t models to maintain; more user-facing surface to learn.\n76\t- **Why rejected**: `mode` on the existing topic achieves the same separation with\n77\t far less duplication and a smaller learning curve.\n78\t\n79\t### Alternative 3: Single unified model (one mode, derive both views)\n80\t\n81\t- **Description**: Keep one internal model and render either wiki or OKF on demand.\n82\t- **Pros**: No mode flag.\n83\t- **Cons**: The two lifecycles diverge on link format, frontmatter contract, and\n84\t conformance; forcing one model creates leaky, ambiguous behavior (e.g., the\n85\t `type` field means \"stage\" in wiki and \"concept kind\" in OKF).\n86\t- **Why rejected**: `mode` cleanly disambiguates the conflicts instead of hiding\n87\t them.\n88\t\n89\t## Consequences\n90\t\n91\t### Positive\n92\t\n93\t- The user's existing `research/` vs `second-brain/` split becomes\n94\t tool-enforced rather than maintained by hand.\n95\t- Default `mode: wiki` means zero regression for existing topics.\n96\t- Reusing the topic model keeps the new surface small and learnable.\n97\t- Phasing keeps an already-large MVP focused on the daily loop.\n98\t\n99\t### Negative\n100\t\n101\t- The MVP is larger than an export-only effort (mode + scaffold + two new verbs).\n102\t- A shared link-emission path must support both wikilinks and OKF markdown links.\n103\t\n104\t### Risks\n105\t\n106\t- **OKF is a v0.1 draft** → pin conformance to v0.1 and isolate the ruleset so it\n107\t can evolve without touching the rest of the tool.\n108\t- **Scope creep** (the MVP touches several surfaces) → strict phasing; export,\n109\t ingest, and codebase→OKF are explicit non-goals for the MVP.\n110\t\n111\t## Implementation Notes\n112\t\n113\tImplementation detail belongs in the TechSpec. At a product level: the link\n114\temission path is currently wiki-only and must become mode-aware; the topic\n115\tscaffold and lint ruleset must branch on `mode`. No existing behavior changes when\n116\t`mode` is absent or `wiki`.\n117\t\n118\t## References\n119\t\n120\t- PRD: `../_prd.md`\n121\t- ADR-002: promote semantics, type governance, and conformance posture\n122\t- OKF spec v0.1: `GoogleCloudPlatform/knowledge-catalog` → `okf/SPEC.md`\n123\t- Announcement: \"How the Open Knowledge Format can improve data sharing\" (Google\n124\t Cloud Blog, 2026-06-12)\n125\t\n```"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:35:50.496323Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01M5VgFdXictFHLdNZKxzo21","blocks":[{"type":"tool_use","id":"toolu_01M5VgFdXictFHLdNZKxzo21","name":"Read","title":"Read .compozy/tasks/okf-dual-mode/adrs/adr-002.md","input":{"file_path":"/Users/pedronauck/Dev/compozy/kb/.compozy/tasks/okf-dual-mode/adrs/adr-002.md"},"raw_input":{"file_path":"/Users/pedronauck/Dev/compozy/kb/.compozy/tasks/okf-dual-mode/adrs/adr-002.md"}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:35:50.496923Z","turn":1,"type":"session.update","update":{"kind":"tool_call_started","tool_call_id":"toolu_01BjkLPaF1u9YoUJhb7zYjsD","tool_call_state":"pending","blocks":[{"type":"tool_use","id":"toolu_01BjkLPaF1u9YoUJhb7zYjsD","name":"Read","title":"Read File","raw_input":{}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:35:50.49789Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01M5VgFdXictFHLdNZKxzo21","usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:35:50.499903Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01M5VgFdXictFHLdNZKxzo21","tool_call_state":"completed","blocks":[{"type":"tool_result","tool_use_id":"toolu_01M5VgFdXictFHLdNZKxzo21","content":"```\n1\t# ADR-002: promote is a mechanical non-LLM transform; type governance and a spec-compatible conformance posture\n2\t\n3\t## Status\n4\t\n5\tAccepted\n6\t\n7\t## Date\n8\t\n9\t2026-06-27\n10\t\n11\t## Context\n12\t\n13\tADR-001 makes the research → operations distill loop the MVP's north star. That\n14\tloop has three behavioral questions the PRD must settle, each with product\n15\tconsequences:\n16\t\n17\t1. **What does `kb promote` do to the content?** It can be a mechanical\n18\t format transform, or it can re-synthesize the content with an LLM.\n19\t2. **Where does the OKF `type` come from?** OKF requires exactly one field —\n20\t `type` — and its value drives how consumers route, filter, and present a\n21\t concept. Free-text typing risks drift (`Voice Profile` vs `voice-profile`).\n22\t3. **How strict is conformance, and what does `kb` emit?** Research surfaced two\n23\t contradictions between the OKF spec and Google's own tooling:\n24\t - The spec requires only `type`, but Google's reference producer requires\n25\t `type` + `title` + `description` + `timestamp`, and all sample bundles carry\n26\t them.\n27\t - The spec *recommends* absolute `/path.md` links, but Google's tooling forbids\n28\t them (they break GitHub rendering) and every sample bundle uses relative\n29\t links.\n30\t - The spec (§9) requires consumers to *tolerate* broken links, unknown types,\n31\t and missing optional fields.\n32\t\n33\t`kb`'s identity, per its own `CLAUDE.md`, is \"the non-LLM workflow\": scaffolding,\n34\tingestion, structural linting. The user also explicitly values a consistent local\n35\tstandard for concept types.\n36\t\n37\t## Decision\n38\t\n39\t**1. `promote` is a mechanical, non-destructive, non-LLM transform.**\n40\t\n41\t`kb promote \u003cwiki-doc\u003e --to \u003cokf-topic\u003e` copies the source content into the OKF\n42\tbundle as a new typed concept and performs only structural work: convert\n43\t`[[wikilinks]]` to relative markdown links, remap wiki frontmatter to the OKF\n44\tcontract, set the concept `type`, and append an entry to the bundle's `log.md`.\n45\tThe source wiki document stays in `research/` (the immutable lab). No LLM is\n46\tinvoked — any intelligent distillation/rewriting remains the job of the operator\n47\tor an external agent calling `kb`, exactly as the user's `second-brain/` was built.\n48\t\n49\t**2. `type` is supplied explicitly and governed by a local vocabulary.**\n50\t\n51\t`promote` (and native authoring) takes the concept kind via `--type`; a canonical\n52\tlist of concept types lives in `kb.toml` (e.g. an `[okf].types` vocabulary). The\n53\tvocabulary exists to prevent duplication and enforce a per-vault standard. OKF\n54\tdeliberately has no *global* type registry; a *local* vocabulary is fully\n55\tcompatible and adds value. A type outside the vocabulary is a lint warning, not a\n56\thard failure, and the vocabulary is extended by editing `kb.toml`.\n57\t\n58\t**3. Conformance is compatible with the real-world tooling, lenient by default.**\n59\t\n60\t- **Emit**: all four producer fields (`type`, `title`, `description`,\n61\t `timestamp`) and **relative, GitHub-safe** markdown links.\n62\t- **Validate**: lenient per spec §9 — tolerate broken cross-links, unknown\n63\t `type` values, and missing optional fields (so externally produced bundles are\n64\t accepted in Phase 2).\n65\t- **Local standards as warnings** on the operator's own bundles: all four fields\n66\t present, and `type` within the `kb.toml` vocabulary.\n67\t- **`--strict`** promotes warnings to errors for CI gates.\n68\t\n69\t## Alternatives Considered\n70\t\n71\t### Alternative 1: LLM-assisted `promote`\n72\t\n73\t- **Description**: `promote` re-synthesizes the wiki document into a concise OKF\n74\t concept via a configured provider.\n75\t- **Pros**: One-command \"magic\"; less manual rewriting.\n76\t- **Cons**: Injects an LLM dependency and nondeterminism into a tool whose\n77\t identity is the non-LLM workflow; harder to test; unpredictable output.\n78\t- **Why rejected**: Contradicts `kb`'s stated identity; distillation already\n79\t belongs to the operator/agent layer. (A future `--distill` flag is possible but\n80\t out of scope.)\n81\t\n82\t### Alternative 2: Derived or defaulted `type`\n83\t\n84\t- **Description**: Infer `type` from the wiki document's `source_kind`/`domain`,\n85\t or default everything to a generic `Reference`.\n86\t- **Pros**: Less typing.\n87\t- **Cons**: The wiki `type` is a stage marker (always `wiki`/`source`), so\n88\t derivation carries no concept semantics; a generic default produces lazy\n89\t bundles where every concept is `Reference`, defeating OKF's routing value.\n90\t- **Why rejected**: Kills the value of the one field OKF requires.\n91\t\n92\t### Alternative 3: Spec-minimal or maximum-rigor conformance\n93\t\n94\t- **Description**: Either emit/validate only `type` (spec-minimal), or make every\n95\t rule a hard error by default (maximum rigor).\n96\t- **Pros**: Minimal is \"pure\"; maximum guarantees an iron standard.\n97\t- **Cons**: Minimal yields impoverished bundles misaligned with the official\n98\t samples; maximum fights OKF's permissive philosophy and would reject\n99\t third-party bundles during Phase 2 ingest.\n100\t- **Why rejected**: The tooling-compatible middle (emit four, validate lenient,\n101\t warn locally, `--strict` opt-in) matches real bundles and the user's desire for\n102\t a standard without sacrificing interoperability.\n103\t\n104\t## Consequences\n105\t\n106\t### Positive\n107\t\n108\t- `promote` is deterministic, testable, and true to `kb`'s identity.\n109\t- Bundles align with Google's real samples and tooling, maximizing portability.\n110\t- The type vocabulary keeps the operator's catalog consistent over time.\n111\t- Lenient validation lets Phase 2 ingest accept real-world external bundles.\n112\t\n113\t### Negative\n114\t\n115\t- The operator (or an agent) still does the intelligent distillation; `promote`\n116\t alone does not condense content.\n117\t- Two documented spec contradictions must be encoded as deliberate, documented\n118\t deviations (emit four, emit relative links).\n119\t\n120\t### Risks\n121\t\n122\t- **Spec churn** (v0.1 draft) → keep emit/validate rules isolated and versioned.\n123\t- **Vocabulary friction** (warnings ignored) → `--strict` in CI keeps the\n124\t standard enforceable when it matters.\n125\t\n126\t## Implementation Notes\n127\t\n128\tDetail belongs in the TechSpec. Product-level notes: `promote` appends to\n129\t`log.md` and the bundle's `index.md` is auto-maintained (OKF index files are\n130\ttype-grouped bullet lists, which are mechanically generatable). Conformance rules\n131\ttarget OKF v0.1 and validate against the official sample bundles (GA4, Stack\n132\tOverflow, Bitcoin) as fixtures.\n133\t\n134\t## References\n135\t\n136\t- PRD: `../_prd.md`\n137\t- ADR-001: OKF as a first-class authoring mode\n138\t- OKF spec v0.1 §4 (frontmatter), §5 (links), §6 (index.md), §7 (log.md),\n139\t §9 (conformance): `GoogleCloudPlatform/knowledge-catalog` → `okf/SPEC.md`\n140\t- OKF reference producer (the stricter four-key reality):\n141\t `okf/src/reference_agent/bundle/document.py`\n142\t\n```"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:35:50.852105Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01BjkLPaF1u9YoUJhb7zYjsD","blocks":[{"type":"tool_use","id":"toolu_01BjkLPaF1u9YoUJhb7zYjsD","name":"Read","title":"Read .compozy/tasks/okf-dual-mode/adrs/adr-003.md","input":{"file_path":"/Users/pedronauck/Dev/compozy/kb/.compozy/tasks/okf-dual-mode/adrs/adr-003.md"},"raw_input":{"file_path":"/Users/pedronauck/Dev/compozy/kb/.compozy/tasks/okf-dual-mode/adrs/adr-003.md"}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:35:50.854915Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01BjkLPaF1u9YoUJhb7zYjsD","usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:35:50.857359Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01BjkLPaF1u9YoUJhb7zYjsD","tool_call_state":"completed","blocks":[{"type":"tool_result","tool_use_id":"toolu_01BjkLPaF1u9YoUJhb7zYjsD","content":"```\n1\t# ADR-003: A cohesive `internal/okf` package and a `mode` field on the topic model\n2\t\n3\t## Status\n4\t\n5\tAccepted\n6\t\n7\t## Date\n8\t\n9\t2026-06-27\n10\t\n11\t## Context\n12\t\n13\tOKF dual-mode (ADR-001) introduces a substantial new concern: an OKF frontmatter\n14\tcontract, a wikilink→markdown transform, `index.md`/`log.md` generation, a\n15\tconformance checker, and the `promote` orchestration. The existing packages own\n16\tthe LLM-Wiki pyramid:\n17\t\n18\t- `internal/topic` — scaffold + `topic.yaml` (`topicMetadataFile` at\n19\t `topic.go:104`; scaffold list `currentTopicDirectories` at `topic.go:38`).\n20\t- `internal/vault` — render/read/write of documents.\n21\t- `internal/lint` — path-prefix structural validation (`schemaForPath`).\n22\t- `internal/ingest` — frontmatter assembly (`buildFrontmatter` at `ingest.go:160`).\n23\t\n24\tTwo structural needs must be settled before any behavior is built: where OKF logic\n25\tlives, and how a topic signals it is an OKF bundle versus a wiki.\n26\t\n27\t## Decision\n28\t\n29\t**1. A new cohesive `internal/okf` package owns all OKF behavior.**\n30\t\n31\tIt contains: the OKF frontmatter contract (the four producer fields), the\n32\t`[[wikilink]]`→relative-markdown transform, `index.md`/`log.md` generation, the\n33\tconformance checker (ADR-005), and the `promote` orchestration. Dependency\n34\tdirection is one-way: `internal/okf` imports `internal/frontmatter`,\n35\t`internal/vault` (document read/write), `internal/config` (the type vocabulary),\n36\tand `internal/models`; none of those import `internal/okf`. This prevents import\n37\tcycles and keeps the wiki packages free of OKF concerns.\n38\t\n39\t**2. Add a `mode` field to the topic model, defaulting to wiki.**\n40\t\n41\t- `internal/models`: typed constants `TopicModeWiki = \"wiki\"` and\n42\t `TopicModeOKF = \"okf\"`, and a `Mode` field on `models.TopicMetadata`\n43\t (`models.go:271`) and `models.TopicInfo`.\n44\t- `internal/topic`: `Mode string \\`yaml:\"mode,omitempty\"\\`` on `topicMetadataFile`\n45\t (`topic.go:104`); written by `writeMetadataFile` and read by\n46\t `readTopicYAMLMetadata` (`topic.go:770`).\n47\t- An absent/empty `mode` is normalized to `wiki` on read, so existing topics and\n48\t existing `topic.yaml` files behave identically (zero regression).\n49\t\n50\t## Alternatives Considered\n51\t\n52\t### Alternative 1: Spread OKF logic across existing packages\n53\t\n54\t- **Description**: `mode` in topic, links in vault, conformance extending lint,\n55\t `promote` in ingest.\n56\t- **Pros**: Maximum reuse; no new package.\n57\t- **Cons**: Dilutes OKF across four packages; entangles it with the wiki pyramid;\n58\t harder to test and to grow in Phase 2/3.\n59\t- **Why rejected**: Cohesion and a clean phase-growth boundary outweigh avoiding a\n60\t new package.\n61\t\n62\t### Alternative 2: Hybrid (okf package + okf logic also in vault/topic)\n63\t\n64\t- **Description**: Core in `internal/okf`, but the link formatter and `mode`\n65\t scattered into vault/topic.\n66\t- **Pros**: Avoids some indirection.\n67\t- **Cons**: The boundary becomes ambiguous (OKF logic in two homes).\n68\t- **Why rejected**: Only the genuinely cross-cutting `LinkFormatter` belongs in\n69\t vault (ADR-004); everything else stays in `internal/okf`. The `mode` field is a\n70\t data-model fact, not OKF logic, so it correctly lives on the topic model.\n71\t\n72\t## Consequences\n73\t\n74\t### Positive\n75\t\n76\t- One obvious home for OKF; isolated unit/integration testing.\n77\t- Clean dependency graph; Phase 2/3 (export/ingest/codebase→OKF) extend one package.\n78\t- `mode` is a tiny, backward-compatible addition.\n79\t\n80\t### Negative\n81\t\n82\t- A new package and a field threaded through topic load/write.\n83\t\n84\t### Risks\n85\t\n86\t- **Mode drift** (a topic with an unknown mode) → normalize unknown/empty to\n87\t `wiki` and reject unknown explicit values at scaffold time.\n88\t\n89\t## Implementation Notes\n90\t\n91\t`topicMetadataFile` marshals via `yaml.Marshal`. The zero-regression story has two\n92\tdistinct parts (N-004): (a) **existing** `topic.yaml` files are never rewritten and\n93\tread-normalize empty `mode`→`wiki`, so they are byte-stable; (b) **new** scaffolds\n94\twrite an explicit `mode` value, so a freshly created wiki topic's `topic.yaml` will\n95\tcontain a visible `mode: wiki` line — a scaffold-output change (update the scaffold\n96\tgolden expectation accordingly), not a change to old files. `omitempty` only\n97\tsuppresses a programmatically-empty mode. Topic resolution (`topic.Resolve`/`Info`)\n98\tmust surface `Mode` so downstream commands (`promote`, `okf check`) can branch.\n99\t\n100\t## References\n101\t\n102\t- ADR-001 (OKF as a first-class mode), ADR-004 (LinkFormatter), ADR-005 (checker)\n103\t- `internal/topic/topic.go:104`, `internal/models/models.go:271`\n104\t- TechSpec: `../_techspec.md`\n105\t\n```"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:35:52.126356Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":""}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:35:52.130203Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":"Now"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:35:52.676481Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":" the remaining ADRs and the QA round-1 files."}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:35:52.679201Z","turn":1,"type":"session.update","update":{"kind":"tool_call_started","tool_call_id":"toolu_01HGjZpTkyXJpYqNh2yMRWs3","tool_call_state":"pending","blocks":[{"type":"tool_use","id":"toolu_01HGjZpTkyXJpYqNh2yMRWs3","name":"Read","title":"Read File","raw_input":{}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:35:54.091406Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01HGjZpTkyXJpYqNh2yMRWs3","blocks":[{"type":"tool_use","id":"toolu_01HGjZpTkyXJpYqNh2yMRWs3","name":"Read","title":"Read .compozy/tasks/okf-dual-mode/adrs/adr-004.md","input":{"file_path":"/Users/pedronauck/Dev/compozy/kb/.compozy/tasks/okf-dual-mode/adrs/adr-004.md"},"raw_input":{"file_path":"/Users/pedronauck/Dev/compozy/kb/.compozy/tasks/okf-dual-mode/adrs/adr-004.md"}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:35:54.091973Z","turn":1,"type":"session.update","update":{"kind":"tool_call_started","tool_call_id":"toolu_01GEGQiUrSQYkMuAw3DDDwcv","tool_call_state":"pending","blocks":[{"type":"tool_use","id":"toolu_01GEGQiUrSQYkMuAw3DDDwcv","name":"Read","title":"Read File","raw_input":{}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:35:54.093167Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01HGjZpTkyXJpYqNh2yMRWs3","usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:35:54.095015Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01HGjZpTkyXJpYqNh2yMRWs3","tool_call_state":"completed","blocks":[{"type":"tool_result","tool_use_id":"toolu_01HGjZpTkyXJpYqNh2yMRWs3","content":"```\n1\t# ADR-004: A `LinkFormatter` abstraction and full migration of the link call sites\n2\t\n3\t## Status\n4\t\n5\tAccepted\n6\t\n7\t## Date\n8\t\n9\t2026-06-27\n10\t\n11\t## Context\n12\t\n13\tLink emission is hardcoded to Obsidian `[[wikilinks]]`:\n14\t\n15\t- `internal/vault/pathutils.go:244` `ToTopicWikiLink(topicSlug, documentPath,\n16\t label)` → `[[slug/path|label]]`, with a thin wrapper `toSourceWikiLink(topic,\n17\t relativePath, label)` at `render.go:186`.\n18\t- These are called from **~43 sites**, all in `internal/vault/render.go` and\n19\t `render_wiki.go` — the **codebase→wiki** render pipeline.\n20\t- There is **no `Renderer`/`Writer` struct** in `internal/vault`; render functions\n21\t receive `topic models.TopicMetadata` directly, so there is no constructor to\n22\t inject a formatter into.\n23\t\n24\tOKF requires standard markdown links instead of wikilinks (ADR-002: relative,\n25\tGitHub-safe). The question is how the link format becomes mode-aware, and how much\n26\tof the refactor lands in the MVP. The user chose to introduce the full abstraction\n27\tand migrate all call sites in the MVP, rather than defer the render-pipeline\n28\trefactor to Phase 3.\n29\t\n30\tA key technical subtlety: an OKF **relative** link needs the **source document's\n31\tdirectory** to compute the path to the target. The current call sites pass only\n32\tthe target path and a label.\n33\t\n34\t## Decision\n35\t\n36\t**1. Define a `LinkFormatter` interface in `internal/vault`** (rendering's home;\n37\tkeeps the `okf`→`vault` dependency one-way and cycle-free):\n38\t\n39\t```go\n40\t// LinkFormatter renders a link from the document being written (fromDir,\n41\t// bundle-relative) to a target document, with an optional label.\n42\ttype LinkFormatter interface {\n43\t Link(fromDir, targetPath, label string) string\n44\t}\n45\t```\n46\t\n47\t- `WikiLinkFormatter` — ignores `fromDir`; emits `[[slug/target|label]]` exactly\n48\t as `ToTopicWikiLink` does today (`ToTopicWikiLink` becomes its implementation).\n49\t- `OKFLinkFormatter` — emits a relative, GitHub-safe markdown link\n50\t (`[label](relpath)`), computing `relpath` from `fromDir` to `targetPath`.\n51\t\n52\t**2. Resolve the formatter from `topic.Mode`** via\n53\t`vault.LinkFormatterFor(topic) LinkFormatter`, and route every call site through a\n54\tsingle chokepoint helper `linkFor(topic, fromDir, targetPath, label)`. The direct\n55\t`ToTopicWikiLink(topic.Slug, …)` calls in `render_wiki.go` are converted to use this\n56\thelper too. (Verified call-site spread: ~7 sites in `render.go` plus ~34 matching\n57\tlines in `render_wiki.go`; treat \"~43 sites\" as the working estimate.)\n58\t\n59\t**3. Scope the MVP correctly.** The 43 render sites belong to the codebase→wiki\n60\tpipeline, which produces **wiki-mode** topics; codebase→OKF is Phase 3. Therefore\n61\tin the MVP these sites continue to resolve `WikiLinkFormatter` (mode = wiki) and\n62\temit byte-identical output. The **active** MVP consumer of `OKFLinkFormatter` is\n63\t`promote`'s body transform (which knows the concept's own directory) and\n64\t`index.md` generation. The OKF branch at the render sites is wired but dormant\n65\tuntil Phase 3.\n66\t\n67\t## Alternatives Considered\n68\t\n69\t### Alternative 1: Defer the render-site refactor; body-transform only in MVP\n70\t\n71\t- **Description**: MVP does only `[[wikilink]]`→markdown in `promote` body text and\n72\t index generation; the 43 sites are untouched until Phase 3.\n73\t- **Pros**: Smallest MVP; least risk.\n74\t- **Cons**: The abstraction lands later; two passes over the render code.\n75\t- **Why rejected**: The user chose to do the foundational abstraction now.\n76\t\n77\t### Alternative 2: Add a `fromDir`/formatter argument to each call site signature\n78\t\n79\t- **Description**: Thread source context explicitly through 43 signatures.\n80\t- **Pros**: Fully explicit.\n81\t- **Cons**: Churns 43 signatures for no MVP-exercised benefit.\n82\t- **Why rejected**: The chokepoint helper + `topic`-resolved formatter achieves the\n83\t same with far less churn.\n84\t\n85\t### Alternative 3: Package-global formatter strategy\n86\t\n87\t- **Description**: A mutable package-level formatter set per run.\n88\t- **Pros**: No threading.\n89\t- **Cons**: Not concurrency-safe; hidden global state; hard to test.\n90\t- **Why rejected**: Violates testability.\n91\t\n92\t## Consequences\n93\t\n94\t### Positive\n95\t\n96\t- One mode-aware seam for all link emission; Phase 3 only flips render topics to\n97\t OKF mode and supplies `fromDir`.\n98\t- `promote` and index generation reuse the same formatter.\n99\t\n100\t### Negative\n101\t\n102\t- A large mechanical refactor whose OKF branch is not exercised at render sites in\n103\t the MVP.\n104\t\n105\t### Risks\n106\t\n107\t- **Silent regression** in wiki output during the refactor → mitigate with golden\n108\t tests asserting byte-identical wiki render output before/after, and keep the\n109\t existing `vault` test suite green.\n110\t- **Relative-path edge cases** (siblings, parents, root) in `OKFLinkFormatter` →\n111\t cover with table-driven unit tests including `fromDir == targetDir` and nested\n112\t cases.\n113\t- **Dormant OKF render branch ships as dead code in the MVP** (N-001) → add one\n114\t render-site-in-`okf`-mode smoke test so the wired branch and its `fromDir`\n115\t plumbing are exercised, keeping the user's \"migrate now\" decision without shipping\n116\t untested code.\n117\t\n118\t## Implementation Notes\n119\t\n120\t`OKFLinkFormatter.Link` uses `filepath.Rel(fromDir, targetPath)` semantics on\n121\tforward-slash bundle paths, never emitting a leading `/` (GitHub-safe, per\n122\tADR-002). `WikiLinkFormatter` preserves `StripMarkdownExtension` behavior. The\n123\tformatter is selected once per document render from `topic.Mode`.\n124\t\n125\t## References\n126\t\n127\t- ADR-002 (relative links), ADR-003 (okf package boundary)\n128\t- `internal/vault/pathutils.go:244`, `render.go:186`, `render_wiki.go`\n129\t- TechSpec: `../_techspec.md`\n130\t\n```"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:35:54.565014Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01GEGQiUrSQYkMuAw3DDDwcv","blocks":[{"type":"tool_use","id":"toolu_01GEGQiUrSQYkMuAw3DDDwcv","name":"Read","title":"Read .compozy/tasks/okf-dual-mode/adrs/adr-005.md","input":{"file_path":"/Users/pedronauck/Dev/compozy/kb/.compozy/tasks/okf-dual-mode/adrs/adr-005.md"},"raw_input":{"file_path":"/Users/pedronauck/Dev/compozy/kb/.compozy/tasks/okf-dual-mode/adrs/adr-005.md"}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:35:54.565711Z","turn":1,"type":"session.update","update":{"kind":"tool_call_started","tool_call_id":"toolu_01UMJHKxGLQWFgcaw6bhPBq3","tool_call_state":"pending","blocks":[{"type":"tool_use","id":"toolu_01UMJHKxGLQWFgcaw6bhPBq3","name":"Read","title":"Read File","raw_input":{}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:35:54.566848Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01GEGQiUrSQYkMuAw3DDDwcv","usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:35:54.568411Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01GEGQiUrSQYkMuAw3DDDwcv","tool_call_state":"completed","blocks":[{"type":"tool_result","tool_use_id":"toolu_01GEGQiUrSQYkMuAw3DDDwcv","content":"```\n1\t# ADR-005: A dedicated OKF conformance checker with vendored official fixtures\n2\t\n3\t## Status\n4\t\n5\tAccepted\n6\t\n7\t## Date\n8\t\n9\t2026-06-27\n10\t\n11\t## Context\n12\t\n13\tOKF v0.1 conformance (§9) is small and deliberately permissive: every non-reserved\n14\t`.md` must have parseable frontmatter with a non-empty `type`; `index.md`/`log.md`\n15\tmust follow their shapes when present; consumers MUST tolerate broken cross-links,\n16\tunknown `type` values, and missing optional fields. ADR-002 set the product\n17\tposture: emit four producer fields and relative links, validate lenient, warn on\n18\tlocal standards (four fields present + `type` in the `kb.toml` vocabulary), and\n19\toffer `--strict`.\n20\t\n21\tThe existing lint engine (`internal/lint/lint.go:337` `schemaForPath`) is a\n22\tpath-prefix switch tuned to the wiki pyramid (`wiki/concepts/`, `raw/github/`,\n23\tetc.) and parses `[[wikilinks]]` specifically (`wikilinkPattern` regex). OKF is\n24\tstructurally different: a recursive tree of freeform directories where `type` is\n25\tthe only hard requirement and links are markdown.\n26\t\n27\t## Decision\n28\t\n29\t**Implement a dedicated conformance checker in `internal/okf`**, not an extension\n30\tof `schemaForPath`.\n31\t\n32\t- Recursively walk the bundle; for every non-reserved `.md`, parse frontmatter via\n33\t `internal/frontmatter` and require a non-empty `type` (§9.2). Validate `index.md`\n34\t (no frontmatter except an optional bundle-root `okf_version`) and `log.md`\n35\t (ISO-date headings) shapes when present (§6/§7).\n36\t- **Lenient by default** (§9): broken links, unknown `type`, and missing optional\n37\t fields are tolerated (not errors) — so externally produced bundles pass, which\n38\t Phase 2 `ingest okf` depends on.\n39\t- **Local-standard warnings** on the operator's own bundle: the four producer\n40\t fields present, and `type` ∈ `config.OKF.Types`.\n41\t- `--strict` promotes warnings to errors (CI gate).\n42\t- **Reuse `models.LintIssue` and the `internal/output` formatters** (table/json/\n43\t tsv) for a consistent UX; a severity distinguishes error from warning.\n44\t\n45\t**Fixtures (per the testing decision):** vendor the three official sample bundles\n46\t(GA4, Stack Overflow, crypto_bitcoin; Apache-2.0) into `internal/okf/testdata/` as\n47\tpositive fixtures, plus synthetic negatives (missing `type`, unterminated\n48\tfrontmatter, list-not-mapping frontmatter) and must-tolerate cases (broken link,\n49\tunknown `type`, frontmatter-less `index.md`). Conformance tests run behind\n50\t`//go:build integration`.\n51\t\n52\t## Alternatives Considered\n53\t\n54\t### Alternative 1: Extend the existing lint engine (`schemaForPath`)\n55\t\n56\t- **Description**: Add OKF cases to the path-prefix switch.\n57\t- **Pros**: One lint entry point.\n58\t- **Cons**: Conflates two structurally different models (path-pyramid wiki vs\n59\t recursive type-required OKF); the wikilink parser does not apply; a single engine\n60\t grows two divergent rule systems.\n61\t- **Why rejected**: Separation keeps both engines simple and correct.\n62\t\n63\t### Alternative 2: Maximum-rigor default (all rules hard errors)\n64\t\n65\t- **Description**: Make four-field + vocabulary + link rules errors without\n66\t `--strict`.\n67\t- **Pros**: Iron standard.\n68\t- **Cons**: Fights OKF's permissive model; rejects third-party bundles on ingest.\n69\t- **Why rejected**: Superseded by ADR-002's lenient-with-warnings posture.\n70\t\n71\t### Alternative 3: Synthetic-only fixtures\n72\t\n73\t- **Description**: Hand-write minimal bundles only.\n74\t- **Pros**: Smaller repo; faster tests.\n75\t- **Cons**: No validation against the bundles real OKF consumers produce/expect.\n76\t- **Why rejected**: The user chose to vendor the official bundles for maximum\n77\t signal.\n78\t\n79\t## Consequences\n80\t\n81\t### Positive\n82\t\n83\t- Spec-correct and tooling-compatible; high-signal conformance tests.\n84\t- Consistent CLI output via shared `LintIssue`/`output` types.\n85\t- Lenient validation unblocks Phase 2 ingest.\n86\t\n87\t### Negative\n88\t\n89\t- The repo grows by the vendored bundles (dozens of `.md` files).\n90\t- Two checking engines coexist (wiki lint and OKF check).\n91\t\n92\t### Risks\n93\t\n94\t- **Spec churn (v0.1 draft)** → isolate rules behind a versioned ruleset in\n95\t `internal/okf`.\n96\t- **Third-party license** → keep the bundles' Apache-2.0 `LICENSE`/attribution in\n97\t `testdata/` and document provenance.\n98\t\n99\t## Implementation Notes\n100\t\n101\t`okf check` returns `[]models.LintIssue` with a severity field; the CLI renders via\n102\t`internal/output`. Reserved filenames (`index.md`, `log.md`) are excluded from the\n103\t`type` requirement. The checker takes the resolved bundle (topic) path; it does not\n104\trequire `mode: okf` so it can validate arbitrary directories and external bundles.\n105\t\n106\t**`index.md` / `log.md` ownership (N-003):** both are kb-owned. `index.md` is fully\n107\tregenerated by scaffold and by `promote` (type-grouped bullet lists); regeneration\n108\tMUST preserve the bundle-root `okf_version` frontmatter — the only place OKF permits\n109\tindex frontmatter. `log.md` is append-only (kb appends a dated entry on scaffold and\n110\tpromote; operators may add entries but kb never rewrites prior ones). Operators\n111\tshould not hand-edit `index.md`, since the next regeneration overwrites it.\n112\t\n113\t## References\n114\t\n115\t- ADR-002 (conformance posture), ADR-003 (okf package)\n116\t- OKF spec §6/§7/§9; official bundles `okf/bundles/{ga4,stackoverflow,crypto_bitcoin}`\n117\t- `internal/lint/lint.go:337`, `internal/frontmatter`, `internal/output`\n118\t- TechSpec: `../_techspec.md`\n119\t\n```"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:35:55.02798Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01UMJHKxGLQWFgcaw6bhPBq3","blocks":[{"type":"tool_use","id":"toolu_01UMJHKxGLQWFgcaw6bhPBq3","name":"Read","title":"Read .compozy/tasks/okf-dual-mode/adrs/adr-006.md","input":{"file_path":"/Users/pedronauck/Dev/compozy/kb/.compozy/tasks/okf-dual-mode/adrs/adr-006.md"},"raw_input":{"file_path":"/Users/pedronauck/Dev/compozy/kb/.compozy/tasks/okf-dual-mode/adrs/adr-006.md"}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:35:55.031429Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01UMJHKxGLQWFgcaw6bhPBq3","usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:35:55.033298Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01UMJHKxGLQWFgcaw6bhPBq3","tool_call_state":"completed","blocks":[{"type":"tool_result","tool_use_id":"toolu_01UMJHKxGLQWFgcaw6bhPBq3","content":"```\n1\t# ADR-006: CLI surface — `kb promote` top-level and a `kb okf` command group\n2\t\n3\t## Status\n4\t\n5\tAccepted\n6\t\n7\t## Date\n8\t\n9\t2026-06-27\n10\t\n11\t## Context\n12\t\n13\tThe MVP adds three user-facing operations now (`promote`, conformance `check`, and\n14\ta `--mode` choice on topic creation), and Phase 2 adds `export okf` and\n15\t`ingest okf`. The existing CLI (`internal/cli/root.go:14`) registers top-level\n16\tcommands (`topic`, `ingest`, `lint`, `inspect`, `index`, `search`, `generate`,\n17\t`migrate`, `version`) via `command.AddCommand(...)`, shares a `--vault` persistent\n18\tflag, resolves the vault with `resolveCommandVaultPath`, and renders output through\n19\t`internal/output`. `topic new` (`topic.go`) takes positional args and has no\n20\t`--mode` flag yet.\n21\t\n22\t`promote` is the daily distill verb (1 wiki doc → 1 OKF concept); `check`,\n23\t`export`, and `ingest` are OKF-ecosystem operations.\n24\t\n25\t## Decision\n26\t\n27\t- **`kb promote \u003cwiki-doc\u003e --to \u003ctopic\u003e --type \u003cType\u003e`** as a **top-level**\n28\t command — it is the daily verb and deserves prominence.\n29\t- **`kb okf`** as a parent command **group**:\n30\t - MVP: `kb okf check \u003ctopic\u003e [--strict] [--format table|json|tsv]`.\n31\t - Phase 2: `kb okf export \u003ctopic\u003e [...]`, `kb okf ingest \u003cbundle\u003e --to \u003ctopic\u003e`.\n32\t- **`kb topic new`** gains `--mode wiki|okf` (default `wiki`).\n33\t\n34\tAll new commands follow the existing cobra patterns: built in `internal/cli`,\n35\tregistered via `AddCommand`, resolving the vault through `resolveCommandVaultPath`,\n36\tdelegating business logic to `internal/okf`, and rendering via `internal/output`.\n37\t\n38\t## Alternatives Considered\n39\t\n40\t### Alternative 1: Everything under `kb okf`\n41\t\n42\t- **Description**: `kb okf promote|check|export|ingest`.\n43\t- **Pros**: Maximum namespace cohesion.\n44\t- **Cons**: Buries `promote` — the most-used verb — one level deeper.\n45\t- **Why rejected**: Daily ergonomics beat namespace purity for the hot verb.\n46\t\n47\t### Alternative 2: `kb promote` + `kb lint --okf`\n48\t\n49\t- **Description**: Conformance as a flag on the existing `lint`.\n50\t- **Pros**: Reuses `lint`.\n51\t- **Cons**: Mixes two engines (wiki pyramid vs OKF conformance) in one command and\n52\t gives no natural home to Phase 2's `export`/`ingest`.\n53\t- **Why rejected**: ADR-005 keeps the engines separate; the `okf` group hosts the\n54\t whole ecosystem.\n55\t\n56\t## Consequences\n57\t\n58\t### Positive\n59\t\n60\t- Discoverable `kb okf …` namespace that grows cleanly across phases.\n61\t- The daily verb stays one keystroke deep.\n62\t- Reuses vault resolution, flags, and output formatting conventions.\n63\t\n64\t### Negative\n65\t\n66\t- Two entry points for OKF (`kb promote` and `kb okf …`) — mitigated by docs and\n67\t `--help` cross-references.\n68\t\n69\t### Risks\n70\t\n71\t- **`--mode` typos / invalid values** → validate against `{wiki, okf}` at parse\n72\t time with a clear error.\n73\t\n74\t## Implementation Notes\n75\t\n76\t`newPromoteCommand` and `newOKFCommand` (with a `check` subcommand) are added to\n77\t`newRootCommand`. `topic new` adds `command.Flags().StringVar(\u0026mode, \"mode\",\n78\t\"wiki\", …)` and passes it into topic creation (ADR-003). Output uses\n79\t`output.OutputFormat*` like `lint`.\n80\t\n81\t## References\n82\t\n83\t- ADR-003 (mode field), ADR-005 (conformance engine)\n84\t- `internal/cli/root.go:14`, `internal/cli/topic.go`, `internal/cli/lint.go`\n85\t- TechSpec: `../_techspec.md`\n86\t\n```"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:35:57.221456Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":""}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:35:57.221853Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":"Now the"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:35:57.222125Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":" round-1 QA files, which are central to round-2 verification."}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:35:57.720373Z","turn":1,"type":"session.update","update":{"kind":"tool_call_started","tool_call_id":"toolu_016V3NcWbz21iHpXVWh82Nch","tool_call_state":"pending","blocks":[{"type":"tool_use","id":"toolu_016V3NcWbz21iHpXVWh82Nch","name":"Read","title":"Read File","raw_input":{}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:35:58.66499Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_016V3NcWbz21iHpXVWh82Nch","blocks":[{"type":"tool_use","id":"toolu_016V3NcWbz21iHpXVWh82Nch","name":"Read","title":"Read .compozy/tasks/okf-dual-mode/qa/peer-review-findings-round1.md","input":{"file_path":"/Users/pedronauck/Dev/compozy/kb/.compozy/tasks/okf-dual-mode/qa/peer-review-findings-round1.md"},"raw_input":{"file_path":"/Users/pedronauck/Dev/compozy/kb/.compozy/tasks/okf-dual-mode/qa/peer-review-findings-round1.md"}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:35:58.667597Z","turn":1,"type":"session.update","update":{"kind":"tool_call_started","tool_call_id":"toolu_01CvRrsT9KTEa3AQVskSAA94","tool_call_state":"pending","blocks":[{"type":"tool_use","id":"toolu_01CvRrsT9KTEa3AQVskSAA94","name":"Read","title":"Read File","raw_input":{}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:35:58.670209Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_016V3NcWbz21iHpXVWh82Nch","usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:35:58.675293Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_016V3NcWbz21iHpXVWh82Nch","tool_call_state":"completed","blocks":[{"type":"tool_result","tool_use_id":"toolu_016V3NcWbz21iHpXVWh82Nch","content":"```\n1\t---\n2\tschema_version: 1\n3\treview_kind: techspec\n4\tround: 1\n5\treadiness: NEEDS_REWORK\n6\treviewer_runtime: claude\n7\treviewer_model: opus\n8\tgenerated_at: 2026-06-27T22:20:41Z\n9\t---\n10\t\n11\t# Summary\n12\t\n13\tThe design is sound and well-bounded — the `mode` field is a clean additive,\n14\tbackward-compatible change; the `internal/okf` package boundary is one-way and\n15\tcycle-free; and the conformance posture is correctly reasoned. It is held back\n16\tfrom approval because the MVP's central verb, `promote`, has two undefined\n17\tcontracts on its critical path: where a promoted concept file lands in the bundle,\n18\tand how a `[[wikilink]]` target (a *source-topic* path) is resolved to a relative\n19\tpath *inside the OKF bundle*. Both must be specified before implementation.\n20\t\n21\t# Blockers\n22\t\n23\t## B-001 — `promote` output contract is undefined (write location, filename, `ConceptResult`)\n24\t\n25\t- Section: `_techspec.md` \"Data Flow → Promote\", \"Core Interfaces\" (`PromoteInput`/`Promoter`/`ConceptResult`)\n26\t- Issue: `PromoteInput` carries `SourceDocPath`, `TargetTopic`, and `Type` but no\n27\t output location. The data flow says promote \"writes the new concept via\n28\t `frontmatter.Generate` + `vault`\" without defining the target path or filename\n29\t inside the bundle: is it the source basename, a slug of `title`, or a\n30\t `type`-grouped subdirectory? `Promoter.Promote` returns `ConceptResult`, which is\n31\t referenced but never defined. The PRD's own open question — \"Catalog granularity …\n32\t sub-areas map to one OKF topic with nested concept directories, or multiple OKF\n33\t topics?\" — is explicitly deferred \"to TechSpec\" and remains unanswered here, yet\n34\t it directly determines the concept path model.\n35\t- Rationale: This is an interface/contract gap on the primary MVP path. The\n36\t scoped-write contract for this review calls out \"payloads, types, or schemas waved\n37\t at instead of defined\" as a blocker, and `CLAUDE.md` requires business logic\n38\t (concept placement) to be concretely specified before Cobra wiring. Without a\n39\t defined path model, the end-to-end promote integration test (\"concept written\n40\t with four fields + relative links … `index.md` updated\") cannot assert a concrete\n41\t location, and `index.md` type-grouping (which must enumerate concept files) has no\n42\t defined tree to walk.\n43\t- Suggested fix: Define the bundle's internal concept-path model explicitly: the\n44\t concept filename derivation (recommend deterministic slug of `title`, collision\n45\t rule stated), whether concepts sit at bundle root or under a `type`/area\n46\t subdirectory, and the full `ConceptResult` struct (at minimum: written path,\n47\t transformed-link count, broken/unresolved-link list). Resolve the PRD \"catalog\n48\t granularity\" open question in the spec (flat root for MVP is the simplest,\n49\t deletable choice) rather than leaving it to implementation.\n50\t\n51\t## B-002 — wikilink→relative-markdown target resolution is unspecified\n52\t\n53\t- Section: `_techspec.md` \"Data Flow → Promote\", \"Implementation Design → Wikilink→markdown body transform\"; `adr-004.md` \"Decision\"\n54\t- Issue: `OKFLinkFormatter.Link(fromDir, targetPath, label)` computes a relative\n55\t path from the new concept's directory to `targetPath`. During promote, the source\n56\t body contains `[[slug/path|label]]`, where `slug/path` is a target in the *source\n57\t wiki topic's* namespace (verified: `ToTopicWikiLink` emits `[[topicSlug/docPath]]`\n58\t at `internal/vault/pathutils.go:245`). The promoted concept lives in a *different*\n59\t topic (the OKF bundle) and the target concept usually is not promoted yet. The\n60\t spec never defines the rule that maps a wiki-namespace target to a bundle-relative\n61\t `targetPath` — so `OKFLinkFormatter.Link` cannot be called deterministically, and\n62\t \"converts `[[wikilinks]]` to relative markdown links\" is not implementable as\n63\t written.\n64\t- Rationale: This is a second interface gap on the critical path, and it is the\n65\t mechanical core that ADR-002 promises is \"deterministic, testable.\" The PRD\n66\t success metric \"links resolved and the four producer fields present\" and the unit\n67\t test \"links to not-yet-promoted concepts (tolerated/flagged)\" both presuppose a\n68\t defined target-derivation rule that does not exist. It is coupled to B-001: the\n69\t link target is whatever path model B-001 settles.\n70\t- Suggested fix: Specify the deterministic mapping from a `[[slug/path|label]]`\n71\t target to a bundle-relative path (e.g., strip the source slug, map to the same\n72\t concept-filename derivation chosen in B-001, emit `[label](relpath)` from the\n73\t concept's `fromDir`), and state explicitly that targets with no promoted\n74\t counterpart still emit a relative link to the *would-be* path and are reported as\n75\t tolerated/unresolved (per §9). Add a unit case asserting the exact emitted\n76\t relative path for a cross-concept link.\n77\t\n78\t# Nits\n79\t\n80\t## N-001 — Dormant OKF render branch ships untested in the MVP\n81\t\n82\t- Section: `_techspec.md` \"Executive Summary\"/\"Primary trade-off\"; `adr-004.md` Decision §3\n83\t- Issue: The ~40+ render call sites (verified: 7 in `render.go`, 34 lines in\n84\t `render_wiki.go`) gain a mode-aware seam whose OKF branch is \"wired but dormant\n85\t until Phase 3\" and never exercised at render sites in the MVP, i.e. shipped dead\n86\t code; ADR-004 Alternative 1 (body-transform only now) is the simpler, deletable\n87\t option the project's bias favors.\n88\t- Suggested fix: Either defer the 43-site migration per ADR-004 Alt 1, or add one\n89\t render-site-in-OKF-mode smoke test so the shipped branch (and its `fromDir`\n90\t plumbing) is not dead and untested.\n91\t\n92\t## N-002 — `frontmatter.Generate` sorts keys; producer field order is alphabetical\n93\t\n94\t- Section: `_techspec.md` \"OKF frontmatter contract\"; `internal/frontmatter/frontmatter.go:396`\n95\t- Issue: `buildMappingNode` does `sort.Strings(keys)`, so the four fields emit as\n96\t `description, timestamp, title, type` (plus `tags`), not Google's `type`-first\n97\t producer order — harmless for conformance (YAML maps are unordered) but worth\n98\t stating so no test freezes a producer order the helper can't honor.\n99\t- Suggested fix: Note in the spec that emitted field order is alphabetical and that\n100\t conformance/tests must not assert producer ordering (or add a dedicated ordered\n101\t encoder if byte-matching the samples is actually required).\n102\t\n103\t## N-003 — `index.md` regeneration may drop root `okf_version` and clobber hand edits\n104\t\n105\t- Section: `_techspec.md` \"Data Flow → Promote\" (\"regenerates `index.md`\"); `adr-005.md`\n106\t- Issue: The scaffold writes a root `index.md` with `okf_version: \"0.1\"`\n107\t frontmatter, but promote \"regenerates `index.md`\" as type-grouped bullet lists;\n108\t the spec does not say the regeneration preserves the root `okf_version`, and the\n109\t PRD open question \"index.md / log.md ownership … whether operators ever hand-edit\n110\t them\" is unresolved.\n111\t- Suggested fix: Specify that regeneration preserves the bundle-root `okf_version`\n112\t and state the hand-edit ownership contract (fully `kb`-owned vs merge-preserving).\n113\t\n114\t## N-004 — Scaffold `mode: wiki` vs `omitempty` \"byte-stable\" claim\n115\t\n116\t- Section: `_techspec.md` Build Order step 1; `adr-003.md` Implementation Notes\n117\t- Issue: ADR-003 argues `omitempty` keeps files \"byte-stable when mode is wiki,\" but\n118\t that only covers *not rewriting* existing files; a newly scaffolded wiki topic\n119\t will gain a visible `mode: wiki` line unless wiki is written as empty — a\n120\t scaffold-output change the zero-regression story should acknowledge.\n121\t- Suggested fix: State whether wiki scaffolds emit `mode: wiki` (recommended for\n122\t clarity) and update the scaffold golden expectation, separating \"old files\n123\t unchanged\" from \"new scaffold output.\"\n124\t\n125\t## N-005 — `[okf].types` default vocabulary contents unspecified\n126\t\n127\t- Section: `_techspec.md` \"Data Models\" (`OKFConfig`); Build Order step 2\n128\t- Issue: The spec adds `[okf].types` \"defaults\" and a `config.example.toml` entry but\n129\t never lists the default concept-type vocabulary.\n130\t- Suggested fix: Enumerate the default `types` list (or state it ships empty) so the\n131\t example file and out-of-vocabulary warning tests have a concrete baseline.\n132\t\n133\t## N-006 — `topic.New` signature change for `--mode` not called out\n134\t\n135\t- Section: `_techspec.md` Build Order steps 5/9; `internal/topic/topic.go:114`\n136\t- Issue: `New(vaultPath, slug, title, domain string)` has no `mode` parameter; the\n137\t spec implies but never states the signature/constructor change needed to thread\n138\t mode from CLI into scaffold.\n139\t- Suggested fix: Name the `New`/`newWithDate` signature change (or a `NewWithMode`\n140\t variant) in the build order so the CLI→topic plumbing is explicit.\n141\t\n142\t# Evidence\n143\t\n144\t- Read in full: `_techspec.md`, `_prd.md`, `adrs/adr-001.md`..`adr-006.md`,\n145\t `CLAUDE.md`, `AGENTS.md`, `CONTRIBUTING.md`.\n146\t- Verified against source: `internal/vault/pathutils.go:239-252` (`ToTopicWikiLink`\n147\t emits `[[slug/path|label]]`); `internal/vault/render.go:186-187` and call sites at\n148\t 220/339/473/555/566/632/643; `internal/vault/render_wiki.go` (34 matching lines);\n149\t `internal/frontmatter/frontmatter.go:102-118` (`Generate`) and `:390-410`\n150\t (`buildMappingNode` alphabetical `sort.Strings`); `internal/models/models.go:271`\n151\t (`TopicMetadata`, has `Slug`, no `Mode`); `internal/models/kb_models.go:115-123`\n152\t (`TopicInfo` exists, no `Mode`); `internal/topic/topic.go:104-110`\n153\t (`topicMetadataFile`, no `Mode`), `:114` (`New` signature), `:607-628`\n154\t (`ensureAgentsSymlink` — AGENTS.md is a symlink to CLAUDE.md), `:770-781`\n155\t (`readTopicYAMLMetadata` returns empty struct when file absent);\n156\t `internal/lint/lint.go:25/703/904` (`wikilinkPattern`, `schemaForPath`).\n157\t- Limitation: I could not read the OKF v0.1 `SPEC.md` or the vendored sample bundles\n158\t (not present in this repo); §9/§6/§7 claims and the \"four producer fields\" /\n159\t field-order behavior are taken from the ADRs, not independently verified against\n160\t the upstream spec.\n161\t\n162\t# Deferred Or Follow-Up\n163\t\n164\t- B-001/B-002 share a root cause (the bundle's internal concept-path model); resolve\n165\t the PRD \"catalog granularity\" open question once and both follow.\n166\t- Confirm whether matching Google's sample bundles requires byte-identical\n167\t producer-field ordering (N-002) or only structural conformance.\n168\t- ADR-004 estimates \"~12 direct `ToTopicWikiLink` calls in `render_wiki.go`\" but the\n169\t file has ~34 matching lines; reconcile the migration-effort estimate (non-blocking).\n170\t\n```"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:35:59.124892Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01CvRrsT9KTEa3AQVskSAA94","blocks":[{"type":"tool_use","id":"toolu_01CvRrsT9KTEa3AQVskSAA94","name":"Read","title":"Read .compozy/tasks/okf-dual-mode/qa/peer-review-incorporation-round1.md","input":{"file_path":"/Users/pedronauck/Dev/compozy/kb/.compozy/tasks/okf-dual-mode/qa/peer-review-incorporation-round1.md"},"raw_input":{"file_path":"/Users/pedronauck/Dev/compozy/kb/.compozy/tasks/okf-dual-mode/qa/peer-review-incorporation-round1.md"}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:35:59.125702Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01CvRrsT9KTEa3AQVskSAA94","usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:35:59.127322Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01CvRrsT9KTEa3AQVskSAA94","tool_call_state":"completed","blocks":[{"type":"tool_result","tool_use_id":"toolu_01CvRrsT9KTEa3AQVskSAA94","content":"```\n1\t# Peer Review — Round 1 Incorporation\n2\t\n3\t- **Decision:** incorporate **both blockers + all six nits** (N-001 handled as a\n4\t smoke test, preserving the ADR-004 \"migrate now\" decision rather than deferring).\n5\t- **Date:** 2026-06-27\n6\t\n7\t## Incorporated\n8\t\n9\t| Finding | Resolution | File(s) changed |\n10\t| --- | --- | --- |\n11\t| B-001 — promote output contract undefined | Added a **Concept Path Model** (flat bundle root for MVP; filename = `slug(title)` with `-N` collision suffix) and defined the full `ConceptResult` struct; resolved the PRD \"catalog granularity\" open question into the spec. | `_techspec.md` (Core Interfaces, Data Flow, new Concept Path Model section) |\n12\t| B-002 — wikilink→relative target resolution unspecified | Specified the deterministic mapping `[[srcSlug/docPath]]` → strip slug → `base(docPath)` → `slug(...)` → bundle-relative path; flat root ⇒ `[label](\u003cslug\u003e.md)`; unresolved targets emit a would-be link and land in `ConceptResult.UnresolvedLinks`. | `_techspec.md` (Concept Path Model, Data Flow) |\n13\t| N-001 — dormant OKF render branch (dead code) | Added an **OKF render smoke test** (render-site-in-`okf`-mode) to keep the wired branch live; recorded as a risk mitigation. | `_techspec.md` (Testing), `adr-004.md` (Risks) |\n14\t| N-002 — alphabetical key order | Documented that emitted field order is `description, tags, timestamp, title, type`; tests must not assert `type`-first ordering. | `_techspec.md` (frontmatter contract) |\n15\t| N-003 — index.md regeneration / okf_version | Stated regeneration preserves the root `okf_version`; defined `index.md` (kb-owned, regenerated) vs `log.md` (append-only) ownership. | `_techspec.md` (Data Flow), `adr-005.md` (Implementation Notes) |\n16\t| N-004 — scaffold `mode: wiki` line | Separated \"old files unchanged\" from \"new scaffold output gains an explicit `mode` line\"; clarified `omitempty` scope. | `adr-003.md` (Implementation Notes) |\n17\t| N-005 — default `[okf].types` vocabulary | Specified the vocabulary ships **empty** (check is a no-op until the operator opts in). | `_techspec.md` (Data Models) |\n18\t| N-006 — `topic.New` signature change | Called out the `New`/`newWithDate` `mode` parameter (or `NewWithMode`) in Build Order step 5. | `_techspec.md` (Build Order) |\n19\t| Follow-up — ~12 vs ~34 migration estimate | Reconciled to \"~7 in render.go + ~34 lines in render_wiki.go (~43 working estimate)\". | `adr-004.md` (Implementation Notes) |\n20\t\n21\t## Deferred\n22\t\n23\t- **None.** All round-1 findings were incorporated. The reviewer's note that it\n24\t could not independently verify the OKF spec/sample bundles (not in repo) is a\n25\t review-environment limitation, not a spec gap — the vendored fixtures (ADR-005)\n26\t will provide that verification at implementation time.\n27\t\n28\t## Files changed\n29\t\n30\t- `_techspec.md`\n31\t- `adrs/adr-003.md`\n32\t- `adrs/adr-004.md`\n33\t- `adrs/adr-005.md`\n34\t\n```"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:36:00.392196Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":""}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:36:00.39234Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":"Now let me read"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:36:00.920933Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":" the project rules files and verify the source code claims in the spec against the actual codebase."}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:36:01.365412Z","turn":1,"type":"session.update","update":{"kind":"tool_call_started","tool_call_id":"toolu_01DjsdkzTkybbCEyu5ULZSL2","tool_call_state":"pending","blocks":[{"type":"tool_use","id":"toolu_01DjsdkzTkybbCEyu5ULZSL2","name":"Read","title":"Read File","raw_input":{}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:36:02.355111Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01DjsdkzTkybbCEyu5ULZSL2","blocks":[{"type":"tool_use","id":"toolu_01DjsdkzTkybbCEyu5ULZSL2","name":"Read","title":"Read AGENTS.md","input":{"file_path":"/Users/pedronauck/Dev/compozy/kb/AGENTS.md"},"raw_input":{"file_path":"/Users/pedronauck/Dev/compozy/kb/AGENTS.md"}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:36:02.356978Z","turn":1,"type":"session.update","update":{"kind":"tool_call_started","tool_call_id":"toolu_013WdSjZP58DhR3zgz5uw3rs","tool_call_state":"pending","blocks":[{"type":"tool_use","id":"toolu_013WdSjZP58DhR3zgz5uw3rs","name":"Read","title":"Read File","raw_input":{}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:36:02.359562Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01DjsdkzTkybbCEyu5ULZSL2","usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:36:02.362269Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01DjsdkzTkybbCEyu5ULZSL2","tool_call_state":"completed","blocks":[{"type":"tool_result","tool_use_id":"toolu_01DjsdkzTkybbCEyu5ULZSL2","content":"```\n1\t# AGENTS.md\n2\t\n3\t## Project Overview\n4\t\n5\t`kb` is a single-binary Go CLI for building and maintaining topic-based knowledge bases in the Karpathy KB pattern. It handles the non-LLM workflow: topic scaffolding, multi-source ingestion, structural linting, codebase analysis, QMD indexing/search, and KB-oriented inspection commands.\n6\t\n7\t**Reference TypeScript source:** `~/dev/projects/kodebase`\n8\t\n9\t## Source of Truth\n10\t\n11\t- KB pivot tech spec: `.compozy/tasks/kb-pivot/_techspec.md`\n12\t- KB pivot task tracker: `.compozy/tasks/kb-pivot/_tasks.md`\n13\t- KB pivot workflow memory: `.compozy/tasks/kb-pivot/memory/`\n14\t\n15\t## Critical Rules\n16\t\n17\t- `make verify` is the non-negotiable completion gate: `fmt -\u003e lint -\u003e test -\u003e build -\u003e boundaries`.\n18\t- `make lint` must report zero findings.\n19\t- Use `go get` for dependency changes.\n20\t- Never use destructive git restore/reset/checkout/clean/rm commands without explicit approval.\n21\t- Prefer local repository inspection over web search for codebase questions.\n22\t\n23\t## Build Commands\n24\t\n25\t```bash\n26\tmake verify # fmt -\u003e lint -\u003e test -\u003e build -\u003e boundaries\n27\tmake fmt # gofmt over repository Go files\n28\tmake lint # golangci-lint v2\n29\tmake test # unit tests with -race via gotestsum\n30\tmake test-integration # unit + integration tests with -race and -tags integration\n31\tmake build # build ./... and bin/kb with ldflags\n32\tmake deps # go mod tidy\n33\tmake help # mage target list\n34\t```\n35\t\n36\t## Package Layout\n37\t\n38\t| Path | Responsibility |\n39\t| --- | --- |\n40\t| `cmd/kb` | Program entrypoint for the `kb` binary |\n41\t| `internal/cli` | Cobra root, subcommands, flag resolution, and command I/O |\n42\t| `internal/topic` | Topic scaffolding, listing, and topic metadata lookup |\n43\t| `internal/ingest` | Ingest orchestration, frontmatter assembly, raw writes, and log entries |\n44\t| `internal/convert` | Converter registry and format-specific file converters |\n45\t| `internal/firecrawl` | Firecrawl REST client for `kb ingest url` |\n46\t| `internal/youtube` | YouTube caption extraction through `yt-dlp` and STT providers |\n47\t| `internal/frontmatter` | Shared frontmatter parsing and generation helpers |\n48\t| `internal/lint` | KB structural lint engine and report rendering |\n49\t| `internal/generate` | Codebase-to-KB pipeline used by `kb ingest codebase` and the hidden legacy `generate` alias |\n50\t| `internal/scanner` | Source discovery and ignore handling for codebase ingest |\n51\t| `internal/adapter` | Tree-sitter parsing adapters |\n52\t| `internal/graph` | Graph normalization for codebase snapshots |\n53\t| `internal/metrics` | File, symbol, directory, and smell metrics |\n54\t| `internal/vault` | Topic path helpers, render/write logic, vault reads, and inspect snapshot loading |\n55\t| `internal/qmd` | QMD subprocess integration for index and search |\n56\t| `internal/output` | Table, JSON, and TSV formatting |\n57\t| `internal/models` | Shared domain models and interfaces |\n58\t| `internal/config` | TOML config plus env-backed runtime overrides |\n59\t| `internal/logger` | Slog logger setup |\n60\t| `internal/version` | Build metadata surfaced by `kb version` |\n61\t\n62\t## CLI Commands\n63\t\n64\t| Command | Purpose |\n65\t| --- | --- |\n66\t| `kb topic new \u003cslug\u003e \u003ctitle\u003e \u003cdomain\u003e` | Scaffold a new knowledge base topic |\n67\t| `kb topic list` | List scaffolded topics |\n68\t| `kb topic info \u003cslug\u003e` | Show metadata for one topic |\n69\t| `kb ingest url \u003curl\u003e --topic \u003cslug\u003e` | Scrape a web URL and ingest into a topic |\n70\t| `kb ingest file \u003cpath\u003e --topic \u003cslug\u003e` | Convert a local file and ingest into a topic |\n71\t| `kb ingest youtube \u003curl\u003e --topic \u003cslug\u003e` | Extract a YouTube transcript and ingest into a topic |\n72\t| `kb ingest codebase \u003cpath\u003e --topic \u003cslug\u003e` | Analyze a codebase and ingest artifacts into a topic |\n73\t| `kb ingest bookmarks \u003cpath\u003e --topic \u003cslug\u003e` | Ingest a bookmark-cluster markdown file into a topic |\n74\t| `kb lint [\u003cslug\u003e]` | Check one topic for structural KB issues |\n75\t| `kb inspect smells` | List smell signals for symbols and files |\n76\t| `kb inspect dead-code` | List dead exports and orphan files |\n77\t| `kb inspect complexity` | Rank functions by cyclomatic complexity |\n78\t| `kb inspect blast-radius` | Rank symbols by blast radius |\n79\t| `kb inspect coupling` | Rank files by instability |\n80\t| `kb inspect symbol \u003cname\u003e` | Find symbols by case-insensitive substring |\n81\t| `kb inspect file \u003cpath\u003e` | Resolve one source file by exact path |\n82\t| `kb inspect backlinks \u003cname-or-path\u003e` | Show inbound relations for a file or symbol |\n83\t| `kb inspect deps \u003cname-or-path\u003e` | Show outgoing relations for a file or symbol |\n84\t| `kb inspect circular-deps` | List detected circular dependency cycles |\n85\t| `kb search \u003cquery\u003e` | Query a vault through QMD hybrid, lexical, or vector modes |\n86\t| `kb index` | Create or update a QMD collection for a topic |\n87\t| `kb version` | Print build version metadata |\n88\t\n89\t### Command Notes\n90\t\n91\t- `topic` subcommands share the root `--vault` flag for vault path resolution.\n92\t- `ingest` subcommands require `--topic \u003cslug\u003e` to identify the target topic.\n93\t- `ingest codebase` accepts `--include`, `--exclude`, `--semantic`, `--progress`, and `--log-format`.\n94\t- `ingest youtube` accepts `--transcribe captions|auto|stt`; `yt-dlp` is required for YouTube metadata, captions, and audio extraction.\n95\t- `inspect` subcommands share `--vault`, `--topic`, and `--format` (`table`, `json`, `tsv`).\n96\t- `lint` accepts `--format`, `--save`, and optional positional `\u003cslug\u003e` or `--topic`.\n97\t- `search` supports `--lex`, `--vec`, `--limit`, `--min-score`, `--full`, `--all`, `--collection`, `--vault`, `--topic`, and `--format`.\n98\t- `index` supports `--vault`, `--topic`, `--name`, `--embed`, `--context`, and `--force-embed`.\n99\t- Hidden compatibility alias: `kb generate \u003cpath\u003e` (delegates to codebase pipeline).\n100\t\n101\t## Runtime Config\n102\t\n103\t- `config.example.toml` documents the TOML keys currently supported by `internal/config`.\n104\t- `APP_CONFIG` overrides the config file path.\n105\t- `.env` is loaded automatically when present.\n106\t- `FIRECRAWL_API_KEY` and `FIRECRAWL_API_URL` configure the Firecrawl client for `ingest url`.\n107\t- `OPENAI_API_KEY`, `OPENAI_API_URL`, `STT_PROVIDER`, and `STT_MODEL` configure the default OpenAI STT provider for `ingest youtube --transcribe auto|stt`.\n108\t- `OPENROUTER_API_KEY`, `OPENROUTER_API_URL`, and `openrouter.stt_model` (TOML-only) configure the optional OpenRouter STT provider when `stt.provider = \"openrouter\"`.\n109\t- Generation, inspect, search, and index behavior is configured by CLI flags rather than TOML keys.\n110\t\n111\t## Architecture Notes\n112\t\n113\t- `internal/generate` is the codebase pipeline orchestration layer. Keep Cobra commands thin and push behavior into internal packages.\n114\t- The codebase pipeline is: scan -\u003e adapter parse -\u003e graph normalize -\u003e metrics compute -\u003e vault render -\u003e vault write -\u003e inspect/search/index read paths.\n115\t- `kb ingest file` routes through a converter registry (`internal/convert`) that matches file extensions to format-specific converters (PDF, DOCX, XLSX, PPTX, EPUB, HTML, CSV, JSON, XML, text, images with OCR).\n116\t- `kb ingest url` uses `internal/firecrawl` for web scraping, then writes through `internal/ingest`.\n117\t- `kb ingest youtube` uses `internal/youtube` for `yt-dlp` caption/audio extraction, with OpenAI STT by default and OpenRouter as an optional provider.\n118\t- Raw KB documents must include frontmatter before being written; use `internal/frontmatter` helpers instead of hand-assembling YAML.\n119\t- `vault.RenderDocuments` returns markdown bodies that already include frontmatter. Base definitions are rendered separately and written as YAML `.base` files.\n120\t- `kb topic new` owns the topic skeleton under the vault root, including `raw/`, `wiki/`, `outputs/`, `bases/`, `CLAUDE.md`, `AGENTS.md`, and `log.md`.\n121\t- Codebase inspection commands operate on `raw/codebase/` beneath the resolved topic. Keep inspect behavior topic-aware rather than vault-global.\n122\t\n123\t## Testing Notes\n124\t\n125\t- Default to table-driven tests with focused helpers and `t.TempDir()` for filesystem isolation.\n126\t- Integration tests use the `integration` build tag and live next to the packages they exercise.\n127\t- CLI integration tests exercise real topic/ingest/lint/inspect flows instead of mocking Cobra wiring when the workflow itself is the behavior under test.\n128\t- QMD-related integration tests must isolate `HOME`, `XDG_CACHE_HOME`, and `XDG_CONFIG_HOME`.\n129\t- Treat failing tests as product bugs first. Fix production behavior instead of weakening assertions.\n130\t\n```"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:36:02.562565Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_013WdSjZP58DhR3zgz5uw3rs","blocks":[{"type":"tool_use","id":"toolu_013WdSjZP58DhR3zgz5uw3rs","name":"Read","title":"Read CONTRIBUTING.md","input":{"file_path":"/Users/pedronauck/Dev/compozy/kb/CONTRIBUTING.md"},"raw_input":{"file_path":"/Users/pedronauck/Dev/compozy/kb/CONTRIBUTING.md"}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:36:02.564782Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_013WdSjZP58DhR3zgz5uw3rs","usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:36:02.568414Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_013WdSjZP58DhR3zgz5uw3rs","tool_call_state":"completed","blocks":[{"type":"tool_result","tool_use_id":"toolu_013WdSjZP58DhR3zgz5uw3rs","content":"```\n1\t# Contributing to kb\n2\t\n3\tThanks for your interest in contributing. Whether it's a bug report, a new language adapter, a file converter, or a documentation fix, every contribution helps.\n4\t\n5\t---\n6\t\n7\t## Prerequisites\n8\t\n9\t- [Go](https://go.dev) \u003e= 1.24\n10\t- [Git](https://git-scm.com)\n11\t\n12\tOptional (auto-downloaded by the build system if missing):\n13\t\n14\t- [golangci-lint](https://golangci-lint.run) v2\n15\t- [gotestsum](https://github.com/gotestyourself/gotestsum)\n16\t\n17\t---\n18\t\n19\t## Getting Started\n20\t\n21\t```bash\n22\tgit clone https://github.com/pedronauck/kodebase-go.git\n23\tcd kodebase-go\n24\tmake verify # Verify everything passes before making changes\n25\t```\n26\t\n27\t### Project Structure\n28\t\n29\t```text\n30\tcmd/\n31\t kb/\n32\t main.go # Program entrypoint\n33\tinternal/\n34\t cli/ # Cobra command tree and command adapters\n35\t root.go\n36\t topic.go # kb topic {new,list,info}\n37\t ingest.go # kb ingest parent command + shared helpers\n38\t ingest_url.go # kb ingest url\n39\t ingest_file.go # kb ingest file\n40\t ingest_youtube.go # kb ingest youtube\n41\t ingest_codebase.go # kb ingest codebase\n42\t ingest_bookmarks.go # kb ingest bookmarks\n43\t lint.go # kb lint\n44\t generate.go # Hidden compatibility alias\n45\t inspect.go # Router for inspect subcommands\n46\t inspect_*.go # Inspect subcommand implementations\n47\t search.go\n48\t index.go\n49\t version.go\n50\t topic/ # Topic scaffolding and metadata\n51\t ingest/ # Ingest orchestration and vault writes\n52\t convert/ # Converter registry and format-specific converters\n53\t firecrawl/ # Firecrawl REST API client\n54\t youtube/ # YouTube transcript extraction + STT fallback\n55\t frontmatter/ # YAML frontmatter parsing and generation\n56\t lint/ # KB structural lint engine\n57\t generate/ # Codebase-to-vault pipeline orchestration\n58\t models/ # Domain types, snapshots, and interfaces\n59\t scanner/ # Workspace discovery and ignore filtering\n60\t adapter/ # Tree-sitter parsing adapters\n61\t go_adapter.go\n62\t ts_adapter.go\n63\t treesitter.go\n64\t graph/ # Graph normalization\n65\t metrics/ # File, symbol, and directory metrics\n66\t vault/ # Rendering, writing, reading, query helpers\n67\t qmd/ # QMD shell client integration\n68\t output/ # Table / JSON / TSV output rendering\n69\t config/ # TOML config and env-backed secrets\n70\t logger/ # Structured slog logger\n71\t version/ # Build metadata\n72\tmagefile.go # Mage build tasks (wrapped by Makefile)\n73\t```\n74\t\n75\t---\n76\t\n77\t## Development Workflow\n78\t\n79\t| Command | Description |\n80\t| ----------------------- | ----------------------------------------- |\n81\t| `make fmt` | Format all Go files with gofmt |\n82\t| `make lint` | Run golangci-lint v2 with zero tolerance |\n83\t| `make test` | Unit tests with race detector |\n84\t| `make test-integration` | Unit + integration tests |\n85\t| `make build` | Build binary to `bin/kb` with ldflags |\n86\t| `make verify` | fmt -\u003e lint -\u003e test -\u003e build -\u003e boundaries|\n87\t| `make deps` | Run `go mod tidy` |\n88\t\n89\t**`make verify` must pass before submitting a PR.** The CI pipeline runs the same command.\n90\t\n91\t---\n92\t\n93\t## Code Style\n94\t\n95\t- **File naming:** `snake_case.go` for all Go files\n96\t- **Exports:** Capitalize public symbols; keep internal logic unexported\n97\t- **Formatting:** `gofmt` (standard Go formatting, enforced by `make fmt`)\n98\t- **Linting:** golangci-lint v2 with zero warnings -- warnings are treated as errors\n99\t- **Imports:** Group in order: stdlib, third-party, internal\n100\t- **Dependencies:** Use `go get` for dependency changes, never hand-edit `go.mod`\n101\t- **CLI commands:** Use [Cobra](https://github.com/spf13/cobra). Keep commands thin -- delegate to packages like `internal/topic`, `internal/ingest`, `internal/lint`, `internal/generate`, `internal/vault`, and `internal/qmd`\n102\t\n103\t---\n104\t\n105\t## Commit Messages\n106\t\n107\tWe use [Conventional Commits](https://www.conventionalcommits.org) enforced by CI.\n108\t\n109\t```text\n110\tfeat(cli): add python language adapter\n111\tfix(cli): correct cyclomatic complexity for method receivers\n112\trefactor(cli): extract shared metric computation\n113\ttest(cli): add coverage for blast-radius edge cases\n114\tdocs(repo): update README with search command examples\n115\tchore(repo): update dependencies\n116\t```\n117\t\n118\t**Valid types:** `feat`, `fix`, `refactor`, `test`, `docs`, `chore`, `build`, `ci`\n119\t\n120\t**Valid scopes:** `cli`, `repo`, `docs`, `test`, `ci`\n121\t\n122\tPR titles must follow the same format (enforced by CI).\n123\t\n124\t---\n125\t\n126\t## Testing\n127\t\n128\t- **Framework:** Standard `testing` package with [gotestsum](https://github.com/gotestyourself/gotestsum) runner\n129\t- **Run tests:** `make test` or `make verify`\n130\t- **Test location:** Co-located with source (`*_test.go` next to the file under test)\n131\t- **File pattern:** `\u003cmodule\u003e_test.go`\n132\t\n133\t### Conventions\n134\t\n135\t- Table-driven tests with `t.Run` subtests\n136\t- `t.TempDir()` for filesystem isolation\n137\t- Focused test helpers with `t.Helper()`\n138\t- Integration tests use the `//go:build integration` build tag and run via `make test-integration`\n139\t- Race detector is always enabled (`-race`)\n140\t\n141\t---\n142\t\n143\t## Adding a New Language Adapter\n144\t\n145\tThis is one of the most impactful contributions you can make. `kb` uses a clean adapter interface that makes adding new languages straightforward.\n146\t\n147\t### Step 1: Create the Adapter File\n148\t\n149\t```text\n150\tinternal/adapter/\u003clanguage\u003e_adapter.go\n151\t```\n152\t\n153\t### Step 2: Implement the LanguageAdapter Interface\n154\t\n155\tThe interface is defined in `internal/models/models.go`:\n156\t\n157\t```go\n158\ttype LanguageAdapter interface {\n159\t Supports(lang SupportedLanguage) bool\n160\t ParseFiles(files []ScannedSourceFile, rootPath string) ([]ParsedFile, error)\n161\t}\n162\t```\n163\t\n164\tYour `ParseFiles` method must return, for each file:\n165\t\n166\t- **`File`** -- A `GraphFile` node with the file's path, language, and symbol IDs\n167\t- **`Symbols`** -- Slice of `SymbolNode` entries (functions, classes, interfaces, types, variables, methods)\n168\t- **`ExternalNodes`** -- External module references\n169\t- **`Relations`** -- Edges connecting files and symbols (`imports`, `exports`, `calls`, `references`, `declares`, `contains`)\n170\t- **`Diagnostics`** -- Any parse warnings or errors\n171\t\n172\t### Step 3: Register Language Extensions\n173\t\n174\tAdd your language's file extensions to the `SupportedLanguage` constants in `internal/models/models.go` and extend `SupportedLanguages()`.\n175\t\n176\t### Step 4: Add Tree-sitter Bindings\n177\t\n178\tAdd the tree-sitter grammar dependency and wire it in `internal/adapter/treesitter.go`.\n179\t\n180\t### Step 5: Wire the Adapter\n181\t\n182\tAdd adapter instantiation and file dispatch in `internal/generate/generate.go`, following the pattern of the existing Go and TypeScript adapters.\n183\t\n184\t### Step 6: Write Tests\n185\t\n186\tAdd `\u003clanguage\u003e_adapter_test.go` in `internal/adapter/` with fixture files in `internal/adapter/testdata/`.\n187\t\n188\t### Step 7: Update Documentation\n189\t\n190\tAdd the language to the \"Supported Languages\" table in `README.md`.\n191\t\n192\t### Reference Implementations\n193\t\n194\t- **`go_adapter.go`** -- Tree-sitter-based Go parser. Relation confidence: `syntactic`.\n195\t- **`ts_adapter.go`** -- Tree-sitter-based TypeScript/JavaScript parser. Relation confidence: `syntactic`.\n196\t\n197\t---\n198\t\n199\t## Adding a New File Converter\n200\t\n201\tThe converter registry (`internal/convert`) makes adding new file format support straightforward.\n202\t\n203\t### Step 1: Create the Converter File\n204\t\n205\t```text\n206\tinternal/convert/\u003cformat\u003e.go\n207\t```\n208\t\n209\t### Step 2: Implement the Converter Interface\n210\t\n211\tThe interface is defined in `internal/models/models.go`:\n212\t\n213\t```go\n214\ttype Converter interface {\n215\t Extensions() []string\n216\t Convert(ctx context.Context, input ConvertInput) (*ConvertResult, error)\n217\t}\n218\t```\n219\t\n220\t### Step 3: Register the Converter\n221\t\n222\tAdd your converter to `NewRegistry()` in `internal/convert/registry.go`.\n223\t\n224\t### Step 4: Write Tests\n225\t\n226\tAdd `\u003cformat\u003e_test.go` in `internal/convert/` with fixture files in `internal/convert/testdata/`.\n227\t\n228\t### Reference Implementations\n229\t\n230\t- **`pdf.go`** -- PDF text extraction via pdfcpu\n231\t- **`docx.go`** -- DOCX XML extraction\n232\t- **`html.go`** -- HTML-to-markdown conversion\n233\t\n234\t---\n235\t\n236\t## Pull Request Process\n237\t\n238\t1. Fork the repository and create a feature branch from `main`\n239\t2. Make your changes following the code style guidelines above\n240\t3. Run `make verify` and ensure it passes with zero warnings\n241\t4. Write a clear PR description explaining what changed and why\n242\t5. PR titles must follow Conventional Commits format with a scope\n243\t6. Keep PRs focused -- one feature or fix per PR\n244\t\n245\t---\n246\t\n247\t## Reporting Issues\n248\t\n249\t- **Bugs:** Use the [bug report template](https://github.com/pedronauck/kodebase-go/issues/new?template=bug-report.yml)\n250\t- **Features:** Use the [feature request template](https://github.com/pedronauck/kodebase-go/issues/new?template=feature-request.yml)\n251\t\n252\tInclude the command you ran, the output you got, and the output you expected. `kb version` output and Go version help us reproduce faster.\n253\t\n254\t---\n255\t\n256\t## Code of Conduct\n257\t\n258\tBe respectful and constructive. We follow the [Contributor Covenant](https://www.contributor-covenant.org/version/2/1/code_of_conduct/).\n259\t\n```"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:36:14.05947Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":""}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:36:14.059645Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":"Now let me verify the key source-code claims the round-2 spec relies on. I'll inspect the actual code"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:36:14.529793Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":" for the new Concept Path Model and wikilink-mapping claims."}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:36:14.533237Z","turn":1,"type":"session.update","update":{"kind":"tool_call_started","tool_call_id":"toolu_01HNyhtTL423gzHwFgcfXmxC","tool_call_state":"pending","blocks":[{"type":"tool_use","id":"toolu_01HNyhtTL423gzHwFgcfXmxC","name":"Bash","title":"Terminal","raw_input":{}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:36:16.607416Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01HNyhtTL423gzHwFgcfXmxC","blocks":[{"type":"tool_use","id":"toolu_01HNyhtTL423gzHwFgcfXmxC","name":"Bash","title":"echo \"=== ToTopicWikiLink + surrounding ===\" \u0026\u0026 sed -n '230,260p' internal/vault/pathutils.go 2\u003e/dev/null; echo \"=== slug helpers across repo ===\" \u0026\u0026 grep -rn \"func [Ss]lug\\|func.*[Ss]lugify\\|kebab\\|func Slugify\\|func slugify\" --include=*.go internal | head -40","input":{"command":"echo \"=== ToTopicWikiLink + surrounding ===\" \u0026\u0026 sed -n '230,260p' internal/vault/pathutils.go 2\u003e/dev/null; echo \"=== slug helpers across repo ===\" \u0026\u0026 grep -rn \"func [Ss]lug\\|func.*[Ss]lugify\\|kebab\\|func Slugify\\|func slugify\" --include=*.go internal | head -40"},"raw_input":{"command":"echo \"=== ToTopicWikiLink + surrounding ===\" \u0026\u0026 sed -n '230,260p' internal/vault/pathutils.go 2\u003e/dev/null; echo \"=== slug helpers across repo ===\" \u0026\u0026 grep -rn \"func [Ss]lug\\|func.*[Ss]lugify\\|kebab\\|func Slugify\\|func slugify\" --include=*.go internal | head -40"}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:36:16.612953Z","turn":1,"type":"session.update","update":{"kind":"tool_call_started","tool_call_id":"toolu_01TDgtNgREpAerzHn2oiPW6C","tool_call_state":"pending","blocks":[{"type":"tool_use","id":"toolu_01TDgtNgREpAerzHn2oiPW6C","name":"Bash","title":"Terminal","raw_input":{}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:36:16.699367Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01HNyhtTL423gzHwFgcfXmxC","usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:36:16.700808Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01HNyhtTL423gzHwFgcfXmxC","tool_call_state":"completed","blocks":[{"type":"tool_result","tool_use_id":"toolu_01HNyhtTL423gzHwFgcfXmxC","content":"```console\n=== ToTopicWikiLink + surrounding ===\nfunc GetTopicIndexPath(indexTitle string) string {\n\treturn fmt.Sprintf(\"wiki/index/%s.md\", indexTitle)\n}\n\n// GetBaseFilePath derives the vault document path for an Obsidian Base definition.\nfunc GetBaseFilePath(baseName string) string {\n\treturn fmt.Sprintf(\"bases/%s.base\", baseName)\n}\n\n// StripMarkdownExtension removes a trailing .md extension from a document path.\nfunc StripMarkdownExtension(documentPath string) string {\n\treturn strings.TrimSuffix(documentPath, \".md\")\n}\n\n// ToTopicWikiLink formats a topic-scoped Obsidian wiki-link.\nfunc ToTopicWikiLink(topicSlug, documentPath, label string) string {\n\ttarget := fmt.Sprintf(\"%s/%s\", topicSlug, StripMarkdownExtension(documentPath))\n\tif label != \"\" {\n\t\treturn fmt.Sprintf(\"[[%s|%s]]\", target, label)\n\t}\n\n\treturn fmt.Sprintf(\"[[%s]]\", target)\n}\n\nfunc stripWikiConceptFilePrefix(articleTitle string) string {\n\tnormalizedTitle := strings.TrimSpace(articleTitle)\n\treturn strings.TrimSpace(strings.TrimPrefix(normalizedTitle, legacyWikiConceptPrefix))\n}\n\nfunc cleanComparablePath(value string) string {\n\tnormalized := ToPosixPath(value)\n=== slug helpers across repo ===\n(eval):1: no matches found: --include=*.go\n```"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:36:18.362273Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01TDgtNgREpAerzHn2oiPW6C","blocks":[{"type":"tool_use","id":"toolu_01TDgtNgREpAerzHn2oiPW6C","name":"Bash","title":"echo \"=== frontmatter.Generate + buildMappingNode sort ===\" \u0026\u0026 sed -n '95,130p' internal/frontmatter/frontmatter.go; echo \"...\" \u0026\u0026 sed -n '385,415p' internal/frontmatter/frontmatter.go; echo \"=== Parse signature ===\" \u0026\u0026 grep -n \"func Parse\\|func Generate\\|type Document\\|type Frontmatter\" internal/frontmatter/frontmatter.go | head","input":{"command":"echo \"=== frontmatter.Generate + buildMappingNode sort ===\" \u0026\u0026 sed -n '95,130p' internal/frontmatter/frontmatter.go; echo \"...\" \u0026\u0026 sed -n '385,415p' internal/frontmatter/frontmatter.go; echo \"=== Parse signature ===\" \u0026\u0026 grep -n \"func Parse\\|func Generate\\|type Document\\|type Frontmatter\" internal/frontmatter/frontmatter.go | head"},"raw_input":{"command":"echo \"=== frontmatter.Generate + buildMappingNode sort ===\" \u0026\u0026 sed -n '95,130p' internal/frontmatter/frontmatter.go; echo \"...\" \u0026\u0026 sed -n '385,415p' internal/frontmatter/frontmatter.go; echo \"=== Parse signature ===\" \u0026\u0026 grep -n \"func Parse\\|func Generate\\|type Document\\|type Frontmatter\" internal/frontmatter/frontmatter.go | head"}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:36:18.404368Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01TDgtNgREpAerzHn2oiPW6C","usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:36:18.405925Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01TDgtNgREpAerzHn2oiPW6C","tool_call_state":"completed","blocks":[{"type":"tool_result","tool_use_id":"toolu_01TDgtNgREpAerzHn2oiPW6C","content":"```console\n=== frontmatter.Generate + buildMappingNode sort ===\n\t}\n\n\treturn normalizeMap(values), body, nil\n}\n\n// Generate encodes frontmatter and prepends it to body. When values is empty,\n// Generate returns body unchanged.\nfunc Generate(values map[string]any, body string) (string, error) {\n\tif len(values) == 0 {\n\t\treturn body, nil\n\t}\n\n\tnode, err := buildNode(values, \"\")\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\tencoded, err := yaml.Marshal(node)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"marshal frontmatter: %w\", err)\n\t}\n\n\treturn \"---\\n\" + strings.TrimRight(string(encoded), \"\\n\") + \"\\n---\\n\" + body, nil\n}\n\n// GetString returns the string value for key, or the zero value when missing.\nfunc GetString(values map[string]any, key string) string {\n\tvalue, exists := values[key]\n\tif !exists {\n\t\treturn \"\"\n\t}\n\n\tswitch typed := value.(type) {\n\tcase string:\n\t\treturn typed\n\tcase fmt.Stringer:\n...\n\t\t\tErr: fmt.Errorf(\"unsupported value type %T\", value),\n\t\t}\n\t}\n}\n\nfunc buildMappingNode(values map[string]any) (*yaml.Node, error) {\n\tnode := \u0026yaml.Node{Kind: yaml.MappingNode, Tag: \"!!map\"}\n\tkeys := make([]string, 0, len(values))\n\tfor key := range values {\n\t\tkeys = append(keys, key)\n\t}\n\tsort.Strings(keys)\n\n\tfor _, key := range keys {\n\t\tvalueNode, err := buildNode(values[key], key)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tnode.Content = append(node.Content,\n\t\t\t\u0026yaml.Node{Kind: yaml.ScalarNode, Tag: \"!!str\", Value: key},\n\t\t\tvalueNode,\n\t\t)\n\t}\n\n\treturn node, nil\n}\n\nfunc formatTime(value time.Time) string {\n\tif value.Hour() == 0 \u0026\u0026 value.Minute() == 0 \u0026\u0026 value.Second() == 0 \u0026\u0026 value.Nanosecond() == 0 {\n\t\treturn value.Format(DateLayout)\n\t}\n=== Parse signature ===\n2 matches in 1 files:\n\ninternal/frontmatter/frontmatter.go:71:func Parse(markdown string) (map[string]any, string, error) {\ninternal/frontmatter/frontmatter.go:102:func Generate(values map[string]any, body string) (string, error) {\n```"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:36:47.476493Z","turn":1,"type":"session.update","update":{"kind":"tool_call_started","tool_call_id":"toolu_01MGjGX6rgL9EUPHr315V93z","tool_call_state":"pending","blocks":[{"type":"tool_use","id":"toolu_01MGjGX6rgL9EUPHr315V93z","name":"Bash","title":"Terminal","raw_input":{}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:36:49.475935Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01MGjGX6rgL9EUPHr315V93z","blocks":[{"type":"tool_use","id":"toolu_01MGjGX6rgL9EUPHr315V93z","name":"Bash","title":"cd /Users/pedronauck/Dev/compozy/kb; echo \"=== sort.Strings line ===\"; grep -n \"sort.Strings\" internal/frontmatter/frontmatter.go; echo \"=== ToTopicWikiLink call sites + documentPath construction ===\"; grep -n \"ToTopicWikiLink\\|toSourceWikiLink\\|documentPath\\|DocumentPath\" internal/vault/render.go | head -40","input":{"command":"cd /Users/pedronauck/Dev/compozy/kb; echo \"=== sort.Strings line ===\"; grep -n \"sort.Strings\" internal/frontmatter/frontmatter.go; echo \"=== ToTopicWikiLink call sites + documentPath construction ===\"; grep -n \"ToTopicWikiLink\\|toSourceWikiLink\\|documentPath\\|DocumentPath\" internal/vault/render.go | head -40"},"raw_input":{"command":"cd /Users/pedronauck/Dev/compozy/kb; echo \"=== sort.Strings line ===\"; grep -n \"sort.Strings\" internal/frontmatter/frontmatter.go; echo \"=== ToTopicWikiLink call sites + documentPath construction ===\"; grep -n \"ToTopicWikiLink\\|toSourceWikiLink\\|documentPath\\|DocumentPath\" internal/vault/render.go | head -40"}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:36:49.477572Z","turn":1,"type":"session.update","update":{"kind":"tool_call_started","tool_call_id":"toolu_013DbSM1hjCmxJgyppT3Jktq","tool_call_state":"pending","blocks":[{"type":"tool_use","id":"toolu_013DbSM1hjCmxJgyppT3Jktq","name":"Bash","title":"Terminal","raw_input":{}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:36:49.524741Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01MGjGX6rgL9EUPHr315V93z","usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:36:49.526224Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01MGjGX6rgL9EUPHr315V93z","tool_call_state":"completed","blocks":[{"type":"tool_result","tool_use_id":"toolu_01MGjGX6rgL9EUPHr315V93z","content":"```console\n=== sort.Strings line ===\n1 matches in 1 files:\n\ninternal/frontmatter/frontmatter.go:396:sort.Strings(keys)\n=== ToTopicWikiLink call sites + documentPath construction ===\n17 matches in 1 files:\n\ninternal/vault/render.go:186:func toSourceWikiLink(topic models.TopicMetadata, relativePath, label string)...\ninternal/vault/render.go:187:return ToTopicWikiLink(topic.Slug, relativePath, label)\ninternal/vault/render.go:194:lookup[file.ID] = GetRawFileDocumentPath(file.FilePath)\ninternal/vault/render.go:198:lookup[symbol.ID] = GetRawSymbolDocumentPath(symbol)\ninternal/vault/render.go:219:if documentPath, exists := documentLookup[nodeID]; exists {\ninternal/vault/render.go:220:return toSourceWikiLink(topic, documentPath, fallbackLabel)\ninternal/vault/render.go:339:symbolLink := toSourceWikiLink(\ninternal/vault/render.go:341:GetRawSymbolDocumentPath(symbol),\ninternal/vault/render.go:389:RelativePath: GetRawFileDocumentPath(file.FilePath),\ninternal/vault/render.go:473:fileLink := toSourceWikiLink(topic, GetRawFileDocumentPath(symbol.FilePath), ...\ninternal/vault/render.go:533:RelativePath: GetRawSymbolDocumentPath(symbol),\ninternal/vault/render.go:555:fileLinks = append(fileLinks, \"- \"+toSourceWikiLink(topic, GetRawFileDocument...\ninternal/vault/render.go:566:symbolLinks = append(symbolLinks, \"- \"+toSourceWikiLink(\ninternal/vault/render.go:568:GetRawSymbolDocumentPath(symbol),\ninternal/vault/render.go:632:fileLinks = append(fileLinks, \"- \"+toSourceWikiLink(topic, GetRawFileDocument...\ninternal/vault/render.go:643:symbolLinks = append(symbolLinks, \"- \"+toSourceWikiLink(\ninternal/vault/render.go:645:GetRawSymbolDocumentPath(symbol),\n```"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:36:50.770767Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_013DbSM1hjCmxJgyppT3Jktq","blocks":[{"type":"tool_use","id":"toolu_013DbSM1hjCmxJgyppT3Jktq","name":"Bash","title":"cd /Users/pedronauck/Dev/compozy/kb; echo \"=== how wiki concept filenames/titles are derived ===\"; grep -rn \"slug\\|Slug\\|kebab\\|ToLower\\|strings.ReplaceAll\\|filepath.Base\\|sanitize\\|Sanitize\\|fileName\\|FileName\\|\\.md\" internal/vault/render_wiki.go | head -50","input":{"command":"cd /Users/pedronauck/Dev/compozy/kb; echo \"=== how wiki concept filenames/titles are derived ===\"; grep -rn \"slug\\|Slug\\|kebab\\|ToLower\\|strings.ReplaceAll\\|filepath.Base\\|sanitize\\|Sanitize\\|fileName\\|FileName\\|\\.md\" internal/vault/render_wiki.go | head -50"},"raw_input":{"command":"cd /Users/pedronauck/Dev/compozy/kb; echo \"=== how wiki concept filenames/titles are derived ===\"; grep -rn \"slug\\|Slug\\|kebab\\|ToLower\\|strings.ReplaceAll\\|filepath.Base\\|sanitize\\|Sanitize\\|fileName\\|FileName\\|\\.md\" internal/vault/render_wiki.go | head -50"}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:36:50.814582Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_013DbSM1hjCmxJgyppT3Jktq","usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:36:50.816107Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_013DbSM1hjCmxJgyppT3Jktq","tool_call_state":"completed","blocks":[{"type":"tool_result","tool_use_id":"toolu_013DbSM1hjCmxJgyppT3Jktq","content":"```console\n=== how wiki concept filenames/titles are derived ===\ninternal/vault/render_wiki.go:108:\t\t\tToTopicWikiLink(topic.Slug, GetWikiConceptPath(article.Title), article.Title),\ninternal/vault/render_wiki.go:116:\t\t\"- \"+ToTopicWikiLink(topic.Slug, GetWikiIndexPath(CodebaseConceptIndexTitle), CodebaseConceptIndexTitle),\ninternal/vault/render_wiki.go:117:\t\t\"- \"+ToTopicWikiLink(topic.Slug, GetWikiIndexPath(CodebaseSourceIndexTitle), CodebaseSourceIndexTitle),\ninternal/vault/render_wiki.go:147:\t\t\tToTopicWikiLink(topic.Slug, GetWikiConceptPath(article.Title), article.Title),\ninternal/vault/render_wiki.go:194:\t\t\tlinks = append(links, ToTopicWikiLink(topic.Slug, GetWikiConceptPath(title), title))\ninternal/vault/render_wiki.go:314:\t\t\tToTopicWikiLink(topic.Slug, GetWikiConceptPath(\"Module Health\"), \"Module Health\"),\ninternal/vault/render_wiki.go:315:\t\t\tToTopicWikiLink(topic.Slug, GetWikiConceptPath(\"Complexity Hotspots\"), \"Complexity Hotspots\"),\ninternal/vault/render_wiki.go:316:\t\t\tToTopicWikiLink(topic.Slug, GetWikiConceptPath(\"Dead Code Report\"), \"Dead Code Report\"),\ninternal/vault/render_wiki.go:395:\t\t\t\tToTopicWikiLink(topic.Slug, GetWikiConceptPath(\"Module Health\"), \"Module Health\"),\ninternal/vault/render_wiki.go:396:\t\t\t\tToTopicWikiLink(topic.Slug, GetWikiConceptPath(\"Dependency Hotspots\"), \"Dependency Hotspots\"),\ninternal/vault/render_wiki.go:461:\t\t\t\tToTopicWikiLink(topic.Slug, GetWikiConceptPath(\"High-Impact Symbols\"), \"High-Impact Symbols\"),\ninternal/vault/render_wiki.go:462:\t\t\t\tToTopicWikiLink(topic.Slug, GetWikiConceptPath(\"Code Smells\"), \"Code Smells\"),\ninternal/vault/render_wiki.go:538:\t\t\t\tToTopicWikiLink(topic.Slug, GetWikiConceptPath(\"Module Health\"), \"Module Health\"),\ninternal/vault/render_wiki.go:616:\t\t\t\tToTopicWikiLink(topic.Slug, GetWikiConceptPath(\"High-Impact Symbols\"), \"High-Impact Symbols\"),\n```"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:36:53.044175Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":""}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:36:53.044392Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":"Let me examine `GetWikiConceptPath` (how wiki concept paths are derived from titles) — this is"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:36:53.516763Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":" the crux of the cross-concept mapping determinism claim."}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:36:53.518733Z","turn":1,"type":"session.update","update":{"kind":"tool_call_started","tool_call_id":"toolu_015LFVxDUv3XuLFvJGm6er77","tool_call_state":"pending","blocks":[{"type":"tool_use","id":"toolu_015LFVxDUv3XuLFvJGm6er77","name":"Bash","title":"Terminal","raw_input":{}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:36:56.878527Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_015LFVxDUv3XuLFvJGm6er77","blocks":[{"type":"tool_use","id":"toolu_015LFVxDUv3XuLFvJGm6er77","name":"Bash","title":"cd /Users/pedronauck/Dev/compozy/kb; echo \"=== GetWikiConceptPath + related path derivation ===\"; grep -n \"func GetWikiConceptPath\\|func GetWikiIndexPath\\|func GetRawFileDocumentPath\\|func GetRawSymbolDocumentPath\\|func.*Slugif\\|func.*[Ss]lug\\|func sanitize\\|func toFile\\|func.*Filename\\|func.*FileName\" internal/vault/pathutils.go","input":{"command":"cd /Users/pedronauck/Dev/compozy/kb; echo \"=== GetWikiConceptPath + related path derivation ===\"; grep -n \"func GetWikiConceptPath\\|func GetWikiIndexPath\\|func GetRawFileDocumentPath\\|func GetRawSymbolDocumentPath\\|func.*Slugif\\|func.*[Ss]lug\\|func sanitize\\|func toFile\\|func.*Filename\\|func.*FileName\" internal/vault/pathutils.go"},"raw_input":{"command":"cd /Users/pedronauck/Dev/compozy/kb; echo \"=== GetWikiConceptPath + related path derivation ===\"; grep -n \"func GetWikiConceptPath\\|func GetWikiIndexPath\\|func GetRawFileDocumentPath\\|func GetRawSymbolDocumentPath\\|func.*Slugif\\|func.*[Ss]lug\\|func sanitize\\|func toFile\\|func.*Filename\\|func.*FileName\" internal/vault/pathutils.go"}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:36:56.883141Z","turn":1,"type":"session.update","update":{"kind":"tool_call_started","tool_call_id":"toolu_01GHbJbx1UgsbcDyy3ANA5EY","tool_call_state":"pending","blocks":[{"type":"tool_use","id":"toolu_01GHbJbx1UgsbcDyy3ANA5EY","name":"Bash","title":"Terminal","raw_input":{}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:36:56.928675Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_015LFVxDUv3XuLFvJGm6er77","usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:36:56.930687Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_015LFVxDUv3XuLFvJGm6er77","tool_call_state":"completed","blocks":[{"type":"tool_result","tool_use_id":"toolu_015LFVxDUv3XuLFvJGm6er77","content":"```console\n=== GetWikiConceptPath + related path derivation ===\n10 matches in 1 files:\n\ninternal/vault/pathutils.go:98:func SlugifySegment(value string) string {\ninternal/vault/pathutils.go:131:func HumanizeSlug(value string) string {\ninternal/vault/pathutils.go:151:func DeriveTopicSlug(rootPath string) string {\ninternal/vault/pathutils.go:166:func DeriveTopicTitle(topicSlug string) string {\ninternal/vault/pathutils.go:175:func DeriveTopicDomain(topicSlug string) string {\ninternal/vault/pathutils.go:192:func GetRawFileDocumentPath(filePath string) string {\ninternal/vault/pathutils.go:197:func GetRawSymbolDocumentPath(symbol models.SymbolNode) string {\ninternal/vault/pathutils.go:219:func GetWikiConceptPath(articleTitle string) string {\ninternal/vault/pathutils.go:225:func GetWikiIndexPath(indexTitle string) string {\ninternal/vault/pathutils.go:245:func ToTopicWikiLink(topicSlug, documentPath, label string) string {\n```"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:36:57.686436Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01GHbJbx1UgsbcDyy3ANA5EY","blocks":[{"type":"tool_use","id":"toolu_01GHbJbx1UgsbcDyy3ANA5EY","name":"Bash","title":"cd /Users/pedronauck/Dev/compozy/kb; echo \"=== body content of GetWikiConceptPath and any slug-ish normalizer ===\"; sed -n '1,120p' internal/vault/pathutils.go","input":{"command":"cd /Users/pedronauck/Dev/compozy/kb; echo \"=== body content of GetWikiConceptPath and any slug-ish normalizer ===\"; sed -n '1,120p' internal/vault/pathutils.go"},"raw_input":{"command":"cd /Users/pedronauck/Dev/compozy/kb; echo \"=== body content of GetWikiConceptPath and any slug-ish normalizer ===\"; sed -n '1,120p' internal/vault/pathutils.go"}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:36:57.730352Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01GHbJbx1UgsbcDyy3ANA5EY","usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:36:57.736169Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01GHbJbx1UgsbcDyy3ANA5EY","tool_call_state":"completed","blocks":[{"type":"tool_result","tool_use_id":"toolu_01GHbJbx1UgsbcDyy3ANA5EY","content":"```console\n=== body content of GetWikiConceptPath and any slug-ish normalizer ===\n// Package vault provides path resolution, document rendering, reading, writing, and query helpers for Obsidian-compatible knowledge vaults.\npackage vault\n\nimport (\n\t\"fmt\"\n\t\"path\"\n\t\"regexp\"\n\t\"strings\"\n\n\t\"github.com/compozy/kb/internal/models\"\n)\n\nvar windowsDriveRootPattern = regexp.MustCompile(`^[A-Za-z]:/?$`)\n\nconst (\n\tCodebaseDashboardTitle = \"Codebase Dashboard\"\n\tCodebaseConceptIndexTitle = \"Codebase Concept Index\"\n\tCodebaseSourceIndexTitle = \"Codebase Source Index\"\n\tTopicDashboardTitle = \"Dashboard\"\n\tTopicConceptIndexTitle = \"Concept Index\"\n\tTopicSourceIndexTitle = \"Source Index\"\n\tlegacyWikiConceptPrefix = \"Kodebase - \"\n)\n\n// ToPosixPath normalizes path separators to forward slashes and trims trailing slashes.\nfunc ToPosixPath(value string) string {\n\tif value == \"\" {\n\t\treturn \"\"\n\t}\n\n\tnormalized := strings.ReplaceAll(value, `\\`, `/`)\n\tif windowsDriveRootPattern.MatchString(normalized) {\n\t\treturn normalized[:2] + \"/\"\n\t}\n\n\tfor len(normalized) \u003e 1 \u0026\u0026 strings.HasSuffix(normalized, \"/\") {\n\t\tnormalized = strings.TrimSuffix(normalized, \"/\")\n\t}\n\n\tif normalized == \"\" {\n\t\treturn \"/\"\n\t}\n\n\treturn normalized\n}\n\n// IsPathInside reports whether targetPath is the same as or nested under parentPath.\nfunc IsPathInside(parentPath, targetPath string) bool {\n\tparent := cleanComparablePath(parentPath)\n\ttarget := cleanComparablePath(targetPath)\n\n\tswitch {\n\tcase parent == \"\":\n\t\treturn target == \"\"\n\tcase target == \"\":\n\t\treturn false\n\t}\n\n\tif parent == target {\n\t\treturn true\n\t}\n\n\tparentDrive, parentParts, parentAbsolute := splitComparablePath(parent)\n\ttargetDrive, targetParts, targetAbsolute := splitComparablePath(target)\n\n\tif parentAbsolute != targetAbsolute {\n\t\treturn false\n\t}\n\n\tif !strings.EqualFold(parentDrive, targetDrive) {\n\t\treturn false\n\t}\n\n\tif len(parentParts) \u003e len(targetParts) {\n\t\treturn false\n\t}\n\n\tfor index := range parentParts {\n\t\tif parentParts[index] != targetParts[index] {\n\t\t\treturn false\n\t\t}\n\t}\n\n\treturn true\n}\n\n// CreateFileID creates a stable file node identifier from a file path.\nfunc CreateFileID(filePath string) string {\n\treturn \"file:\" + ToPosixPath(filePath)\n}\n\n// CreateExternalID creates a stable external node identifier from a module source.\nfunc CreateExternalID(source string) string {\n\treturn \"external:\" + source\n}\n\n// SlugifySegment converts a free-form segment into a filesystem-friendly slug.\nfunc SlugifySegment(value string) string {\n\ttrimmed := strings.TrimSpace(strings.ToLower(value))\n\tif trimmed == \"\" {\n\t\treturn \"item\"\n\t}\n\n\tvar builder strings.Builder\n\tlastWasDash := false\n\n\tfor _, r := range trimmed {\n\t\tif (r \u003e= 'a' \u0026\u0026 r \u003c= 'z') || (r \u003e= '0' \u0026\u0026 r \u003c= '9') {\n\t\t\tbuilder.WriteRune(r)\n\t\t\tlastWasDash = false\n\t\t\tcontinue\n\t\t}\n\n\t\tif lastWasDash {\n\t\t\tcontinue\n\t\t}\n\n\t\tbuilder.WriteByte('-')\n\t\tlastWasDash = true\n\t}\n```"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:37:00.69911Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":""}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:37:00.700063Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":"A canonical slug helper already exists (`v"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:37:01.158537Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":"ault.SlugifySegment`). Let me see its full body and `GetWikiConceptPath`, to test whether the spec's `slug(title)` re"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:37:01.619074Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":"uses it and whether cross-concept link derivation is act"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:37:02.086024Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":"ually consistent."}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:37:02.087026Z","turn":1,"type":"session.update","update":{"kind":"tool_call_started","tool_call_id":"toolu_01BShgj7FRKBNnUkUe6b1EbC","tool_call_state":"pending","blocks":[{"type":"tool_use","id":"toolu_01BShgj7FRKBNnUkUe6b1EbC","name":"Bash","title":"Terminal","raw_input":{}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:37:03.610513Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01BShgj7FRKBNnUkUe6b1EbC","blocks":[{"type":"tool_use","id":"toolu_01BShgj7FRKBNnUkUe6b1EbC","name":"Bash","title":"cd /Users/pedronauck/Dev/compozy/kb; echo \"=== SlugifySegment full + GetWikiConceptPath + GetRawFileDocumentPath/Symbol ===\"; sed -n '98,235p' internal/vault/pathutils.go","input":{"command":"cd /Users/pedronauck/Dev/compozy/kb; echo \"=== SlugifySegment full + GetWikiConceptPath + GetRawFileDocumentPath/Symbol ===\"; sed -n '98,235p' internal/vault/pathutils.go"},"raw_input":{"command":"cd /Users/pedronauck/Dev/compozy/kb; echo \"=== SlugifySegment full + GetWikiConceptPath + GetRawFileDocumentPath/Symbol ===\"; sed -n '98,235p' internal/vault/pathutils.go"}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:37:03.641914Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01BShgj7FRKBNnUkUe6b1EbC","usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:37:03.644283Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01BShgj7FRKBNnUkUe6b1EbC","tool_call_state":"completed","blocks":[{"type":"tool_result","tool_use_id":"toolu_01BShgj7FRKBNnUkUe6b1EbC","content":"```console\n=== SlugifySegment full + GetWikiConceptPath + GetRawFileDocumentPath/Symbol ===\nfunc SlugifySegment(value string) string {\n\ttrimmed := strings.TrimSpace(strings.ToLower(value))\n\tif trimmed == \"\" {\n\t\treturn \"item\"\n\t}\n\n\tvar builder strings.Builder\n\tlastWasDash := false\n\n\tfor _, r := range trimmed {\n\t\tif (r \u003e= 'a' \u0026\u0026 r \u003c= 'z') || (r \u003e= '0' \u0026\u0026 r \u003c= '9') {\n\t\t\tbuilder.WriteRune(r)\n\t\t\tlastWasDash = false\n\t\t\tcontinue\n\t\t}\n\n\t\tif lastWasDash {\n\t\t\tcontinue\n\t\t}\n\n\t\tbuilder.WriteByte('-')\n\t\tlastWasDash = true\n\t}\n\n\tslug := strings.Trim(builder.String(), \"-\")\n\tif slug == \"\" {\n\t\treturn \"item\"\n\t}\n\n\treturn slug\n}\n\n// HumanizeSlug converts a hyphenated slug into a title-cased label.\nfunc HumanizeSlug(value string) string {\n\tif value == \"\" {\n\t\treturn \"\"\n\t}\n\n\tsegments := strings.Split(value, \"-\")\n\thumanized := make([]string, 0, len(segments))\n\n\tfor _, segment := range segments {\n\t\tif segment == \"\" {\n\t\t\tcontinue\n\t\t}\n\n\t\thumanized = append(humanized, strings.ToUpper(segment[:1])+segment[1:])\n\t}\n\n\treturn strings.Join(humanized, \" \")\n}\n\n// DeriveTopicSlug derives the topic slug from a root path.\nfunc DeriveTopicSlug(rootPath string) string {\n\tnormalized := ToPosixPath(rootPath)\n\tif normalized == \"\" {\n\t\treturn SlugifySegment(\"\")\n\t}\n\n\tbase := strings.TrimRight(normalized, \"/\")\n\tif base == \"\" {\n\t\tbase = normalized\n\t}\n\n\treturn SlugifySegment(path.Base(base))\n}\n\n// DeriveTopicTitle converts a topic slug into a human-readable title.\nfunc DeriveTopicTitle(topicSlug string) string {\n\tif title := HumanizeSlug(topicSlug); title != \"\" {\n\t\treturn title\n\t}\n\n\treturn \"Knowledge Base\"\n}\n\n// DeriveTopicDomain returns the topic domain identifier for a topic slug.\nfunc DeriveTopicDomain(topicSlug string) string {\n\treturn topicSlug\n}\n\n// CreateSymbolID creates a stable symbol identifier from a symbol node.\nfunc CreateSymbolID(symbol models.SymbolNode) string {\n\treturn strings.Join([]string{\n\t\t\"symbol\",\n\t\tToPosixPath(symbol.FilePath),\n\t\tSlugifySegment(symbol.Name),\n\t\tSlugifySegment(symbol.SymbolKind),\n\t\tfmt.Sprintf(\"%d\", symbol.StartLine),\n\t\tfmt.Sprintf(\"%d\", symbol.EndLine),\n\t}, \":\")\n}\n\n// GetRawFileDocumentPath derives the vault document path for a raw file snapshot.\nfunc GetRawFileDocumentPath(filePath string) string {\n\treturn fmt.Sprintf(\"raw/codebase/files/%s.md\", normalizeDocumentPathSegment(filePath))\n}\n\n// GetRawSymbolDocumentPath derives the vault document path for a raw symbol snapshot.\nfunc GetRawSymbolDocumentPath(symbol models.SymbolNode) string {\n\tfileSlug := SlugifySegment(strings.ReplaceAll(ToPosixPath(symbol.FilePath), \".\", \"-\"))\n\tsymbolSlug := fmt.Sprintf(\"%s--%s-l%d\", SlugifySegment(symbol.Name), fileSlug, symbol.StartLine)\n\treturn fmt.Sprintf(\"raw/codebase/symbols/%s.md\", symbolSlug)\n}\n\n// GetRawDirectoryIndexPath derives the vault document path for a raw directory index.\nfunc GetRawDirectoryIndexPath(directoryPath string) string {\n\tnormalized := normalizeDocumentPathSegment(directoryPath)\n\tif normalized == \"\" || normalized == \".\" {\n\t\treturn \"raw/codebase/indexes/directories/root.md\"\n\t}\n\n\treturn fmt.Sprintf(\"raw/codebase/indexes/directories/%s.md\", normalized)\n}\n\n// GetRawLanguageIndexPath derives the vault document path for a raw language index.\nfunc GetRawLanguageIndexPath(language string) string {\n\treturn fmt.Sprintf(\"raw/codebase/indexes/languages/%s.md\", strings.TrimSpace(language))\n}\n\n// GetWikiConceptPath derives the vault document path for a generated wiki concept article.\nfunc GetWikiConceptPath(articleTitle string) string {\n\tnormalizedTitle := strings.TrimSpace(articleTitle)\n\treturn fmt.Sprintf(\"wiki/codebase/concepts/%s.md\", normalizedTitle)\n}\n\n// GetWikiIndexPath derives the vault document path for a generated wiki index page.\nfunc GetWikiIndexPath(indexTitle string) string {\n\treturn fmt.Sprintf(\"wiki/codebase/index/%s.md\", indexTitle)\n}\n\n// GetTopicIndexPath derives the vault document path for a top-level topic index page.\nfunc GetTopicIndexPath(indexTitle string) string {\n\treturn fmt.Sprintf(\"wiki/index/%s.md\", indexTitle)\n}\n\n// GetBaseFilePath derives the vault document path for an Obsidian Base definition.\nfunc GetBaseFilePath(baseName string) string {\n```"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:39:17.555531Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":""}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:39:17.55577Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":"The slug"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:39:18.012238Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":" helper finding is significant. Let me verify what frontmatter fields source wiki/research docs actually carry (to test"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:39:18.552358Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":" the four-field remap contract) and check the OKF scaffold reserved-file concern."}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:39:18.596229Z","turn":1,"type":"session.update","update":{"kind":"tool_call_started","tool_call_id":"toolu_016yACeuqun9h1yaxmmFRnG5","tool_call_state":"pending","blocks":[{"type":"tool_use","id":"toolu_016yACeuqun9h1yaxmmFRnG5","name":"Bash","title":"Terminal","raw_input":{}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:39:21.605297Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_016yACeuqun9h1yaxmmFRnG5","blocks":[{"type":"tool_use","id":"toolu_016yACeuqun9h1yaxmmFRnG5","name":"Bash","title":"cd /Users/pedronauck/Dev/compozy/kb; echo \"=== buildFrontmatter fields (ingest) ===\"; sed -n '150,230p' internal/ingest/ingest.go 2\u003e/dev/null; echo \"=== grep frontmatter keys used: title/description/timestamp/stage/type/created/date ===\"; grep -rn '\"title\"\\|\"description\"\\|\"timestamp\"\\|\"stage\"\\|\"type\"\\|\"created\"\\|\"date\"\\|\"tags\"\\|\"source_kind\"\\|\"domain\"' internal/ingest/*.go internal/vault/render*.go | grep -iv test | head -40","input":{"command":"cd /Users/pedronauck/Dev/compozy/kb; echo \"=== buildFrontmatter fields (ingest) ===\"; sed -n '150,230p' internal/ingest/ingest.go 2\u003e/dev/null; echo \"=== grep frontmatter keys used: title/description/timestamp/stage/type/created/date ===\"; grep -rn '\"title\"\\|\"description\"\\|\"timestamp\"\\|\"stage\"\\|\"type\"\\|\"created\"\\|\"date\"\\|\"tags\"\\|\"source_kind\"\\|\"domain\"' internal/ingest/*.go internal/vault/render*.go | grep -iv test | head -40"},"raw_input":{"command":"cd /Users/pedronauck/Dev/compozy/kb; echo \"=== buildFrontmatter fields (ingest) ===\"; sed -n '150,230p' internal/ingest/ingest.go 2\u003e/dev/null; echo \"=== grep frontmatter keys used: title/description/timestamp/stage/type/created/date ===\"; grep -rn '\"title\"\\|\"description\"\\|\"timestamp\"\\|\"stage\"\\|\"type\"\\|\"created\"\\|\"date\"\\|\"tags\"\\|\"source_kind\"\\|\"domain\"' internal/ingest/*.go internal/vault/render*.go | grep -iv test | head -40"}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:39:21.655769Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_016yACeuqun9h1yaxmmFRnG5","usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:39:21.658114Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_016yACeuqun9h1yaxmmFRnG5","tool_call_state":"completed","blocks":[{"type":"tool_result","tool_use_id":"toolu_016yACeuqun9h1yaxmmFRnG5","content":"```console\n=== buildFrontmatter fields (ingest) ===\n\t\treturn \"\", \"\", errors.New(\"convert source: converter returned nil result\")\n\t}\n\tif strings.TrimSpace(result.Markdown) == \"\" {\n\t\treturn \"\", \"\", errors.New(\"convert source: markdown output is empty\")\n\t}\n\n\ttitle := deriveTitle(firstNonEmpty(strings.TrimSpace(options.Title), strings.TrimSpace(result.Title)), options.SourcePath, options.SourceURL, options.SourceKind)\n\treturn title, result.Markdown, nil\n}\n\nfunc buildFrontmatter(\n\ttopicInfo models.TopicInfo,\n\toptions Options,\n\ttitle string,\n\tmarkdown string,\n\tscrapedAt time.Time,\n) (map[string]any, error) {\n\tstamp := scrapedAt.Format(frontmatter.DateLayout)\n\tvalues := map[string]any{\n\t\t\"title\": title,\n\t\t\"type\": \"source\",\n\t\t\"stage\": \"raw\",\n\t\t\"domain\": topicInfo.Domain,\n\t\t\"source_kind\": string(options.SourceKind),\n\t\t\"scraped\": stamp,\n\t\t\"tags\": []string{topicInfo.Domain, \"raw\", string(options.SourceKind)},\n\t}\n\n\tif sourceURL := strings.TrimSpace(options.SourceURL); sourceURL != \"\" {\n\t\tvalues[\"source_url\"] = sourceURL\n\t}\n\tif sourcePath := normalizedSourcePath(options.SourcePath); sourcePath != \"\" {\n\t\tvalues[\"source_path\"] = sourcePath\n\t}\n\tif err := mergeExtraFrontmatter(values, options.ExtraFrontmatter); err != nil {\n\t\treturn nil, err\n\t}\n\n\tif options.SourceKind == models.SourceKindBookmarkCluster {\n\t\tsourceURLs := extractSourceURLs(markdown)\n\t\tif len(sourceURLs) == 0 {\n\t\t\treturn nil, errors.New(\"bookmark cluster must contain at least one URL\")\n\t\t}\n\n\t\tvalues[\"status\"] = \"seeded\"\n\t\tvalues[\"created\"] = stamp\n\t\tvalues[\"updated\"] = stamp\n\t\tvalues[\"source_urls\"] = sourceURLs\n\t}\n\n\treturn values, nil\n}\n\nfunc mergeExtraFrontmatter(values map[string]any, extra map[string]any) error {\n\tif len(extra) == 0 {\n\t\treturn nil\n\t}\n\treserved := map[string]struct{}{\n\t\t\"title\": {},\n\t\t\"type\": {},\n\t\t\"stage\": {},\n\t\t\"domain\": {},\n\t\t\"source_kind\": {},\n\t\t\"scraped\": {},\n\t\t\"tags\": {},\n\t\t\"source_url\": {},\n\t\t\"source_path\": {},\n\t}\n\tfor key, value := range extra {\n\t\tcleanKey := strings.TrimSpace(key)\n\t\tif cleanKey == \"\" {\n\t\t\treturn errors.New(\"extra frontmatter key cannot be empty\")\n\t\t}\n\t\tif _, exists := reserved[cleanKey]; exists {\n\t\t\treturn fmt.Errorf(\"extra frontmatter cannot override reserved key %q\", cleanKey)\n\t\t}\n\t\tvalues[cleanKey] = value\n\t}\n\treturn nil\n}\n\n=== grep frontmatter keys used: title/description/timestamp/stage/type/created/date ===\ninternal/ingest/ingest.go:169:\t\t\"title\": title,\ninternal/ingest/ingest.go:170:\t\t\"type\": \"source\",\ninternal/ingest/ingest.go:171:\t\t\"stage\": \"raw\",\ninternal/ingest/ingest.go:172:\t\t\"domain\": topicInfo.Domain,\ninternal/ingest/ingest.go:173:\t\t\"source_kind\": string(options.SourceKind),\ninternal/ingest/ingest.go:175:\t\t\"tags\": []string{topicInfo.Domain, \"raw\", string(options.SourceKind)},\ninternal/ingest/ingest.go:195:\t\tvalues[\"created\"] = stamp\ninternal/ingest/ingest.go:208:\t\t\"title\": {},\ninternal/ingest/ingest.go:209:\t\t\"type\": {},\ninternal/ingest/ingest.go:210:\t\t\"stage\": {},\ninternal/ingest/ingest.go:211:\t\t\"domain\": {},\ninternal/ingest/ingest.go:212:\t\t\"source_kind\": {},\ninternal/ingest/ingest.go:214:\t\t\"tags\": {},\ninternal/vault/render_base.go:247:\t\t\t\"type\": string(view.Type),\ninternal/vault/render_wiki.go:53:\t\t\"created\": topic.Today,\ninternal/vault/render_wiki.go:54:\t\t\"domain\": topic.Domain,\ninternal/vault/render_wiki.go:57:\t\t\"stage\": \"compiled\",\ninternal/vault/render_wiki.go:58:\t\t\"tags\": []string{topic.Domain, \"wiki\", \"codebase\", \"starter\"},\ninternal/vault/render_wiki.go:59:\t\t\"title\": article.Title,\ninternal/vault/render_wiki.go:60:\t\t\"type\": \"wiki\",\ninternal/vault/render_wiki.go:125:\t\t\t\"domain\": topic.Domain,\ninternal/vault/render_wiki.go:126:\t\t\t\"title\": CodebaseDashboardTitle,\ninternal/vault/render_wiki.go:127:\t\t\t\"type\": \"index\",\ninternal/vault/render_wiki.go:161:\t\t\t\"domain\": topic.Domain,\ninternal/vault/render_wiki.go:162:\t\t\t\"title\": CodebaseConceptIndexTitle,\ninternal/vault/render_wiki.go:163:\t\t\t\"type\": \"index\",\ninternal/vault/render_wiki.go:213:\t\t\t\"domain\": topic.Domain,\ninternal/vault/render_wiki.go:214:\t\t\t\"title\": CodebaseSourceIndexTitle,\ninternal/vault/render_wiki.go:215:\t\t\t\"type\": \"index\",\ninternal/vault/render.go:392:\t\t\t\"domain\": topic.Domain,\ninternal/vault/render.go:404:\t\t\t\"source_kind\": \"codebase-file\",\ninternal/vault/render.go:406:\t\t\t\"stage\": \"raw\",\ninternal/vault/render.go:408:\t\t\t\"tags\": []string{topic.Domain, \"raw\", \"codebase\", \"file\", string(file.Language)},\ninternal/vault/render.go:409:\t\t\t\"title\": fmt.Sprintf(\"Codebase File: %s\", file.FilePath),\ninternal/vault/render.go:410:\t\t\t\"type\": \"source\",\ninternal/vault/render.go:426:\t\t\"domain\": topic.Domain,\ninternal/vault/render.go:438:\t\t\"source_kind\": \"codebase-symbol\",\ninternal/vault/render.go:440:\t\t\"stage\": \"raw\",\ninternal/vault/render.go:444:\t\t\"tags\": []string{\ninternal/vault/render.go:452:\t\t\"title\": fmt.Sprintf(\"Codebase Symbol: %s\", symbol.Name),\n```"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:39:32.946638Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":""}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:39:32.947184Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":"Critical: kb-produced wiki/source frontmatter has **no `description` or `timestamp` field**. Let me confirm the scaff"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:39:33.412976Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":"old file set (CLAUDE.md/AGENTS.md) that the OKF checker would walk, to ground the reserved-file finding."}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:39:33.888904Z","turn":1,"type":"session.update","update":{"kind":"tool_call_started","tool_call_id":"toolu_01MjGWC1DU6pVBGDqSnmBTov","tool_call_state":"pending","blocks":[{"type":"tool_use","id":"toolu_01MjGWC1DU6pVBGDqSnmBTov","name":"Bash","title":"Terminal","raw_input":{}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:39:36.259729Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01MjGWC1DU6pVBGDqSnmBTov","blocks":[{"type":"tool_use","id":"toolu_01MjGWC1DU6pVBGDqSnmBTov","name":"Bash","title":"cd /Users/pedronauck/Dev/compozy/kb; echo \"=== scaffold dirs + files written by topic.New ===\"; grep -n \"CLAUDE.md\\|AGENTS.md\\|log.md\\|index.md\\|topic.yaml\\|ensureAgentsSymlink\\|currentTopicDirectories\\|func New\\|func newWithDate\\|writeMetadataFile\\|Symlink\" internal/topic/topic.go | head -40; echo \"=== DateLayout def ===\"; grep -rn \"DateLayout\\s*=\" internal/frontmatter/*.go","input":{"command":"cd /Users/pedronauck/Dev/compozy/kb; echo \"=== scaffold dirs + files written by topic.New ===\"; grep -n \"CLAUDE.md\\|AGENTS.md\\|log.md\\|index.md\\|topic.yaml\\|ensureAgentsSymlink\\|currentTopicDirectories\\|func New\\|func newWithDate\\|writeMetadataFile\\|Symlink\" internal/topic/topic.go | head -40; echo \"=== DateLayout def ===\"; grep -rn \"DateLayout\\s*=\" internal/frontmatter/*.go"},"raw_input":{"command":"cd /Users/pedronauck/Dev/compozy/kb; echo \"=== scaffold dirs + files written by topic.New ===\"; grep -n \"CLAUDE.md\\|AGENTS.md\\|log.md\\|index.md\\|topic.yaml\\|ensureAgentsSymlink\\|currentTopicDirectories\\|func New\\|func newWithDate\\|writeMetadataFile\\|Symlink\" internal/topic/topic.go | head -40; echo \"=== DateLayout def ===\"; grep -rn \"DateLayout\\s*=\" internal/frontmatter/*.go"}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:39:36.308179Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01MjGWC1DU6pVBGDqSnmBTov","usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:39:36.310017Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_01MjGWC1DU6pVBGDqSnmBTov","tool_call_state":"completed","blocks":[{"type":"tool_result","tool_use_id":"toolu_01MjGWC1DU6pVBGDqSnmBTov","content":"```console\n=== scaffold dirs + files written by topic.New ===\n26 matches in 1 files:\n\ninternal/topic/topic.go:27:topicMarkerFile = \"CLAUDE.md\"\ninternal/topic/topic.go:28:topicMetadataFileName = \"topic.yaml\"\ninternal/topic/topic.go:38:currentTopicDirectories = []string{\ninternal/topic/topic.go:78:{assetPath: claudeTemplatePath, outputPath: \"CLAUDE.md\"},\ninternal/topic/topic.go:79:{assetPath: logTemplatePath, outputPath: \"log.md\"},\ninternal/topic/topic.go:114:func New(vaultPath, slug, title, domain string) (models.TopicInfo, error) {\ninternal/topic/topic.go:186:func newWithDate(vaultPath, slug, title, domain string, now time.Time) (model...\ninternal/topic/topic.go:231:if err := writeMetadataFile(topicPath, topicMetadataForRef(topicRef, cleanTit...\ninternal/topic/topic.go:234:if err := ensureAgentsSymlink(topicPath); err != nil {\ninternal/topic/topic.go:235:return models.TopicInfo{}, fmt.Errorf(\"new topic: ensure AGENTS.md symlink: %...\ninternal/topic/topic.go:240:if err := appendScaffoldEntry(filepath.Join(topicPath, \"log.md\"), context); e...\ninternal/topic/topic.go:416:for _, relativePath := range currentTopicDirectories {\ninternal/topic/topic.go:446:// RenderClaudeTemplate renders the default topic CLAUDE.md content for the\ninternal/topic/topic.go:503:// WriteMetadataFile writes the structured topic.yaml metadata file.\ninternal/topic/topic.go:509:return writeMetadataFile(topicPath, topicMetadataForRef(topicRef, title, doma...\ninternal/topic/topic.go:526:func writeMetadataFile(topicPath string, metadata topicMetadataFile) error {\ninternal/topic/topic.go:607:func ensureAgentsSymlink(topicPath string) error {\ninternal/topic/topic.go:608:agentsPath := filepath.Join(topicPath, \"AGENTS.md\")\ninternal/topic/topic.go:610:return fmt.Errorf(\"remove existing AGENTS.md: %w\", err)\ninternal/topic/topic.go:612:if err := os.Symlink(\"CLAUDE.md\", agentsPath); err != nil {\ninternal/topic/topic.go:613:return fmt.Errorf(\"create AGENTS.md symlink: %w\", err)\ninternal/topic/topic.go:620:agentsPath := filepath.Join(topicPath, \"AGENTS.md\")\ninternal/topic/topic.go:627:if err := os.Symlink(\"CLAUDE.md\", agentsPath); err != nil {\ninternal/topic/topic.go:628:return fmt.Errorf(\"create AGENTS.md symlink: %w\", err)\ninternal/topic/topic.go:634:logPath := filepath.Join(topicPath, \"log.md\")\n[+1 more]\n=== DateLayout def ===\ninternal/frontmatter/frontmatter.go:18:\tDateLayout = \"2006-01-02\"\n```"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:40:32.267399Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":""}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:40:32.267872Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":"I have enough verified evidence. Let me confirm the round-1 bloc"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:40:32.595301Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":"ker closures are actually in the spec text (Concept Path Model section + ConceptResult), then write the findings file"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:40:33.068073Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":".\n\nConfirmed via source inspection:\n- **B-001 closed**: spec now has a \"Concept Path Model"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:40:33.537033Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":"\" section, full `ConceptResult` struct, and resolves catalog granularity (flat root).\n- **B-002 closed**: de"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:40:34.005189Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":"terministic wikilink→relative mapping rule now defined.\n- **New issues found**: kb-"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:40:34.481157Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":"produced wiki/source frontmatter carries `title/type/stage/domain/source"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:40:34.961134Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":"_kind/scraped/tags` but **no `description` or `timestamp`** (`internal/ingest/ingest.go:168-"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:40:35.41822Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":"176`, `internal/vault/render_wiki.go:53-60`); `DateLayout = \"2006-01-02\"` (date-only, not IS"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:40:35.888787Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":"O 8601); OKF scaffold writes `CLAUDE.md` + `AGENTS.md` symlink (`internal/topic/topic.go:27,612`"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:40:36.363185Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":") which the §9.2 type rule would hard-error; canonical `vault.SlugifySegment` already exists (`"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:40:36.831952Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":"pathutils.go:98`) but the spec describes a fresh `slug()`."}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:40:36.834068Z","turn":1,"type":"session.update","update":{"kind":"tool_call_started","tool_call_id":"toolu_017fnNMCbetZaupQqCo47Z8L","tool_call_state":"pending","blocks":[{"type":"tool_use","id":"toolu_017fnNMCbetZaupQqCo47Z8L","name":"Edit","title":"Write","raw_input":{}}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:41:39.725977Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_017fnNMCbetZaupQqCo47Z8L","blocks":[{"type":"tool_use","id":"toolu_017fnNMCbetZaupQqCo47Z8L","name":"Edit","title":"Write .compozy/tasks/okf-dual-mode/qa/peer-review-findings-round2.md","input":{"content":"---\nschema_version: 1\nreview_kind: techspec\nround: 2\nreadiness: NEEDS_REWORK\nreviewer_runtime: claude\nreviewer_model: opus\ngenerated_at: 2026-06-27T23:58:00Z\n---\n\n# Summary\n\nBoth round-1 blockers are genuinely closed: the spec now defines a concrete Concept\nPath Model (flat bundle root, `slug(title)` filenames with `-N` collision suffix), a\nfull `ConceptResult` struct, and a deterministic `[[wikilink]]`→relative-markdown\nmapping rule, resolving the PRD catalog-granularity open question. It is held back\nfrom approval because three contracts on the `promote`/`check` critical path are\nstill undefined or self-contradictory: the four-field frontmatter remap has no source\nmapping for `description`/`timestamp` (neither field exists in any `kb`-produced doc),\nthe conformance checker would hard-error on the `CLAUDE.md`/`AGENTS.md` files its own\nOKF scaffold writes, and the filename-vs-link key derivation is asymmetric, silently\nbreaking links to already-promoted concepts.\n\n# Blockers\n\n## B-201 — Four-field frontmatter remap is undefined for `description` and `timestamp`\n\n- Section: `_techspec.md` \"Data Models → OKF frontmatter contract\" (lines 131-135), \"Data Flow → Promote\", \"Testing → Frontmatter remap\"\n- Issue: The contract names four producer fields (`type`, `title`, `description`,\n `timestamp`) but only defines two sources: `type` ← `--type` and (implicitly)\n `title` ← source `title`. There is no source mapping for `description` or\n `timestamp`, and verification against the code shows neither field is ever produced\n by `kb`: every ingest/source/wiki document carries `title, type, stage, domain,\n source_kind, scraped/created, tags` and nothing else (`internal/ingest/ingest.go:168-176`,\n `internal/vault/render_wiki.go:53-60`, `internal/vault/render.go:392-452`). So\n `description` has no source at all, and the spec does not say whether `timestamp`\n maps from `scraped`/`created`, from the promote-time clock, or something else.\n Compounding this, `timestamp` is specified as \"ISO 8601\" but the only available\n source timestamps use `frontmatter.DateLayout = \"2006-01-02\"` (date-only;\n `internal/frontmatter/frontmatter.go:18`), so even a `scraped→timestamp` mapping\n would not satisfy a full ISO-8601/RFC3339 expectation without a defined formatting\n rule.\n- Rationale: This is an interface/contract gap on the primary MVP path — the\n scoped-write contract names \"payloads, types, or schemas waved at instead of\n defined\" as a blocker, and ADR-002 promises `promote` is a \"deterministic,\n testable\" mechanical transform. \"Remaps wiki frontmatter to the OKF contract\" is\n not implementable as written when two of the four target fields have no defined\n origin. It also makes the \"Frontmatter remap\" unit test and the end-to-end\n \"concept written with four fields\" assertion impossible to specify, and renders the\n local-standard \"all four producer fields present\" warning permanently true-fires\n (description always empty) with no design answer.\n- Suggested fix: Add an explicit remap table — source frontmatter key → OKF key — and\n define fallbacks: `title` ← source `title` (fallback to humanized source basename\n when absent), `description` ← a named source field or an explicit \"empty + warning\"\n policy, `timestamp` ← a named source date field (`scraped`/`created`) normalized to\n RFC3339, with the promote-time clock as the documented fallback. Inject the clock as\n a seam so golden/integration tests assert a fixed value instead of freezing a\n wall-clock literal.\n\n## B-202 — OKF conformance checker hard-errors on the scaffold's own `CLAUDE.md`/`AGENTS.md`\n\n- Section: `_techspec.md` \"Data Flow → Scaffold\" (lines 43-45), \"Testing → End-to-end promote\"; `adr-005.md` \"Decision\" / \"Implementation Notes\"\n- Issue: The OKF scaffold writes `topic.yaml`, `index.md`, `log.md`, \"an OKF-flavored\n `CLAUDE.md`, and the `AGENTS.md` symlink.\" `CLAUDE.md` and `AGENTS.md` are `.md`\n files with no `type` frontmatter (`internal/topic/topic.go:27` `topicMarkerFile =\n \"CLAUDE.md\"`; `:607-613` `AGENTS.md` is a symlink to `CLAUDE.md`). The checker\n \"recursively walks the bundle; for every non-reserved `.md` … require a non-empty\n `type` (§9.2)\" and ADR-005 declares only `index.md`/`log.md` reserved. Therefore a\n freshly scaffolded bundle fails `kb okf check` with hard errors on `CLAUDE.md` and\n `AGENTS.md` — directly contradicting the end-to-end integration test (\"scaffold an\n OKF topic … `kb okf check` passes\") and the PRD success metric \"the operator's\n catalog passes the check.\" The same gap likely affects the vendored official\n bundles (GA4/Stack Overflow/Bitcoin) if any carries a `README.md` or other\n non-concept `.md`, which would break the headline \"conformance passes on all three\n official sample bundles\" criterion.\n- Rationale: This is a designed-in conformance contradiction (scaffold output vs.\n checker rule) and a missing-verification surface: the spec's own success criteria\n cannot pass as specified. The reserved/excluded-file set is a checker contract that\n is currently incomplete.\n- Suggested fix: Define the full reserved/excluded set the walker skips —\n `index.md`, `log.md`, `CLAUDE.md`, `AGENTS.md` (and skip symlinks so `AGENTS.md`\n is not followed into `CLAUDE.md`), plus whatever the official bundles legitimately\n carry (`README.md`, license/attribution files) — or scope the `type` requirement to\n concept files only. State the rule explicitly and add a unit case asserting these\n files are not flagged, plus the integration assertion that a scaffolded-then-promoted\n bundle passes `kb okf check`.\n\n## B-203 — Concept filename and inbound-link key derive from different inputs (silent broken links to promoted concepts)\n\n- Section: `_techspec.md` \"Concept Path Model\" (lines 149-160)\n- Issue: A concept's filename is `slug(title)` (keyed on the source doc's frontmatter\n `title`), but an inbound cross-concept link's target is computed as\n `slug(base(docPath))` (keyed on the wikilink's path basename). These coincide only\n when `base(docPath) == title`. For codebase-rendered wiki concepts that holds\n (`GetWikiConceptPath` embeds the title verbatim as the basename,\n `internal/vault/pathutils.go:219-222`), but for ingested/authored research docs the\n basename is a slugified source path/URL (`GetRawFileDocumentPath`/\n `GetRawSymbolDocumentPath`, `pathutils.go:192-200`) or an arbitrary Obsidian\n note name that need not equal the frontmatter `title`. When they differ, a body\n link to an *already-promoted* concept resolves to a phantom `slug(base(path)).md`\n while the real file is `slug(title).md` — and the spec records this in\n `UnresolvedLinks` as \"no promoted counterpart exists yet,\" which is false. The same\n asymmetry makes `index.md` (links keyed on each concept's own `title`) and body\n cross-links (keyed on `base(path)`) point at two different addresses for one\n concept.\n- Rationale: This defeats the determinism that round-1 B-002's fix was supposed to\n deliver and undermines the PRD success metric \"links resolved.\" It is a correctness\n gap on the headline `promote` guarantee, not merely a tolerated-broken-link case.\n- Suggested fix: Derive both the filename and the link target from a single canonical\n key. Simplest deletable option: key the concept filename off `slug(base(sourceDocPath))`\n (the same input links use) so a target's would-be path always equals its eventual\n filename; or, if title-named files are required, build the bundle's path→file\n resolution from the maintained `index.md`/a promote manifest and resolve links\n against it. State which, and add a unit case that promotes two linked concepts and\n asserts the inbound link resolves to the real file (not a phantom).\n\n# Nits\n\n## N-201 — Re-implements slugging instead of reusing `vault.SlugifySegment`\n\n- Section: `_techspec.md` \"Concept Path Model\" (`slug(title)`)\n- Issue: A canonical slug primitive already exists (`vault.SlugifySegment`, `internal/vault/pathutils.go:98-127`: lowercase, `[a-z0-9]`, single-dash, `\"item\"` empty fallback); the spec describes a fresh `slug()` (\"lowercase, ASCII, kebab-case\") without naming it, and `internal/okf` may legally import `vault` per ADR-003.\n- Suggested fix: Specify reuse of `vault.SlugifySegment` for filename and link derivation, and note its `\"item\"` empty fallback and accent-dropping behavior (e.g. `Conteúdo`→`conte-do`) so the operator's Portuguese catalog names are accounted for.\n\n## N-202 — `promote --to` does not validate the target is `mode: okf`\n\n- Section: `_techspec.md` \"Concept Path Model → `--to`\" (line 160), \"Core Interfaces\" (`PromoteInput.TargetTopic`)\n- Issue: `LinkFormatterFor(topic)` returns `WikiLinkFormatter` for any non-okf topic, so promoting into a wiki-mode `--to` target would silently emit `[[wikilinks]]` into an OKF concept; the spec assumes an OKF target but states no guard.\n- Suggested fix: Require `promote` to error when `--to` resolves to a non-`okf` topic, and add a unit/integration case asserting that rejection.\n\n## N-203 — Wikilink `#anchor` fragment handling unspecified in the mapping rule\n\n- Section: `_techspec.md` \"Concept Path Model → Wikilink target mapping\"; \"Testing → Wikilink→markdown body transform\" (`[[a#anchor]]`)\n- Issue: A `[[a#anchor]]` test case is listed, but the strip→`base`→`slug` rule never says whether the `#anchor` fragment is preserved as `[label](slug.md#anchor)` or dropped.\n- Suggested fix: State that the fragment is carried through to the emitted relative link and add the asserted-output case.\n\n## N-204 — `TopicMode = string` is a type alias, not a defined type\n\n- Section: `_techspec.md` \"Data Models\" (lines 113-118); `adr-003.md` \"typed constants\"\n- Issue: `type TopicMode = string` is an alias with zero type distinction, contradicting ADR-003's \"typed constants\" and giving the scaffold/normalization no compiler help for exhaustiveness.\n- Suggested fix: Use `type TopicMode string` (defined type) or justify the alias explicitly; keep the `topicMetadataFile.Mode` field's type consistent with it.\n\n## N-205 — Observability omits the new failure/skip events\n\n- Section: `_techspec.md` \"Monitoring and Observability\"\n- Issue: The logged events cover transform/broken-link counts but not \"promote rejected: target not okf\" (N-202) or \"reserved file skipped\" (B-202), which are the new actionable failure surfaces.\n- Suggested fix: Add those events to the `promote`/`okf check` debug/info logging list.\n\n# Evidence\n\n- Read in full: `_techspec.md`, `_prd.md`, `adrs/adr-001.md`..`adr-006.md`,\n `qa/peer-review-findings-round1.md`, `qa/peer-review-incorporation-round1.md`,\n `CLAUDE.md`, `AGENTS.md`, `CONTRIBUTING.md`.\n- Verified against source: `internal/vault/pathutils.go:98-127` (`SlugifySegment`),\n `:192-200` (`GetRawFileDocumentPath`/`GetRawSymbolDocumentPath`), `:219-222`\n (`GetWikiConceptPath` embeds title verbatim, no slug), `:245-252` (`ToTopicWikiLink`\n emits `[[slug/path|label]]`); `internal/frontmatter/frontmatter.go:18` (`DateLayout`\n = `2006-01-02`), `:71` (`Parse`), `:102-118` (`Generate`), `:390-410`\n (`buildMappingNode` alphabetical `sort.Strings`, confirmed line 396);\n `internal/ingest/ingest.go:160-205` (`buildFrontmatter` field set — no\n `description`/`timestamp`); `internal/vault/render_wiki.go:53-60` and\n `internal/vault/render.go:392-452` (wiki/source frontmatter field sets);\n `internal/topic/topic.go:27` (`topicMarkerFile = \"CLAUDE.md\"`), `:78-79`\n (scaffold writes `CLAUDE.md`/`log.md`), `:114`/`:186` (`New`/`newWithDate`\n signatures), `:607-628` (`AGENTS.md` symlink to `CLAUDE.md`).\n- Confirmed round-1 closures in the current spec text: B-001 — \"Concept Path Model\"\n section (lines 144-165), `ConceptResult` struct (lines 89-95), `PromoteInput`\n (lines 83-87), catalog-granularity resolved (lines 162-165); B-002 — deterministic\n `[[wikilink]]`→relative mapping (lines 153-159). Incorporated nits N-001..N-006\n verified present (smoke test line 221-223; field-order note lines 137-140; empty\n vocabulary lines 124-128; `mode` normalization lines 120/246-248; `New`/`newWithDate`\n mode parameter lines 257-259; index.md `okf_version` preservation lines 49-53).\n- Limitation: the OKF v0.1 `SPEC.md` and the vendored sample bundles are not present\n in this repo, so the §9.2 reserved-file exemptions (B-202) and the exact ISO-8601\n shape OKF expects for `timestamp` (B-201) are reasoned from the ADRs, not verified\n against the upstream spec/bundles.\n\n# Deferred Or Follow-Up\n\n- B-201/B-202/B-203 are independent contract gaps but share a theme: the `promote`\n output and the `okf check` input contracts are under-specified at the field/file\n level. Resolving them needs a remap table, a reserved-file set, and a single\n filename/link key — all spec-text edits, not architecture changes.\n- Re-confirm at implementation time (against the vendored bundles) whether official\n OKF bundles carry non-concept `.md` files that the reserved/excluded set must cover\n (B-202) and the precise `timestamp` format the samples use (B-201).","file_path":"/Users/pedronauck/Dev/compozy/kb/.compozy/tasks/okf-dual-mode/qa/peer-review-findings-round2.md"},"raw_input":{"content":"---\nschema_version: 1\nreview_kind: techspec\nround: 2\nreadiness: NEEDS_REWORK\nreviewer_runtime: claude\nreviewer_model: opus\ngenerated_at: 2026-06-27T23:58:00Z\n---\n\n# Summary\n\nBoth round-1 blockers are genuinely closed: the spec now defines a concrete Concept\nPath Model (flat bundle root, `slug(title)` filenames with `-N` collision suffix), a\nfull `ConceptResult` struct, and a deterministic `[[wikilink]]`→relative-markdown\nmapping rule, resolving the PRD catalog-granularity open question. It is held back\nfrom approval because three contracts on the `promote`/`check` critical path are\nstill undefined or self-contradictory: the four-field frontmatter remap has no source\nmapping for `description`/`timestamp` (neither field exists in any `kb`-produced doc),\nthe conformance checker would hard-error on the `CLAUDE.md`/`AGENTS.md` files its own\nOKF scaffold writes, and the filename-vs-link key derivation is asymmetric, silently\nbreaking links to already-promoted concepts.\n\n# Blockers\n\n## B-201 — Four-field frontmatter remap is undefined for `description` and `timestamp`\n\n- Section: `_techspec.md` \"Data Models → OKF frontmatter contract\" (lines 131-135), \"Data Flow → Promote\", \"Testing → Frontmatter remap\"\n- Issue: The contract names four producer fields (`type`, `title`, `description`,\n `timestamp`) but only defines two sources: `type` ← `--type` and (implicitly)\n `title` ← source `title`. There is no source mapping for `description` or\n `timestamp`, and verification against the code shows neither field is ever produced\n by `kb`: every ingest/source/wiki document carries `title, type, stage, domain,\n source_kind, scraped/created, tags` and nothing else (`internal/ingest/ingest.go:168-176`,\n `internal/vault/render_wiki.go:53-60`, `internal/vault/render.go:392-452`). So\n `description` has no source at all, and the spec does not say whether `timestamp`\n maps from `scraped`/`created`, from the promote-time clock, or something else.\n Compounding this, `timestamp` is specified as \"ISO 8601\" but the only available\n source timestamps use `frontmatter.DateLayout = \"2006-01-02\"` (date-only;\n `internal/frontmatter/frontmatter.go:18`), so even a `scraped→timestamp` mapping\n would not satisfy a full ISO-8601/RFC3339 expectation without a defined formatting\n rule.\n- Rationale: This is an interface/contract gap on the primary MVP path — the\n scoped-write contract names \"payloads, types, or schemas waved at instead of\n defined\" as a blocker, and ADR-002 promises `promote` is a \"deterministic,\n testable\" mechanical transform. \"Remaps wiki frontmatter to the OKF contract\" is\n not implementable as written when two of the four target fields have no defined\n origin. It also makes the \"Frontmatter remap\" unit test and the end-to-end\n \"concept written with four fields\" assertion impossible to specify, and renders the\n local-standard \"all four producer fields present\" warning permanently true-fires\n (description always empty) with no design answer.\n- Suggested fix: Add an explicit remap table — source frontmatter key → OKF key — and\n define fallbacks: `title` ← source `title` (fallback to humanized source basename\n when absent), `description` ← a named source field or an explicit \"empty + warning\"\n policy, `timestamp` ← a named source date field (`scraped`/`created`) normalized to\n RFC3339, with the promote-time clock as the documented fallback. Inject the clock as\n a seam so golden/integration tests assert a fixed value instead of freezing a\n wall-clock literal.\n\n## B-202 — OKF conformance checker hard-errors on the scaffold's own `CLAUDE.md`/`AGENTS.md`\n\n- Section: `_techspec.md` \"Data Flow → Scaffold\" (lines 43-45), \"Testing → End-to-end promote\"; `adr-005.md` \"Decision\" / \"Implementation Notes\"\n- Issue: The OKF scaffold writes `topic.yaml`, `index.md`, `log.md`, \"an OKF-flavored\n `CLAUDE.md`, and the `AGENTS.md` symlink.\" `CLAUDE.md` and `AGENTS.md` are `.md`\n files with no `type` frontmatter (`internal/topic/topic.go:27` `topicMarkerFile =\n \"CLAUDE.md\"`; `:607-613` `AGENTS.md` is a symlink to `CLAUDE.md`). The checker\n \"recursively walks the bundle; for every non-reserved `.md` … require a non-empty\n `type` (§9.2)\" and ADR-005 declares only `index.md`/`log.md` reserved. Therefore a\n freshly scaffolded bundle fails `kb okf check` with hard errors on `CLAUDE.md` and\n `AGENTS.md` — directly contradicting the end-to-end integration test (\"scaffold an\n OKF topic … `kb okf check` passes\") and the PRD success metric \"the operator's\n catalog passes the check.\" The same gap likely affects the vendored official\n bundles (GA4/Stack Overflow/Bitcoin) if any carries a `README.md` or other\n non-concept `.md`, which would break the headline \"conformance passes on all three\n official sample bundles\" criterion.\n- Rationale: This is a designed-in conformance contradiction (scaffold output vs.\n checker rule) and a missing-verification surface: the spec's own success criteria\n cannot pass as specified. The reserved/excluded-file set is a checker contract that\n is currently incomplete.\n- Suggested fix: Define the full reserved/excluded set the walker skips —\n `index.md`, `log.md`, `CLAUDE.md`, `AGENTS.md` (and skip symlinks so `AGENTS.md`\n is not followed into `CLAUDE.md`), plus whatever the official bundles legitimately\n carry (`README.md`, license/attribution files) — or scope the `type` requirement to\n concept files only. State the rule explicitly and add a unit case asserting these\n files are not flagged, plus the integration assertion that a scaffolded-then-promoted\n bundle passes `kb okf check`.\n\n## B-203 — Concept filename and inbound-link key derive from different inputs (silent broken links to promoted concepts)\n\n- Section: `_techspec.md` \"Concept Path Model\" (lines 149-160)\n- Issue: A concept's filename is `slug(title)` (keyed on the source doc's frontmatter\n `title`), but an inbound cross-concept link's target is computed as\n `slug(base(docPath))` (keyed on the wikilink's path basename). These coincide only\n when `base(docPath) == title`. For codebase-rendered wiki concepts that holds\n (`GetWikiConceptPath` embeds the title verbatim as the basename,\n `internal/vault/pathutils.go:219-222`), but for ingested/authored research docs the\n basename is a slugified source path/URL (`GetRawFileDocumentPath`/\n `GetRawSymbolDocumentPath`, `pathutils.go:192-200`) or an arbitrary Obsidian\n note name that need not equal the frontmatter `title`. When they differ, a body\n link to an *already-promoted* concept resolves to a phantom `slug(base(path)).md`\n while the real file is `slug(title).md` — and the spec records this in\n `UnresolvedLinks` as \"no promoted counterpart exists yet,\" which is false. The same\n asymmetry makes `index.md` (links keyed on each concept's own `title`) and body\n cross-links (keyed on `base(path)`) point at two different addresses for one\n concept.\n- Rationale: This defeats the determinism that round-1 B-002's fix was supposed to\n deliver and undermines the PRD success metric \"links resolved.\" It is a correctness\n gap on the headline `promote` guarantee, not merely a tolerated-broken-link case.\n- Suggested fix: Derive both the filename and the link target from a single canonical\n key. Simplest deletable option: key the concept filename off `slug(base(sourceDocPath))`\n (the same input links use) so a target's would-be path always equals its eventual\n filename; or, if title-named files are required, build the bundle's path→file\n resolution from the maintained `index.md`/a promote manifest and resolve links\n against it. State which, and add a unit case that promotes two linked concepts and\n asserts the inbound link resolves to the real file (not a phantom).\n\n# Nits\n\n## N-201 — Re-implements slugging instead of reusing `vault.SlugifySegment`\n\n- Section: `_techspec.md` \"Concept Path Model\" (`slug(title)`)\n- Issue: A canonical slug primitive already exists (`vault.SlugifySegment`, `internal/vault/pathutils.go:98-127`: lowercase, `[a-z0-9]`, single-dash, `\"item\"` empty fallback); the spec describes a fresh `slug()` (\"lowercase, ASCII, kebab-case\") without naming it, and `internal/okf` may legally import `vault` per ADR-003.\n- Suggested fix: Specify reuse of `vault.SlugifySegment` for filename and link derivation, and note its `\"item\"` empty fallback and accent-dropping behavior (e.g. `Conteúdo`→`conte-do`) so the operator's Portuguese catalog names are accounted for.\n\n## N-202 — `promote --to` does not validate the target is `mode: okf`\n\n- Section: `_techspec.md` \"Concept Path Model → `--to`\" (line 160), \"Core Interfaces\" (`PromoteInput.TargetTopic`)\n- Issue: `LinkFormatterFor(topic)` returns `WikiLinkFormatter` for any non-okf topic, so promoting into a wiki-mode `--to` target would silently emit `[[wikilinks]]` into an OKF concept; the spec assumes an OKF target but states no guard.\n- Suggested fix: Require `promote` to error when `--to` resolves to a non-`okf` topic, and add a unit/integration case asserting that rejection.\n\n## N-203 — Wikilink `#anchor` fragment handling unspecified in the mapping rule\n\n- Section: `_techspec.md` \"Concept Path Model → Wikilink target mapping\"; \"Testing → Wikilink→markdown body transform\" (`[[a#anchor]]`)\n- Issue: A `[[a#anchor]]` test case is listed, but the strip→`base`→`slug` rule never says whether the `#anchor` fragment is preserved as `[label](slug.md#anchor)` or dropped.\n- Suggested fix: State that the fragment is carried through to the emitted relative link and add the asserted-output case.\n\n## N-204 — `TopicMode = string` is a type alias, not a defined type\n\n- Section: `_techspec.md` \"Data Models\" (lines 113-118); `adr-003.md` \"typed constants\"\n- Issue: `type TopicMode = string` is an alias with zero type distinction, contradicting ADR-003's \"typed constants\" and giving the scaffold/normalization no compiler help for exhaustiveness.\n- Suggested fix: Use `type TopicMode string` (defined type) or justify the alias explicitly; keep the `topicMetadataFile.Mode` field's type consistent with it.\n\n## N-205 — Observability omits the new failure/skip events\n\n- Section: `_techspec.md` \"Monitoring and Observability\"\n- Issue: The logged events cover transform/broken-link counts but not \"promote rejected: target not okf\" (N-202) or \"reserved file skipped\" (B-202), which are the new actionable failure surfaces.\n- Suggested fix: Add those events to the `promote`/`okf check` debug/info logging list.\n\n# Evidence\n\n- Read in full: `_techspec.md`, `_prd.md`, `adrs/adr-001.md`..`adr-006.md`,\n `qa/peer-review-findings-round1.md`, `qa/peer-review-incorporation-round1.md`,\n `CLAUDE.md`, `AGENTS.md`, `CONTRIBUTING.md`.\n- Verified against source: `internal/vault/pathutils.go:98-127` (`SlugifySegment`),\n `:192-200` (`GetRawFileDocumentPath`/`GetRawSymbolDocumentPath`), `:219-222`\n (`GetWikiConceptPath` embeds title verbatim, no slug), `:245-252` (`ToTopicWikiLink`\n emits `[[slug/path|label]]`); `internal/frontmatter/frontmatter.go:18` (`DateLayout`\n = `2006-01-02`), `:71` (`Parse`), `:102-118` (`Generate`), `:390-410`\n (`buildMappingNode` alphabetical `sort.Strings`, confirmed line 396);\n `internal/ingest/ingest.go:160-205` (`buildFrontmatter` field set — no\n `description`/`timestamp`); `internal/vault/render_wiki.go:53-60` and\n `internal/vault/render.go:392-452` (wiki/source frontmatter field sets);\n `internal/topic/topic.go:27` (`topicMarkerFile = \"CLAUDE.md\"`), `:78-79`\n (scaffold writes `CLAUDE.md`/`log.md`), `:114`/`:186` (`New`/`newWithDate`\n signatures), `:607-628` (`AGENTS.md` symlink to `CLAUDE.md`).\n- Confirmed round-1 closures in the current spec text: B-001 — \"Concept Path Model\"\n section (lines 144-165), `ConceptResult` struct (lines 89-95), `PromoteInput`\n (lines 83-87), catalog-granularity resolved (lines 162-165); B-002 — deterministic\n `[[wikilink]]`→relative mapping (lines 153-159). Incorporated nits N-001..N-006\n verified present (smoke test line 221-223; field-order note lines 137-140; empty\n vocabulary lines 124-128; `mode` normalization lines 120/246-248; `New`/`newWithDate`\n mode parameter lines 257-259; index.md `okf_version` preservation lines 49-53).\n- Limitation: the OKF v0.1 `SPEC.md` and the vendored sample bundles are not present\n in this repo, so the §9.2 reserved-file exemptions (B-202) and the exact ISO-8601\n shape OKF expects for `timestamp` (B-201) are reasoned from the ADRs, not verified\n against the upstream spec/bundles.\n\n# Deferred Or Follow-Up\n\n- B-201/B-202/B-203 are independent contract gaps but share a theme: the `promote`\n output and the `okf check` input contracts are under-specified at the field/file\n level. Resolving them needs a remap table, a reserved-file set, and a single\n filename/link key — all spec-text edits, not architecture changes.\n- Re-confirm at implementation time (against the vendored bundles) whether official\n OKF bundles carry non-concept `.md` files that the reserved/excluded set must cover\n (B-202) and the precise `timestamp` format the samples use (B-201).\n","file_path":"/Users/pedronauck/Dev/compozy/kb/.compozy/tasks/okf-dual-mode/qa/peer-review-findings-round2.md"}},{"type":"diff","file_path":"/Users/pedronauck/Dev/compozy/kb/.compozy/tasks/okf-dual-mode/qa/peer-review-findings-round2.md","diff":"+++ /Users/pedronauck/Dev/compozy/kb/.compozy/tasks/okf-dual-mode/qa/peer-review-findings-round2.md\n---\nschema_version: 1\nreview_kind: techspec\nround: 2\nreadiness: NEEDS_REWORK\nreviewer_runtime: claude\nreviewer_model: opus\ngenerated_at: 2026-06-27T23:58:00Z\n---\n\n# Summary\n\nBoth round-1 blockers are genuinely closed: the spec now defines a concrete Concept\nPath Model (flat bundle root, `slug(title)` filenames with `-N` collision suffix), a\nfull `ConceptResult` struct, and a deterministic `[[wikilink]]`→relative-markdown\nmapping rule, resolving the PRD catalog-granularity open question. It is held back\nfrom approval because three contracts on the `promote`/`check` critical path are\nstill undefined or self-contradictory: the four-field frontmatter remap has no source\nmapping for `description`/`timestamp` (neither field exists in any `kb`-produced doc),\nthe conformance checker would hard-error on the `CLAUDE.md`/`AGENTS.md` files its own\nOKF scaffold writes, and the filename-vs-link key derivation is asymmetric, silently\nbreaking links to already-promoted concepts.\n\n# Blockers\n\n## B-201 — Four-field frontmatter remap is undefined for `description` and `timestamp`\n\n- Section: `_techspec.md` \"Data Models → OKF frontmatter contract\" (lines 131-135), \"Data Flow → Promote\", \"Testing → Frontmatter remap\"\n- Issue: The contract names four producer fields (`type`, `title`, `description`,\n `timestamp`) but only defines two sources: `type` ← `--type` and (implicitly)\n `title` ← source `title`. There is no source mapping for `description` or\n `timestamp`, and verification against the code shows neither field is ever produced\n by `kb`: every ingest/source/wiki document carries `title, type, stage, domain,\n source_kind, scraped/created, tags` and nothing else (`internal/ingest/ingest.go:168-176`,\n `internal/vault/render_wiki.go:53-60`, `internal/vault/render.go:392-452`). So\n `description` has no source at all, and the spec does not say whether `timestamp`\n maps from `scraped`/`created`, from the promote-time clock, or something else.\n Compounding this, `timestamp` is specified as \"ISO 8601\" but the only available\n source timestamps use `frontmatter.DateLayout = \"2006-01-02\"` (date-only;\n `internal/frontmatter/frontmatter.go:18`), so even a `scraped→timestamp` mapping\n would not satisfy a full ISO-8601/RFC3339 expectation without a defined formatting\n rule.\n- Rationale: This is an interface/contract gap on the primary MVP path — the\n scoped-write contract names \"payloads, types, or schemas waved at instead of\n defined\" as a blocker, and ADR-002 promises `promote` is a \"deterministic,\n testable\" mechanical transform. \"Remaps wiki frontmatter to the OKF contract\" is\n not implementable as written when two of the four target fields have no defined\n origin. It also makes the \"Frontmatter remap\" unit test and the end-to-end\n \"concept written with four fields\" assertion impossible to specify, and renders the\n local-standard \"all four producer fields present\" warning permanently true-fires\n (description always empty) with no design answer.\n- Suggested fix: Add an explicit remap table — source frontmatter key → OKF key — and\n define fallbacks: `title` ← source `title` (fallback to humanized source basename\n when absent), `description` ← a named source field or an explicit \"empty + warning\"\n policy, `timestamp` ← a named source date field (`scraped`/`created`) normalized to\n RFC3339, with the promote-time clock as the documented fallback. Inject the clock as\n a seam so golden/integration tests assert a fixed value instead of freezing a\n wall-clock literal.\n\n## B-202 — OKF conformance checker hard-errors on the scaffold's own `CLAUDE.md`/`AGENTS.md`\n\n- Section: `_techspec.md` \"Data Flow → Scaffold\" (lines 43-45), \"Testing → End-to-end promote\"; `adr-005.md` \"Decision\" / \"Implementation Notes\"\n- Issue: The OKF scaffold writes `topic.yaml`, `index.md`, `log.md`, \"an OKF-flavored\n `CLAUDE.md`, and the `AGENTS.md` symlink.\" `CLAUDE.md` and `AGENTS.md` are `.md`\n files with no `type` frontmatter (`internal/topic/topic.go:27` `topicMarkerFile =\n \"CLAUDE.md\"`; `:607-613` `AGENTS.md` is a symlink to `CLAUDE.md`). The checker\n \"recursively walks the bundle; for every non-reserved `.md` … require a non-empty\n `type` (§9.2)\" and ADR-005 declares only `index.md`/`log.md` reserved. Therefore a\n freshly scaffolded bundle fails `kb okf check` with hard errors on `CLAUDE.md` and\n `AGENTS.md` — directly contradicting the end-to-end integration test (\"scaffold an\n OKF topic … `kb okf check` passes\") and the PRD success metric \"the operator's\n catalog passes the check.\" The same gap likely affects the vendored official\n bundles (GA4/Stack Overflow/Bitcoin) if any carries a `README.md` or other\n non-concept `.md`, which would break the headline \"conformance passes on all three\n official sample bundles\" criterion.\n- Rationale: This is a designed-in conformance contradiction (scaffold output vs.\n checker rule) and a missing-verification surface: the spec's own success criteria\n cannot pass as specified. The reserved/excluded-file set is a checker contract that\n is currently incomplete.\n- Suggested fix: Define the full reserved/excluded set the walker skips —\n `index.md`, `log.md`, `CLAUDE.md`, `AGENTS.md` (and skip symlinks so `AGENTS.md`\n is not followed into `CLAUDE.md`), plus whatever the official bundles legitimately\n carry (`README.md`, license/attribution files) — or scope the `type` requirement to\n concept files only. State the rule explicitly and add a unit case asserting these\n files are not flagged, plus the integration assertion that a scaffolded-then-promoted\n bundle passes `kb okf check`.\n\n## B-203 — Concept filename and inbound-link key derive from different inputs (silent broken links to promoted concepts)\n\n- Section: `_techspec.md` \"Concept Path Model\" (lines 149-160)\n- Issue: A concept's filename is `slug(title)` (keyed on the source doc's frontmatter\n `title`), but an inbound cross-concept link's target is computed as\n `slug(base(docPath))` (keyed on the wikilink's path basename). These coincide only\n when `base(docPath) == title`. For codebase-rendered wiki concepts that holds\n (`GetWikiConceptPath` embeds the title verbatim as the basename,\n `internal/vault/pathutils.go:219-222`), but for ingested/authored research docs the\n basename is a slugified source path/URL (`GetRawFileDocumentPath`/\n `GetRawSymbolDocumentPath`, `pathutils.go:192-200`) or an arbitrary Obsidian\n note name that need not equal the frontmatter `title`. When they differ, a body\n link to an *already-promoted* concept resolves to a phantom `slug(base(path)).md`\n while the real file is `slug(title).md` — and the spec records this in\n `UnresolvedLinks` as \"no promoted counterpart exists yet,\" which is false. The same\n asymmetry makes `index.md` (links keyed on each concept's own `title`) and body\n cross-links (keyed on `base(path)`) point at two different addresses for one\n concept.\n- Rationale: This defeats the determinism that round-1 B-002's fix was supposed to\n deliver and undermines the PRD success metric \"links resolved.\" It is a correctness\n gap on the headline `promote` guarantee, not merely a tolerated-broken-link case.\n- Suggested fix: Derive both the filename and the link target from a single canonical\n key. Simplest deletable option: key the concept filename off `slug(base(sourceDocPath))`\n (the same input links use) so a target's would-be path always equals its eventual\n filename; or, if title-named files are required, build the bundle's path→file\n resolution from the maintained `index.md`/a promote manifest and resolve links\n against it. State which, and add a unit case that promotes two linked concepts and\n asserts the inbound link resolves to the real file (not a phantom).\n\n# Nits\n\n## N-201 — Re-implements slugging instead of reusing `vault.SlugifySegment`\n\n- Section: `_techspec.md` \"Concept Path Model\" (`slug(title)`)\n- Issue: A canonical slug primitive already exists (`vault.SlugifySegment`, `internal/vault/pathutils.go:98-127`: lowercase, `[a-z0-9]`, single-dash, `\"item\"` empty fallback); the spec describes a fresh `slug()` (\"lowercase, ASCII, kebab-case\") without naming it, and `internal/okf` may legally import `vault` per ADR-003.\n- Suggested fix: Specify reuse of `vault.SlugifySegment` for filename and link derivation, and note its `\"item\"` empty fallback and accent-dropping behavior (e.g. `Conteúdo`→`conte-do`) so the operator's Portuguese catalog names are accounted for.\n\n## N-202 — `promote --to` does not validate the target is `mode: okf`\n\n- Section: `_techspec.md` \"Concept Path Model → `--to`\" (line 160), \"Core Interfaces\" (`PromoteInput.TargetTopic`)\n- Issue: `LinkFormatterFor(topic)` returns `WikiLinkFormatter` for any non-okf topic, so promoting into a wiki-mode `--to` target would silently emit `[[wikilinks]]` into an OKF concept; the spec assumes an OKF target but states no guard.\n- Suggested fix: Require `promote` to error when `--to` resolves to a non-`okf` topic, and add a unit/integration case asserting that rejection.\n\n## N-203 — Wikilink `#anchor` fragment handling unspecified in the mapping rule\n\n- Section: `_techspec.md` \"Concept Path Model → Wikilink target mapping\"; \"Testing → Wikilink→markdown body transform\" (`[[a#anchor]]`)\n- Issue: A `[[a#anchor]]` test case is listed, but the strip→`base`→`slug` rule never says whether the `#anchor` fragment is preserved as `[label](slug.md#anchor)` or dropped.\n- Suggested fix: State that the fragment is carried through to the emitted relative link and add the asserted-output case.\n\n## N-204 — `TopicMode = string` is a type alias, not a defined type\n\n- Section: `_techspec.md` \"Data Models\" (lines 113-118); `adr-003.md` \"typed constants\"\n- Issue: `type TopicMode = string` is an alias with zero type distinction, contradicting ADR-003's \"typed constants\" and giving the scaffold/normalization no compiler help for exhaustiveness.\n- Suggested fix: Use `type TopicMode string` (defined type) or justify the alias explicitly; keep the `topicMetadataFile.Mode` field's type consistent with it.\n\n## N-205 — Observability omits the new failure/skip events\n\n- Section: `_techspec.md` \"Monitoring and Observability\"\n- Issue: The logged events cover transform/broken-link counts but not \"promote rejected: target not okf\" (N-202) or \"reserved file skipped\" (B-202), which are the new actionable failure surfaces.\n- Suggested fix: Add those events to the `promote`/`okf check` debug/info logging list.\n\n# Evidence\n\n- Read in full: `_techspec.md`, `_prd.md`, `adrs/adr-001.md`..`adr-006.md`,\n `qa/peer-review-findings-round1.md`, `qa/peer-review-incorporation-round1.md`,\n `CLAUDE.md`, `AGENTS.md`, `CONTRIBUTING.md`.\n- Verified against source: `internal/vault/pathutils.go:98-127` (`SlugifySegment`),\n `:192-200` (`GetRawFileDocumentPath`/`GetRawSymbolDocumentPath`), `:219-222`\n (`GetWikiConceptPath` embeds title verbatim, no slug), `:245-252` (`ToTopicWikiLink`\n emits `[[slug/path|label]]`); `internal/frontmatter/frontmatter.go:18` (`DateLayout`\n = `2006-01-02`), `:71` (`Parse`), `:102-118` (`Generate`), `:390-410`\n (`buildMappingNode` alphabetical `sort.Strings`, confirmed line 396);\n `internal/ingest/ingest.go:160-205` (`buildFrontmatter` field set — no\n `description`/`timestamp`); `internal/vault/render_wiki.go:53-60` and\n `internal/vault/render.go:392-452` (wiki/source frontmatter field sets);\n `internal/topic/topic.go:27` (`topicMarkerFile = \"CLAUDE.md\"`), `:78-79`\n (scaffold writes `CLAUDE.md`/`log.md`), `:114`/`:186` (`New`/`newWithDate`\n signatures), `:607-628` (`AGENTS.md` symlink to `CLAUDE.md`).\n- Confirmed round-1 closures in the current spec text: B-001 — \"Concept Path Model\"\n section (lines 144-165), `ConceptResult` struct (lines 89-95), `PromoteInput`\n (lines 83-87), catalog-granularity resolved (lines 162-165); B-002 — deterministic\n `[[wikilink]]`→relative mapping (lines 153-159). Incorporated nits N-001..N-006\n verified present (smoke test line 221-223; field-order note lines 137-140; empty\n vocabulary lines 124-128; `mode` normalization lines 120/246-248; `New`/`newWithDate`\n mode parameter lines 257-259; index.md `okf_version` preservation lines 49-53).\n- Limitation: the OKF v0.1 `SPEC.md` and the vendored sample bundles are not present\n in this repo, so the §9.2 reserved-file exemptions (B-202) and the exact ISO-8601\n shape OKF expects for `timestamp` (B-201) are reasoned from the ADRs, not verified\n against the upstream spec/bundles.\n\n# Deferred Or Follow-Up\n\n- B-201/B-202/B-203 are independent contract gaps but share a theme: the `promote`\n output and the `okf check` input contracts are under-specified at the field/file\n level. Resolving them needs a remap table, a reserved-file set, and a single\n filename/link key — all spec-text edits, not architecture changes.\n- Re-confirm at implementation time (against the vendored bundles) whether official\n OKF bundles carry non-concept `.md` files that the reserved/excluded set must cover\n (B-202) and the precise `timestamp` format the samples use (B-201).\n","new_text":"---\nschema_version: 1\nreview_kind: techspec\nround: 2\nreadiness: NEEDS_REWORK\nreviewer_runtime: claude\nreviewer_model: opus\ngenerated_at: 2026-06-27T23:58:00Z\n---\n\n# Summary\n\nBoth round-1 blockers are genuinely closed: the spec now defines a concrete Concept\nPath Model (flat bundle root, `slug(title)` filenames with `-N` collision suffix), a\nfull `ConceptResult` struct, and a deterministic `[[wikilink]]`→relative-markdown\nmapping rule, resolving the PRD catalog-granularity open question. It is held back\nfrom approval because three contracts on the `promote`/`check` critical path are\nstill undefined or self-contradictory: the four-field frontmatter remap has no source\nmapping for `description`/`timestamp` (neither field exists in any `kb`-produced doc),\nthe conformance checker would hard-error on the `CLAUDE.md`/`AGENTS.md` files its own\nOKF scaffold writes, and the filename-vs-link key derivation is asymmetric, silently\nbreaking links to already-promoted concepts.\n\n# Blockers\n\n## B-201 — Four-field frontmatter remap is undefined for `description` and `timestamp`\n\n- Section: `_techspec.md` \"Data Models → OKF frontmatter contract\" (lines 131-135), \"Data Flow → Promote\", \"Testing → Frontmatter remap\"\n- Issue: The contract names four producer fields (`type`, `title`, `description`,\n `timestamp`) but only defines two sources: `type` ← `--type` and (implicitly)\n `title` ← source `title`. There is no source mapping for `description` or\n `timestamp`, and verification against the code shows neither field is ever produced\n by `kb`: every ingest/source/wiki document carries `title, type, stage, domain,\n source_kind, scraped/created, tags` and nothing else (`internal/ingest/ingest.go:168-176`,\n `internal/vault/render_wiki.go:53-60`, `internal/vault/render.go:392-452`). So\n `description` has no source at all, and the spec does not say whether `timestamp`\n maps from `scraped`/`created`, from the promote-time clock, or something else.\n Compounding this, `timestamp` is specified as \"ISO 8601\" but the only available\n source timestamps use `frontmatter.DateLayout = \"2006-01-02\"` (date-only;\n `internal/frontmatter/frontmatter.go:18`), so even a `scraped→timestamp` mapping\n would not satisfy a full ISO-8601/RFC3339 expectation without a defined formatting\n rule.\n- Rationale: This is an interface/contract gap on the primary MVP path — the\n scoped-write contract names \"payloads, types, or schemas waved at instead of\n defined\" as a blocker, and ADR-002 promises `promote` is a \"deterministic,\n testable\" mechanical transform. \"Remaps wiki frontmatter to the OKF contract\" is\n not implementable as written when two of the four target fields have no defined\n origin. It also makes the \"Frontmatter remap\" unit test and the end-to-end\n \"concept written with four fields\" assertion impossible to specify, and renders the\n local-standard \"all four producer fields present\" warning permanently true-fires\n (description always empty) with no design answer.\n- Suggested fix: Add an explicit remap table — source frontmatter key → OKF key — and\n define fallbacks: `title` ← source `title` (fallback to humanized source basename\n when absent), `description` ← a named source field or an explicit \"empty + warning\"\n policy, `timestamp` ← a named source date field (`scraped`/`created`) normalized to\n RFC3339, with the promote-time clock as the documented fallback. Inject the clock as\n a seam so golden/integration tests assert a fixed value instead of freezing a\n wall-clock literal.\n\n## B-202 — OKF conformance checker hard-errors on the scaffold's own `CLAUDE.md`/`AGENTS.md`\n\n- Section: `_techspec.md` \"Data Flow → Scaffold\" (lines 43-45), \"Testing → End-to-end promote\"; `adr-005.md` \"Decision\" / \"Implementation Notes\"\n- Issue: The OKF scaffold writes `topic.yaml`, `index.md`, `log.md`, \"an OKF-flavored\n `CLAUDE.md`, and the `AGENTS.md` symlink.\" `CLAUDE.md` and `AGENTS.md` are `.md`\n files with no `type` frontmatter (`internal/topic/topic.go:27` `topicMarkerFile =\n \"CLAUDE.md\"`; `:607-613` `AGENTS.md` is a symlink to `CLAUDE.md`). The checker\n \"recursively walks the bundle; for every non-reserved `.md` … require a non-empty\n `type` (§9.2)\" and ADR-005 declares only `index.md`/`log.md` reserved. Therefore a\n freshly scaffolded bundle fails `kb okf check` with hard errors on `CLAUDE.md` and\n `AGENTS.md` — directly contradicting the end-to-end integration test (\"scaffold an\n OKF topic … `kb okf check` passes\") and the PRD success metric \"the operator's\n catalog passes the check.\" The same gap likely affects the vendored official\n bundles (GA4/Stack Overflow/Bitcoin) if any carries a `README.md` or other\n non-concept `.md`, which would break the headline \"conformance passes on all three\n official sample bundles\" criterion.\n- Rationale: This is a designed-in conformance contradiction (scaffold output vs.\n checker rule) and a missing-verification surface: the spec's own success criteria\n cannot pass as specified. The reserved/excluded-file set is a checker contract that\n is currently incomplete.\n- Suggested fix: Define the full reserved/excluded set the walker skips —\n `index.md`, `log.md`, `CLAUDE.md`, `AGENTS.md` (and skip symlinks so `AGENTS.md`\n is not followed into `CLAUDE.md`), plus whatever the official bundles legitimately\n carry (`README.md`, license/attribution files) — or scope the `type` requirement to\n concept files only. State the rule explicitly and add a unit case asserting these\n files are not flagged, plus the integration assertion that a scaffolded-then-promoted\n bundle passes `kb okf check`.\n\n## B-203 — Concept filename and inbound-link key derive from different inputs (silent broken links to promoted concepts)\n\n- Section: `_techspec.md` \"Concept Path Model\" (lines 149-160)\n- Issue: A concept's filename is `slug(title)` (keyed on the source doc's frontmatter\n `title`), but an inbound cross-concept link's target is computed as\n `slug(base(docPath))` (keyed on the wikilink's path basename). These coincide only\n when `base(docPath) == title`. For codebase-rendered wiki concepts that holds\n (`GetWikiConceptPath` embeds the title verbatim as the basename,\n `internal/vault/pathutils.go:219-222`), but for ingested/authored research docs the\n basename is a slugified source path/URL (`GetRawFileDocumentPath`/\n `GetRawSymbolDocumentPath`, `pathutils.go:192-200`) or an arbitrary Obsidian\n note name that need not equal the frontmatter `title`. When they differ, a body\n link to an *already-promoted* concept resolves to a phantom `slug(base(path)).md`\n while the real file is `slug(title).md` — and the spec records this in\n `UnresolvedLinks` as \"no promoted counterpart exists yet,\" which is false. The same\n asymmetry makes `index.md` (links keyed on each concept's own `title`) and body\n cross-links (keyed on `base(path)`) point at two different addresses for one\n concept.\n- Rationale: This defeats the determinism that round-1 B-002's fix was supposed to\n deliver and undermines the PRD success metric \"links resolved.\" It is a correctness\n gap on the headline `promote` guarantee, not merely a tolerated-broken-link case.\n- Suggested fix: Derive both the filename and the link target from a single canonical\n key. Simplest deletable option: key the concept filename off `slug(base(sourceDocPath))`\n (the same input links use) so a target's would-be path always equals its eventual\n filename; or, if title-named files are required, build the bundle's path→file\n resolution from the maintained `index.md`/a promote manifest and resolve links\n against it. State which, and add a unit case that promotes two linked concepts and\n asserts the inbound link resolves to the real file (not a phantom).\n\n# Nits\n\n## N-201 — Re-implements slugging instead of reusing `vault.SlugifySegment`\n\n- Section: `_techspec.md` \"Concept Path Model\" (`slug(title)`)\n- Issue: A canonical slug primitive already exists (`vault.SlugifySegment`, `internal/vault/pathutils.go:98-127`: lowercase, `[a-z0-9]`, single-dash, `\"item\"` empty fallback); the spec describes a fresh `slug()` (\"lowercase, ASCII, kebab-case\") without naming it, and `internal/okf` may legally import `vault` per ADR-003.\n- Suggested fix: Specify reuse of `vault.SlugifySegment` for filename and link derivation, and note its `\"item\"` empty fallback and accent-dropping behavior (e.g. `Conteúdo`→`conte-do`) so the operator's Portuguese catalog names are accounted for.\n\n## N-202 — `promote --to` does not validate the target is `mode: okf`\n\n- Section: `_techspec.md` \"Concept Path Model → `--to`\" (line 160), \"Core Interfaces\" (`PromoteInput.TargetTopic`)\n- Issue: `LinkFormatterFor(topic)` returns `WikiLinkFormatter` for any non-okf topic, so promoting into a wiki-mode `--to` target would silently emit `[[wikilinks]]` into an OKF concept; the spec assumes an OKF target but states no guard.\n- Suggested fix: Require `promote` to error when `--to` resolves to a non-`okf` topic, and add a unit/integration case asserting that rejection.\n\n## N-203 — Wikilink `#anchor` fragment handling unspecified in the mapping rule\n\n- Section: `_techspec.md` \"Concept Path Model → Wikilink target mapping\"; \"Testing → Wikilink→markdown body transform\" (`[[a#anchor]]`)\n- Issue: A `[[a#anchor]]` test case is listed, but the strip→`base`→`slug` rule never says whether the `#anchor` fragment is preserved as `[label](slug.md#anchor)` or dropped.\n- Suggested fix: State that the fragment is carried through to the emitted relative link and add the asserted-output case.\n\n## N-204 — `TopicMode = string` is a type alias, not a defined type\n\n- Section: `_techspec.md` \"Data Models\" (lines 113-118); `adr-003.md` \"typed constants\"\n- Issue: `type TopicMode = string` is an alias with zero type distinction, contradicting ADR-003's \"typed constants\" and giving the scaffold/normalization no compiler help for exhaustiveness.\n- Suggested fix: Use `type TopicMode string` (defined type) or justify the alias explicitly; keep the `topicMetadataFile.Mode` field's type consistent with it.\n\n## N-205 — Observability omits the new failure/skip events\n\n- Section: `_techspec.md` \"Monitoring and Observability\"\n- Issue: The logged events cover transform/broken-link counts but not \"promote rejected: target not okf\" (N-202) or \"reserved file skipped\" (B-202), which are the new actionable failure surfaces.\n- Suggested fix: Add those events to the `promote`/`okf check` debug/info logging list.\n\n# Evidence\n\n- Read in full: `_techspec.md`, `_prd.md`, `adrs/adr-001.md`..`adr-006.md`,\n `qa/peer-review-findings-round1.md`, `qa/peer-review-incorporation-round1.md`,\n `CLAUDE.md`, `AGENTS.md`, `CONTRIBUTING.md`.\n- Verified against source: `internal/vault/pathutils.go:98-127` (`SlugifySegment`),\n `:192-200` (`GetRawFileDocumentPath`/`GetRawSymbolDocumentPath`), `:219-222`\n (`GetWikiConceptPath` embeds title verbatim, no slug), `:245-252` (`ToTopicWikiLink`\n emits `[[slug/path|label]]`); `internal/frontmatter/frontmatter.go:18` (`DateLayout`\n = `2006-01-02`), `:71` (`Parse`), `:102-118` (`Generate`), `:390-410`\n (`buildMappingNode` alphabetical `sort.Strings`, confirmed line 396);\n `internal/ingest/ingest.go:160-205` (`buildFrontmatter` field set — no\n `description`/`timestamp`); `internal/vault/render_wiki.go:53-60` and\n `internal/vault/render.go:392-452` (wiki/source frontmatter field sets);\n `internal/topic/topic.go:27` (`topicMarkerFile = \"CLAUDE.md\"`), `:78-79`\n (scaffold writes `CLAUDE.md`/`log.md`), `:114`/`:186` (`New`/`newWithDate`\n signatures), `:607-628` (`AGENTS.md` symlink to `CLAUDE.md`).\n- Confirmed round-1 closures in the current spec text: B-001 — \"Concept Path Model\"\n section (lines 144-165), `ConceptResult` struct (lines 89-95), `PromoteInput`\n (lines 83-87), catalog-granularity resolved (lines 162-165); B-002 — deterministic\n `[[wikilink]]`→relative mapping (lines 153-159). Incorporated nits N-001..N-006\n verified present (smoke test line 221-223; field-order note lines 137-140; empty\n vocabulary lines 124-128; `mode` normalization lines 120/246-248; `New`/`newWithDate`\n mode parameter lines 257-259; index.md `okf_version` preservation lines 49-53).\n- Limitation: the OKF v0.1 `SPEC.md` and the vendored sample bundles are not present\n in this repo, so the §9.2 reserved-file exemptions (B-202) and the exact ISO-8601\n shape OKF expects for `timestamp` (B-201) are reasoned from the ADRs, not verified\n against the upstream spec/bundles.\n\n# Deferred Or Follow-Up\n\n- B-201/B-202/B-203 are independent contract gaps but share a theme: the `promote`\n output and the `okf check` input contracts are under-specified at the field/file\n level. Resolving them needs a remap table, a reserved-file set, and a single\n filename/link key — all spec-text edits, not architecture changes.\n- Re-confirm at implementation time (against the vendored bundles) whether official\n OKF bundles carry non-concept `.md` files that the reserved/excluded set must cover\n (B-202) and the precise `timestamp` format the samples use (B-201).\n"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:41:39.730472Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_017fnNMCbetZaupQqCo47Z8L","usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:41:39.733799Z","turn":1,"type":"session.update","update":{"kind":"tool_call_updated","tool_call_id":"toolu_017fnNMCbetZaupQqCo47Z8L","tool_call_state":"completed","blocks":[{"type":"tool_result","tool_use_id":"toolu_017fnNMCbetZaupQqCo47Z8L","content":"File created successfully at: /Users/pedronauck/Dev/compozy/kb/.compozy/tasks/okf-dual-mode/qa/peer-review-findings-round2.md (file state is current in your context — no need to Read it back)"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:41:41.609388Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":""}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:41:41.61073Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":"W"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:41:42.170648Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":"rote /Users/pedronauck/Dev/compozy/kb/.compozy/tasks/okf-dual-mode/qa/peer-review-findings-round"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:41:42.220555Z","turn":1,"type":"session.update","update":{"kind":"agent_message_chunk","blocks":[{"type":"text","text":"2.md"}],"usage":{},"status":"running"},"usage":{}} +{"run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","time":"2026-06-27T22:41:42.284396Z","turn":1,"type":"session.update","update":{"usage":{},"status":"completed"},"usage":{}} +{"output":"I'll read all the context files in full before reasoning. Let me start with the spec under review and supporting documents.\n\nNow let me read the ADRs and the round-1 QA files.\n\nNow the remaining ADRs and the QA round-1 files.\n\nNow the round-1 QA files, which are central to round-2 verification.\n\nNow let me read the project rules files and verify the source code claims in the spec against the actual codebase.\n\nNow let me verify the key source-code claims the round-2 spec relies on. I'll inspect the actual code for the new Concept Path Model and wikilink-mapping claims.\n\nLet me examine `GetWikiConceptPath` (how wiki concept paths are derived from titles) — this is the crux of the cross-concept mapping determinism claim.\n\nA canonical slug helper already exists (`vault.SlugifySegment`). Let me see its full body and `GetWikiConceptPath`, to test whether the spec's `slug(title)` reuses it and whether cross-concept link derivation is actually consistent.\n\nThe slug helper finding is significant. Let me verify what frontmatter fields source wiki/research docs actually carry (to test the four-field remap contract) and check the OKF scaffold reserved-file concern.\n\nCritical: kb-produced wiki/source frontmatter has **no `description` or `timestamp` field**. Let me confirm the scaffold file set (CLAUDE.md/AGENTS.md) that the OKF checker would walk, to ground the reserved-file finding.\n\nI have enough verified evidence. Let me confirm the round-1 blocker closures are actually in the spec text (Concept Path Model section + ConceptResult), then write the findings file.\n\nConfirmed via source inspection:\n- **B-001 closed**: spec now has a \"Concept Path Model\" section, full `ConceptResult` struct, and resolves catalog granularity (flat root).\n- **B-002 closed**: deterministic wikilink→relative mapping rule now defined.\n- **New issues found**: kb-produced wiki/source frontmatter carries `title/type/stage/domain/source_kind/scraped/tags` but **no `description` or `timestamp`** (`internal/ingest/ingest.go:168-176`, `internal/vault/render_wiki.go:53-60`); `DateLayout = \"2006-01-02\"` (date-only, not ISO 8601); OKF scaffold writes `CLAUDE.md` + `AGENTS.md` symlink (`internal/topic/topic.go:27,612`) which the §9.2 type rule would hard-error; canonical `vault.SlugifySegment` already exists (`pathutils.go:98`) but the spec describes a fresh `slug()`.\n\nWrote /Users/pedronauck/Dev/compozy/kb/.compozy/tasks/okf-dual-mode/qa/peer-review-findings-round2.md","run_id":"exec-20260627-223538-509407000-28f47269409fb1b1","status":"succeeded","time":"2026-06-27T22:41:42.296552Z","type":"run.succeeded"} diff --git a/.compozy/tasks/okf-dual-mode/qa/peer-review-findings-round1.md b/.compozy/tasks/okf-dual-mode/qa/peer-review-findings-round1.md new file mode 100644 index 0000000..f45d0a7 --- /dev/null +++ b/.compozy/tasks/okf-dual-mode/qa/peer-review-findings-round1.md @@ -0,0 +1,169 @@ +--- +schema_version: 1 +review_kind: techspec +round: 1 +readiness: NEEDS_REWORK +reviewer_runtime: claude +reviewer_model: opus +generated_at: 2026-06-27T22:20:41Z +--- + +# Summary + +The design is sound and well-bounded — the `mode` field is a clean additive, +backward-compatible change; the `internal/okf` package boundary is one-way and +cycle-free; and the conformance posture is correctly reasoned. It is held back +from approval because the MVP's central verb, `promote`, has two undefined +contracts on its critical path: where a promoted concept file lands in the bundle, +and how a `[[wikilink]]` target (a *source-topic* path) is resolved to a relative +path *inside the OKF bundle*. Both must be specified before implementation. + +# Blockers + +## B-001 — `promote` output contract is undefined (write location, filename, `ConceptResult`) + +- Section: `_techspec.md` "Data Flow → Promote", "Core Interfaces" (`PromoteInput`/`Promoter`/`ConceptResult`) +- Issue: `PromoteInput` carries `SourceDocPath`, `TargetTopic`, and `Type` but no + output location. The data flow says promote "writes the new concept via + `frontmatter.Generate` + `vault`" without defining the target path or filename + inside the bundle: is it the source basename, a slug of `title`, or a + `type`-grouped subdirectory? `Promoter.Promote` returns `ConceptResult`, which is + referenced but never defined. The PRD's own open question — "Catalog granularity … + sub-areas map to one OKF topic with nested concept directories, or multiple OKF + topics?" — is explicitly deferred "to TechSpec" and remains unanswered here, yet + it directly determines the concept path model. +- Rationale: This is an interface/contract gap on the primary MVP path. The + scoped-write contract for this review calls out "payloads, types, or schemas waved + at instead of defined" as a blocker, and `CLAUDE.md` requires business logic + (concept placement) to be concretely specified before Cobra wiring. Without a + defined path model, the end-to-end promote integration test ("concept written + with four fields + relative links … `index.md` updated") cannot assert a concrete + location, and `index.md` type-grouping (which must enumerate concept files) has no + defined tree to walk. +- Suggested fix: Define the bundle's internal concept-path model explicitly: the + concept filename derivation (recommend deterministic slug of `title`, collision + rule stated), whether concepts sit at bundle root or under a `type`/area + subdirectory, and the full `ConceptResult` struct (at minimum: written path, + transformed-link count, broken/unresolved-link list). Resolve the PRD "catalog + granularity" open question in the spec (flat root for MVP is the simplest, + deletable choice) rather than leaving it to implementation. + +## B-002 — wikilink→relative-markdown target resolution is unspecified + +- Section: `_techspec.md` "Data Flow → Promote", "Implementation Design → Wikilink→markdown body transform"; `adr-004.md` "Decision" +- Issue: `OKFLinkFormatter.Link(fromDir, targetPath, label)` computes a relative + path from the new concept's directory to `targetPath`. During promote, the source + body contains `[[slug/path|label]]`, where `slug/path` is a target in the *source + wiki topic's* namespace (verified: `ToTopicWikiLink` emits `[[topicSlug/docPath]]` + at `internal/vault/pathutils.go:245`). The promoted concept lives in a *different* + topic (the OKF bundle) and the target concept usually is not promoted yet. The + spec never defines the rule that maps a wiki-namespace target to a bundle-relative + `targetPath` — so `OKFLinkFormatter.Link` cannot be called deterministically, and + "converts `[[wikilinks]]` to relative markdown links" is not implementable as + written. +- Rationale: This is a second interface gap on the critical path, and it is the + mechanical core that ADR-002 promises is "deterministic, testable." The PRD + success metric "links resolved and the four producer fields present" and the unit + test "links to not-yet-promoted concepts (tolerated/flagged)" both presuppose a + defined target-derivation rule that does not exist. It is coupled to B-001: the + link target is whatever path model B-001 settles. +- Suggested fix: Specify the deterministic mapping from a `[[slug/path|label]]` + target to a bundle-relative path (e.g., strip the source slug, map to the same + concept-filename derivation chosen in B-001, emit `[label](relpath)` from the + concept's `fromDir`), and state explicitly that targets with no promoted + counterpart still emit a relative link to the *would-be* path and are reported as + tolerated/unresolved (per §9). Add a unit case asserting the exact emitted + relative path for a cross-concept link. + +# Nits + +## N-001 — Dormant OKF render branch ships untested in the MVP + +- Section: `_techspec.md` "Executive Summary"/"Primary trade-off"; `adr-004.md` Decision §3 +- Issue: The ~40+ render call sites (verified: 7 in `render.go`, 34 lines in + `render_wiki.go`) gain a mode-aware seam whose OKF branch is "wired but dormant + until Phase 3" and never exercised at render sites in the MVP, i.e. shipped dead + code; ADR-004 Alternative 1 (body-transform only now) is the simpler, deletable + option the project's bias favors. +- Suggested fix: Either defer the 43-site migration per ADR-004 Alt 1, or add one + render-site-in-OKF-mode smoke test so the shipped branch (and its `fromDir` + plumbing) is not dead and untested. + +## N-002 — `frontmatter.Generate` sorts keys; producer field order is alphabetical + +- Section: `_techspec.md` "OKF frontmatter contract"; `internal/frontmatter/frontmatter.go:396` +- Issue: `buildMappingNode` does `sort.Strings(keys)`, so the four fields emit as + `description, timestamp, title, type` (plus `tags`), not Google's `type`-first + producer order — harmless for conformance (YAML maps are unordered) but worth + stating so no test freezes a producer order the helper can't honor. +- Suggested fix: Note in the spec that emitted field order is alphabetical and that + conformance/tests must not assert producer ordering (or add a dedicated ordered + encoder if byte-matching the samples is actually required). + +## N-003 — `index.md` regeneration may drop root `okf_version` and clobber hand edits + +- Section: `_techspec.md` "Data Flow → Promote" ("regenerates `index.md`"); `adr-005.md` +- Issue: The scaffold writes a root `index.md` with `okf_version: "0.1"` + frontmatter, but promote "regenerates `index.md`" as type-grouped bullet lists; + the spec does not say the regeneration preserves the root `okf_version`, and the + PRD open question "index.md / log.md ownership … whether operators ever hand-edit + them" is unresolved. +- Suggested fix: Specify that regeneration preserves the bundle-root `okf_version` + and state the hand-edit ownership contract (fully `kb`-owned vs merge-preserving). + +## N-004 — Scaffold `mode: wiki` vs `omitempty` "byte-stable" claim + +- Section: `_techspec.md` Build Order step 1; `adr-003.md` Implementation Notes +- Issue: ADR-003 argues `omitempty` keeps files "byte-stable when mode is wiki," but + that only covers *not rewriting* existing files; a newly scaffolded wiki topic + will gain a visible `mode: wiki` line unless wiki is written as empty — a + scaffold-output change the zero-regression story should acknowledge. +- Suggested fix: State whether wiki scaffolds emit `mode: wiki` (recommended for + clarity) and update the scaffold golden expectation, separating "old files + unchanged" from "new scaffold output." + +## N-005 — `[okf].types` default vocabulary contents unspecified + +- Section: `_techspec.md` "Data Models" (`OKFConfig`); Build Order step 2 +- Issue: The spec adds `[okf].types` "defaults" and a `config.example.toml` entry but + never lists the default concept-type vocabulary. +- Suggested fix: Enumerate the default `types` list (or state it ships empty) so the + example file and out-of-vocabulary warning tests have a concrete baseline. + +## N-006 — `topic.New` signature change for `--mode` not called out + +- Section: `_techspec.md` Build Order steps 5/9; `internal/topic/topic.go:114` +- Issue: `New(vaultPath, slug, title, domain string)` has no `mode` parameter; the + spec implies but never states the signature/constructor change needed to thread + mode from CLI into scaffold. +- Suggested fix: Name the `New`/`newWithDate` signature change (or a `NewWithMode` + variant) in the build order so the CLI→topic plumbing is explicit. + +# Evidence + +- Read in full: `_techspec.md`, `_prd.md`, `adrs/adr-001.md`..`adr-006.md`, + `CLAUDE.md`, `AGENTS.md`, `CONTRIBUTING.md`. +- Verified against source: `internal/vault/pathutils.go:239-252` (`ToTopicWikiLink` + emits `[[slug/path|label]]`); `internal/vault/render.go:186-187` and call sites at + 220/339/473/555/566/632/643; `internal/vault/render_wiki.go` (34 matching lines); + `internal/frontmatter/frontmatter.go:102-118` (`Generate`) and `:390-410` + (`buildMappingNode` alphabetical `sort.Strings`); `internal/models/models.go:271` + (`TopicMetadata`, has `Slug`, no `Mode`); `internal/models/kb_models.go:115-123` + (`TopicInfo` exists, no `Mode`); `internal/topic/topic.go:104-110` + (`topicMetadataFile`, no `Mode`), `:114` (`New` signature), `:607-628` + (`ensureAgentsSymlink` — AGENTS.md is a symlink to CLAUDE.md), `:770-781` + (`readTopicYAMLMetadata` returns empty struct when file absent); + `internal/lint/lint.go:25/703/904` (`wikilinkPattern`, `schemaForPath`). +- Limitation: I could not read the OKF v0.1 `SPEC.md` or the vendored sample bundles + (not present in this repo); §9/§6/§7 claims and the "four producer fields" / + field-order behavior are taken from the ADRs, not independently verified against + the upstream spec. + +# Deferred Or Follow-Up + +- B-001/B-002 share a root cause (the bundle's internal concept-path model); resolve + the PRD "catalog granularity" open question once and both follow. +- Confirm whether matching Google's sample bundles requires byte-identical + producer-field ordering (N-002) or only structural conformance. +- ADR-004 estimates "~12 direct `ToTopicWikiLink` calls in `render_wiki.go`" but the + file has ~34 matching lines; reconcile the migration-effort estimate (non-blocking). diff --git a/.compozy/tasks/okf-dual-mode/qa/peer-review-findings-round2.md b/.compozy/tasks/okf-dual-mode/qa/peer-review-findings-round2.md new file mode 100644 index 0000000..8f3d34d --- /dev/null +++ b/.compozy/tasks/okf-dual-mode/qa/peer-review-findings-round2.md @@ -0,0 +1,186 @@ +--- +schema_version: 1 +review_kind: techspec +round: 2 +readiness: NEEDS_REWORK +reviewer_runtime: claude +reviewer_model: opus +generated_at: 2026-06-27T23:58:00Z +--- + +# Summary + +Both round-1 blockers are genuinely closed: the spec now defines a concrete Concept +Path Model (flat bundle root, `slug(title)` filenames with `-N` collision suffix), a +full `ConceptResult` struct, and a deterministic `[[wikilink]]`→relative-markdown +mapping rule, resolving the PRD catalog-granularity open question. It is held back +from approval because three contracts on the `promote`/`check` critical path are +still undefined or self-contradictory: the four-field frontmatter remap has no source +mapping for `description`/`timestamp` (neither field exists in any `kb`-produced doc), +the conformance checker would hard-error on the `CLAUDE.md`/`AGENTS.md` files its own +OKF scaffold writes, and the filename-vs-link key derivation is asymmetric, silently +breaking links to already-promoted concepts. + +# Blockers + +## B-201 — Four-field frontmatter remap is undefined for `description` and `timestamp` + +- Section: `_techspec.md` "Data Models → OKF frontmatter contract" (lines 131-135), "Data Flow → Promote", "Testing → Frontmatter remap" +- Issue: The contract names four producer fields (`type`, `title`, `description`, + `timestamp`) but only defines two sources: `type` ← `--type` and (implicitly) + `title` ← source `title`. There is no source mapping for `description` or + `timestamp`, and verification against the code shows neither field is ever produced + by `kb`: every ingest/source/wiki document carries `title, type, stage, domain, + source_kind, scraped/created, tags` and nothing else (`internal/ingest/ingest.go:168-176`, + `internal/vault/render_wiki.go:53-60`, `internal/vault/render.go:392-452`). So + `description` has no source at all, and the spec does not say whether `timestamp` + maps from `scraped`/`created`, from the promote-time clock, or something else. + Compounding this, `timestamp` is specified as "ISO 8601" but the only available + source timestamps use `frontmatter.DateLayout = "2006-01-02"` (date-only; + `internal/frontmatter/frontmatter.go:18`), so even a `scraped→timestamp` mapping + would not satisfy a full ISO-8601/RFC3339 expectation without a defined formatting + rule. +- Rationale: This is an interface/contract gap on the primary MVP path — the + scoped-write contract names "payloads, types, or schemas waved at instead of + defined" as a blocker, and ADR-002 promises `promote` is a "deterministic, + testable" mechanical transform. "Remaps wiki frontmatter to the OKF contract" is + not implementable as written when two of the four target fields have no defined + origin. It also makes the "Frontmatter remap" unit test and the end-to-end + "concept written with four fields" assertion impossible to specify, and renders the + local-standard "all four producer fields present" warning permanently true-fires + (description always empty) with no design answer. +- Suggested fix: Add an explicit remap table — source frontmatter key → OKF key — and + define fallbacks: `title` ← source `title` (fallback to humanized source basename + when absent), `description` ← a named source field or an explicit "empty + warning" + policy, `timestamp` ← a named source date field (`scraped`/`created`) normalized to + RFC3339, with the promote-time clock as the documented fallback. Inject the clock as + a seam so golden/integration tests assert a fixed value instead of freezing a + wall-clock literal. + +## B-202 — OKF conformance checker hard-errors on the scaffold's own `CLAUDE.md`/`AGENTS.md` + +- Section: `_techspec.md` "Data Flow → Scaffold" (lines 43-45), "Testing → End-to-end promote"; `adr-005.md` "Decision" / "Implementation Notes" +- Issue: The OKF scaffold writes `topic.yaml`, `index.md`, `log.md`, "an OKF-flavored + `CLAUDE.md`, and the `AGENTS.md` symlink." `CLAUDE.md` and `AGENTS.md` are `.md` + files with no `type` frontmatter (`internal/topic/topic.go:27` `topicMarkerFile = + "CLAUDE.md"`; `:607-613` `AGENTS.md` is a symlink to `CLAUDE.md`). The checker + "recursively walks the bundle; for every non-reserved `.md` … require a non-empty + `type` (§9.2)" and ADR-005 declares only `index.md`/`log.md` reserved. Therefore a + freshly scaffolded bundle fails `kb okf check` with hard errors on `CLAUDE.md` and + `AGENTS.md` — directly contradicting the end-to-end integration test ("scaffold an + OKF topic … `kb okf check` passes") and the PRD success metric "the operator's + catalog passes the check." The same gap likely affects the vendored official + bundles (GA4/Stack Overflow/Bitcoin) if any carries a `README.md` or other + non-concept `.md`, which would break the headline "conformance passes on all three + official sample bundles" criterion. +- Rationale: This is a designed-in conformance contradiction (scaffold output vs. + checker rule) and a missing-verification surface: the spec's own success criteria + cannot pass as specified. The reserved/excluded-file set is a checker contract that + is currently incomplete. +- Suggested fix: Define the full reserved/excluded set the walker skips — + `index.md`, `log.md`, `CLAUDE.md`, `AGENTS.md` (and skip symlinks so `AGENTS.md` + is not followed into `CLAUDE.md`), plus whatever the official bundles legitimately + carry (`README.md`, license/attribution files) — or scope the `type` requirement to + concept files only. State the rule explicitly and add a unit case asserting these + files are not flagged, plus the integration assertion that a scaffolded-then-promoted + bundle passes `kb okf check`. + +## B-203 — Concept filename and inbound-link key derive from different inputs (silent broken links to promoted concepts) + +- Section: `_techspec.md` "Concept Path Model" (lines 149-160) +- Issue: A concept's filename is `slug(title)` (keyed on the source doc's frontmatter + `title`), but an inbound cross-concept link's target is computed as + `slug(base(docPath))` (keyed on the wikilink's path basename). These coincide only + when `base(docPath) == title`. For codebase-rendered wiki concepts that holds + (`GetWikiConceptPath` embeds the title verbatim as the basename, + `internal/vault/pathutils.go:219-222`), but for ingested/authored research docs the + basename is a slugified source path/URL (`GetRawFileDocumentPath`/ + `GetRawSymbolDocumentPath`, `pathutils.go:192-200`) or an arbitrary Obsidian + note name that need not equal the frontmatter `title`. When they differ, a body + link to an *already-promoted* concept resolves to a phantom `slug(base(path)).md` + while the real file is `slug(title).md` — and the spec records this in + `UnresolvedLinks` as "no promoted counterpart exists yet," which is false. The same + asymmetry makes `index.md` (links keyed on each concept's own `title`) and body + cross-links (keyed on `base(path)`) point at two different addresses for one + concept. +- Rationale: This defeats the determinism that round-1 B-002's fix was supposed to + deliver and undermines the PRD success metric "links resolved." It is a correctness + gap on the headline `promote` guarantee, not merely a tolerated-broken-link case. +- Suggested fix: Derive both the filename and the link target from a single canonical + key. Simplest deletable option: key the concept filename off `slug(base(sourceDocPath))` + (the same input links use) so a target's would-be path always equals its eventual + filename; or, if title-named files are required, build the bundle's path→file + resolution from the maintained `index.md`/a promote manifest and resolve links + against it. State which, and add a unit case that promotes two linked concepts and + asserts the inbound link resolves to the real file (not a phantom). + +# Nits + +## N-201 — Re-implements slugging instead of reusing `vault.SlugifySegment` + +- Section: `_techspec.md` "Concept Path Model" (`slug(title)`) +- Issue: A canonical slug primitive already exists (`vault.SlugifySegment`, `internal/vault/pathutils.go:98-127`: lowercase, `[a-z0-9]`, single-dash, `"item"` empty fallback); the spec describes a fresh `slug()` ("lowercase, ASCII, kebab-case") without naming it, and `internal/okf` may legally import `vault` per ADR-003. +- Suggested fix: Specify reuse of `vault.SlugifySegment` for filename and link derivation, and note its `"item"` empty fallback and accent-dropping behavior (e.g. `Conteúdo`→`conte-do`) so the operator's Portuguese catalog names are accounted for. + +## N-202 — `promote --to` does not validate the target is `mode: okf` + +- Section: `_techspec.md` "Concept Path Model → `--to`" (line 160), "Core Interfaces" (`PromoteInput.TargetTopic`) +- Issue: `LinkFormatterFor(topic)` returns `WikiLinkFormatter` for any non-okf topic, so promoting into a wiki-mode `--to` target would silently emit `[[wikilinks]]` into an OKF concept; the spec assumes an OKF target but states no guard. +- Suggested fix: Require `promote` to error when `--to` resolves to a non-`okf` topic, and add a unit/integration case asserting that rejection. + +## N-203 — Wikilink `#anchor` fragment handling unspecified in the mapping rule + +- Section: `_techspec.md` "Concept Path Model → Wikilink target mapping"; "Testing → Wikilink→markdown body transform" (`[[a#anchor]]`) +- Issue: A `[[a#anchor]]` test case is listed, but the strip→`base`→`slug` rule never says whether the `#anchor` fragment is preserved as `[label](slug.md#anchor)` or dropped. +- Suggested fix: State that the fragment is carried through to the emitted relative link and add the asserted-output case. + +## N-204 — `TopicMode = string` is a type alias, not a defined type + +- Section: `_techspec.md` "Data Models" (lines 113-118); `adr-003.md` "typed constants" +- Issue: `type TopicMode = string` is an alias with zero type distinction, contradicting ADR-003's "typed constants" and giving the scaffold/normalization no compiler help for exhaustiveness. +- Suggested fix: Use `type TopicMode string` (defined type) or justify the alias explicitly; keep the `topicMetadataFile.Mode` field's type consistent with it. + +## N-205 — Observability omits the new failure/skip events + +- Section: `_techspec.md` "Monitoring and Observability" +- Issue: The logged events cover transform/broken-link counts but not "promote rejected: target not okf" (N-202) or "reserved file skipped" (B-202), which are the new actionable failure surfaces. +- Suggested fix: Add those events to the `promote`/`okf check` debug/info logging list. + +# Evidence + +- Read in full: `_techspec.md`, `_prd.md`, `adrs/adr-001.md`..`adr-006.md`, + `qa/peer-review-findings-round1.md`, `qa/peer-review-incorporation-round1.md`, + `CLAUDE.md`, `AGENTS.md`, `CONTRIBUTING.md`. +- Verified against source: `internal/vault/pathutils.go:98-127` (`SlugifySegment`), + `:192-200` (`GetRawFileDocumentPath`/`GetRawSymbolDocumentPath`), `:219-222` + (`GetWikiConceptPath` embeds title verbatim, no slug), `:245-252` (`ToTopicWikiLink` + emits `[[slug/path|label]]`); `internal/frontmatter/frontmatter.go:18` (`DateLayout` + = `2006-01-02`), `:71` (`Parse`), `:102-118` (`Generate`), `:390-410` + (`buildMappingNode` alphabetical `sort.Strings`, confirmed line 396); + `internal/ingest/ingest.go:160-205` (`buildFrontmatter` field set — no + `description`/`timestamp`); `internal/vault/render_wiki.go:53-60` and + `internal/vault/render.go:392-452` (wiki/source frontmatter field sets); + `internal/topic/topic.go:27` (`topicMarkerFile = "CLAUDE.md"`), `:78-79` + (scaffold writes `CLAUDE.md`/`log.md`), `:114`/`:186` (`New`/`newWithDate` + signatures), `:607-628` (`AGENTS.md` symlink to `CLAUDE.md`). +- Confirmed round-1 closures in the current spec text: B-001 — "Concept Path Model" + section (lines 144-165), `ConceptResult` struct (lines 89-95), `PromoteInput` + (lines 83-87), catalog-granularity resolved (lines 162-165); B-002 — deterministic + `[[wikilink]]`→relative mapping (lines 153-159). Incorporated nits N-001..N-006 + verified present (smoke test line 221-223; field-order note lines 137-140; empty + vocabulary lines 124-128; `mode` normalization lines 120/246-248; `New`/`newWithDate` + mode parameter lines 257-259; index.md `okf_version` preservation lines 49-53). +- Limitation: the OKF v0.1 `SPEC.md` and the vendored sample bundles are not present + in this repo, so the §9.2 reserved-file exemptions (B-202) and the exact ISO-8601 + shape OKF expects for `timestamp` (B-201) are reasoned from the ADRs, not verified + against the upstream spec/bundles. + +# Deferred Or Follow-Up + +- B-201/B-202/B-203 are independent contract gaps but share a theme: the `promote` + output and the `okf check` input contracts are under-specified at the field/file + level. Resolving them needs a remap table, a reserved-file set, and a single + filename/link key — all spec-text edits, not architecture changes. +- Re-confirm at implementation time (against the vendored bundles) whether official + OKF bundles carry non-concept `.md` files that the reserved/excluded set must cover + (B-202) and the precise `timestamp` format the samples use (B-201). diff --git a/.compozy/tasks/okf-dual-mode/qa/peer-review-incorporation-round1.md b/.compozy/tasks/okf-dual-mode/qa/peer-review-incorporation-round1.md new file mode 100644 index 0000000..2831d1b --- /dev/null +++ b/.compozy/tasks/okf-dual-mode/qa/peer-review-incorporation-round1.md @@ -0,0 +1,33 @@ +# Peer Review — Round 1 Incorporation + +- **Decision:** incorporate **both blockers + all six nits** (N-001 handled as a + smoke test, preserving the ADR-004 "migrate now" decision rather than deferring). +- **Date:** 2026-06-27 + +## Incorporated + +| Finding | Resolution | File(s) changed | +| --- | --- | --- | +| B-001 — promote output contract undefined | Added a **Concept Path Model** (flat bundle root for MVP; filename = `slug(title)` with `-N` collision suffix) and defined the full `ConceptResult` struct; resolved the PRD "catalog granularity" open question into the spec. | `_techspec.md` (Core Interfaces, Data Flow, new Concept Path Model section) | +| B-002 — wikilink→relative target resolution unspecified | Specified the deterministic mapping `[[srcSlug/docPath]]` → strip slug → `base(docPath)` → `slug(...)` → bundle-relative path; flat root ⇒ `[label](<slug>.md)`; unresolved targets emit a would-be link and land in `ConceptResult.UnresolvedLinks`. | `_techspec.md` (Concept Path Model, Data Flow) | +| N-001 — dormant OKF render branch (dead code) | Added an **OKF render smoke test** (render-site-in-`okf`-mode) to keep the wired branch live; recorded as a risk mitigation. | `_techspec.md` (Testing), `adr-004.md` (Risks) | +| N-002 — alphabetical key order | Documented that emitted field order is `description, tags, timestamp, title, type`; tests must not assert `type`-first ordering. | `_techspec.md` (frontmatter contract) | +| N-003 — index.md regeneration / okf_version | Stated regeneration preserves the root `okf_version`; defined `index.md` (kb-owned, regenerated) vs `log.md` (append-only) ownership. | `_techspec.md` (Data Flow), `adr-005.md` (Implementation Notes) | +| N-004 — scaffold `mode: wiki` line | Separated "old files unchanged" from "new scaffold output gains an explicit `mode` line"; clarified `omitempty` scope. | `adr-003.md` (Implementation Notes) | +| N-005 — default `[okf].types` vocabulary | Specified the vocabulary ships **empty** (check is a no-op until the operator opts in). | `_techspec.md` (Data Models) | +| N-006 — `topic.New` signature change | Called out the `New`/`newWithDate` `mode` parameter (or `NewWithMode`) in Build Order step 5. | `_techspec.md` (Build Order) | +| Follow-up — ~12 vs ~34 migration estimate | Reconciled to "~7 in render.go + ~34 lines in render_wiki.go (~43 working estimate)". | `adr-004.md` (Implementation Notes) | + +## Deferred + +- **None.** All round-1 findings were incorporated. The reviewer's note that it + could not independently verify the OKF spec/sample bundles (not in repo) is a + review-environment limitation, not a spec gap — the vendored fixtures (ADR-005) + will provide that verification at implementation time. + +## Files changed + +- `_techspec.md` +- `adrs/adr-003.md` +- `adrs/adr-004.md` +- `adrs/adr-005.md` diff --git a/.compozy/tasks/okf-dual-mode/qa/peer-review-incorporation-round2.md b/.compozy/tasks/okf-dual-mode/qa/peer-review-incorporation-round2.md new file mode 100644 index 0000000..4c80d68 --- /dev/null +++ b/.compozy/tasks/okf-dual-mode/qa/peer-review-incorporation-round2.md @@ -0,0 +1,28 @@ +# Peer Review — Round 2 Incorporation + +- **Decision:** incorporate **all 3 blockers + all 5 nits**. +- **Date:** 2026-06-27 +- **Round-1 closure:** B-001/B-002 confirmed closed by the reviewer; no action needed. + +## Incorporated + +| Finding | Resolution | File(s) | +| --- | --- | --- | +| B-201 — `description`/`timestamp` had no source | Added a **source→OKF remap table**: `type`←`--type`; `title`←source title (fallback humanized basename); `description`←`--description` flag → first body sentence → empty+warning; `timestamp`←promote-time clock (RFC3339), injected as a `Clock` seam for tests; `tags`←source when present. | `_techspec.md` (Data Models), `adr-002.md` | +| B-202 — checker rejects scaffold's own `CLAUDE.md`/`AGENTS.md` | Defined the full **reserved/excluded set** the walker skips: `index.md`, `log.md`, `CLAUDE.md`, `AGENTS.md`, symlinks, `README`/license; `type` requirement scoped to concept files. | `_techspec.md` (Data Flow→Check), `adr-005.md` | +| B-203 — filename vs link key asymmetric | Switched to a **single canonical key** `vault.SlugifySegment(base(path))` for both filename and link target, so a link to an already-promoted concept always resolves. Documented the filename trade-off (path-key now; title+manifest is a Phase-2 option). | `_techspec.md` (Concept Path Model, Data Flow) | +| N-201 — reuse slug primitive | Named `vault.SlugifySegment` as the key (with `"item"` fallback + accent-drop `Conteúdo`→`conte-do`). | `_techspec.md` (Concept Path Model) | +| N-202 — `--to` not validated as okf | `promote` errors when `--to` is not a `mode: okf` topic; added test + log event. | `_techspec.md` (Concept Path Model, Testing, Monitoring), `adr-006.md` | +| N-203 — `#anchor` handling | Fragment carried through as `[label](<key>.md#anchor)`. | `_techspec.md` (Concept Path Model) | +| N-204 — `TopicMode` alias | Changed to a defined type `type TopicMode string`. | `_techspec.md` (Data Models) | +| N-205 — missing failure/skip events | Added `promote rejected: target not okf`, `description fallback used`, `reserved file skipped` to observability. | `_techspec.md` (Monitoring) | + +## Deferred + +- **None.** All round-2 findings incorporated. The reviewer's environment limitation + (OKF `SPEC.md` / sample bundles not in repo) is re-confirmed at implementation time + against the vendored fixtures — already an ADR-005 task. + +## Files changed + +- `_techspec.md`, `adrs/adr-002.md`, `adrs/adr-005.md`, `adrs/adr-006.md` diff --git a/.compozy/tasks/okf-dual-mode/qa/peer-review-prompt-round1.md b/.compozy/tasks/okf-dual-mode/qa/peer-review-prompt-round1.md new file mode 100644 index 0000000..55a9b38 --- /dev/null +++ b/.compozy/tasks/okf-dual-mode/qa/peer-review-prompt-round1.md @@ -0,0 +1,103 @@ +You are an architecture reviewer pressure-testing a spec (TechSpec, design doc, RFC, or +detailed PRD) authored by another LLM or engineer. Bias toward simple, deletable solutions +over compatibility shims when the requirements allow it. Your job is to find what is wrong or +under-specified, not to be polite. + +CONTEXT FILES TO READ: +- Spec under review: /Users/pedronauck/Dev/compozy/kb/.compozy/tasks/okf-dual-mode/_techspec.md +- Additional context (specs, ADRs, RFCs, design docs, research) or `none`: +/Users/pedronauck/Dev/compozy/kb/.compozy/tasks/okf-dual-mode/_prd.md +/Users/pedronauck/Dev/compozy/kb/.compozy/tasks/okf-dual-mode/adrs/adr-001.md +/Users/pedronauck/Dev/compozy/kb/.compozy/tasks/okf-dual-mode/adrs/adr-002.md +/Users/pedronauck/Dev/compozy/kb/.compozy/tasks/okf-dual-mode/adrs/adr-003.md +/Users/pedronauck/Dev/compozy/kb/.compozy/tasks/okf-dual-mode/adrs/adr-004.md +/Users/pedronauck/Dev/compozy/kb/.compozy/tasks/okf-dual-mode/adrs/adr-005.md +/Users/pedronauck/Dev/compozy/kb/.compozy/tasks/okf-dual-mode/adrs/adr-006.md +- Project rules (read every one that exists, ignore the rest): +/Users/pedronauck/Dev/compozy/kb/CLAUDE.md +/Users/pedronauck/Dev/compozy/kb/AGENTS.md +/Users/pedronauck/Dev/compozy/kb/CONTRIBUTING.md + +Before reasoning, read every context file above in full. Also read any project convention +files that exist in the repository even if not listed (for example `CLAUDE.md`, `AGENTS.md`, +`.cursor/rules/*`, `.cursorrules`, `CONTRIBUTING.md`) and hold the project's own rules as the +authority for what counts as a blocker. + +TARGET FINDINGS FILE: +/Users/pedronauck/Dev/compozy/kb/.compozy/tasks/okf-dual-mode/qa/peer-review-findings-round1.md + +SCOPED-WRITE CONTRACT: +1. You may write exactly one file: the target findings file above. +2. Do not edit the spec, ADRs, research files, source code, tests, configs, docs, ledgers, prompts, summaries, or any other file. +3. Do not create sibling artifacts, temp files, backups, or alternate output files. +4. If you cannot write the exact target file, stop and report the failure briefly. Do not print the review findings to stdout as a fallback. +5. After writing the file, your final chat response must be one sentence: `Wrote /Users/pedronauck/Dev/compozy/kb/.compozy/tasks/okf-dual-mode/qa/peer-review-findings-round1.md`. + +YOUR JOB: +1. Read every context file fully before reasoning. +2. Identify BLOCKERS (issues that prevent approval): + - Boundary or coupling leaks across modules, packages, layers, or services. + - Persistent data changes without a safe migration / backfill / rollout plan. + - Missing verification for risky behavior (no test/contract/observability surface named). + - Security, permission, or tenancy regressions designed into the plan. + - Interface/contract gaps: payloads, types, or schemas waved at instead of defined. + - Hidden coupling to deferred or out-of-scope features. + - Partial-surface completion baked into the plan (backend only, frontend only, docs/tests "later"). + - Test-shape violations designed in (assertions that freeze incidental literals instead of proving behavior). +3. Identify NITS (non-blocking improvements): clarity, naming, test-density, observability/event coverage, doc co-ship completeness. +4. Issue a READINESS verdict: READY / BLOCKED / NEEDS_REWORK. + +CONSTRAINTS: +- Prefer "delete the old thing" over "preserve compat" unless the spec gives a concrete reason to keep both. +- Hard cuts: a rename or removal should touch all affected surfaces (code, storage, APIs, CLI, docs, specs) in the same change, not leave a half-migrated state. +- Respect the project's own rules files: tests should prove behavior, not freeze incidental literals. +- Reuse canonical helpers/primitives over inline re-implementations. +- New or changed persistent data needs explicit migration / backfill / constraint reasoning. +- Generated or codegen'd artifacts co-ship with their source change. +- Honor any additional rules stated in the project convention files you read. + +FINDINGS FILE FORMAT: +Write `/Users/pedronauck/Dev/compozy/kb/.compozy/tasks/okf-dual-mode/qa/peer-review-findings-round1.md` as Markdown with this exact frontmatter and headings: + +--- +schema_version: 1 +review_kind: techspec +round: 1 +readiness: READY|BLOCKED|NEEDS_REWORK +reviewer_runtime: claude +reviewer_model: opus +generated_at: <ISO-8601 timestamp> +--- + +# Summary + +Two sentences explaining the readiness verdict. + +# Blockers + +Use `None.` when there are no blockers. Otherwise, use one item per blocker: + +## B-NNN — <short title> + +- Section: <spec section anchor or file path> +- Issue: <one paragraph> +- Rationale: <why this blocks approval, with project rule/constraint reference> +- Suggested fix: <concrete change> + +# Nits + +Use `None.` when there are no nits. Otherwise, use one item per nit: + +## N-NNN — <short title> + +- Section: <spec section anchor or file path> +- Issue: <one line> +- Suggested fix: <one line> + +# Evidence + +List files read and any limitations. Do not invent evidence. + +# Deferred Or Follow-Up + +List non-blocking follow-ups, or `None.`. diff --git a/.compozy/tasks/okf-dual-mode/qa/peer-review-prompt-round2.md b/.compozy/tasks/okf-dual-mode/qa/peer-review-prompt-round2.md new file mode 100644 index 0000000..3d2b5ff --- /dev/null +++ b/.compozy/tasks/okf-dual-mode/qa/peer-review-prompt-round2.md @@ -0,0 +1,111 @@ +You are an architecture reviewer pressure-testing a spec (TechSpec, design doc, RFC, or +detailed PRD) authored by another LLM or engineer. Bias toward simple, deletable solutions +over compatibility shims when the requirements allow it. Your job is to find what is wrong or +under-specified, not to be polite. + +CONTEXT FILES TO READ: +- Spec under review: /Users/pedronauck/Dev/compozy/kb/.compozy/tasks/okf-dual-mode/_techspec.md +- Additional context (specs, ADRs, RFCs, design docs, research) or `none`: +/Users/pedronauck/Dev/compozy/kb/.compozy/tasks/okf-dual-mode/_prd.md +/Users/pedronauck/Dev/compozy/kb/.compozy/tasks/okf-dual-mode/adrs/adr-001.md +/Users/pedronauck/Dev/compozy/kb/.compozy/tasks/okf-dual-mode/adrs/adr-002.md +/Users/pedronauck/Dev/compozy/kb/.compozy/tasks/okf-dual-mode/adrs/adr-003.md +/Users/pedronauck/Dev/compozy/kb/.compozy/tasks/okf-dual-mode/adrs/adr-004.md +/Users/pedronauck/Dev/compozy/kb/.compozy/tasks/okf-dual-mode/adrs/adr-005.md +/Users/pedronauck/Dev/compozy/kb/.compozy/tasks/okf-dual-mode/adrs/adr-006.md +/Users/pedronauck/Dev/compozy/kb/.compozy/tasks/okf-dual-mode/qa/peer-review-findings-round1.md +/Users/pedronauck/Dev/compozy/kb/.compozy/tasks/okf-dual-mode/qa/peer-review-incorporation-round1.md +- Project rules (read every one that exists, ignore the rest): +/Users/pedronauck/Dev/compozy/kb/CLAUDE.md +/Users/pedronauck/Dev/compozy/kb/AGENTS.md +/Users/pedronauck/Dev/compozy/kb/CONTRIBUTING.md + +Before reasoning, read every context file above in full. Also read any project convention +files that exist in the repository even if not listed (for example `CLAUDE.md`, `AGENTS.md`, +`.cursor/rules/*`, `.cursorrules`, `CONTRIBUTING.md`) and hold the project's own rules as the +authority for what counts as a blocker. + +This is REVIEW ROUND 2. The round-1 findings and the incorporation record are included as +context. Verify that the round-1 blockers (B-001 promote output contract / concept-path model; +B-002 wikilink→relative target resolution) are actually closed by the current spec, AND +pressure-test the spec afresh for any new or remaining issues. Do not assume closure; confirm +it against the updated spec text, and do not rubber-stamp. + +TARGET FINDINGS FILE: +/Users/pedronauck/Dev/compozy/kb/.compozy/tasks/okf-dual-mode/qa/peer-review-findings-round2.md + +SCOPED-WRITE CONTRACT: +1. You may write exactly one file: the target findings file above. +2. Do not edit the spec, ADRs, research files, source code, tests, configs, docs, ledgers, prompts, summaries, or any other file. +3. Do not create sibling artifacts, temp files, backups, or alternate output files. +4. If you cannot write the exact target file, stop and report the failure briefly. Do not print the review findings to stdout as a fallback. +5. After writing the file, your final chat response must be one sentence: `Wrote /Users/pedronauck/Dev/compozy/kb/.compozy/tasks/okf-dual-mode/qa/peer-review-findings-round2.md`. + +YOUR JOB: +1. Read every context file fully before reasoning. +2. Identify BLOCKERS (issues that prevent approval): + - Boundary or coupling leaks across modules, packages, layers, or services. + - Persistent data changes without a safe migration / backfill / rollout plan. + - Missing verification for risky behavior (no test/contract/observability surface named). + - Security, permission, or tenancy regressions designed into the plan. + - Interface/contract gaps: payloads, types, or schemas waved at instead of defined. + - Hidden coupling to deferred or out-of-scope features. + - Partial-surface completion baked into the plan (backend only, frontend only, docs/tests "later"). + - Test-shape violations designed in (assertions that freeze incidental literals instead of proving behavior). +3. Identify NITS (non-blocking improvements): clarity, naming, test-density, observability/event coverage, doc co-ship completeness. +4. Issue a READINESS verdict: READY / BLOCKED / NEEDS_REWORK. + +CONSTRAINTS: +- Prefer "delete the old thing" over "preserve compat" unless the spec gives a concrete reason to keep both. +- Hard cuts: a rename or removal should touch all affected surfaces (code, storage, APIs, CLI, docs, specs) in the same change, not leave a half-migrated state. +- Respect the project's own rules files: tests should prove behavior, not freeze incidental literals. +- Reuse canonical helpers/primitives over inline re-implementations. +- New or changed persistent data needs explicit migration / backfill / constraint reasoning. +- Generated or codegen'd artifacts co-ship with their source change. +- Honor any additional rules stated in the project convention files you read. + +FINDINGS FILE FORMAT: +Write `/Users/pedronauck/Dev/compozy/kb/.compozy/tasks/okf-dual-mode/qa/peer-review-findings-round2.md` as Markdown with this exact frontmatter and headings: + +--- +schema_version: 1 +review_kind: techspec +round: 2 +readiness: READY|BLOCKED|NEEDS_REWORK +reviewer_runtime: claude +reviewer_model: opus +generated_at: <ISO-8601 timestamp> +--- + +# Summary + +Two sentences explaining the readiness verdict. + +# Blockers + +Use `None.` when there are no blockers. Otherwise, use one item per blocker: + +## B-NNN — <short title> + +- Section: <spec section anchor or file path> +- Issue: <one paragraph> +- Rationale: <why this blocks approval, with project rule/constraint reference> +- Suggested fix: <concrete change> + +# Nits + +Use `None.` when there are no nits. Otherwise, use one item per nit: + +## N-NNN — <short title> + +- Section: <spec section anchor or file path> +- Issue: <one line> +- Suggested fix: <one line> + +# Evidence + +List files read and any limitations. Do not invent evidence. + +# Deferred Or Follow-Up + +List non-blocking follow-ups, or `None.`. diff --git a/.compozy/tasks/okf-dual-mode/qa/peer-review-result-round1.err b/.compozy/tasks/okf-dual-mode/qa/peer-review-result-round1.err new file mode 100644 index 0000000..e69de29 diff --git a/.compozy/tasks/okf-dual-mode/qa/peer-review-result-round2.err b/.compozy/tasks/okf-dual-mode/qa/peer-review-result-round2.err new file mode 100644 index 0000000..e69de29 diff --git a/.compozy/tasks/okf-dual-mode/qa/peer-review-status-after-round1.txt b/.compozy/tasks/okf-dual-mode/qa/peer-review-status-after-round1.txt new file mode 100644 index 0000000..bc25e22 --- /dev/null +++ b/.compozy/tasks/okf-dual-mode/qa/peer-review-status-after-round1.txt @@ -0,0 +1,33 @@ + M .agents/skills/compozy/SKILL.md + M .agents/skills/compozy/references/cli-reference.md + M .agents/skills/compozy/references/config-reference.md + M .agents/skills/compozy/references/skills-reference.md + M .agents/skills/compozy/references/workflow-guide.md + M .agents/skills/cy-create-prd/SKILL.md + M .agents/skills/cy-create-prd/references/question-protocol.md + M .agents/skills/cy-create-tasks/SKILL.md + M .agents/skills/cy-create-tasks/references/task-context-schema.md + M .agents/skills/cy-create-tasks/references/task-template.md + M .agents/skills/cy-create-techspec/SKILL.md + M .agents/skills/cy-execute-task/SKILL.md + M .agents/skills/cy-execute-task/references/tracking-checklist.md + M .agents/skills/cy-fix-reviews/SKILL.md + D .agents/skills/cy-idea-factory/SKILL.md + D .agents/skills/cy-idea-factory/references/adr-template.md + D .agents/skills/cy-idea-factory/references/business-analyst.md + D .agents/skills/cy-idea-factory/references/council.md + D .agents/skills/cy-idea-factory/references/idea-template.md + D .agents/skills/cy-idea-factory/references/product-strategist.md + D .agents/skills/cy-idea-factory/references/question-protocol.md + M .agents/skills/cy-review-round/SKILL.md + M .agents/skills/git-rebase/SKILL.md + D .agents/skills/git-rebase/references/automation.md + D .agents/skills/git-rebase/references/resolution-patterns.md + D .agents/skills/git-rebase/references/scripts-tools.md + D .agents/skills/git-rebase/references/strategies.md + D .agents/skills/git-rebase/references/troubleshooting.md + D .agents/skills/git-rebase/scripts/analyze-conflicts.sh + D .agents/skills/git-rebase/scripts/pre-rebase-backup.sh + D .agents/skills/git-rebase/scripts/validate-merge.sh +?? .claude/ledger/2026-06-27-MEMORY-okf-dual-mode-prd.md +?? .compozy/tasks/okf-dual-mode/ diff --git a/.compozy/tasks/okf-dual-mode/qa/peer-review-status-after-round2.txt b/.compozy/tasks/okf-dual-mode/qa/peer-review-status-after-round2.txt new file mode 100644 index 0000000..bc25e22 --- /dev/null +++ b/.compozy/tasks/okf-dual-mode/qa/peer-review-status-after-round2.txt @@ -0,0 +1,33 @@ + M .agents/skills/compozy/SKILL.md + M .agents/skills/compozy/references/cli-reference.md + M .agents/skills/compozy/references/config-reference.md + M .agents/skills/compozy/references/skills-reference.md + M .agents/skills/compozy/references/workflow-guide.md + M .agents/skills/cy-create-prd/SKILL.md + M .agents/skills/cy-create-prd/references/question-protocol.md + M .agents/skills/cy-create-tasks/SKILL.md + M .agents/skills/cy-create-tasks/references/task-context-schema.md + M .agents/skills/cy-create-tasks/references/task-template.md + M .agents/skills/cy-create-techspec/SKILL.md + M .agents/skills/cy-execute-task/SKILL.md + M .agents/skills/cy-execute-task/references/tracking-checklist.md + M .agents/skills/cy-fix-reviews/SKILL.md + D .agents/skills/cy-idea-factory/SKILL.md + D .agents/skills/cy-idea-factory/references/adr-template.md + D .agents/skills/cy-idea-factory/references/business-analyst.md + D .agents/skills/cy-idea-factory/references/council.md + D .agents/skills/cy-idea-factory/references/idea-template.md + D .agents/skills/cy-idea-factory/references/product-strategist.md + D .agents/skills/cy-idea-factory/references/question-protocol.md + M .agents/skills/cy-review-round/SKILL.md + M .agents/skills/git-rebase/SKILL.md + D .agents/skills/git-rebase/references/automation.md + D .agents/skills/git-rebase/references/resolution-patterns.md + D .agents/skills/git-rebase/references/scripts-tools.md + D .agents/skills/git-rebase/references/strategies.md + D .agents/skills/git-rebase/references/troubleshooting.md + D .agents/skills/git-rebase/scripts/analyze-conflicts.sh + D .agents/skills/git-rebase/scripts/pre-rebase-backup.sh + D .agents/skills/git-rebase/scripts/validate-merge.sh +?? .claude/ledger/2026-06-27-MEMORY-okf-dual-mode-prd.md +?? .compozy/tasks/okf-dual-mode/ diff --git a/.compozy/tasks/okf-dual-mode/qa/peer-review-status-before-round1.txt b/.compozy/tasks/okf-dual-mode/qa/peer-review-status-before-round1.txt new file mode 100644 index 0000000..bc25e22 --- /dev/null +++ b/.compozy/tasks/okf-dual-mode/qa/peer-review-status-before-round1.txt @@ -0,0 +1,33 @@ + M .agents/skills/compozy/SKILL.md + M .agents/skills/compozy/references/cli-reference.md + M .agents/skills/compozy/references/config-reference.md + M .agents/skills/compozy/references/skills-reference.md + M .agents/skills/compozy/references/workflow-guide.md + M .agents/skills/cy-create-prd/SKILL.md + M .agents/skills/cy-create-prd/references/question-protocol.md + M .agents/skills/cy-create-tasks/SKILL.md + M .agents/skills/cy-create-tasks/references/task-context-schema.md + M .agents/skills/cy-create-tasks/references/task-template.md + M .agents/skills/cy-create-techspec/SKILL.md + M .agents/skills/cy-execute-task/SKILL.md + M .agents/skills/cy-execute-task/references/tracking-checklist.md + M .agents/skills/cy-fix-reviews/SKILL.md + D .agents/skills/cy-idea-factory/SKILL.md + D .agents/skills/cy-idea-factory/references/adr-template.md + D .agents/skills/cy-idea-factory/references/business-analyst.md + D .agents/skills/cy-idea-factory/references/council.md + D .agents/skills/cy-idea-factory/references/idea-template.md + D .agents/skills/cy-idea-factory/references/product-strategist.md + D .agents/skills/cy-idea-factory/references/question-protocol.md + M .agents/skills/cy-review-round/SKILL.md + M .agents/skills/git-rebase/SKILL.md + D .agents/skills/git-rebase/references/automation.md + D .agents/skills/git-rebase/references/resolution-patterns.md + D .agents/skills/git-rebase/references/scripts-tools.md + D .agents/skills/git-rebase/references/strategies.md + D .agents/skills/git-rebase/references/troubleshooting.md + D .agents/skills/git-rebase/scripts/analyze-conflicts.sh + D .agents/skills/git-rebase/scripts/pre-rebase-backup.sh + D .agents/skills/git-rebase/scripts/validate-merge.sh +?? .claude/ledger/2026-06-27-MEMORY-okf-dual-mode-prd.md +?? .compozy/tasks/okf-dual-mode/ diff --git a/.compozy/tasks/okf-dual-mode/qa/peer-review-status-before-round2.txt b/.compozy/tasks/okf-dual-mode/qa/peer-review-status-before-round2.txt new file mode 100644 index 0000000..bc25e22 --- /dev/null +++ b/.compozy/tasks/okf-dual-mode/qa/peer-review-status-before-round2.txt @@ -0,0 +1,33 @@ + M .agents/skills/compozy/SKILL.md + M .agents/skills/compozy/references/cli-reference.md + M .agents/skills/compozy/references/config-reference.md + M .agents/skills/compozy/references/skills-reference.md + M .agents/skills/compozy/references/workflow-guide.md + M .agents/skills/cy-create-prd/SKILL.md + M .agents/skills/cy-create-prd/references/question-protocol.md + M .agents/skills/cy-create-tasks/SKILL.md + M .agents/skills/cy-create-tasks/references/task-context-schema.md + M .agents/skills/cy-create-tasks/references/task-template.md + M .agents/skills/cy-create-techspec/SKILL.md + M .agents/skills/cy-execute-task/SKILL.md + M .agents/skills/cy-execute-task/references/tracking-checklist.md + M .agents/skills/cy-fix-reviews/SKILL.md + D .agents/skills/cy-idea-factory/SKILL.md + D .agents/skills/cy-idea-factory/references/adr-template.md + D .agents/skills/cy-idea-factory/references/business-analyst.md + D .agents/skills/cy-idea-factory/references/council.md + D .agents/skills/cy-idea-factory/references/idea-template.md + D .agents/skills/cy-idea-factory/references/product-strategist.md + D .agents/skills/cy-idea-factory/references/question-protocol.md + M .agents/skills/cy-review-round/SKILL.md + M .agents/skills/git-rebase/SKILL.md + D .agents/skills/git-rebase/references/automation.md + D .agents/skills/git-rebase/references/resolution-patterns.md + D .agents/skills/git-rebase/references/scripts-tools.md + D .agents/skills/git-rebase/references/strategies.md + D .agents/skills/git-rebase/references/troubleshooting.md + D .agents/skills/git-rebase/scripts/analyze-conflicts.sh + D .agents/skills/git-rebase/scripts/pre-rebase-backup.sh + D .agents/skills/git-rebase/scripts/validate-merge.sh +?? .claude/ledger/2026-06-27-MEMORY-okf-dual-mode-prd.md +?? .compozy/tasks/okf-dual-mode/ diff --git a/.compozy/tasks/okf-dual-mode/qa/peer-review-summary-round1.md b/.compozy/tasks/okf-dual-mode/qa/peer-review-summary-round1.md new file mode 100644 index 0000000..26cb708 --- /dev/null +++ b/.compozy/tasks/okf-dual-mode/qa/peer-review-summary-round1.md @@ -0,0 +1,54 @@ +# Spec Peer Review — Round 1 Summary + +- **Spec:** `.compozy/tasks/okf-dual-mode/_techspec.md` +- **Reviewer runtime:** `compozy exec` · claude / opus / xhigh (cross-LLM, independent) +- **Verdict:** **NEEDS_REWORK** — 2 blockers, 6 nits +- **Validator:** `validate-findings.sh` passed (exit 0); status before==after (scoped-write honored) + +## Blockers (must resolve before approval) + +- **B-001 — `promote` output contract undefined.** No bundle write-location / + filename derivation, and `ConceptResult` is referenced but never defined. Tied to + the PRD's deferred "catalog granularity" open question. *Rationale:* interface gap + on the primary MVP path; the end-to-end promote test can't assert a concrete + location and `index.md` type-grouping has no defined tree to walk. +- **B-002 — wikilink→relative target resolution unspecified.** `[[slug/path]]` lives + in the *source* topic's namespace; the promoted concept lives in a *different* + topic (the OKF bundle). The mapping rule from wiki-namespace target → bundle- + relative `targetPath` is undefined, so `OKFLinkFormatter.Link` can't be called + deterministically. *Rationale:* the mechanical core ADR-002 calls "deterministic, + testable"; couples to B-001. + +> Root cause: both blockers reduce to **the bundle's internal concept-path model**. +> Reviewer recommends resolving the "catalog granularity" question once (flat +> bundle root for the MVP = simplest, deletable) — and both blockers follow. + +## Nits (non-blocking) + +- **N-001** — dormant OKF render branch ships untested in MVP (re-raises ADR-004 Alt 1: body-transform-only is simpler). +- **N-002** — `frontmatter.Generate` sorts keys alphabetically → emitted order is `description,tags,timestamp,title,type`, not `type`-first; tests must not freeze producer order. +- **N-003** — `index.md` regeneration must preserve the root `okf_version`; state hand-edit ownership. +- **N-004** — new wiki scaffolds will gain a visible `mode: wiki` line; separate "old files unchanged" from "new scaffold output" in the zero-regression story. +- **N-005** — default `[okf].types` vocabulary contents unspecified (enumerate or ship empty). +- **N-006** — `topic.New(vaultPath, slug, title, domain)` signature change for `--mode` not called out in the Build Order. + +## Sections / ADRs likely affected by incorporation + +- `_techspec.md`: Data Flow → Promote; Core Interfaces (`PromoteInput`/`ConceptResult`); Data Models; Testing; Build Order steps 1, 5, 7, 9. +- `adr-004.md` (link target resolution + migration-effort estimate ~12 vs ~34 lines). +- `adr-005.md` (index.md regeneration / okf_version). +- PRD open questions: "catalog granularity" and "index.md/log.md ownership" should be resolved into the TechSpec. + +## Reviewer limitation noted + +Could not read OKF `SPEC.md` / sample bundles (not present in repo): the §9/§6/§7 +claims and "four producer fields" / field-order behavior were taken from the ADRs, +not independently verified against upstream. + +## Operational artifacts (round 1) + +- Findings: `qa/peer-review-findings-round1.md` +- Prompt: `qa/peer-review-prompt-round1.md` +- Events: `qa/peer-review-events-round1.jsonl` +- Stderr: `qa/peer-review-result-round1.err` +- Status snapshots: `qa/peer-review-status-{before,after}-round1.txt` diff --git a/.compozy/tasks/okf-dual-mode/qa/peer-review-summary-round2.md b/.compozy/tasks/okf-dual-mode/qa/peer-review-summary-round2.md new file mode 100644 index 0000000..383ee0b --- /dev/null +++ b/.compozy/tasks/okf-dual-mode/qa/peer-review-summary-round2.md @@ -0,0 +1,59 @@ +# Spec Peer Review — Round 2 Summary + +- **Spec:** `.compozy/tasks/okf-dual-mode/_techspec.md` (post round-1 incorporation) +- **Reviewer runtime:** `compozy exec` · claude / opus / xhigh (cross-LLM) +- **Verdict:** **NEEDS_REWORK** — 3 blockers, 5 nits +- **Round-1 closure:** B-001 and B-002 **confirmed closed** against current spec text. +- **Validator:** passed (exit 0); status before==after (scoped-write honored). + +## Blockers (new, field/file-level contract gaps) + +- **B-201 — four-field remap undefined for `description`/`timestamp`.** No `kb`-produced + doc carries `description` or `timestamp`; the only date source is `DateLayout` + (`2006-01-02`, date-only), so the "ISO 8601" `timestamp` and the `description` field + have no defined origin. *Fix:* explicit source→OKF remap table with fallbacks; inject + the clock as a seam for tests. +- **B-202 — checker hard-errors on the scaffold's own `CLAUDE.md`/`AGENTS.md`.** Only + `index.md`/`log.md` are reserved, so a freshly scaffolded bundle fails `kb okf check` + — contradicting the e2e test and the PRD success metric; likely also breaks the + official-bundle pass if they carry `README.md`. *Fix:* define the full + reserved/excluded set (`index.md`, `log.md`, `CLAUDE.md`, `AGENTS.md`, skip symlinks, + `README`/license), scope `type` to concept files, add assertions. +- **B-203 — filename vs inbound-link key are asymmetric.** Filename = `slug(title)`, + link target = `slug(base(docPath))`; they diverge for ingested/authored docs, so a + link to an already-promoted concept resolves to a phantom path and is falsely logged + as unresolved. *Fix:* derive both from one canonical key (recommend keying the + filename off `slug(base(sourceDocPath))` — the same input links use — or resolve via + a maintained index/manifest). + +## Nits + +- **N-201** — reuse `vault.SlugifySegment` (not a fresh `slug()`); note `"item"` empty + fallback and accent-drop (`Conteúdo`→`conte-do`) for PT-BR names. +- **N-202** — `promote --to` must reject a non-`okf` target (else `[[wikilinks]]` leak + into an OKF concept). +- **N-203** — specify `#anchor` fragment handling in the wikilink mapping (`[label](slug.md#anchor)`). +- **N-204** — `type TopicMode = string` is an alias; use a defined type per ADR-003. +- **N-205** — add observability events for "promote rejected: target not okf" and + "reserved file skipped". + +## Sections / ADRs likely affected by incorporation + +- `_techspec.md`: Data Models (frontmatter remap table, `TopicMode` defined type), + Concept Path Model (single canonical key, anchor handling), Data Flow → Scaffold + + Promote, Testing, Monitoring/Observability. +- `adr-005.md` (reserved/excluded file set), `adr-003.md` (defined `TopicMode`), + `adr-002.md` (description/timestamp sourcing if it touches the product contract). + +## Reviewer limitation noted + +OKF `SPEC.md` / sample bundles not in repo: the §9.2 reserved-file exemptions (B-202) +and the exact `timestamp` ISO-8601 shape (B-201) are reasoned from ADRs, to be +re-confirmed at implementation time against the vendored bundles. + +## Operational artifacts (round 2) + +- Findings: `qa/peer-review-findings-round2.md` +- Prompt: `qa/peer-review-prompt-round2.md` +- Events: `qa/peer-review-events-round2.jsonl` · Stderr: `qa/peer-review-result-round2.err` +- Status snapshots: `qa/peer-review-status-{before,after}-round2.txt` diff --git a/.compozy/tasks/pr-25/reviews-001/issue_001.md b/.compozy/tasks/pr-25/reviews-001/issue_001.md new file mode 100644 index 0000000..6cda32d --- /dev/null +++ b/.compozy/tasks/pr-25/reviews-001/issue_001.md @@ -0,0 +1,110 @@ +--- +provider: coderabbit +pr: "25" +round: 1 +round_created_at: 2026-06-28T02:05:08.396169Z +status: resolved +file: internal/cli/okf.go +line: 74 +author: coderabbitai[bot] +provider_ref: thread:PRRT_kwDOR-Fawc6MxHSr,comment:PRRC_kwDOR-Fawc7P2i8f +--- + +# Issue 001: _🎯 Functional Correctness_ | _🟠 Major_ | _⚡ Quick win_ +## Review Comment + +_🎯 Functional Correctness_ | _🟠 Major_ | _⚡ Quick win_ + +**Reject non-OKF topics before running bundle checks.** + +`runOKFTopicInfo` gives you `TopicInfo.Mode`, but this command ignores it and always hands `RootPath` to `kokf.Check`. Since `kokf.Check` only validates that the path is a directory, `kb okf check <wiki-topic>` will lint the wrong tree instead of failing fast. + + + + + + +<details> +<summary>Suggested fix</summary> + +```diff + import ( + "fmt" + "os" + + "github.com/spf13/cobra" + ++ "github.com/compozy/kb/internal/models" + kokf "github.com/compozy/kb/internal/okf" + "github.com/compozy/kb/internal/output" + ktopic "github.com/compozy/kb/internal/topic" + ) +@@ + topicInfo, err := runOKFTopicInfo(vaultPath, topicSlug) + if err != nil { + return fmt.Errorf("okf check: %w", err) + } ++ if topicInfo.Mode != models.TopicModeOKF { ++ return fmt.Errorf("okf check: topic %q is not an OKF topic", topicSlug) ++ } + + issues, err := runOKFCheck(commandContext(cmd), topicInfo.RootPath, kokf.CheckOptions{ +``` +</details> + +<!-- suggestion_start --> + +<details> +<summary>📝 Committable suggestion</summary> + +> ‼️ **IMPORTANT** +> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements. + +```suggestion + topicInfo, err := runOKFTopicInfo(vaultPath, topicSlug) + if err != nil { + return fmt.Errorf("okf check: %w", err) + } + if topicInfo.Mode != models.TopicModeOKF { + return fmt.Errorf("okf check: topic %q is not an OKF topic", topicSlug) + } + + issues, err := runOKFCheck(commandContext(cmd), topicInfo.RootPath, kokf.CheckOptions{ + Types: cfg.OKF.Types, + Strict: options.Strict, + }) +``` + +</details> + +<!-- suggestion_end --> + +<details> +<summary>🤖 Prompt for AI Agents</summary> + +``` +Verify each finding against current code. Fix only still-valid issues, skip the +rest with a brief reason, keep changes minimal, and validate. + +In `@internal/cli/okf.go` around lines 66 - 74, The okf check command currently +ignores TopicInfo.Mode from runOKFTopicInfo and always passes TopicInfo.RootPath +into kokf.Check, so non-OKF topics can be linted as directories instead of being +rejected. Update the okf check flow in the command handler to inspect the +returned TopicInfo.Mode before calling kokf.Check, fail fast for non-OKF topics, +and only run the bundle check when the topic is actually an OKF topic. +``` + +</details> + +<!-- fingerprinting:phantom:poseidon:grasshopper --> + +<!-- cr-indicator-types:potential_issue --> + +<!-- cr-comment:v1:2ddd87dea620484e1aac90b5 --> + +<!-- This is an auto-generated comment by CodeRabbit --> + +## Triage + +- Decision: `valid` +- Notes: `runOKFCheckCommand` resolves `TopicInfo` but currently calls `kokf.Check` without checking `TopicInfo.Mode`. A wiki topic directory can therefore be checked as if it were an OKF bundle. Fix by rejecting non-OKF topics before invoking the checker and add CLI coverage for that path. diff --git a/.compozy/tasks/pr-25/reviews-001/issue_002.md b/.compozy/tasks/pr-25/reviews-001/issue_002.md new file mode 100644 index 0000000..c15aa1a --- /dev/null +++ b/.compozy/tasks/pr-25/reviews-001/issue_002.md @@ -0,0 +1,56 @@ +--- +provider: coderabbit +pr: "25" +round: 1 +round_created_at: 2026-06-28T02:05:08.396169Z +status: resolved +file: internal/cli/okf_integration_test.go +line: 87 +author: coderabbitai[bot] +provider_ref: thread:PRRT_kwDOR-Fawc6MxHSX,comment:PRRC_kwDOR-Fawc7P2i8H +--- + +# Issue 002: _🎯 Functional Correctness_ | _🟡 Minor_ | _⚡ Quick win_ +## Review Comment + +_🎯 Functional Correctness_ | _🟡 Minor_ | _⚡ Quick win_ + +**The promotion log assertion does not validate the promoted entry.** + +`frontmatter.DateLayout[:4]` is the literal `"2006"`, not the current year, and the `**Creation**` fallback is already present on a freshly scaffolded topic. A missing `InsertLogEntry` call would still pass here; assert a promotion-specific marker such as the promoted title or output path instead. + +As per path instructions, "`**/*_test.go`: Ensure tests verify behavior outcomes, not just function calls`." + +<details> +<summary>🤖 Prompt for AI Agents</summary> + +``` +Verify each finding against current code. Fix only still-valid issues, skip the +rest with a brief reason, keep changes minimal, and validate. + +In `@internal/cli/okf_integration_test.go` around lines 80 - 87, The promotion log +check in the test is too weak because it can pass without verifying that +InsertLogEntry actually recorded the promotion. Update the assertion in +okf_integration_test.go to look for a promotion-specific outcome in log.md, +using unique symbols like InsertLogEntry, frontmatter.DateLayout, and the +promoted topic/asset name, rather than the literal year prefix or the generic +**Creation** entry. Ensure the test fails unless the promotion entry itself is +present. +``` + +</details> + +<!-- fingerprinting:phantom:poseidon:grasshopper --> + +<!-- cr-indicator-types:potential_issue --> + +<!-- cr-comment:v1:dca60aa4fc7207a5540cf599 --> + +_Source: Path instructions_ + +<!-- This is an auto-generated comment by CodeRabbit --> + +## Triage + +- Decision: `valid` +- Notes: The integration test reads `log.md` but only checks the literal layout year prefix or the scaffold creation entry. Both can exist without a promotion log entry. Fix by asserting promotion-specific content such as the promoted title/path/source in the log. diff --git a/.compozy/tasks/pr-25/reviews-001/issue_003.md b/.compozy/tasks/pr-25/reviews-001/issue_003.md new file mode 100644 index 0000000..f9a2777 --- /dev/null +++ b/.compozy/tasks/pr-25/reviews-001/issue_003.md @@ -0,0 +1,121 @@ +--- +provider: coderabbit +pr: "25" +round: 1 +round_created_at: 2026-06-28T02:05:08.396169Z +status: resolved +file: internal/cli/okf_test.go +line: 75 +author: coderabbitai[bot] +provider_ref: thread:PRRT_kwDOR-Fawc6MxHSb,comment:PRRC_kwDOR-Fawc7P2i8K +--- + +# Issue 003: _🎯 Functional Correctness_ | _🟡 Minor_ | _⚡ Quick win_ +## Review Comment + +_🎯 Functional Correctness_ | _🟡 Minor_ | _⚡ Quick win_ + +**Assert that the positional source document reaches `kokf.Promote`.** + +This test only checks flags and config-derived fields. If `args[0]` stopped being forwarded, the command would be broken at runtime and this would still pass. + +As per path instructions, "`**/*_test.go`: Focus on critical paths: parsing`" and "`Ensure tests verify behavior outcomes, not just function calls`." + + + + + +<details> +<summary>Suggested assertion</summary> + +```diff + if gotInput.VaultPath != "/tmp/vault" || gotInput.TargetTopic.Slug != "catalog" || gotInput.Type != "Playbook" { + t.Fatalf("unexpected promote input: %#v", gotInput) + } ++ if gotInput.SourceDocPath != "research/wiki/concepts/Alpha.md" { ++ t.Fatalf("source doc path = %q, want research/wiki/concepts/Alpha.md", gotInput.SourceDocPath) ++ } + if gotInput.Description != "Alpha description." { + t.Fatalf("description = %q", gotInput.Description) + } +``` +</details> + +<!-- suggestion_start --> + +<details> +<summary>📝 Committable suggestion</summary> + +> ‼️ **IMPORTANT** +> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements. + +```suggestion + command.SetArgs([]string{ + "promote", "research/wiki/concepts/Alpha.md", + "--to", "catalog", + "--type", "Playbook", + "--description", "Alpha description.", + "--vault", "/tmp/vault", + }) + + if err := command.ExecuteContext(context.Background()); err != nil { + t.Fatalf("ExecuteContext returned error: %v", err) + } + if gotInput.VaultPath != "/tmp/vault" || gotInput.TargetTopic.Slug != "catalog" || gotInput.Type != "Playbook" { + t.Fatalf("unexpected promote input: %#v", gotInput) + } + if gotInput.SourceDocPath != "research/wiki/concepts/Alpha.md" { + t.Fatalf("source doc path = %q, want research/wiki/concepts/Alpha.md", gotInput.SourceDocPath) + } + if gotInput.Description != "Alpha description." { + t.Fatalf("description = %q", gotInput.Description) + } + if len(gotInput.Types) != 1 || gotInput.Types[0] != "Playbook" { + t.Fatalf("types = %#v, want Playbook", gotInput.Types) + } + + var result kokf.ConceptResult + if err := json.Unmarshal(stdout.Bytes(), &result); err != nil { + t.Fatalf("stdout did not contain JSON: %v\n%s", err, stdout.String()) + } + if result.WrittenPath != "alpha.md" || result.Type != "Playbook" { + t.Fatalf("unexpected result: %#v", result) + } +``` + +</details> + +<!-- suggestion_end --> + +<details> +<summary>🤖 Prompt for AI Agents</summary> + +``` +Verify each finding against current code. Fix only still-valid issues, skip the +rest with a brief reason, keep changes minimal, and validate. + +In `@internal/cli/okf_test.go` around lines 48 - 75, The test for the promote +command currently verifies flags and output but does not confirm the positional +source document is forwarded into kokf.Promote. Update the assertion around the +promote path in okf_test.go to capture and verify the input passed to Promot(e) +includes the source argument from command args[0] +(research/wiki/concepts/Alpha.md), so a regression in positional-argument +plumbing will fail the test. +``` + +</details> + +<!-- fingerprinting:phantom:poseidon:grasshopper --> + +<!-- cr-indicator-types:potential_issue --> + +<!-- cr-comment:v1:fa18721bdfae2345e4e6824c --> + +_Source: Path instructions_ + +<!-- This is an auto-generated comment by CodeRabbit --> + +## Triage + +- Decision: `valid` +- Notes: `TestPromoteCommandResolvesTargetAndPrintsJSON` captures `kokf.PromoteInput` but does not assert `SourceDocPath`, so losing the positional argument would not fail the test. Fix by asserting the exact source document path forwarded from `args[0]`. diff --git a/.compozy/tasks/pr-25/reviews-001/issue_004.md b/.compozy/tasks/pr-25/reviews-001/issue_004.md new file mode 100644 index 0000000..421f9df --- /dev/null +++ b/.compozy/tasks/pr-25/reviews-001/issue_004.md @@ -0,0 +1,31 @@ +--- +provider: coderabbit +pr: "25" +round: 1 +round_created_at: 2026-06-28T02:05:08.396169Z +status: resolved +file: internal/cli/topic_test.go +line: 82 +severity: nitpick +author: coderabbitai[bot] +provider_ref: review:4586821980,nitpick_hash:8ce3d8c0a195 +review_hash: 8ce3d8c0a195 +source_review_id: "4586821980" +source_review_submitted_at: "2026-06-28T02:04:32Z" +--- + +# Issue 004: Use the required subtest pattern for the new mode cases. +## Review Comment + +These additions are a good fit for a small table-driven block with `t.Run("Should...")`, which is the test shape required in this repo. + +As per coding guidelines, "`**/*_test.go`: Default to table-driven tests with focused helpers and `t.TempDir()` for filesystem isolation.`" As per path instructions, "`**/*_test.go`: MUST use t.Run("Should...") pattern for ALL test cases`." + +<!-- cr-comment:v1:028c3d0377c2690f9fbf6343 --> + +_Sources: Coding guidelines, Path instructions_ + +## Triage + +- Decision: `valid` +- Notes: The new mode test in `internal/cli/topic_test.go` is a direct top-level body rather than a `t.Run("Should...")` case, which violates the repo test convention for new cases. Fix by wrapping the OKF-mode command coverage in a behavior-named subtest. diff --git a/.compozy/tasks/pr-25/reviews-001/issue_005.md b/.compozy/tasks/pr-25/reviews-001/issue_005.md new file mode 100644 index 0000000..4c9ec7f --- /dev/null +++ b/.compozy/tasks/pr-25/reviews-001/issue_005.md @@ -0,0 +1,56 @@ +--- +provider: coderabbit +pr: "25" +round: 1 +round_created_at: 2026-06-28T02:05:08.396169Z +status: resolved +file: internal/cli/topic_test.go +line: 140 +author: coderabbitai[bot] +provider_ref: thread:PRRT_kwDOR-Fawc6MxHSu,comment:PRRC_kwDOR-Fawc7P2i8i +--- + +# Issue 005: _🎯 Functional Correctness_ | _🟡 Minor_ | _⚡ Quick win_ +## Review Comment + +_🎯 Functional Correctness_ | _🟡 Minor_ | _⚡ Quick win_ + +**Tighten the invalid-mode assertion.** + +`strings.Contains(err.Error(), "invalid --mode")` can still pass on the wrong failure path. Compare the full message or use the shared error helper so this only passes on the intended validation error. + +As per path instructions, "`**/*_test.go`: MUST have specific error assertions (ErrorContains, ErrorAs)`." + +<details> +<summary>🤖 Prompt for AI Agents</summary> + +``` +Verify each finding against current code. Fix only still-valid issues, skip the +rest with a brief reason, keep changes minimal, and validate. + +In `@internal/cli/topic_test.go` around lines 127 - 140, The invalid-mode test in +TestTopicNewCommandRejectsInvalidMode is too loose because +strings.Contains(err.Error(), "invalid --mode") can match unrelated failures; +update the assertion to use the shared error-checking helper or compare the full +validation message from newRootCommand()/command.ExecuteContext so only the +intended --mode validation error passes. Keep the test anchored on the specific +invalid-mode path by asserting against the exact error content rather than a +substring. +``` + +</details> + +<!-- fingerprinting:phantom:poseidon:grasshopper --> + +<!-- cr-indicator-types:potential_issue --> + +<!-- cr-comment:v1:b0d03294055de1a0067118fe --> + +_Source: Path instructions_ + +<!-- This is an auto-generated comment by CodeRabbit --> + +## Triage + +- Decision: `valid` +- Notes: The invalid-mode assertion only checks `strings.Contains(err.Error(), "invalid --mode")`, which could pass for the wrong validation path. Fix by asserting the complete expected validation message. diff --git a/.compozy/tasks/pr-25/reviews-001/issue_006.md b/.compozy/tasks/pr-25/reviews-001/issue_006.md new file mode 100644 index 0000000..1c08b57 --- /dev/null +++ b/.compozy/tasks/pr-25/reviews-001/issue_006.md @@ -0,0 +1,31 @@ +--- +provider: coderabbit +pr: "25" +round: 1 +round_created_at: 2026-06-28T02:05:08.396169Z +status: resolved +file: internal/config/config_test.go +line: 40 +severity: minor +author: coderabbitai[bot] +provider_ref: review:4586821980,nitpick_hash:fc7f6cc4e9e2 +review_hash: fc7f6cc4e9e2 +source_review_id: "4586821980" +source_review_submitted_at: "2026-06-28T02:04:32Z" +--- + +# Issue 006: Wrap these new config cases in t.Run("Should...") subtests. +## Review Comment + +The OKF assertions add meaningful coverage, but they extend `_test.go` cases without the required subtest structure, and the normalization scenario is a good fit for a small table-driven case. As per coding guidelines, `**/*_test.go`: "Default to table-driven tests with focused helpers and `t.TempDir()` for filesystem isolation." As per path instructions, `**/*_test.go`: "MUST use `t.Run("Should...")` pattern for ALL test cases." + +Also applies to: 109-183 + +<!-- cr-comment:v1:46f8af9c987318fa272413f3 --> + +_Sources: Coding guidelines, Path instructions_ + +## Triage + +- Decision: `valid` +- Notes: The OKF default and normalization assertions were appended inside broad config tests rather than behavior-named subtests. Fix by wrapping those OKF-specific assertions in `t.Run("Should...")` blocks while preserving the existing config coverage. diff --git a/.compozy/tasks/pr-25/reviews-001/issue_007.md b/.compozy/tasks/pr-25/reviews-001/issue_007.md new file mode 100644 index 0000000..9a9009f --- /dev/null +++ b/.compozy/tasks/pr-25/reviews-001/issue_007.md @@ -0,0 +1,27 @@ +--- +provider: coderabbit +pr: "25" +round: 1 +round_created_at: 2026-06-28T02:05:08.396169Z +status: resolved +file: internal/generate/generate.go +line: 451 +severity: major +author: coderabbitai[bot] +provider_ref: review:4586821980,nitpick_hash:55582f299925 +review_hash: 55582f299925 +source_review_id: "4586821980" +source_review_submitted_at: "2026-06-28T02:04:32Z" +--- + +# Issue 007: Don’t hardcode wiki mode in generated topic metadata. +## Review Comment + +`linkFor` now dispatches through `LinkFormatterFor(topic)`, so forcing `Mode: wiki` here makes downstream renders emit wiki-style links even when the resolved topic is OKF. Carry the actual topic mode into `models.TopicMetadata` instead. + +<!-- cr-comment:v1:6c680e125ef7fc3b8a530621 --> + +## Triage + +- Decision: `valid` +- Notes: `runner.createTopicMetadata` always sets `Mode: models.TopicModeWiki`. When generation targets an existing OKF topic, downstream renderers use wiki-style links despite the topic mode. Fix by carrying the resolved topic mode into generation metadata instead of hard-coding wiki. Regression coverage belongs in `internal/generate/generate_test.go`, which is outside the listed production files but is the canonical test suite for `resolveTarget`. diff --git a/.compozy/tasks/pr-25/reviews-001/issue_008.md b/.compozy/tasks/pr-25/reviews-001/issue_008.md new file mode 100644 index 0000000..2ccf58e --- /dev/null +++ b/.compozy/tasks/pr-25/reviews-001/issue_008.md @@ -0,0 +1,77 @@ +--- +provider: coderabbit +pr: "25" +round: 1 +round_created_at: 2026-06-28T02:05:08.396169Z +status: resolved +file: internal/okf/official_integration_test.go +line: 16 +author: coderabbitai[bot] +provider_ref: thread:PRRT_kwDOR-Fawc6MxHSx,comment:PRRC_kwDOR-Fawc7P2i8k +--- + +# Issue 008: _📐 Maintainability & Code Quality_ | _🟡 Minor_ | _⚡ Quick win_ +## Review Comment + +_📐 Maintainability & Code Quality_ | _🟡 Minor_ | _⚡ Quick win_ + +**Use `Should...` subtest names for official bundle cases.** + +The subtest names are currently just bundle IDs. Rename them to the required behavior-oriented pattern. As per path instructions, `MUST use t.Run("Should...") pattern for ALL test cases`. + +<details> +<summary>Proposed fix</summary> + +```diff +- t.Run(bundle, func(t *testing.T) { ++ t.Run("Should pass lenient conformance for "+bundle, func(t *testing.T) { +``` +</details> + +<!-- suggestion_start --> + +<details> +<summary>📝 Committable suggestion</summary> + +> ‼️ **IMPORTANT** +> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements. + +```suggestion + for _, bundle := range []string{"ga4", "stackoverflow", "crypto_bitcoin"} { + bundle := bundle + t.Run("Should pass lenient conformance for "+bundle, func(t *testing.T) { +``` + +</details> + +<!-- suggestion_end --> + +<details> +<summary>🤖 Prompt for AI Agents</summary> + +``` +Verify each finding against current code. Fix only still-valid issues, skip the +rest with a brief reason, keep changes minimal, and validate. + +In `@internal/okf/official_integration_test.go` around lines 14 - 16, The official +integration subtests are using raw bundle IDs as names instead of the required +behavior-oriented `Should...` pattern. Update the `t.Run` call in +`official_integration_test.go` so each case name follows the `Should...` +convention while still iterating over the same bundle list, keeping the test +logic in `t.Run` and the surrounding loop unchanged. +``` + +</details> + +<!-- cr-indicator-types:potential_issue --> + +<!-- cr-comment:v1:a3d3e1c78f85240461833e5b --> + +_Source: Path instructions_ + +<!-- This is an auto-generated comment by CodeRabbit --> + +## Triage + +- Decision: `valid` +- Notes: Official bundle integration subtests use raw bundle IDs as test names. The repo convention requires behavior-oriented `Should...` subtest names. Fix by renaming the subtests while keeping the same cases and assertions. diff --git a/.compozy/tasks/pr-25/reviews-001/issue_009.md b/.compozy/tasks/pr-25/reviews-001/issue_009.md new file mode 100644 index 0000000..50ccff7 --- /dev/null +++ b/.compozy/tasks/pr-25/reviews-001/issue_009.md @@ -0,0 +1,55 @@ +--- +provider: coderabbit +pr: "25" +round: 1 +round_created_at: 2026-06-28T02:05:08.396169Z +status: resolved +file: internal/okf/okf.go +line: 128 +author: coderabbitai[bot] +provider_ref: thread:PRRT_kwDOR-Fawc6MxHS5,comment:PRRC_kwDOR-Fawc7P2i8r +--- + +# Issue 009: _🗄️ Data Integrity & Integration_ | _🟠 Major_ | _⚡ Quick win_ +## Review Comment + +_🗄️ Data Integrity & Integration_ | _🟠 Major_ | _⚡ Quick win_ + +**Make concept path allocation atomic.** + +`allocateConceptPath` checks for a missing file with `os.Stat`, but `Promote` writes later with `os.WriteFile`. Two concurrent promotions for the same key can both select the same path and the last writer wins, while index/log updates race around the overwritten concept. Reserve/write the file with `O_CREATE|O_EXCL` or retry the full path-dependent transform on collision. + + + + + +Also applies to: 389-398 + +<details> +<summary>🤖 Prompt for AI Agents</summary> + +``` +Verify each finding against current code. Fix only still-valid issues, skip the +rest with a brief reason, keep changes minimal, and validate. + +In `@internal/okf/okf.go` around lines 94 - 128, Make concept path allocation +atomic in Promote/allocateConceptPath: the current os.Stat check can race with +the later os.WriteFile in Promot e, letting two concurrent promotions pick the +same concept path. Update allocateConceptPath to reserve the file with exclusive +creation (O_CREATE|O_EXCL) or otherwise atomically claim the path before +writing, and on collision retry the path selection and dependent transform using +conceptKey, allocateConceptPath, and os.WriteFile. +``` + +</details> + +<!-- cr-indicator-types:potential_issue --> + +<!-- cr-comment:v1:86eeac6d5e42ab419a6ac1a8 --> + +<!-- This is an auto-generated comment by CodeRabbit --> + +## Triage + +- Decision: `valid` +- Notes: `allocateConceptPath` uses `os.Stat` and returns a path that `Promote` writes later with `os.WriteFile`, so concurrent promotions can pick the same filename and overwrite each other. Fix by atomically reserving the path with exclusive creation and writing the generated document through that reserved file. diff --git a/.compozy/tasks/pr-25/reviews-001/issue_010.md b/.compozy/tasks/pr-25/reviews-001/issue_010.md new file mode 100644 index 0000000..d33944e --- /dev/null +++ b/.compozy/tasks/pr-25/reviews-001/issue_010.md @@ -0,0 +1,87 @@ +--- +provider: coderabbit +pr: "25" +round: 1 +round_created_at: 2026-06-28T02:05:08.396169Z +status: resolved +file: internal/okf/okf.go +line: 580 +author: coderabbitai[bot] +provider_ref: thread:PRRT_kwDOR-Fawc6MxHS7,comment:PRRC_kwDOR-Fawc7P2i8s +--- + +# Issue 010: _🗄️ Data Integrity & Integration_ | _🟠 Major_ | _⚡ Quick win_ +## Review Comment + +_🗄️ Data Integrity & Integration_ | _🟠 Major_ | _⚡ Quick win_ + +**Return parse errors instead of dropping concepts from the regenerated index.** + +A malformed concept frontmatter currently makes `loadConcepts` silently skip that file, so `RegenerateIndex` can rewrite `index.md` without existing concepts. Preserve the error and only skip files that truly have no frontmatter. + +<details> +<summary>Proposed fix</summary> + +```diff + values, _, err := frontmatter.Parse(string(content)) +- if err != nil || len(values) == 0 { ++ if err != nil { ++ return fmt.Errorf("parse concept %s: %w", relativePath, err) ++ } ++ if len(values) == 0 { + return nil + } +``` +</details> + +<!-- suggestion_start --> + +<details> +<summary>📝 Committable suggestion</summary> + +> ‼️ **IMPORTANT** +> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements. + +```suggestion + values, _, err := frontmatter.Parse(string(content)) + if err != nil { + return fmt.Errorf("parse concept %s: %w", relativePath, err) + } + if len(values) == 0 { + return nil + } +``` + +</details> + +<!-- suggestion_end --> + +<details> +<summary>🤖 Prompt for AI Agents</summary> + +``` +Verify each finding against current code. Fix only still-valid issues, skip the +rest with a brief reason, keep changes minimal, and validate. + +In `@internal/okf/okf.go` around lines 577 - 580, In loadConcepts, the +frontmatter.Parse error is currently being swallowed by returning nil, which +lets malformed concept files disappear from the regenerated index. Update the +logic around frontmatter.Parse so it preserves and returns parse errors, and +only skips files when there is truly no frontmatter (for example, an empty +values result without an error). Make sure the handling in loadConcepts still +supports RegenerateIndex but does not silently drop concepts from malformed +files. +``` + +</details> + +<!-- cr-indicator-types:potential_issue --> + +<!-- cr-comment:v1:bb73ea14d5a07e58bd7ee4db --> + +<!-- This is an auto-generated comment by CodeRabbit --> + +## Triage + +- Decision: `valid` +- Notes: `loadConcepts` currently treats `frontmatter.Parse` errors the same as absent frontmatter and silently skips the malformed file. `RegenerateIndex` can then drop an existing concept. Fix by returning parse errors and only skipping files that parse successfully with no frontmatter values. diff --git a/.compozy/tasks/pr-25/reviews-001/issue_011.md b/.compozy/tasks/pr-25/reviews-001/issue_011.md new file mode 100644 index 0000000..0c1ca41 --- /dev/null +++ b/.compozy/tasks/pr-25/reviews-001/issue_011.md @@ -0,0 +1,29 @@ +--- +provider: coderabbit +pr: "25" +round: 1 +round_created_at: 2026-06-28T02:05:08.396169Z +status: resolved +file: internal/okf/okf_test.go +line: 15 +severity: nitpick +author: coderabbitai[bot] +provider_ref: review:4586821980,nitpick_hash:799c4d0503e6 +review_hash: 799c4d0503e6 +source_review_id: "4586821980" +source_review_submitted_at: "2026-06-28T02:04:32Z" +--- + +# Issue 011: Wrap test cases in Should... subtests. +## Review Comment + +These scenarios are direct top-level test bodies; the repo test instructions require `t.Run("Should...")` for all test cases. The repeated strict/lenient check cases are also good candidates for a small table. As per path instructions, `MUST use t.Run("Should...") pattern for ALL test cases`; as per coding guidelines, default to table-driven tests. + +<!-- cr-comment:v1:aed6da973f24c45c676f882e --> + +_Sources: Coding guidelines, Path instructions_ + +## Triage + +- Decision: `valid` +- Notes: New OKF tests include direct top-level scenarios and repeated strict/lenient checks that are not wrapped in `t.Run("Should...")` cases. Fix by adding behavior-named subtests and keeping assertions focused on real OKF behavior. diff --git a/.compozy/tasks/pr-25/reviews-001/issue_012.md b/.compozy/tasks/pr-25/reviews-001/issue_012.md new file mode 100644 index 0000000..d6cf337 --- /dev/null +++ b/.compozy/tasks/pr-25/reviews-001/issue_012.md @@ -0,0 +1,75 @@ +--- +provider: coderabbit +pr: "25" +round: 1 +round_created_at: 2026-06-28T02:05:08.396169Z +status: resolved +file: internal/okf/okf_test.go +line: 224 +author: coderabbitai[bot] +provider_ref: thread:PRRT_kwDOR-Fawc6MxHS2,comment:PRRC_kwDOR-Fawc7P2i8o +--- + +# Issue 012: _🎯 Functional Correctness_ | _🟡 Minor_ | _⚡ Quick win_ +## Review Comment + +_🎯 Functional Correctness_ | _🟡 Minor_ | _⚡ Quick win_ + +**Assert diagnostic messages in `assertIssue`.** + +Matching only severity/file/target lets the tests pass when the checker reports the right field for the wrong reason. Add an expected message substring so these tests fail on behavior changes, not just missing entries. As per path instructions, tests must have specific error assertions and verify behavior outcomes. + +<details> +<summary>Proposed helper shape</summary> + +```diff +-func assertIssue(t *testing.T, issues []models.LintIssue, severity models.DiagnosticSeverity, filePath, target string) { ++func assertIssue( ++ t *testing.T, ++ issues []models.LintIssue, ++ severity models.DiagnosticSeverity, ++ filePath, target, messageContains string, ++) { + t.Helper() + for _, issue := range issues { +- if issue.Severity == severity && issue.FilePath == filePath && issue.Target == target { ++ if issue.Severity == severity && ++ issue.FilePath == filePath && ++ issue.Target == target && ++ strings.Contains(issue.Message, messageContains) { + return + } + } +``` +</details> + +<details> +<summary>🤖 Prompt for AI Agents</summary> + +``` +Verify each finding against current code. Fix only still-valid issues, skip the +rest with a brief reason, keep changes minimal, and validate. + +In `@internal/okf/okf_test.go` around lines 216 - 224, The test helper assertIssue +currently matches only severity, file path, and target, so it can miss +regressions in the actual diagnostic text. Update assertIssue in okf_test.go to +also accept an expected message substring and verify it against each +models.LintIssue’s message/diagnostic text before returning. Then update the +callers to pass the expected message so the tests assert the exact behavior +using the existing assertIssue helper. +``` + +</details> + +<!-- cr-indicator-types:potential_issue --> + +<!-- cr-comment:v1:868612d702d0a12b68003f1e --> + +_Source: Path instructions_ + +<!-- This is an auto-generated comment by CodeRabbit --> + +## Triage + +- Decision: `valid` +- Notes: `assertIssue` matches severity, file, and target but not the diagnostic message. A checker regression could report the right target for the wrong reason. Fix by requiring an expected message substring and updating callers. diff --git a/.compozy/tasks/pr-25/reviews-001/issue_013.md b/.compozy/tasks/pr-25/reviews-001/issue_013.md new file mode 100644 index 0000000..ed8b666 --- /dev/null +++ b/.compozy/tasks/pr-25/reviews-001/issue_013.md @@ -0,0 +1,55 @@ +--- +provider: coderabbit +pr: "25" +round: 1 +round_created_at: 2026-06-28T02:05:08.396169Z +status: resolved +file: internal/topic/topic.go +line: 440 +author: coderabbitai[bot] +provider_ref: thread:PRRT_kwDOR-Fawc6MxHTB,comment:PRRC_kwDOR-Fawc7P2i82 +--- + +# Issue 013: _🗄️ Data Integrity & Integration_ | _🟠 Major_ | _⚡ Quick win_ +## Review Comment + +_🗄️ Data Integrity & Integration_ | _🟠 Major_ | _⚡ Quick win_ + +**Keep the standard topic directories for OKF topics.** + +Short-circuiting here means `kb topic new --mode okf` no longer creates `raw/`, `wiki/`, `outputs/`, or `bases/`. That breaks the topic skeleton contract and leaves `raw/codebase/` missing until some later command recreates it. Keep the normal scaffold and layer the OKF-specific files on top. + +As per coding guidelines, `kb topic new` owns the topic skeleton under the vault root, including `raw/`, `wiki/`, `outputs/`, `bases/`, `CLAUDE.md`, `AGENTS.md`, and `log.md`. Based on learnings, codebase inspection commands operate on `raw/codebase/` beneath the resolved topic. + +<details> +<summary>🤖 Prompt for AI Agents</summary> + +``` +Verify each finding against current code. Fix only still-valid issues, skip the +rest with a brief reason, keep changes minimal, and validate. + +In `@internal/topic/topic.go` around lines 437 - 440, The createTopicSkeleton +helper is incorrectly short-circuiting for TopicModeOKF, which skips the +standard topic scaffold. Update createTopicSkeleton so it always creates the +normal topic directories and base files owned by kb topic new, then apply any +OKF-specific additions on top; keep the scaffold generation for raw, wiki, +outputs, bases, CLAUDE.md, AGENTS.md, and log.md, and ensure raw/codebase +remains present for later inspection commands. +``` + +</details> + +<!-- fingerprinting:phantom:poseidon:grasshopper --> + +<!-- cr-indicator-types:potential_issue --> + +<!-- cr-comment:v1:bcdc5e0433253d84d0d11502 --> + +_Sources: Coding guidelines, Learnings_ + +<!-- This is an auto-generated comment by CodeRabbit --> + +## Triage + +- Decision: `valid` +- Notes: `createTopicSkeleton` returns after creating only the OKF root directory for OKF topics, skipping the standard `raw/`, `wiki/`, `outputs/`, and `bases/` directories owned by `kb topic new`. Fix by always creating the standard skeleton, then layering OKF-specific files/templates on top. diff --git a/.compozy/tasks/pr-25/reviews-001/issue_014.md b/.compozy/tasks/pr-25/reviews-001/issue_014.md new file mode 100644 index 0000000..ffe74e8 --- /dev/null +++ b/.compozy/tasks/pr-25/reviews-001/issue_014.md @@ -0,0 +1,29 @@ +--- +provider: coderabbit +pr: "25" +round: 1 +round_created_at: 2026-06-28T02:05:08.396169Z +status: resolved +file: internal/topic/topic.go +line: 552 +severity: major +author: coderabbitai[bot] +provider_ref: review:4586821980,nitpick_hash:062b4b1f33ec +review_hash: 062b4b1f33ec +source_review_id: "4586821980" +source_review_submitted_at: "2026-06-28T02:04:32Z" +--- + +# Issue 014: Preserve mode when rewriting topic.yaml. +## Review Comment + +`WriteMetadataFile` always passes `""`, so the YAML omits `mode`. `readTopicMetadata` then falls back to wiki, which will silently downgrade an OKF topic the next time a caller rewrites metadata through this helper. + +Also applies to: 560-566 + +<!-- cr-comment:v1:7bc4a93f80bde71e91e1364d --> + +## Triage + +- Decision: `valid` +- Notes: `topic.WriteMetadataFile` rewrites `topic.yaml` with an empty mode, so any caller that rewrites an OKF topic metadata file silently downgrades it to the wiki default on the next read. Fix by preserving the existing metadata mode when rewriting, falling back to wiki only when no mode exists. diff --git a/.compozy/tasks/pr-25/reviews-001/issue_015.md b/.compozy/tasks/pr-25/reviews-001/issue_015.md new file mode 100644 index 0000000..5db8bf4 --- /dev/null +++ b/.compozy/tasks/pr-25/reviews-001/issue_015.md @@ -0,0 +1,54 @@ +--- +provider: coderabbit +pr: "25" +round: 1 +round_created_at: 2026-06-28T02:05:08.396169Z +status: resolved +file: internal/topic/topic_test.go +line: 312 +author: coderabbitai[bot] +provider_ref: thread:PRRT_kwDOR-Fawc6MxHS_,comment:PRRC_kwDOR-Fawc7P2i8y +--- + +# Issue 015: _📐 Maintainability & Code Quality_ | _🟠 Major_ | _⚡ Quick win_ +## Review Comment + +_📐 Maintainability & Code Quality_ | _🟠 Major_ | _⚡ Quick win_ + +**Wrap the new cases in `t.Run("Should...")` subtests.** + +These additions are meaningful, but this repo’s Go test rules require the `t.Run("Should...")` pattern and default to table-driven cases for `*_test.go`. Converting the OKF and legacy-mode coverage into named subtests would bring the new tests back in line with the suite conventions. + +As per coding guidelines, `**/*_test.go`: Default to table-driven tests with focused helpers and `t.TempDir()` for filesystem isolation. As per path instructions, `**/*_test.go`: MUST use `t.Run("Should...")` pattern for ALL test cases. + +<details> +<summary>🤖 Prompt for AI Agents</summary> + +``` +Verify each finding against current code. Fix only still-valid issues, skip the +rest with a brief reason, keep changes minimal, and validate. + +In `@internal/topic/topic_test.go` around lines 236 - 312, Wrap the new test +coverage in named subtests using the required t.Run("Should...") pattern in +TestNewWithModeCreatesOKFTopicSkeleton and +TestReadTopicMetadataDefaultsMissingModeToWiki. Keep the existing assertions and +helpers, but move each case into a subtest block so the *_test.go file follows +the repo’s test conventions and remains table-driven/focused where appropriate. +``` + +</details> + +<!-- fingerprinting:phantom:poseidon:grasshopper --> + +<!-- cr-indicator-types:refactor_suggestion --> + +<!-- cr-comment:v1:ba365448fd2c7e058117c274 --> + +_Sources: Coding guidelines, Path instructions_ + +<!-- This is an auto-generated comment by CodeRabbit --> + +## Triage + +- Decision: `valid` +- Notes: The OKF skeleton and legacy-mode topic tests are direct top-level test bodies. The repo test convention requires new cases to use behavior-named `t.Run("Should...")` blocks. Fix by wrapping those scenarios without weakening their assertions. diff --git a/.compozy/tasks/pr-25/reviews-001/issue_016.md b/.compozy/tasks/pr-25/reviews-001/issue_016.md new file mode 100644 index 0000000..5ef8bb1 --- /dev/null +++ b/.compozy/tasks/pr-25/reviews-001/issue_016.md @@ -0,0 +1,29 @@ +--- +provider: coderabbit +pr: "25" +round: 1 +round_created_at: 2026-06-28T02:05:08.396169Z +status: resolved +file: internal/vault/pathutils_test.go +line: 199 +severity: nitpick +author: coderabbitai[bot] +provider_ref: review:4586821980,nitpick_hash:4f089a4d674b +review_hash: 4f089a4d674b +source_review_id: "4586821980" +source_review_submitted_at: "2026-06-28T02:04:32Z" +--- + +# Issue 016: Use t.Run("Should...") consistently in the new test cases. +## Review Comment + +This block mixes ad-hoc assertions with subtests, and the subtest names do not follow the required `Should...` pattern. Split the wiki/OKF/selector checks into named subtests so failures stay consistent with the repo’s test contract. As per path instructions, `**/*_test.go` “MUST use t.Run("Should...") pattern for ALL test cases.” + +<!-- cr-comment:v1:8850e596056339e4bad336c4 --> + +_Source: Path instructions_ + +## Triage + +- Decision: `valid` +- Notes: The new link formatter test cases use names like `same directory` and include some direct assertions outside behavior-named subtests. Fix by using `t.Run("Should...")` names consistently for the formatter scenarios and related selector checks. diff --git a/.compozy/tasks/pr-25/reviews-001/issue_017.md b/.compozy/tasks/pr-25/reviews-001/issue_017.md new file mode 100644 index 0000000..7c9cc33 --- /dev/null +++ b/.compozy/tasks/pr-25/reviews-001/issue_017.md @@ -0,0 +1,58 @@ +--- +provider: coderabbit +pr: "25" +round: 1 +round_created_at: 2026-06-28T02:05:08.396169Z +status: resolved +file: internal/vault/render.go +line: 191 +author: coderabbitai[bot] +provider_ref: thread:PRRT_kwDOR-Fawc6MxHTK,comment:PRRC_kwDOR-Fawc7P2i8- +--- + +# Issue 017: _🎯 Functional Correctness_ | _🟠 Major_ | _⚡ Quick win_ +## Review Comment + +_🎯 Functional Correctness_ | _🟠 Major_ | _⚡ Quick win_ + +**Pass the current document directory into `linkFor` for OKF mode.** + +These call sites still use `fromDir == ""`, so nested raw documents render links as if they were at the topic root. In OKF mode that breaks navigation on GitHub—for example, a link inside `raw/codebase/files/commands/run.ts.md` to `raw/codebase/symbols/main--commands-run-ts-l1.md` needs a relative target like `../../symbols/main--commands-run-ts-l1.md`, not `raw/codebase/symbols/...`. Thread the source document’s directory through `linkForNode` instead of hard-coding the root. + + + + + + +Also applies to: 224-224 + +<details> +<summary>🤖 Prompt for AI Agents</summary> + +``` +Verify each finding against current code. Fix only still-valid issues, skip the +rest with a brief reason, keep changes minimal, and validate. + +In `@internal/vault/render.go` around lines 190 - 191, Update the OKF link +generation path so `linkFor` receives the current document directory instead of +an empty root path. Thread the source directory through `linkForNode` and any +related callers that currently pass fromDir as "", so +`LinkFormatterFor(...).Link(...)` can build correct relative links for nested +raw documents. Focus on the `linkFor` and `linkForNode` flow in `render.go` and +preserve existing behavior outside OKF mode. +``` + +</details> + +<!-- fingerprinting:phantom:poseidon:grasshopper --> + +<!-- cr-indicator-types:potential_issue --> + +<!-- cr-comment:v1:7d09aebfa51c588d385156b7 --> + +<!-- This is an auto-generated comment by CodeRabbit --> + +## Triage + +- Decision: `valid` +- Notes: Raw document relation helpers pass `fromDir == ""` into `linkFor`, so OKF links in nested raw documents are generated from the bundle root. Fix by threading the current rendered document directory into relation and source-link helpers. diff --git a/.compozy/tasks/pr-25/reviews-001/issue_018.md b/.compozy/tasks/pr-25/reviews-001/issue_018.md new file mode 100644 index 0000000..265a826 --- /dev/null +++ b/.compozy/tasks/pr-25/reviews-001/issue_018.md @@ -0,0 +1,55 @@ +--- +provider: coderabbit +pr: "25" +round: 1 +round_created_at: 2026-06-28T02:05:08.396169Z +status: resolved +file: internal/vault/render_test.go +line: 290 +author: coderabbitai[bot] +provider_ref: thread:PRRT_kwDOR-Fawc6MxHTF,comment:PRRC_kwDOR-Fawc7P2i85 +--- + +# Issue 018: _🎯 Functional Correctness_ | _🟡 Minor_ | _⚡ Quick win_ +## Review Comment + +_🎯 Functional Correctness_ | _🟡 Minor_ | _⚡ Quick win_ + +**Assert the real relative targets for nested OKF documents.** + +These expectations currently lock in the broken root-scoped links. From `raw/codebase/files/commands/run.ts.md`, the symbol link should resolve relative to that directory (for this fixture, `../../symbols/main--commands-run-ts-l1.md`), and from `wiki/codebase/index/Codebase Dashboard.md` the concept link should be `../concepts/Codebase%20Overview.md`. As per path instructions, tests should verify behavior outcomes and not be weakened to fit broken behavior. + +<details> +<summary>🤖 Prompt for AI Agents</summary> + +``` +Verify each finding against current code. Fix only still-valid issues, skip the +rest with a brief reason, keep changes minimal, and validate. + +In `@internal/vault/render_test.go` around lines 266 - 290, The OKF link +assertions in TestRenderDocumentsUseOKFMarkdownLinkSyntax are currently +expecting root-scoped paths instead of the true relative targets. Update the +test to verify the actual nested relative links produced by +vault.RenderDocuments for the raw/codebase/files/commands/run.ts.md document and +the wiki/codebase/index/Codebase Dashboard.md document, using the symbol and +concept link outputs from the fixture rather than the broken root-relative +paths. Keep the checks focused on the rendered document bodies and ensure they +assert the correct relative markdown destinations. +``` + +</details> + +<!-- fingerprinting:phantom:poseidon:grasshopper --> + +<!-- cr-indicator-types:potential_issue --> + +<!-- cr-comment:v1:962b78f82ef77b6e60e8c4ce --> + +_Source: Path instructions_ + +<!-- This is an auto-generated comment by CodeRabbit --> + +## Triage + +- Decision: `valid` +- Notes: `TestRenderDocumentsUseOKFMarkdownLinkSyntax` currently asserts root-scoped OKF paths, which locks in the broken behavior. Fix the production link generation and update the test to assert true document-relative markdown targets. diff --git a/.compozy/tasks/pr-25/reviews-001/issue_019.md b/.compozy/tasks/pr-25/reviews-001/issue_019.md new file mode 100644 index 0000000..8870bbd --- /dev/null +++ b/.compozy/tasks/pr-25/reviews-001/issue_019.md @@ -0,0 +1,61 @@ +--- +provider: coderabbit +pr: "25" +round: 1 +round_created_at: 2026-06-28T02:05:08.396169Z +status: resolved +file: internal/vault/render_wiki.go +line: 117 +author: coderabbitai[bot] +provider_ref: thread:PRRT_kwDOR-Fawc6MxHTJ,comment:PRRC_kwDOR-Fawc7P2i89 +--- + +# Issue 019: _🎯 Functional Correctness_ | _🟠 Major_ | _⚡ Quick win_ +## Review Comment + +_🎯 Functional Correctness_ | _🟠 Major_ | _⚡ Quick win_ + +**Use each rendered document’s own directory when building OKF links.** + +All of these calls pass `""` as `fromDir`, so OKF-mode links in `wiki/codebase/index/*.md` and `wiki/codebase/concepts/*.md` are rendered as topic-rooted paths like `wiki/codebase/...`. GitHub resolves markdown targets relative to the current file, so those links break; e.g. dashboard → concept should be `../concepts/...`, and concept → concept links should usually be sibling-relative. Compute `fromDir` from the document being rendered before calling `linkFor`. + + + + + + +Also applies to: 147-147, 194-194, 314-316, 395-396, 461-462, 538-538, 616-616 + +<details> +<summary>🤖 Prompt for AI Agents</summary> + +``` +Verify each finding against current code. Fix only still-valid issues, skip the +rest with a brief reason, keep changes minimal, and validate. + +In `@internal/vault/render_wiki.go` around lines 108 - 117, The OKF link +generation in the wiki renderer is using an empty fromDir, so links in rendered +markdown are rooted at the topic instead of relative to the current document. +Update the linkFor calls in the wiki rendering flow to pass the rendered +document’s own directory as fromDir before building links, especially for the +codebase index and concept pages. Use the current document context from the +render function that emits wiki pages so dashboard/index-to-concept and +concept-to-concept links resolve with correct relative paths. Apply the same +fromDir fix to every linkFor invocation in this render path, including the +navigation and article link sections. +``` + +</details> + +<!-- fingerprinting:phantom:poseidon:grasshopper --> + +<!-- cr-indicator-types:potential_issue --> + +<!-- cr-comment:v1:1d183f40075d3c882bcdf343 --> + +<!-- This is an auto-generated comment by CodeRabbit --> + +## Triage + +- Decision: `valid` +- Notes: Wiki renderers call `linkFor` with an empty source directory while rendering files under `wiki/codebase/index/` and `wiki/codebase/concepts/`. OKF markdown links therefore resolve from the bundle root. Fix by computing each rendered document's directory and passing it into link generation. diff --git a/.compozy/tasks/pr-25/reviews-001/issue_020.md b/.compozy/tasks/pr-25/reviews-001/issue_020.md new file mode 100644 index 0000000..e2aa879 --- /dev/null +++ b/.compozy/tasks/pr-25/reviews-001/issue_020.md @@ -0,0 +1,83 @@ +--- +provider: coderabbit +pr: "25" +round: 1 +round_created_at: 2026-06-28T02:05:08.396169Z +status: resolved +file: internal/vault/writer.go +line: 477 +author: coderabbitai[bot] +provider_ref: thread:PRRT_kwDOR-Fawc6MxHTN,comment:PRRC_kwDOR-Fawc7P2i9E +--- + +# Issue 020: _🎯 Functional Correctness_ | _🟠 Major_ | _⚡ Quick win_ +## Review Comment + +_🎯 Functional Correctness_ | _🟠 Major_ | _⚡ Quick win_ + +**Pass `wiki/index` as the source directory for these OKF bridge links.** + +This block is rendered into files under `wiki/index/`, but `linkFor(topic, "", ...)` makes the OKF formatter resolve from the bundle root. That produces `wiki/codebase/index/...` instead of `../codebase/index/...`, so the bridge navigation breaks in OKF bundles. + + +<details> +<summary>Proposed fix</summary> + +```diff +- "- " + linkFor(topic, "", GetWikiIndexPath(CodebaseDashboardTitle), CodebaseDashboardTitle), +- "- " + linkFor(topic, "", GetWikiIndexPath(CodebaseConceptIndexTitle), CodebaseConceptIndexTitle), +- "- " + linkFor(topic, "", GetWikiIndexPath(CodebaseSourceIndexTitle), CodebaseSourceIndexTitle), ++ "- " + linkFor(topic, "wiki/index", GetWikiIndexPath(CodebaseDashboardTitle), CodebaseDashboardTitle), ++ "- " + linkFor(topic, "wiki/index", GetWikiIndexPath(CodebaseConceptIndexTitle), CodebaseConceptIndexTitle), ++ "- " + linkFor(topic, "wiki/index", GetWikiIndexPath(CodebaseSourceIndexTitle), CodebaseSourceIndexTitle), +``` +</details> + +<!-- suggestion_start --> + +<details> +<summary>📝 Committable suggestion</summary> + +> ‼️ **IMPORTANT** +> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements. + +```suggestion + "- " + linkFor(topic, "wiki/index", GetWikiIndexPath(CodebaseDashboardTitle), CodebaseDashboardTitle), + "- " + linkFor(topic, "wiki/index", GetWikiIndexPath(CodebaseConceptIndexTitle), CodebaseConceptIndexTitle), + "- " + linkFor(topic, "wiki/index", GetWikiIndexPath(CodebaseSourceIndexTitle), CodebaseSourceIndexTitle), +``` + +</details> + +<!-- suggestion_end --> + +<details> +<summary>🤖 Prompt for AI Agents</summary> + +``` +Verify each finding against current code. Fix only still-valid issues, skip the +rest with a brief reason, keep changes minimal, and validate. + +In `@internal/vault/writer.go` around lines 475 - 477, The OKF bridge links in +writer.go are being resolved from the bundle root because linkFor is called with +an empty source directory, which breaks navigation for files rendered under +wiki/index/. Update the three linkFor calls in this block to pass wiki/index as +the source directory so GetWikiIndexPath(CodebaseDashboardTitle), +GetWikiIndexPath(CodebaseConceptIndexTitle), and +GetWikiIndexPath(CodebaseSourceIndexTitle) resolve correctly in OKF bundles. +``` + +</details> + +<!-- fingerprinting:phantom:poseidon:grasshopper --> + +<!-- cr-indicator-types:potential_issue --> + +<!-- cr-comment:v1:e8adea6f301827daae62ae35 --> + +<!-- This is an auto-generated comment by CodeRabbit --> + +## Triage + +- Decision: `valid` +- Notes: Topic index bridge links are rendered into `wiki/index/` files but call `linkFor` with an empty source directory. OKF links point at `wiki/codebase/...` instead of `../codebase/...`. Fix by passing `wiki/index` for these bridge links. Regression coverage is in `internal/vault/writer_test.go` because the bridge helper is unexported and `WriteVault` is the observable behavior. diff --git a/.compozy/tasks/pr-25/reviews-002/issue_001.md b/.compozy/tasks/pr-25/reviews-002/issue_001.md new file mode 100644 index 0000000..aecc691 --- /dev/null +++ b/.compozy/tasks/pr-25/reviews-002/issue_001.md @@ -0,0 +1,37 @@ +--- +provider: coderabbit +pr: "25" +round: 2 +round_created_at: 2026-06-28T02:25:19.579096Z +status: resolved +file: internal/cli/okf_test.go +line: 18 +severity: nitpick +author: coderabbitai[bot] +provider_ref: review:4586874593,nitpick_hash:887d2e909d89 +review_hash: 887d2e909d89 +source_review_id: "4586874593" +source_review_submitted_at: "2026-06-28T02:24:54Z" +--- + +# Issue 001: Use the required t.Run("Should...") wrapper for these cases. +## Review Comment + +Both tests assert real CLI behavior, but the top-level cases still bypass the mandated subtest pattern. As per path instructions, `**/*_test.go`: `MUST use t.Run("Should...") pattern for ALL test cases`. + +Also applies to: 131-168 + +<!-- cr-comment:v1:d1b118684a42be33f6d1f393 --> + +_Source: Path instructions_ + +## Triage + +- Decision: `valid` +- Notes: + - The reviewed CLI tests are standalone top-level cases with no `t.Run("Should...")` wrapper. + - Local test conventions in this repository use `Should...` subtest names for concrete test cases, and the CodeRabbit path instruction explicitly requires that shape for `*_test.go` files. + - Root cause: the OKF CLI coverage added real command behavior assertions directly in the top-level test functions. + - Fix approach: preserve the existing assertions and test doubles while wrapping each reviewed OKF CLI case in a `t.Run("Should...")` subtest. + - Implemented: wrapped the OKF CLI command behavior cases in `t.Run("Should...")` subtests without changing assertions or command setup. + - Verification: `rtk go test ./internal/cli ./internal/vault` passed with 223 tests across 2 packages; `rtk make verify MAGE=` passed with 887 tests, 1 skipped, 0 lint issues, and package boundaries respected. diff --git a/.compozy/tasks/pr-25/reviews-002/issue_002.md b/.compozy/tasks/pr-25/reviews-002/issue_002.md new file mode 100644 index 0000000..b56fdc0 --- /dev/null +++ b/.compozy/tasks/pr-25/reviews-002/issue_002.md @@ -0,0 +1,35 @@ +--- +provider: coderabbit +pr: "25" +round: 2 +round_created_at: 2026-06-28T02:25:19.579096Z +status: resolved +file: internal/vault/writer_test.go +line: 213 +severity: nitpick +author: coderabbitai[bot] +provider_ref: review:4586874593,nitpick_hash:2cbbcbd7bb28 +review_hash: 2cbbcbd7bb28 +source_review_id: "4586874593" +source_review_submitted_at: "2026-06-28T02:24:54Z" +--- + +# Issue 002: Wrap this case in t.Run("Should..."). +## Review Comment + +This test hits the right behavior, but it skips the repo’s required subtest shape for Go tests. As per path instructions, `**/*_test.go`: `MUST use t.Run("Should...") pattern for ALL test cases`. + +<!-- cr-comment:v1:089aa296be46cc91a3cad901 --> + +_Source: Path instructions_ + +## Triage + +- Decision: `valid` +- Notes: + - `TestWriteVaultUsesRelativeOKFTopicIndexBridgeLinks` verifies real vault rendering behavior directly in the top-level test body. + - Local test convention and the CodeRabbit path instruction require concrete test cases to use `t.Run("Should...")`. + - Root cause: the OKF bridge-link regression test was added as a direct body rather than a named subtest. + - Fix approach: keep the same setup and assertions, but wrap the case in `t.Run("Should use relative OKF topic index bridge links", ...)`. + - Implemented: wrapped the bridge-link regression body in `t.Run("Should use relative OKF topic index bridge links", ...)` and kept the existing parallel execution inside the subtest. + - Verification: `rtk go test ./internal/cli ./internal/vault` passed with 223 tests across 2 packages; `rtk make verify MAGE=` passed with 887 tests, 1 skipped, 0 lint issues, and package boundaries respected. diff --git a/AGENTS.md b/AGENTS.md index ead6156..d1f2170 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -63,9 +63,11 @@ make help # mage target list | Command | Purpose | | --- | --- | -| `kb topic new <slug> <title> <domain>` | Scaffold a new knowledge base topic | +| `kb topic new <slug> <title> <domain>` | Scaffold a new knowledge base topic (`--mode wiki\|okf`) | | `kb topic list` | List scaffolded topics | | `kb topic info <slug>` | Show metadata for one topic | +| `kb promote <wiki-doc> --to <okf-topic> --type <type>` | Promote a compiled wiki document into an OKF topic | +| `kb okf check <topic>` | Check an OKF topic for bundle conformance | | `kb ingest url <url> --topic <slug>` | Scrape a web URL and ingest into a topic | | `kb ingest file <path> --topic <slug>` | Convert a local file and ingest into a topic | | `kb ingest youtube <url> --topic <slug>` | Extract a YouTube transcript and ingest into a topic | @@ -88,7 +90,9 @@ make help # mage target list ### Command Notes -- `topic` subcommands share the root `--vault` flag for vault path resolution. +- `topic` subcommands share the root `--vault` flag for vault path resolution; `topic new --mode okf` creates a root-level OKF bundle instead of the wiki/raw scaffold. +- `promote` requires `--to <okf-topic>` and `--type <type>`; `--description` overrides the generated concept description. +- `okf check` accepts `--strict` and `--format table|json|tsv`. - `ingest` subcommands require `--topic <slug>` to identify the target topic. - `ingest codebase` accepts `--include`, `--exclude`, `--semantic`, `--progress`, and `--log-format`. - `ingest youtube` accepts `--transcribe captions|auto|stt`; `yt-dlp` is required for YouTube metadata, captions, and audio extraction. @@ -104,6 +108,7 @@ make help # mage target list - `APP_CONFIG` overrides the config file path. - `.env` is loaded automatically when present. - `FIRECRAWL_API_KEY` and `FIRECRAWL_API_URL` configure the Firecrawl client for `ingest url`. +- `[okf].types` configures the optional OKF type vocabulary used by `promote` and `okf check`. - `OPENAI_API_KEY`, `OPENAI_API_URL`, `STT_PROVIDER`, and `STT_MODEL` configure the default OpenAI STT provider for `ingest youtube --transcribe auto|stt`. - `OPENROUTER_API_KEY`, `OPENROUTER_API_URL`, and `openrouter.stt_model` (TOML-only) configure the optional OpenRouter STT provider when `stt.provider = "openrouter"`. - Generation, inspect, search, and index behavior is configured by CLI flags rather than TOML keys. diff --git a/CLAUDE.md b/CLAUDE.md index d0ab51d..dfeaf44 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -78,7 +78,9 @@ make help ## CLI Surface -- `kb topic {new,list,info}` +- `kb topic {new,list,info}` (`topic new --mode wiki|okf`) +- `kb promote <wiki-doc> --to <okf-topic> --type <type>` +- `kb okf check <topic>` - `kb ingest {url,file,youtube,channel,instagram,codebase,bookmarks}` - `kb lint [<slug>]` - `kb inspect {smells|dead-code|complexity|blast-radius|coupling|symbol|file|backlinks|deps|circular-deps}` @@ -89,7 +91,7 @@ make help ## Runtime Config Notes -- `config.example.toml` documents every TOML section currently accepted by `internal/config`: `[app]`, `[log]`, `[vault]`, `[firecrawl]`, `[openrouter]`, `[stt]`, `[youtube]`, and `[instagram]`. +- `config.example.toml` documents every TOML section currently accepted by `internal/config`: `[app]`, `[log]`, `[vault]`, `[firecrawl]`, `[openrouter]`, `[stt]`, `[youtube]`, `[instagram]`, and `[okf]`. - `APP_CONFIG` selects the TOML file path. - `kb ingest youtube` requires `yt-dlp` for metadata, captions, and audio extraction. `[youtube].yt_dlp_path`, `[youtube].proxy`, `[youtube].cookies_file`, `[youtube].user_agent`, `[youtube].retry_attempts`, `[youtube].retry_backoff`, and `[youtube].transcription` configure that path. - YouTube transcription policy is `captions|auto|stt`: `captions` uses YouTube captions only, `auto` uses manual captions when present and STT when only automatic captions or no captions are available, and `stt` forces audio transcription. @@ -97,6 +99,7 @@ make help - OpenAI is the default STT provider through `/v1/audio/transcriptions`. Configure it with `[stt]` plus `OPENAI_API_KEY`, `OPENAI_API_URL`, `STT_PROVIDER`, and `STT_MODEL`. - OpenRouter is an optional STT provider when `stt.provider = "openrouter"`; configure it with `OPENROUTER_API_KEY`, `OPENROUTER_API_URL`, and `openrouter.stt_model`. - Long STT audio is segmented with `ffmpeg`; keep `[stt].ffmpeg_path`, `[stt].chunk_duration`, `[stt].max_chunk_bytes`, and `[stt].concurrency` aligned with provider upload limits. +- `[okf].types` configures the optional OKF type vocabulary used by `kb promote` and `kb okf check`. ## Testing Conventions diff --git a/README.md b/README.md index 29464f9..70e85cb 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,9 @@ $ kb topic new rust-lang "Rust Language" programming "domain": "programming" } +# scaffold an OKF bundle topic +$ kb topic new rust-catalog "Rust Catalog" programming --mode okf + # ingest a web article $ kb ingest url https://doc.rust-lang.org/book/ch04-01-what-is-ownership.html --topic rust-lang @@ -102,6 +105,12 @@ $ kb ingest codebase ./my-rust-project --topic rust-lang --progress never # lint the topic for structural issues $ kb lint rust-lang + +# promote a compiled wiki document into the OKF bundle +$ kb promote .kb/vault/rust-lang/wiki/concepts/Ownership.md --to rust-catalog --type Concept + +# check the OKF bundle +$ kb okf check rust-catalog --strict ``` Analyze codebase snapshots from the terminal: @@ -181,11 +190,29 @@ The key difference: `kb` outputs compound. A SonarQube scan from last month is s Scaffold and manage knowledge base topics. ```bash -kb topic new <slug> <title> <domain> # Create a new topic +kb topic new <slug> <title> <domain> [--mode wiki|okf] # Create a new topic kb topic list # List all topics in the vault kb topic info <slug> # Show metadata for a topic ``` +`--mode wiki` is the default scaffold. `--mode okf` creates a root-level Open Knowledge File bundle with `index.md`, `log.md`, and OKF authoring guidance. + +### `kb promote` + +Promote a compiled wiki document into an OKF topic without modifying the source document. + +```bash +kb promote <wiki-doc> --to <okf-topic> --type <type> [--description <text>] +``` + +### `kb okf` + +Check an OKF topic for bundle conformance. + +```bash +kb okf check <topic> [--strict] [--format table|json|tsv] +``` + ### `kb ingest` Ingest source material into a topic. `url`, `file`, `youtube`, and `bookmarks` require an existing topic; `codebase` can bootstrap one on first run. diff --git a/config.example.toml b/config.example.toml index a9ea428..0c91297 100644 --- a/config.example.toml +++ b/config.example.toml @@ -5,6 +5,7 @@ # - `[app]` # - `[log]` # - `[vault]` +# - `[okf]` # - `[firecrawl]` # - `[openrouter]` # - `[stt]` @@ -48,6 +49,11 @@ level = "info" root = "." topic_globs = ["*"] +[okf] +# Local OKF concept type vocabulary. Empty means `kb okf check` will not warn +# about unknown types until the operator opts into a local standard. +types = [] + [firecrawl] api_key = "" api_url = "https://api.firecrawl.dev" diff --git a/internal/cli/okf.go b/internal/cli/okf.go new file mode 100644 index 0000000..8789c8f --- /dev/null +++ b/internal/cli/okf.go @@ -0,0 +1,94 @@ +package cli + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + + "github.com/compozy/kb/internal/models" + kokf "github.com/compozy/kb/internal/okf" + "github.com/compozy/kb/internal/output" + ktopic "github.com/compozy/kb/internal/topic" +) + +type okfCheckOptions struct { + Format string + Strict bool +} + +var runOKFCheck = kokf.Check +var runOKFTopicInfo = ktopic.Info +var okfGetwd = os.Getwd + +func newOKFCommand() *cobra.Command { + command := &cobra.Command{ + Use: "okf", + Short: "Work with Open Knowledge Format bundles", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + return cmd.Help() + }, + } + command.AddCommand(newOKFCheckCommand()) + return command +} + +func newOKFCheckCommand() *cobra.Command { + options := &okfCheckOptions{ + Format: string(output.OutputFormatTable), + } + command := &cobra.Command{ + Use: "check <topic>", + Short: "Check an OKF topic for conformance", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runOKFCheckCommand(cmd, options, args[0]) + }, + } + command.Flags().StringVar(&options.Format, "format", string(output.OutputFormatTable), "Output format (table|json|tsv)") + command.Flags().BoolVar(&options.Strict, "strict", false, "Promote local-standard warnings to errors") + return command +} + +func runOKFCheckCommand(cmd *cobra.Command, options *okfCheckOptions, topicSlug string) error { + format, err := parseInspectOutputFormat(options.Format) + if err != nil { + return err + } + vaultPath, err := resolveCommandVaultPath(cmd, okfGetwd, "okf check") + if err != nil { + return err + } + cfg, err := loadCLIConfig() + if err != nil { + return fmt.Errorf("okf check: %w", err) + } + topicInfo, err := runOKFTopicInfo(vaultPath, topicSlug) + if err != nil { + return fmt.Errorf("okf check: %w", err) + } + if topicInfo.Mode != models.TopicModeOKF { + return fmt.Errorf("okf check: topic %q is not an OKF topic", topicSlug) + } + + issues, err := runOKFCheck(commandContext(cmd), topicInfo.RootPath, kokf.CheckOptions{ + Types: cfg.OKF.Types, + Strict: options.Strict, + }) + if err != nil { + return err + } + _, writeErr := cmd.OutOrStdout().Write([]byte(output.FormatOutput(output.FormatOptions{ + Format: format, + Columns: kokf.Columns(), + Data: kokf.Rows(issues), + }))) + if writeErr != nil { + return fmt.Errorf("okf check: write output: %w", writeErr) + } + if kokf.HasErrors(issues) { + return fmt.Errorf("okf check: found %d issue(s)", len(issues)) + } + return nil +} diff --git a/internal/cli/okf_integration_test.go b/internal/cli/okf_integration_test.go new file mode 100644 index 0000000..4da4cd8 --- /dev/null +++ b/internal/cli/okf_integration_test.go @@ -0,0 +1,111 @@ +//go:build integration + +package cli + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/compozy/kb/internal/models" + kokf "github.com/compozy/kb/internal/okf" +) + +func TestCLIIntegrationOKFPromoteAndCheck(t *testing.T) { + vaultRoot := t.TempDir() + wikiTopic := scaffoldTopicForIntegration(t, vaultRoot, "research", "Research", "ops") + okfTopic := runCLIJSON[models.TopicInfo](t, + "topic", "new", "catalog", "Catalog", "ops", + "--mode", "okf", + "--vault", vaultRoot, + ) + if okfTopic.Mode != models.TopicModeOKF { + t.Fatalf("OKF topic mode = %q, want okf", okfTopic.Mode) + } + + sourcePath := filepath.Join(wikiTopic.RootPath, "wiki", "concepts", "Alpha Note.md") + writeFile(t, sourcePath, strings.Join([]string{ + "---", + "title: Alpha Note", + "type: wiki", + "stage: compiled", + "tags: [ops]", + "---", + "Alpha note explains the operations flow. See [[research/wiki/concepts/Beta Note|Beta]].", + }, "\n")) + + promoteOutput := runCLI(t, + "promote", sourcePath, + "--to", okfTopic.Slug, + "--type", "Playbook", + "--description", "Operational flow.", + "--vault", vaultRoot, + ) + var result kokf.ConceptResult + if err := json.Unmarshal([]byte(promoteOutput), &result); err != nil { + t.Fatalf("promote output is not JSON: %v\n%s", err, promoteOutput) + } + if result.WrittenPath != "alpha-note.md" { + t.Fatalf("written path = %q, want alpha-note.md", result.WrittenPath) + } + + sourceAfter := readFile(t, sourcePath) + if !strings.Contains(sourceAfter, "stage: compiled") { + t.Fatalf("source was unexpectedly changed:\n%s", sourceAfter) + } + conceptPath := filepath.Join(okfTopic.RootPath, "alpha-note.md") + values, body := readMarkdownDocument(t, conceptPath) + if values["type"] != "Playbook" || values["description"] != "Operational flow." { + t.Fatalf("unexpected concept frontmatter: %#v", values) + } + if !strings.Contains(body, "[Beta](beta-note.md)") { + t.Fatalf("body missing relative OKF link:\n%s", body) + } + if _, ok := values["stage"]; ok { + t.Fatalf("wiki stage leaked into OKF concept: %#v", values) + } + + checkOutput := runCLI(t, "okf", "check", okfTopic.Slug, "--format", "json", "--vault", vaultRoot) + var issues []models.LintIssue + if err := json.Unmarshal([]byte(checkOutput), &issues); err != nil { + t.Fatalf("check output is not JSON: %v\n%s", err, checkOutput) + } + if len(issues) != 0 { + t.Fatalf("freshly promoted bundle has issues: %#v", issues) + } + + index := readFile(t, filepath.Join(okfTopic.RootPath, "index.md")) + if !strings.Contains(index, "## Playbook") || !strings.Contains(index, "[Alpha Note](alpha-note.md)") { + t.Fatalf("index.md missing promoted concept:\n%s", index) + } + logContent := readFile(t, filepath.Join(okfTopic.RootPath, "log.md")) + if !strings.Contains(logContent, "Promoted [Alpha Note](alpha-note.md) from `wiki/concepts/Alpha Note.md`") { + t.Fatalf("log.md missing promotion entry:\n%s", logContent) + } +} + +func TestCLIIntegrationOKFCheckFailsStrictWarnings(t *testing.T) { + vaultRoot := t.TempDir() + okfTopic := runCLIJSON[models.TopicInfo](t, + "topic", "new", "catalog", "Catalog", "ops", + "--mode", "okf", + "--vault", vaultRoot, + ) + writeFile(t, filepath.Join(okfTopic.RootPath, "concept.md"), "---\ntype: Unknown\n---\nBody.\n") + + errText := runCLIError(t, "okf", "check", okfTopic.Slug, "--strict", "--vault", vaultRoot) + if !strings.Contains(errText, "found") { + t.Fatalf("strict check error = %q", errText) + } +} + +func readFile(t *testing.T, filePath string) string { + t.Helper() + content, err := os.ReadFile(filePath) + if err != nil { + t.Fatalf("read %s: %v", filePath, err) + } + return string(content) +} diff --git a/internal/cli/okf_test.go b/internal/cli/okf_test.go new file mode 100644 index 0000000..c95c133 --- /dev/null +++ b/internal/cli/okf_test.go @@ -0,0 +1,183 @@ +package cli + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + kconfig "github.com/compozy/kb/internal/config" + "github.com/compozy/kb/internal/models" + kokf "github.com/compozy/kb/internal/okf" +) + +func TestPromoteCommandResolvesTargetAndPrintsJSON(t *testing.T) { + t.Run("Should resolve target and print JSON", func(t *testing.T) { + originalPromote := runPromote + originalTopicInfo := runPromoteTopicInfo + t.Cleanup(func() { + runPromote = originalPromote + runPromoteTopicInfo = originalTopicInfo + }) + t.Setenv(kconfig.EnvConfigPath, writeCLIConfig(t, "[okf]\ntypes = [\"Playbook\"]\n")) + + var gotInput kokf.PromoteInput + runPromoteTopicInfo = func(vaultPath, slug string) (models.TopicInfo, error) { + return models.TopicInfo{ + Slug: slug, + Mode: models.TopicModeOKF, + RootPath: filepath.Join(vaultPath, slug), + }, nil + } + runPromote = func(ctx context.Context, input kokf.PromoteInput) (kokf.ConceptResult, error) { + gotInput = input + return kokf.ConceptResult{ + WrittenPath: "alpha.md", + Type: input.Type, + LinksRewritten: 1, + }, nil + } + + command := newRootCommand() + var stdout bytes.Buffer + command.SetOut(&stdout) + command.SetErr(new(bytes.Buffer)) + command.SetArgs([]string{ + "promote", "research/wiki/concepts/Alpha.md", + "--to", "catalog", + "--type", "Playbook", + "--description", "Alpha description.", + "--vault", "/tmp/vault", + }) + + if err := command.ExecuteContext(context.Background()); err != nil { + t.Fatalf("ExecuteContext returned error: %v", err) + } + if gotInput.VaultPath != "/tmp/vault" || gotInput.TargetTopic.Slug != "catalog" || gotInput.Type != "Playbook" { + t.Fatalf("unexpected promote input: %#v", gotInput) + } + if gotInput.SourceDocPath != "research/wiki/concepts/Alpha.md" { + t.Fatalf("source doc path = %q, want research/wiki/concepts/Alpha.md", gotInput.SourceDocPath) + } + if gotInput.Description != "Alpha description." { + t.Fatalf("description = %q", gotInput.Description) + } + if len(gotInput.Types) != 1 || gotInput.Types[0] != "Playbook" { + t.Fatalf("types = %#v, want Playbook", gotInput.Types) + } + + var result kokf.ConceptResult + if err := json.Unmarshal(stdout.Bytes(), &result); err != nil { + t.Fatalf("stdout did not contain JSON: %v\n%s", err, stdout.String()) + } + if result.WrittenPath != "alpha.md" || result.Type != "Playbook" { + t.Fatalf("unexpected result: %#v", result) + } + }) +} + +func TestOKFCheckCommandRendersIssuesAndFailsOnErrors(t *testing.T) { + t.Run("Should render issues and fail on errors", func(t *testing.T) { + originalCheck := runOKFCheck + originalTopicInfo := runOKFTopicInfo + t.Cleanup(func() { + runOKFCheck = originalCheck + runOKFTopicInfo = originalTopicInfo + }) + t.Setenv(kconfig.EnvConfigPath, writeCLIConfig(t, "[okf]\ntypes = [\"Playbook\"]\n")) + + runOKFTopicInfo = func(vaultPath, slug string) (models.TopicInfo, error) { + return models.TopicInfo{ + Slug: slug, + Mode: models.TopicModeOKF, + RootPath: filepath.Join(vaultPath, slug), + }, nil + } + runOKFCheck = func(ctx context.Context, bundlePath string, options kokf.CheckOptions) ([]models.LintIssue, error) { + if bundlePath != "/tmp/vault/catalog" { + return nil, fmt.Errorf("bundle path = %q", bundlePath) + } + if !options.Strict || len(options.Types) != 1 || options.Types[0] != "Playbook" { + return nil, fmt.Errorf("unexpected options: %#v", options) + } + return []models.LintIssue{{ + Kind: models.LintIssueKindFormat, + Severity: models.SeverityError, + FilePath: "bad.md", + Target: "type", + Message: "missing type", + }}, nil + } + + command := newRootCommand() + var stdout bytes.Buffer + command.SetOut(&stdout) + command.SetErr(new(bytes.Buffer)) + command.SetArgs([]string{"okf", "check", "catalog", "--strict", "--format", "json", "--vault", "/tmp/vault"}) + + err := command.ExecuteContext(context.Background()) + if err == nil { + t.Fatal("expected okf check to fail on error issues") + } + if !strings.Contains(err.Error(), "found 1 issue") { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(stdout.String(), `"filePath": "bad.md"`) { + t.Fatalf("stdout missing issue JSON:\n%s", stdout.String()) + } + }) +} + +func TestOKFCheckCommandRejectsNonOKFTopic(t *testing.T) { + t.Run("Should reject non-OKF topic", func(t *testing.T) { + originalCheck := runOKFCheck + originalTopicInfo := runOKFTopicInfo + t.Cleanup(func() { + runOKFCheck = originalCheck + runOKFTopicInfo = originalTopicInfo + }) + t.Setenv(kconfig.EnvConfigPath, writeCLIConfig(t, "[okf]\ntypes = [\"Playbook\"]\n")) + + checkCalled := false + runOKFTopicInfo = func(vaultPath, slug string) (models.TopicInfo, error) { + return models.TopicInfo{ + Slug: slug, + Mode: models.TopicModeWiki, + RootPath: filepath.Join(vaultPath, slug), + }, nil + } + runOKFCheck = func(ctx context.Context, bundlePath string, options kokf.CheckOptions) ([]models.LintIssue, error) { + checkCalled = true + return nil, nil + } + + command := newRootCommand() + command.SetOut(new(bytes.Buffer)) + command.SetErr(new(bytes.Buffer)) + command.SetArgs([]string{"okf", "check", "research", "--vault", "/tmp/vault"}) + + err := command.ExecuteContext(context.Background()) + if err == nil { + t.Fatal("expected non-OKF topic rejection") + } + if err.Error() != `okf check: topic "research" is not an OKF topic` { + t.Fatalf("error = %q, want non-OKF topic rejection", err.Error()) + } + if checkCalled { + t.Fatal("runOKFCheck was called for a non-OKF topic") + } + }) +} + +func writeCLIConfig(t *testing.T, content string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "kb.toml") + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatalf("write config: %v", err) + } + return path +} diff --git a/internal/cli/promote.go b/internal/cli/promote.go new file mode 100644 index 0000000..9b332bb --- /dev/null +++ b/internal/cli/promote.go @@ -0,0 +1,61 @@ +package cli + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + + kokf "github.com/compozy/kb/internal/okf" + ktopic "github.com/compozy/kb/internal/topic" +) + +var runPromote = kokf.Promote +var runPromoteTopicInfo = ktopic.Info +var promoteGetwd = os.Getwd + +func newPromoteCommand() *cobra.Command { + var targetTopic string + var conceptType string + var description string + + command := &cobra.Command{ + Use: "promote <wiki-doc>", + Short: "Promote a wiki document into an OKF topic", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + vaultPath, err := resolveCommandVaultPath(cmd, promoteGetwd, "promote") + if err != nil { + return err + } + cfg, err := loadCLIConfig() + if err != nil { + return fmt.Errorf("promote: %w", err) + } + target, err := runPromoteTopicInfo(vaultPath, targetTopic) + if err != nil { + return fmt.Errorf("promote: %w", err) + } + + result, err := runPromote(commandContext(cmd), kokf.PromoteInput{ + SourceDocPath: args[0], + VaultPath: vaultPath, + TargetTopic: target, + Type: conceptType, + Description: description, + Types: cfg.OKF.Types, + }) + if err != nil { + return err + } + return writeJSON(cmd, result) + }, + } + + command.Flags().StringVar(&targetTopic, "to", "", "Target OKF topic slug") + command.Flags().StringVar(&conceptType, "type", "", "OKF concept type") + command.Flags().StringVar(&description, "description", "", "OKF concept description") + _ = command.MarkFlagRequired("to") + _ = command.MarkFlagRequired("type") + return command +} diff --git a/internal/cli/root.go b/internal/cli/root.go index 2b123a6..22ab258 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -23,6 +23,8 @@ func newRootCommand() *cobra.Command { bindRootPersistentFlags(command) command.AddCommand(newTopicCommand()) command.AddCommand(newIngestCommand()) + command.AddCommand(newPromoteCommand()) + command.AddCommand(newOKFCommand()) command.AddCommand(newLintCommand()) command.AddCommand(newGenerateCommand()) command.AddCommand(newInspectCommand()) diff --git a/internal/cli/topic.go b/internal/cli/topic.go index 3746632..d609335 100644 --- a/internal/cli/topic.go +++ b/internal/cli/topic.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "os" + "strings" "github.com/spf13/cobra" @@ -13,6 +14,7 @@ import ( ) var runTopicNew = ktopic.New +var runTopicNewWithMode = ktopic.NewWithMode var runTopicList = ktopic.List var runTopicInfo = ktopic.Info var topicGetwd = os.Getwd @@ -37,7 +39,8 @@ func newTopicCommand() *cobra.Command { } func newTopicNewCommand() *cobra.Command { - return &cobra.Command{ + var mode string + command := &cobra.Command{ Use: "new <slug> <title> <domain>", Short: "Scaffold a new knowledge base topic", Args: cobra.ExactArgs(3), @@ -47,7 +50,16 @@ func newTopicNewCommand() *cobra.Command { return err } - info, err := runTopicNew(vaultPath, args[0], args[1], args[2]) + topicMode, err := parseTopicMode(mode) + if err != nil { + return err + } + var info models.TopicInfo + if topicMode == models.TopicModeWiki { + info, err = runTopicNew(vaultPath, args[0], args[1], args[2]) + } else { + info, err = runTopicNewWithMode(vaultPath, args[0], args[1], args[2], topicMode) + } if err != nil { return err } @@ -55,6 +67,8 @@ func newTopicNewCommand() *cobra.Command { return writeTopicInfoJSON(cmd, info) }, } + command.Flags().StringVar(&mode, "mode", string(models.TopicModeWiki), "Topic mode (wiki|okf)") + return command } func newTopicListCommand() *cobra.Command { @@ -98,6 +112,17 @@ func newTopicListCommand() *cobra.Command { } } +func parseTopicMode(value string) (models.TopicMode, error) { + switch strings.TrimSpace(value) { + case "", string(models.TopicModeWiki): + return models.TopicModeWiki, nil + case string(models.TopicModeOKF): + return models.TopicModeOKF, nil + default: + return "", fmt.Errorf(`invalid --mode %q: expected "wiki" or "okf"`, value) + } +} + func newTopicInfoCommand() *cobra.Command { return &cobra.Command{ Use: "info <slug>", diff --git a/internal/cli/topic_test.go b/internal/cli/topic_test.go index 44d7b67..f0aa9ff 100644 --- a/internal/cli/topic_test.go +++ b/internal/cli/topic_test.go @@ -79,6 +79,69 @@ func TestTopicNewCommandPassesArgsAndPrintsJSON(t *testing.T) { } } +func TestTopicNewCommandPassesOKFMode(t *testing.T) { + t.Run("Should pass OKF mode to topic creation", func(t *testing.T) { + originalNewWithMode := runTopicNewWithMode + originalGetwd := topicGetwd + t.Cleanup(func() { + runTopicNewWithMode = originalNewWithMode + topicGetwd = originalGetwd + }) + + var gotMode models.TopicMode + runTopicNewWithMode = func(vaultPath, slug, title, domain string, mode models.TopicMode) (models.TopicInfo, error) { + gotMode = mode + return models.TopicInfo{ + Slug: slug, + Title: title, + Domain: domain, + Mode: mode, + RootPath: filepath.Join(vaultPath, slug), + }, nil + } + topicGetwd = func() (string, error) { + return "/workspace/repo", nil + } + + command := newRootCommand() + var stdout bytes.Buffer + command.SetOut(&stdout) + command.SetErr(new(bytes.Buffer)) + command.SetArgs([]string{"topic", "new", "ops-catalog", "Ops Catalog", "ops", "--mode", "okf", "--vault", "/tmp/vault"}) + + if err := command.ExecuteContext(context.Background()); err != nil { + t.Fatalf("ExecuteContext returned error: %v", err) + } + if gotMode != models.TopicModeOKF { + t.Fatalf("mode = %q, want okf", gotMode) + } + + var info models.TopicInfo + if err := json.Unmarshal(stdout.Bytes(), &info); err != nil { + t.Fatalf("stdout did not contain JSON: %v\n%s", err, stdout.String()) + } + if info.Mode != models.TopicModeOKF { + t.Fatalf("payload mode = %q, want okf", info.Mode) + } + }) +} + +func TestTopicNewCommandRejectsInvalidMode(t *testing.T) { + command := newRootCommand() + command.SetOut(new(bytes.Buffer)) + command.SetErr(new(bytes.Buffer)) + command.SetArgs([]string{"topic", "new", "ops", "Ops", "ops", "--mode", "catalog", "--vault", "/tmp/vault"}) + + err := command.ExecuteContext(context.Background()) + if err == nil { + t.Fatal("expected invalid mode error") + } + want := `invalid --mode "catalog": expected "wiki" or "okf"` + if err.Error() != want { + t.Fatalf("error = %q, want %q", err.Error(), want) + } +} + func TestTopicNewCommandRequiresThreeArgs(t *testing.T) { command := newRootCommand() command.SetOut(new(bytes.Buffer)) diff --git a/internal/config/config.go b/internal/config/config.go index 14d3417..5083362 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -47,6 +47,7 @@ type Config struct { App AppConfig `toml:"app"` Log LogConfig `toml:"log"` Vault VaultConfig `toml:"vault"` + OKF OKFConfig `toml:"okf"` Firecrawl FirecrawlConfig `toml:"firecrawl"` OpenRouter OpenRouterConfig `toml:"openrouter"` STT STTConfig `toml:"stt"` @@ -71,6 +72,11 @@ type VaultConfig struct { TopicGlobs []string `toml:"topic_globs"` } +// OKFConfig controls local Open Knowledge Format producer standards. +type OKFConfig struct { + Types []string `toml:"types"` +} + // FirecrawlConfig controls URL scraping API access. type FirecrawlConfig struct { APIKey string `toml:"api_key"` @@ -211,6 +217,7 @@ func (c *Config) applyDefaults() { if len(c.Vault.TopicGlobs) == 0 { c.Vault.TopicGlobs = []string{defaultTopicGlob} } + c.OKF.Types = normalizeConfigStringList(c.OKF.Types) if strings.TrimSpace(c.YouTube.YTDLPPath) == "" { c.YouTube.YTDLPPath = defaultYouTubeYTDLPPath } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 9edc0c1..023f75f 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -59,6 +59,11 @@ func TestDefaultConfigHasValidDefaults(t *testing.T) { if len(cfg.Vault.TopicGlobs) != 1 || cfg.Vault.TopicGlobs[0] != "*" { t.Errorf("expected default vault.topic_globs [*], got %#v", cfg.Vault.TopicGlobs) } + t.Run("Should default OKF types to empty", func(t *testing.T) { + if len(cfg.OKF.Types) != 0 { + t.Errorf("expected default okf.types to be empty, got %#v", cfg.OKF.Types) + } + }) if cfg.Firecrawl.APIURL != defaultFirecrawlAPIURL { t.Errorf("expected default firecrawl.api_url %q, got %q", defaultFirecrawlAPIURL, cfg.Firecrawl.APIURL) } @@ -118,6 +123,9 @@ level = "debug" root = "." topic_globs = ["*", "harness/*"] +[okf] +types = ["Playbook", " Reference ", ""] + [firecrawl] api_key = "firecrawl-key" api_url = "https://firecrawl.internal" @@ -172,6 +180,11 @@ allow_translated_captions = true if len(cfg.Vault.TopicGlobs) != 2 || cfg.Vault.TopicGlobs[1] != "harness/*" { t.Errorf("expected vault.topic_globs to include harness/*, got %#v", cfg.Vault.TopicGlobs) } + t.Run("Should normalize OKF type vocabulary", func(t *testing.T) { + if !reflect.DeepEqual(cfg.OKF.Types, []string{"Playbook", "Reference"}) { + t.Errorf("expected normalized okf.types, got %#v", cfg.OKF.Types) + } + }) if cfg.Firecrawl.APIKey != "firecrawl-key" { t.Errorf("expected firecrawl.api_key 'firecrawl-key', got %q", cfg.Firecrawl.APIKey) } diff --git a/internal/generate/generate.go b/internal/generate/generate.go index be6a9a1..1856b53 100644 --- a/internal/generate/generate.go +++ b/internal/generate/generate.go @@ -3,6 +3,7 @@ package generate import ( "context" + "errors" "fmt" "path/filepath" "sort" @@ -14,6 +15,7 @@ import ( "github.com/compozy/kb/internal/metrics" "github.com/compozy/kb/internal/models" "github.com/compozy/kb/internal/scanner" + ktopic "github.com/compozy/kb/internal/topic" "github.com/compozy/kb/internal/vault" ) @@ -52,6 +54,7 @@ type generationTarget struct { RootPath string TopicSlug string VaultPath string + Mode models.TopicMode } // Generate runs the full repository-to-vault pipeline and returns a structured @@ -430,10 +433,19 @@ func resolveTarget(opts models.GenerateOptions) (generationTarget, error) { topicSlugSource = rootPath } + topicSlug := vault.DeriveTopicSlug(topicSlugSource) + topicMode := models.TopicModeWiki + if topicInfo, infoErr := ktopic.Info(resolvedVaultPath, topicSlug); infoErr == nil { + topicMode = topicInfo.Mode + } else if !errors.Is(infoErr, ktopic.ErrTopicNotFound) { + return generationTarget{}, fmt.Errorf("resolve topic metadata: %w", infoErr) + } + return generationTarget{ RootPath: rootPath, - TopicSlug: vault.DeriveTopicSlug(topicSlugSource), + TopicSlug: topicSlug, VaultPath: resolvedVaultPath, + Mode: topicMode, }, nil } @@ -453,6 +465,7 @@ func (r runner) createTopicMetadata(target generationTarget, opts models.Generat Title: title, Slug: target.TopicSlug, Domain: vault.DeriveTopicDomain(domainSource), + Mode: target.Mode, Today: r.now().Format("2006-01-02"), VaultPath: target.VaultPath, TopicPath: filepath.Join(target.VaultPath, target.TopicSlug), diff --git a/internal/generate/generate_test.go b/internal/generate/generate_test.go index e1805cc..9594327 100644 --- a/internal/generate/generate_test.go +++ b/internal/generate/generate_test.go @@ -9,6 +9,7 @@ import ( "github.com/compozy/kb/internal/models" "github.com/compozy/kb/internal/scanner" + ktopic "github.com/compozy/kb/internal/topic" "github.com/compozy/kb/internal/vault" ) @@ -593,6 +594,30 @@ func TestResolveTargetDefaultsVaultPathAndTopicSlugFromRootPath(t *testing.T) { } } +func TestResolveTargetPreservesExistingTopicMode(t *testing.T) { + t.Parallel() + + t.Run("Should preserve OKF mode from existing topic metadata", func(t *testing.T) { + rootPath := t.TempDir() + vaultPath := t.TempDir() + if _, err := ktopic.NewWithMode(vaultPath, "catalog", "Catalog", "ops", models.TopicModeOKF); err != nil { + t.Fatalf("NewWithMode returned error: %v", err) + } + + target, err := resolveTarget(models.GenerateOptions{ + RootPath: rootPath, + VaultPath: vaultPath, + TopicSlug: "catalog", + }) + if err != nil { + t.Fatalf("resolveTarget returned error: %v", err) + } + if target.Mode != models.TopicModeOKF { + t.Fatalf("mode = %q, want okf", target.Mode) + } + }) +} + func TestGenerateRespectsCanceledContext(t *testing.T) { t.Parallel() diff --git a/internal/models/kb_models.go b/internal/models/kb_models.go index 11d0ef2..2087683 100644 --- a/internal/models/kb_models.go +++ b/internal/models/kb_models.go @@ -114,11 +114,12 @@ type LintIssue struct { // TopicInfo captures topic metadata for list and info operations. type TopicInfo struct { - Slug string `json:"slug"` - Title string `json:"title"` - Domain string `json:"domain"` - RootPath string `json:"rootPath"` - ArticleCount int `json:"articleCount"` - SourceCount int `json:"sourceCount"` - LastLogEntry string `json:"lastLogEntry,omitempty"` + Slug string `json:"slug"` + Title string `json:"title"` + Domain string `json:"domain"` + Mode TopicMode `json:"mode"` + RootPath string `json:"rootPath"` + ArticleCount int `json:"articleCount"` + SourceCount int `json:"sourceCount"` + LastLogEntry string `json:"lastLogEntry,omitempty"` } diff --git a/internal/models/models.go b/internal/models/models.go index 3352045..061f0aa 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -102,6 +102,16 @@ const ( DocIndex DocumentKind = "index" ) +// TopicMode identifies the lifecycle and file contract for a topic. +type TopicMode string + +const ( + // TopicModeWiki preserves the existing LLM-wiki topic lifecycle. + TopicModeWiki TopicMode = "wiki" + // TopicModeOKF uses the Open Knowledge Format bundle lifecycle. + TopicModeOKF TopicMode = "okf" +) + // ManagedArea identifies the managed subtree within a generated topic. type ManagedArea string @@ -269,13 +279,14 @@ type RenderedDocument struct { // TopicMetadata captures the derived topic information for a vault render. type TopicMetadata struct { - RootPath string `json:"rootPath"` - Title string `json:"title"` - Slug string `json:"slug"` - Domain string `json:"domain"` - Today string `json:"today"` - VaultPath string `json:"vaultPath"` - TopicPath string `json:"topicPath"` + RootPath string `json:"rootPath"` + Title string `json:"title"` + Slug string `json:"slug"` + Domain string `json:"domain"` + Mode TopicMode `json:"mode"` + Today string `json:"today"` + VaultPath string `json:"vaultPath"` + TopicPath string `json:"topicPath"` } // GenerateOptions configures a full knowledge-base generation run. diff --git a/internal/okf/official_integration_test.go b/internal/okf/official_integration_test.go new file mode 100644 index 0000000..d02cb70 --- /dev/null +++ b/internal/okf/official_integration_test.go @@ -0,0 +1,27 @@ +//go:build integration + +package okf + +import ( + "context" + "path/filepath" + "testing" +) + +func TestOfficialBundlesPassLenientConformance(t *testing.T) { + t.Parallel() + + for _, bundle := range []string{"ga4", "stackoverflow", "crypto_bitcoin"} { + bundle := bundle + t.Run("Should pass lenient conformance for "+bundle, func(t *testing.T) { + t.Parallel() + issues, err := Check(context.Background(), filepath.Join("testdata", "official", bundle), CheckOptions{}) + if err != nil { + t.Fatalf("Check returned error: %v", err) + } + if HasErrors(issues) { + t.Fatalf("official bundle %s has error issues: %#v", bundle, issues) + } + }) + } +} diff --git a/internal/okf/okf.go b/internal/okf/okf.go new file mode 100644 index 0000000..d136904 --- /dev/null +++ b/internal/okf/okf.go @@ -0,0 +1,775 @@ +// Package okf implements Open Knowledge Format promotion and conformance checks. +package okf + +import ( + "context" + "errors" + "fmt" + "io/fs" + "os" + "path" + "path/filepath" + "regexp" + "sort" + "strings" + "time" + + "github.com/compozy/kb/internal/frontmatter" + "github.com/compozy/kb/internal/models" + "github.com/compozy/kb/internal/vault" +) + +const ( + OKFVersion = "0.1" +) + +var ( + wikilinkPattern = regexp.MustCompile(`\[\[([^\[\]]+)\]\]`) + nonSentenceSpace = regexp.MustCompile(`\s+`) + markdownLinkPattern = regexp.MustCompile(`\[[^\]]+\]\([^)]+\)`) + markdownSyntaxRunes = strings.NewReplacer("#", "", "*", "", "`", "", "_", "", ">", "", "|", " ") + errTargetNotOKFTopic = errors.New("target topic must use mode okf") +) + +// PromoteInput carries the data needed to promote one wiki document into an OKF bundle. +type PromoteInput struct { + SourceDocPath string + VaultPath string + TargetTopic models.TopicInfo + Type string + Description string + Types []string + Clock func() time.Time +} + +// ConceptResult reports the concept written by Promote. +type ConceptResult struct { + WrittenPath string `json:"writtenPath"` + Type string `json:"type"` + LinksRewritten int `json:"linksRewritten"` + UnresolvedLinks []string `json:"unresolvedLinks"` + Warnings []string `json:"warnings,omitempty"` +} + +// CheckOptions configures OKF conformance checking. +type CheckOptions struct { + Types []string + Strict bool +} + +// Promote performs a mechanical, non-destructive wiki-to-OKF conversion. +func Promote(ctx context.Context, input PromoteInput) (ConceptResult, error) { + if ctx == nil { + ctx = context.Background() + } + if err := ctx.Err(); err != nil { + return ConceptResult{}, fmt.Errorf("promote: %w", err) + } + if input.TargetTopic.Mode != models.TopicModeOKF { + return ConceptResult{}, fmt.Errorf("promote: %w: %s", errTargetNotOKFTopic, input.TargetTopic.Slug) + } + conceptType := strings.TrimSpace(input.Type) + if conceptType == "" { + return ConceptResult{}, fmt.Errorf("promote: --type is required") + } + if strings.TrimSpace(input.TargetTopic.RootPath) == "" { + return ConceptResult{}, fmt.Errorf("promote: target topic root path is required") + } + + sourcePath, sourceTopicRoot, sourceTopicSlug, sourceRelativePath, err := resolveSourceDocument(input) + if err != nil { + return ConceptResult{}, fmt.Errorf("promote: %w", err) + } + _ = sourceTopicRoot + + sourceBytes, err := os.ReadFile(sourcePath) + if err != nil { + return ConceptResult{}, fmt.Errorf("promote: read source %q: %w", sourcePath, err) + } + sourceValues, body, err := frontmatter.Parse(string(sourceBytes)) + if err != nil { + return ConceptResult{}, fmt.Errorf("promote: parse source frontmatter: %w", err) + } + + key := conceptKey(sourceRelativePath) + writtenPath, absoluteTargetPath, targetFile, err := allocateConceptPath(input.TargetTopic.RootPath, key) + if err != nil { + return ConceptResult{}, fmt.Errorf("promote: allocate concept path: %w", err) + } + conceptWritten := false + defer func() { + if conceptWritten { + return + } + _ = targetFile.Close() + _ = os.Remove(absoluteTargetPath) + }() + + warnings := typeWarnings(conceptType, input.Types) + description, descriptionWarnings := resolveDescription(input.Description, body) + warnings = append(warnings, descriptionWarnings...) + title := strings.TrimSpace(frontmatter.GetString(sourceValues, "title")) + if title == "" { + title = vault.HumanizeSlug(key) + } + clock := input.Clock + if clock == nil { + clock = func() time.Time { return time.Now().UTC() } + } + now := clock().UTC() + + transformedBody, rewriteCount, unresolvedLinks := transformWikiLinks(body, sourceTopicSlug, input.TargetTopic.RootPath, path.Dir(writtenPath), writtenPath) + values := map[string]any{ + "description": description, + "timestamp": now.Format(time.RFC3339), + "title": title, + "type": conceptType, + } + if tags := frontmatter.GetStringSlice(sourceValues, "tags"); len(tags) > 0 { + values["tags"] = tags + } + + document, err := frontmatter.Generate(values, transformedBody) + if err != nil { + return ConceptResult{}, fmt.Errorf("promote: generate concept frontmatter: %w", err) + } + if _, err := targetFile.Write([]byte(document)); err != nil { + return ConceptResult{}, fmt.Errorf("promote: write concept %q: %w", absoluteTargetPath, err) + } + if err := targetFile.Close(); err != nil { + return ConceptResult{}, fmt.Errorf("promote: close concept %q: %w", absoluteTargetPath, err) + } + conceptWritten = true + + if err := RegenerateIndex(input.TargetTopic.RootPath); err != nil { + return ConceptResult{}, fmt.Errorf("promote: regenerate index: %w", err) + } + if err := InsertLogEntry(input.TargetTopic.RootPath, now, title, writtenPath, sourceRelativePath); err != nil { + return ConceptResult{}, fmt.Errorf("promote: update log: %w", err) + } + + return ConceptResult{ + WrittenPath: writtenPath, + Type: conceptType, + LinksRewritten: rewriteCount, + UnresolvedLinks: unresolvedLinks, + Warnings: warnings, + }, nil +} + +// Check validates an OKF bundle and returns diagnostics. +func Check(ctx context.Context, bundlePath string, options CheckOptions) ([]models.LintIssue, error) { + if ctx == nil { + ctx = context.Background() + } + cleanBundlePath := strings.TrimSpace(bundlePath) + if cleanBundlePath == "" { + return nil, fmt.Errorf("okf check: bundle path is required") + } + info, err := os.Stat(cleanBundlePath) + if err != nil { + return nil, fmt.Errorf("okf check: stat bundle path %q: %w", cleanBundlePath, err) + } + if !info.IsDir() { + return nil, fmt.Errorf("okf check: bundle path must be a directory: %s", cleanBundlePath) + } + + typeSet := normalizeTypeSet(options.Types) + issues := make([]models.LintIssue, 0) + err = filepath.WalkDir(cleanBundlePath, func(currentPath string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if err := ctx.Err(); err != nil { + return err + } + if currentPath == cleanBundlePath { + return nil + } + if entry.Type()&fs.ModeSymlink != 0 { + return nil + } + if entry.IsDir() { + if strings.HasPrefix(entry.Name(), ".") { + return filepath.SkipDir + } + return nil + } + if !strings.EqualFold(filepath.Ext(entry.Name()), ".md") { + return nil + } + + relativePath, err := filepath.Rel(cleanBundlePath, currentPath) + if err != nil { + return fmt.Errorf("derive relative path for %q: %w", currentPath, err) + } + relativePath = vault.ToPosixPath(relativePath) + switch { + case isIndexFile(relativePath): + issues = append(issues, checkIndexFile(cleanBundlePath, currentPath, relativePath, options.Strict)...) + case isLogFile(relativePath): + issues = append(issues, checkLogFile(currentPath, relativePath, options.Strict)...) + case isExcludedMarkdown(entry.Name()): + return nil + default: + issues = append(issues, checkConceptFile(currentPath, relativePath, typeSet, options.Strict)...) + } + return nil + }) + if err != nil { + return nil, fmt.Errorf("okf check: %w", err) + } + + sortIssues(issues) + return issues, nil +} + +// Columns returns the stable output column order for OKF diagnostics. +func Columns() []string { + return []string{"severity", "kind", "filePath", "target", "message"} +} + +// Rows converts issues into formatter rows. +func Rows(issues []models.LintIssue) []map[string]any { + rows := make([]map[string]any, 0, len(issues)) + for _, issue := range issues { + rows = append(rows, map[string]any{ + "severity": issue.Severity, + "kind": issue.Kind, + "filePath": issue.FilePath, + "target": issue.Target, + "message": issue.Message, + }) + } + return rows +} + +// HasErrors reports whether any diagnostic should fail the command. +func HasErrors(issues []models.LintIssue) bool { + for _, issue := range issues { + if issue.Severity == models.SeverityError { + return true + } + } + return false +} + +// RegenerateIndex rewrites the root OKF index from concept frontmatter. +func RegenerateIndex(bundlePath string) error { + concepts, okfVersion, err := loadConcepts(bundlePath) + if err != nil { + return err + } + sort.Slice(concepts, func(i, j int) bool { + if concepts[i].Type != concepts[j].Type { + return concepts[i].Type < concepts[j].Type + } + if concepts[i].Title != concepts[j].Title { + return concepts[i].Title < concepts[j].Title + } + return concepts[i].RelativePath < concepts[j].RelativePath + }) + + var body strings.Builder + body.WriteString("# OKF Bundle Index\n") + currentType := "" + for _, concept := range concepts { + if concept.Type != currentType { + currentType = concept.Type + body.WriteString("\n## ") + body.WriteString(currentType) + body.WriteString("\n\n") + } + body.WriteString("* ") + body.WriteString(vault.OKFLinkFormatter{}.Link("", concept.RelativePath, concept.Title)) + if concept.Description != "" { + body.WriteString(" - ") + body.WriteString(concept.Description) + } + body.WriteString("\n") + } + + values := map[string]any{"okf_version": firstNonEmpty(okfVersion, OKFVersion)} + content, err := frontmatter.Generate(values, body.String()) + if err != nil { + return fmt.Errorf("generate index frontmatter: %w", err) + } + if err := os.WriteFile(filepath.Join(bundlePath, "index.md"), []byte(content), 0o644); err != nil { + return fmt.Errorf("write index.md: %w", err) + } + return nil +} + +// InsertLogEntry inserts a newest-first OKF log entry at the bundle root. +func InsertLogEntry(bundlePath string, when time.Time, title, conceptPath, sourcePath string) error { + logPath := filepath.Join(bundlePath, "log.md") + content, err := os.ReadFile(logPath) + if errors.Is(err, os.ErrNotExist) { + content = []byte("# Directory Update Log\n") + } else if err != nil { + return fmt.Errorf("read log.md: %w", err) + } + entry := fmt.Sprintf( + "* **Creation**: Promoted %s from `%s`.", + vault.OKFLinkFormatter{}.Link("", conceptPath, strings.TrimSpace(title)), + sourcePath, + ) + next := insertLogEntry(string(content), when.UTC().Format(frontmatter.DateLayout), entry) + if err := os.WriteFile(logPath, []byte(next), 0o644); err != nil { + return fmt.Errorf("write log.md: %w", err) + } + return nil +} + +type conceptInfo struct { + RelativePath string + Type string + Title string + Description string +} + +func resolveSourceDocument(input PromoteInput) (absolutePath, topicRoot, topicSlug, topicRelativePath string, err error) { + candidates := make([]string, 0, 3) + source := strings.TrimSpace(input.SourceDocPath) + if source == "" { + return "", "", "", "", fmt.Errorf("source document path is required") + } + if filepath.IsAbs(source) { + candidates = append(candidates, source) + } else { + candidates = append(candidates, source) + if strings.TrimSpace(input.VaultPath) != "" { + candidates = append(candidates, filepath.Join(input.VaultPath, filepath.FromSlash(source))) + } + } + for _, candidate := range candidates { + info, statErr := os.Stat(candidate) + if statErr != nil { + continue + } + if info.IsDir() { + return "", "", "", "", fmt.Errorf("source document %q must be a file", candidate) + } + absolutePath, err := filepath.Abs(candidate) + if err != nil { + return "", "", "", "", fmt.Errorf("resolve source path %q: %w", candidate, err) + } + topicRoot, topicSlug = discoverTopicRoot(input.VaultPath, absolutePath) + if topicRoot != "" { + relative, relErr := filepath.Rel(topicRoot, absolutePath) + if relErr != nil { + return "", "", "", "", fmt.Errorf("derive source topic path: %w", relErr) + } + return absolutePath, topicRoot, topicSlug, vault.ToPosixPath(relative), nil + } + return absolutePath, "", "", vault.ToPosixPath(filepath.Base(absolutePath)), nil + } + return "", "", "", "", fmt.Errorf("source document not found: %s", source) +} + +func discoverTopicRoot(vaultPath, sourcePath string) (string, string) { + cleanVault := strings.TrimSpace(vaultPath) + current := filepath.Dir(sourcePath) + for { + if cleanVault != "" && !vault.IsPathInside(cleanVault, current) { + return "", "" + } + if isRegularFile(filepath.Join(current, "CLAUDE.md")) { + if cleanVault == "" { + return current, filepath.Base(current) + } + relative, err := filepath.Rel(cleanVault, current) + if err != nil { + return current, filepath.Base(current) + } + return current, vault.ToPosixPath(relative) + } + parent := filepath.Dir(current) + if parent == current { + break + } + current = parent + } + return "", "" +} + +func isRegularFile(filePath string) bool { + info, err := os.Stat(filePath) + return err == nil && !info.IsDir() +} + +func allocateConceptPath(bundleRoot, key string) (string, string, *os.File, error) { + for suffix := 1; ; suffix++ { + name := key + if suffix > 1 { + name = fmt.Sprintf("%s-%d", key, suffix) + } + relativePath := name + ".md" + absolutePath := filepath.Join(bundleRoot, filepath.FromSlash(relativePath)) + file, err := os.OpenFile(absolutePath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644) + if err == nil { + return relativePath, absolutePath, file, nil + } + if errors.Is(err, os.ErrExist) { + continue + } + return "", "", nil, err + } +} + +func conceptKey(documentPath string) string { + base := path.Base(vault.StripMarkdownExtension(vault.ToPosixPath(documentPath))) + return vault.SlugifySegment(base) +} + +func typeWarnings(conceptType string, types []string) []string { + typeSet := normalizeTypeSet(types) + if len(typeSet) == 0 { + return nil + } + if _, ok := typeSet[conceptType]; ok { + return nil + } + return []string{fmt.Sprintf("type %q is outside the configured OKF vocabulary", conceptType)} +} + +func normalizeTypeSet(types []string) map[string]struct{} { + typeSet := make(map[string]struct{}) + for _, value := range types { + value = strings.TrimSpace(value) + if value == "" { + continue + } + typeSet[value] = struct{}{} + } + return typeSet +} + +func resolveDescription(flagValue, body string) (string, []string) { + if description := strings.TrimSpace(flagValue); description != "" { + return description, nil + } + if sentence := firstBodySentence(body); sentence != "" { + return sentence, nil + } + return "", []string{"description is empty because the source body has no sentence fallback"} +} + +func firstBodySentence(body string) string { + normalized := markdownSyntaxRunes.Replace(markdownLinkPattern.ReplaceAllString(body, " ")) + normalized = nonSentenceSpace.ReplaceAllString(strings.TrimSpace(normalized), " ") + for index := 0; index < len(normalized); index++ { + switch normalized[index] { + case '.', '!', '?': + if index == len(normalized)-1 || normalized[index+1] == ' ' { + return strings.TrimSpace(normalized[:index+1]) + } + } + } + return "" +} + +func transformWikiLinks(body, sourceTopicSlug, bundleRoot, fromDir, currentPath string) (string, int, []string) { + count := 0 + unresolved := make([]string, 0) + formatter := vault.OKFLinkFormatter{} + transformed := wikilinkPattern.ReplaceAllStringFunc(body, func(match string) string { + inner := strings.TrimSuffix(strings.TrimPrefix(match, "[["), "]]") + target, label := splitWikiLink(inner) + targetPath := mappedTargetPath(target, sourceTopicSlug) + if strings.TrimSpace(label) == "" { + label = fallbackWikiLabel(target) + } + count++ + if stripFragment(targetPath) != stripFragment(currentPath) && !isRegularFile(filepath.Join(bundleRoot, filepath.FromSlash(stripFragment(targetPath)))) { + unresolved = append(unresolved, targetPath) + } + return formatter.Link(fromDir, targetPath, label) + }) + sort.Strings(unresolved) + return transformed, count, dedupeStrings(unresolved) +} + +func splitWikiLink(inner string) (string, string) { + parts := strings.SplitN(inner, "|", 2) + target := strings.TrimSpace(parts[0]) + if len(parts) == 1 { + return target, "" + } + return target, strings.TrimSpace(parts[1]) +} + +func mappedTargetPath(target, sourceTopicSlug string) string { + target = vault.ToPosixPath(strings.TrimSpace(target)) + fragment := "" + if before, after, found := strings.Cut(target, "#"); found { + target = before + fragment = "#" + after + } + target = strings.TrimPrefix(target, "/") + if sourceTopicSlug != "" { + prefix := strings.Trim(sourceTopicSlug, "/") + "/" + target = strings.TrimPrefix(target, prefix) + } + return conceptKey(target) + ".md" + fragment +} + +func stripFragment(target string) string { + if before, _, found := strings.Cut(target, "#"); found { + return before + } + return target +} + +func fallbackWikiLabel(target string) string { + target = stripFragment(target) + return vault.HumanizeSlug(conceptKey(target)) +} + +func dedupeStrings(values []string) []string { + if len(values) == 0 { + return nil + } + deduped := values[:0] + var previous string + for index, value := range values { + if index > 0 && value == previous { + continue + } + deduped = append(deduped, value) + previous = value + } + return append([]string(nil), deduped...) +} + +func loadConcepts(bundlePath string) ([]conceptInfo, string, error) { + okfVersion := OKFVersion + indexPath := filepath.Join(bundlePath, "index.md") + if content, err := os.ReadFile(indexPath); err == nil { + values, _, parseErr := frontmatter.Parse(string(content)) + if parseErr != nil { + return nil, "", fmt.Errorf("parse index.md: %w", parseErr) + } + if version := strings.TrimSpace(frontmatter.GetString(values, "okf_version")); version != "" { + okfVersion = version + } + } else if !errors.Is(err, os.ErrNotExist) { + return nil, "", fmt.Errorf("read index.md: %w", err) + } + + concepts := make([]conceptInfo, 0) + err := filepath.WalkDir(bundlePath, func(currentPath string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if currentPath == bundlePath { + return nil + } + if entry.Type()&fs.ModeSymlink != 0 { + return nil + } + if entry.IsDir() { + if strings.HasPrefix(entry.Name(), ".") { + return filepath.SkipDir + } + return nil + } + if !strings.EqualFold(filepath.Ext(entry.Name()), ".md") || isExcludedMarkdown(entry.Name()) { + return nil + } + relativePath, err := filepath.Rel(bundlePath, currentPath) + if err != nil { + return err + } + relativePath = vault.ToPosixPath(relativePath) + if isIndexFile(relativePath) || isLogFile(relativePath) { + return nil + } + content, err := os.ReadFile(currentPath) + if err != nil { + return err + } + values, _, err := frontmatter.Parse(string(content)) + if err != nil { + return fmt.Errorf("parse concept %s: %w", relativePath, err) + } + if len(values) == 0 { + return nil + } + conceptType := strings.TrimSpace(frontmatter.GetString(values, "type")) + title := strings.TrimSpace(frontmatter.GetString(values, "title")) + if title == "" { + title = vault.HumanizeSlug(conceptKey(relativePath)) + } + concepts = append(concepts, conceptInfo{ + RelativePath: relativePath, + Type: firstNonEmpty(conceptType, "Concept"), + Title: title, + Description: strings.TrimSpace(frontmatter.GetString(values, "description")), + }) + return nil + }) + if err != nil { + return nil, "", fmt.Errorf("scan concepts: %w", err) + } + return concepts, okfVersion, nil +} + +func checkConceptFile(filePath, relativePath string, typeSet map[string]struct{}, strict bool) []models.LintIssue { + content, err := os.ReadFile(filePath) + if err != nil { + return []models.LintIssue{newIssue(models.SeverityError, relativePath, "", fmt.Sprintf("read concept: %v", err))} + } + values, _, err := frontmatter.Parse(string(content)) + if err != nil { + return []models.LintIssue{newIssue(models.SeverityError, relativePath, "", fmt.Sprintf("frontmatter is invalid: %v", err))} + } + if len(values) == 0 { + return []models.LintIssue{newIssue(models.SeverityError, relativePath, "type", "concept must start with YAML frontmatter")} + } + issues := make([]models.LintIssue, 0) + conceptType := strings.TrimSpace(frontmatter.GetString(values, "type")) + if conceptType == "" { + issues = append(issues, newIssue(models.SeverityError, relativePath, "type", "concept frontmatter must include non-empty type")) + } + for _, field := range []string{"title", "description", "timestamp"} { + if strings.TrimSpace(frontmatter.GetString(values, field)) == "" { + issues = append(issues, newIssue(warningSeverity(strict), relativePath, field, fmt.Sprintf("producer field %q is missing", field))) + } + } + if len(typeSet) > 0 && conceptType != "" { + if _, ok := typeSet[conceptType]; !ok { + issues = append(issues, newIssue(warningSeverity(strict), relativePath, "type", fmt.Sprintf("type %q is outside the configured OKF vocabulary", conceptType))) + } + } + return issues +} + +func checkIndexFile(bundlePath, filePath, relativePath string, strict bool) []models.LintIssue { + content, err := os.ReadFile(filePath) + if err != nil { + return []models.LintIssue{newIssue(models.SeverityError, relativePath, "", fmt.Sprintf("read index: %v", err))} + } + values, _, err := frontmatter.Parse(string(content)) + if err != nil { + return []models.LintIssue{newIssue(models.SeverityError, relativePath, "", fmt.Sprintf("index frontmatter is invalid: %v", err))} + } + if len(values) == 0 { + return nil + } + if relativePath != "index.md" { + return []models.LintIssue{newIssue(models.SeverityError, relativePath, "frontmatter", "only the bundle-root index.md may declare okf_version frontmatter")} + } + for key := range values { + if key != "okf_version" { + return []models.LintIssue{newIssue(models.SeverityError, relativePath, key, "root index.md frontmatter may only contain okf_version")} + } + } + _ = bundlePath + _ = strict + return nil +} + +func checkLogFile(filePath, relativePath string, strict bool) []models.LintIssue { + content, err := os.ReadFile(filePath) + if err != nil { + return []models.LintIssue{newIssue(models.SeverityError, relativePath, "", fmt.Sprintf("read log: %v", err))} + } + issues := make([]models.LintIssue, 0) + for line := range strings.SplitSeq(string(content), "\n") { + trimmed := strings.TrimSpace(line) + if !strings.HasPrefix(trimmed, "## ") { + continue + } + dateValue := strings.TrimSpace(strings.TrimPrefix(trimmed, "## ")) + if _, err := time.Parse(frontmatter.DateLayout, dateValue); err != nil { + issues = append(issues, newIssue(models.SeverityError, relativePath, "date", fmt.Sprintf("log heading %q must use YYYY-MM-DD", trimmed))) + } + } + _ = strict + return issues +} + +func newIssue(severity models.DiagnosticSeverity, filePath, target, message string) models.LintIssue { + return models.LintIssue{ + Kind: models.LintIssueKindFormat, + Severity: severity, + FilePath: filePath, + Target: target, + Message: message, + } +} + +func warningSeverity(strict bool) models.DiagnosticSeverity { + if strict { + return models.SeverityError + } + return models.SeverityWarning +} + +func sortIssues(issues []models.LintIssue) { + sort.Slice(issues, func(i, j int) bool { + if issues[i].Severity != issues[j].Severity { + return issues[i].Severity < issues[j].Severity + } + if issues[i].FilePath != issues[j].FilePath { + return issues[i].FilePath < issues[j].FilePath + } + if issues[i].Target != issues[j].Target { + return issues[i].Target < issues[j].Target + } + return issues[i].Message < issues[j].Message + }) +} + +func isIndexFile(relativePath string) bool { + return strings.EqualFold(path.Base(vault.ToPosixPath(relativePath)), "index.md") +} + +func isLogFile(relativePath string) bool { + return strings.EqualFold(path.Base(vault.ToPosixPath(relativePath)), "log.md") +} + +func isExcludedMarkdown(name string) bool { + lower := strings.ToLower(strings.TrimSpace(name)) + switch lower { + case "claude.md", "agents.md", "readme.md", "license.md", "notice.md", "attribution.md": + return true + default: + return strings.HasPrefix(lower, "license.") || strings.HasPrefix(lower, "notice.") || strings.HasPrefix(lower, "attribution.") + } +} + +func insertLogEntry(content, dateHeading, entry string) string { + lines := strings.Split(strings.ReplaceAll(content, "\r\n", "\n"), "\n") + if len(lines) == 0 || strings.TrimSpace(lines[0]) == "" { + lines = []string{"# Directory Update Log"} + } + heading := "## " + dateHeading + for index, line := range lines { + if strings.TrimSpace(line) != heading { + continue + } + next := append([]string{}, lines[:index+1]...) + next = append(next, entry) + next = append(next, lines[index+1:]...) + return strings.TrimRight(strings.Join(next, "\n"), "\n") + "\n" + } + next := []string{strings.TrimRight(strings.Join(lines[:1], "\n"), "\n"), "", heading, "", entry} + if len(lines) > 1 { + rest := strings.TrimSpace(strings.Join(lines[1:], "\n")) + if rest != "" { + next = append(next, "", rest) + } + } + return strings.TrimRight(strings.Join(next, "\n"), "\n") + "\n" +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if trimmed := strings.TrimSpace(value); trimmed != "" { + return trimmed + } + } + return "" +} diff --git a/internal/okf/okf_test.go b/internal/okf/okf_test.go new file mode 100644 index 0000000..16a17b0 --- /dev/null +++ b/internal/okf/okf_test.go @@ -0,0 +1,353 @@ +package okf + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/compozy/kb/internal/frontmatter" + "github.com/compozy/kb/internal/models" +) + +func TestPromoteWritesConceptIndexAndLog(t *testing.T) { + t.Parallel() + + t.Run("Should write promoted concept index and log entry", func(t *testing.T) { + vaultPath := t.TempDir() + sourceTopic := filepath.Join(vaultPath, "research") + targetTopic := filepath.Join(vaultPath, "catalog") + mkdirAll(t, filepath.Join(sourceTopic, "wiki", "concepts")) + mkdirAll(t, targetTopic) + writeFile(t, filepath.Join(sourceTopic, "CLAUDE.md"), "# Research\n") + writeFile(t, filepath.Join(targetTopic, "CLAUDE.md"), "# Catalog\n") + writeFile(t, filepath.Join(targetTopic, "index.md"), "---\nokf_version: \"0.1\"\n---\n# Old Index\n") + writeFile(t, filepath.Join(targetTopic, "log.md"), "# Directory Update Log\n") + sourcePath := filepath.Join(sourceTopic, "wiki", "concepts", "Alpha Note.md") + sourceContent := strings.Join([]string{ + "---", + "title: Alpha Note", + "type: wiki", + "stage: compiled", + "tags: [systems, alpha]", + "---", + "Alpha note explains the operational flow. See [[research/wiki/concepts/Beta Note|Beta]] and [[research/wiki/concepts/Alpha Note#details|details]].", + "", + "## Details", + }, "\n") + writeFile(t, sourcePath, sourceContent) + + result, err := Promote(context.Background(), PromoteInput{ + SourceDocPath: sourcePath, + VaultPath: vaultPath, + TargetTopic: models.TopicInfo{ + Slug: "catalog", + Mode: models.TopicModeOKF, + RootPath: targetTopic, + }, + Type: "Playbook", + Description: "Operational alpha note.", + Types: []string{"Playbook"}, + Clock: func() time.Time { + return time.Date(2026, 6, 27, 10, 11, 12, 0, time.UTC) + }, + }) + if err != nil { + t.Fatalf("Promote returned error: %v", err) + } + if result.WrittenPath != "alpha-note.md" { + t.Fatalf("written path = %q, want alpha-note.md", result.WrittenPath) + } + if result.LinksRewritten != 2 { + t.Fatalf("links rewritten = %d, want 2", result.LinksRewritten) + } + if len(result.UnresolvedLinks) != 1 || result.UnresolvedLinks[0] != "beta-note.md" { + t.Fatalf("unresolved links = %#v, want beta-note.md", result.UnresolvedLinks) + } + if got := readFile(t, sourcePath); got != sourceContent { + t.Fatalf("source document changed:\n%s", got) + } + + values, body := parseMarkdown(t, filepath.Join(targetTopic, "alpha-note.md")) + for key, want := range map[string]any{ + "type": "Playbook", + "title": "Alpha Note", + "description": "Operational alpha note.", + "timestamp": "2026-06-27T10:11:12Z", + } { + if got := values[key]; got != want { + t.Fatalf("frontmatter[%s] = %#v, want %#v", key, got, want) + } + } + if _, ok := values["stage"]; ok { + t.Fatalf("wiki stage leaked into OKF frontmatter: %#v", values) + } + if !strings.Contains(body, "[Beta](beta-note.md)") || !strings.Contains(body, "[details](alpha-note.md#details)") { + t.Fatalf("body missing transformed links:\n%s", body) + } + + index := readFile(t, filepath.Join(targetTopic, "index.md")) + for _, fragment := range []string{"okf_version: \"0.1\"", "## Playbook", "[Alpha Note](alpha-note.md)", "Operational alpha note."} { + if !strings.Contains(index, fragment) { + t.Fatalf("index.md missing %q:\n%s", fragment, index) + } + } + log := readFile(t, filepath.Join(targetTopic, "log.md")) + for _, fragment := range []string{"## 2026-06-27", "**Creation**", "[Alpha Note](alpha-note.md)", "`wiki/concepts/Alpha Note.md`"} { + if !strings.Contains(log, fragment) { + t.Fatalf("log.md missing %q:\n%s", fragment, log) + } + } + }) +} + +func TestPromoteRejectsNonOKFTargetBeforeWriting(t *testing.T) { + t.Parallel() + + t.Run("Should reject non-OKF target before writing", func(t *testing.T) { + targetTopic := t.TempDir() + _, err := Promote(context.Background(), PromoteInput{ + SourceDocPath: "missing.md", + TargetTopic: models.TopicInfo{ + Slug: "wiki-topic", + Mode: models.TopicModeWiki, + RootPath: targetTopic, + }, + Type: "Reference", + }) + if err == nil || !strings.Contains(err.Error(), "target topic must use mode okf") { + t.Fatalf("error = %v, want non-OKF target rejection", err) + } + if entries, readErr := os.ReadDir(targetTopic); readErr != nil || len(entries) != 0 { + t.Fatalf("target changed before rejection: entries=%d err=%v", len(entries), readErr) + } + }) +} + +func TestPromoteWarnsWhenSourceBodyHasNoSentenceFallback(t *testing.T) { + t.Parallel() + + t.Run("Should warn when source body has no sentence fallback", func(t *testing.T) { + vaultPath := t.TempDir() + sourceTopic := filepath.Join(vaultPath, "research") + targetTopic := filepath.Join(vaultPath, "catalog") + mkdirAll(t, filepath.Join(sourceTopic, "wiki", "concepts")) + mkdirAll(t, targetTopic) + writeFile(t, filepath.Join(sourceTopic, "CLAUDE.md"), "# Research\n") + writeFile(t, filepath.Join(targetTopic, "CLAUDE.md"), "# Catalog\n") + writeFile(t, filepath.Join(targetTopic, "index.md"), "---\nokf_version: \"0.1\"\n---\n# Index\n") + writeFile(t, filepath.Join(targetTopic, "log.md"), "# Directory Update Log\n") + sourcePath := filepath.Join(sourceTopic, "wiki", "concepts", "No Sentence.md") + writeFile(t, sourcePath, "---\ntitle: No Sentence\n---\nbody text without punctuation") + + result, err := Promote(context.Background(), PromoteInput{ + SourceDocPath: sourcePath, + VaultPath: vaultPath, + TargetTopic: models.TopicInfo{ + Slug: "catalog", + Mode: models.TopicModeOKF, + RootPath: targetTopic, + }, + Type: "Reference", + Clock: func() time.Time { + return time.Date(2026, 6, 27, 10, 11, 12, 0, time.UTC) + }, + }) + if err != nil { + t.Fatalf("Promote returned error: %v", err) + } + if len(result.Warnings) != 1 || !strings.Contains(result.Warnings[0], "source body has no sentence fallback") { + t.Fatalf("warnings = %#v, want no sentence fallback warning", result.Warnings) + } + + values, _ := parseMarkdown(t, filepath.Join(targetTopic, "no-sentence.md")) + if got := values["description"]; got != "" { + t.Fatalf("description = %#v, want empty string", got) + } + }) +} + +func TestCheckReportsConformanceAndStrictWarnings(t *testing.T) { + t.Parallel() + + writeBundle := func(t *testing.T) string { + t.Helper() + bundle := t.TempDir() + writeFile(t, filepath.Join(bundle, "CLAUDE.md"), "# Catalog\n") + writeFile(t, filepath.Join(bundle, "index.md"), "---\nokf_version: \"0.1\"\n---\n# Index\n") + writeFile(t, filepath.Join(bundle, "log.md"), "# Directory Update Log\n\n## 2026-06-27\n* **Creation**: Created bundle.\n") + writeFile(t, filepath.Join(bundle, "good.md"), "---\ntype: Playbook\ntitle: Good\ndescription: Good concept.\ntimestamp: 2026-06-27T10:00:00Z\n---\nBody with [broken](missing.md).\n") + writeFile(t, filepath.Join(bundle, "missing-type.md"), "---\ntitle: Missing\n---\nBody.\n") + writeFile(t, filepath.Join(bundle, "missing-fields.md"), "---\ntype: Unknown\n---\nBody.\n") + return bundle + } + + testCases := []struct { + name string + strict bool + assertions []expectedIssue + }{ + { + name: "Should report lenient conformance diagnostics", + strict: false, + assertions: []expectedIssue{ + {severity: models.SeverityError, filePath: "missing-type.md", target: "type", messageContains: "concept frontmatter must include non-empty type"}, + {severity: models.SeverityWarning, filePath: "missing-fields.md", target: "title", messageContains: `producer field "title" is missing`}, + {severity: models.SeverityWarning, filePath: "missing-fields.md", target: "type", messageContains: `type "Unknown" is outside the configured OKF vocabulary`}, + }, + }, + { + name: "Should promote warnings to errors in strict mode", + strict: true, + assertions: []expectedIssue{ + {severity: models.SeverityError, filePath: "missing-fields.md", target: "title", messageContains: `producer field "title" is missing`}, + }, + }, + } + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + bundle := writeBundle(t) + issues, err := Check(context.Background(), bundle, CheckOptions{ + Types: []string{"Playbook"}, + Strict: testCase.strict, + }) + if err != nil { + t.Fatalf("Check returned error: %v", err) + } + for _, assertion := range testCase.assertions { + assertIssue(t, issues, assertion) + } + if !HasErrors(issues) { + t.Fatal("HasErrors = false, want true for missing type") + } + }) + } +} + +func TestCheckAllowsLenientExternalBundleTraits(t *testing.T) { + t.Parallel() + + t.Run("Should allow lenient external bundle traits", func(t *testing.T) { + bundle := t.TempDir() + writeFile(t, filepath.Join(bundle, "index.md"), "# Index\n") + writeFile(t, filepath.Join(bundle, "concept.md"), "---\ntype: External Type\n---\nSee [missing](missing.md).\n") + + issues, err := Check(context.Background(), bundle, CheckOptions{}) + if err != nil { + t.Fatalf("Check returned error: %v", err) + } + if HasErrors(issues) { + t.Fatalf("lenient external bundle should not have errors: %#v", issues) + } + }) +} + +func TestAllocateConceptPathReservesPath(t *testing.T) { + t.Parallel() + + t.Run("Should reserve concept paths with exclusive creation", func(t *testing.T) { + bundle := t.TempDir() + firstRelativePath, firstAbsolutePath, firstFile, err := allocateConceptPath(bundle, "alpha") + if err != nil { + t.Fatalf("allocate first path: %v", err) + } + t.Cleanup(func() { _ = firstFile.Close() }) + if firstRelativePath != "alpha.md" { + t.Fatalf("first relative path = %q, want alpha.md", firstRelativePath) + } + if _, err := os.Stat(firstAbsolutePath); err != nil { + t.Fatalf("reserved first path does not exist: %v", err) + } + + secondRelativePath, _, secondFile, err := allocateConceptPath(bundle, "alpha") + if err != nil { + t.Fatalf("allocate second path: %v", err) + } + t.Cleanup(func() { _ = secondFile.Close() }) + if secondRelativePath != "alpha-2.md" { + t.Fatalf("second relative path = %q, want alpha-2.md", secondRelativePath) + } + }) +} + +func TestRegenerateIndexReturnsConceptParseErrors(t *testing.T) { + t.Parallel() + + t.Run("Should return parse errors instead of dropping malformed concepts", func(t *testing.T) { + bundle := t.TempDir() + writeFile(t, filepath.Join(bundle, "index.md"), "---\nokf_version: \"0.1\"\n---\n# Index\n") + writeFile(t, filepath.Join(bundle, "bad.md"), "---\ntype: Playbook\n\nBody without closed frontmatter.\n") + + err := RegenerateIndex(bundle) + if err == nil { + t.Fatal("expected malformed concept frontmatter error") + } + if !strings.Contains(err.Error(), "parse concept bad.md") { + t.Fatalf("error = %q, want parse concept bad.md", err.Error()) + } + }) +} + +type expectedIssue struct { + severity models.DiagnosticSeverity + filePath string + target string + messageContains string +} + +func assertIssue(t *testing.T, issues []models.LintIssue, expected expectedIssue) { + t.Helper() + for _, issue := range issues { + if issue.Severity == expected.severity && + issue.FilePath == expected.filePath && + issue.Target == expected.target && + strings.Contains(issue.Message, expected.messageContains) { + return + } + } + t.Fatalf( + "missing issue severity=%s file=%s target=%s message containing %q in %#v", + expected.severity, + expected.filePath, + expected.target, + expected.messageContains, + issues, + ) +} + +func parseMarkdown(t *testing.T, filePath string) (map[string]any, string) { + t.Helper() + values, body, err := frontmatter.Parse(readFile(t, filePath)) + if err != nil { + t.Fatalf("parse %s: %v", filePath, err) + } + return values, body +} + +func mkdirAll(t *testing.T, dir string) { + t.Helper() + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("mkdir %s: %v", dir, err) + } +} + +func writeFile(t *testing.T, filePath string, content string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(filePath), 0o755); err != nil { + t.Fatalf("mkdir parent for %s: %v", filePath, err) + } + if err := os.WriteFile(filePath, []byte(content), 0o644); err != nil { + t.Fatalf("write %s: %v", filePath, err) + } +} + +func readFile(t *testing.T, filePath string) string { + t.Helper() + content, err := os.ReadFile(filePath) + if err != nil { + t.Fatalf("read %s: %v", filePath, err) + } + return string(content) +} diff --git a/internal/okf/testdata/official/LICENSE.md b/internal/okf/testdata/official/LICENSE.md new file mode 100644 index 0000000..6b0b127 --- /dev/null +++ b/internal/okf/testdata/official/LICENSE.md @@ -0,0 +1,203 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + diff --git a/internal/okf/testdata/official/README.md b/internal/okf/testdata/official/README.md new file mode 100644 index 0000000..b3caa76 --- /dev/null +++ b/internal/okf/testdata/official/README.md @@ -0,0 +1,10 @@ +# Official OKF Fixtures + +These fixtures are copied from the Apache-2.0 licensed Google Cloud +`GoogleCloudPlatform/knowledge-catalog` repository: + +- Source repository: https://github.com/GoogleCloudPlatform/knowledge-catalog +- Fixture source path: `okf/bundles/{ga4,stackoverflow,crypto_bitcoin}` +- License: `LICENSE.md` in this directory + +They are used to verify lenient OKF v0.1 conformance against real sample bundles. diff --git a/internal/okf/testdata/official/crypto_bitcoin/datasets/crypto_bitcoin.md b/internal/okf/testdata/official/crypto_bitcoin/datasets/crypto_bitcoin.md new file mode 100644 index 0000000..2029190 --- /dev/null +++ b/internal/okf/testdata/official/crypto_bitcoin/datasets/crypto_bitcoin.md @@ -0,0 +1,55 @@ +--- +type: BigQuery Dataset +resource: https://bigquery.googleapis.com/v2/projects/bigquery-public-data/datasets/crypto_bitcoin +title: Cryptocurrency Bitcoin +description: This BigQuery public dataset contains a complete history of the Bitcoin + blockchain and updates every 10 minutes. +tags: +- cryptocurrency +- bitcoin +- blockchain +- public data +- gcp +- data-analytics +timestamp: '2026-05-28T22:44:47+00:00' +--- + +The `crypto_bitcoin` dataset provides a comprehensive and up-to-date record of the entire Bitcoin blockchain. It includes detailed information about [blocks](../tables/blocks.md), [transactions](../tables/transactions.md), transaction [inputs](../tables/inputs.md), and [outputs](../tables/outputs.md). This dataset is part of the BigQuery Public Datasets program, making it freely accessible for analysis and research into Bitcoin's operations, economics, and historical trends. Researchers, developers, and enthusiasts can use this data to understand transaction patterns, network activity, and the overall state of the Bitcoin network. + +# Schema + +This dataset contains the following tables, providing a complete history of the Bitcoin blockchain: + +* [blocks](../tables/blocks.md) +* [inputs](../tables/inputs.md) +* [outputs](../tables/outputs.md) +* [transactions](../tables/transactions.md) + +# Common query patterns + +```sql +-- Count the total number of blocks in the Bitcoin blockchain +SELECT + COUNT(*) +FROM + `bigquery-public-data.crypto_bitcoin.blocks`; +``` + +```sql +-- Get the total number of transactions over time +SELECT + DATE(block_timestamp) AS transaction_date, + COUNT(transaction_id) AS total_transactions +FROM + `bigquery-public-data.crypto_bitcoin.transactions` +GROUP BY + transaction_date +ORDER BY + transaction_date DESC +LIMIT 100; +``` + +# Citations + +[1] [BigQuery Public Dataset: crypto_bitcoin](https://bigquery.googleapis.com/v2/projects/bigquery-public-data/datasets/crypto_bitcoin) +[2] [Bitcoin in BigQuery: blockchain analytics on public data](https://cloud.google.com/blog/products/gcp/bitcoin-in-bigquery-blockchain-analytics-on-public-data) diff --git a/internal/okf/testdata/official/crypto_bitcoin/datasets/index.md b/internal/okf/testdata/official/crypto_bitcoin/datasets/index.md new file mode 100644 index 0000000..9f118b6 --- /dev/null +++ b/internal/okf/testdata/official/crypto_bitcoin/datasets/index.md @@ -0,0 +1,3 @@ +# BigQuery Dataset + +* [Cryptocurrency Bitcoin](crypto_bitcoin.md) - This BigQuery public dataset contains a complete history of the Bitcoin blockchain and updates every 10 minutes. diff --git a/internal/okf/testdata/official/crypto_bitcoin/index.md b/internal/okf/testdata/official/crypto_bitcoin/index.md new file mode 100644 index 0000000..6e45252 --- /dev/null +++ b/internal/okf/testdata/official/crypto_bitcoin/index.md @@ -0,0 +1,4 @@ +# Subdirectories + +* [datasets](datasets/index.md) - This BigQuery public dataset contains a complete history of the Bitcoin blockchain and updates every 10 minutes. +* [tables](tables/index.md) - This directory contains BigQuery table details for Bitcoin blocks, transactions, inputs, and outputs. diff --git a/internal/okf/testdata/official/crypto_bitcoin/tables/blocks.md b/internal/okf/testdata/official/crypto_bitcoin/tables/blocks.md new file mode 100644 index 0000000..feea276 --- /dev/null +++ b/internal/okf/testdata/official/crypto_bitcoin/tables/blocks.md @@ -0,0 +1,32 @@ +--- +type: BigQuery Table +resource: https://bigquery.googleapis.com/v2/projects/bigquery-public-data/datasets/crypto_bitcoin/tables/blocks +title: Bitcoin Blocks Table +description: Details about the Bitcoin Blocks BigQuery table, including its schema. +tags: +- bitcoin +- bigquery +- blocks +- blockchain +timestamp: '2026-05-28T22:43:59+00:00' +--- + +# Schema + +| Field | Type | +| --- | --- | +| hash | hex_string | +| size | bigint | +| stripped_size | bigint | +| weight | bigint | +| number | bigint | +| version | bigint | +| merkle_root | hex_string | +| timestamp | bigint | +| nonce | hex_string | +| bits | hex_string | +| coinbase_param | hex_string | +| transaction_count | bigint | + +# Citations +- https://github.com/blockchain-etl/bitcoin-etl diff --git a/internal/okf/testdata/official/crypto_bitcoin/tables/index.md b/internal/okf/testdata/official/crypto_bitcoin/tables/index.md new file mode 100644 index 0000000..7583606 --- /dev/null +++ b/internal/okf/testdata/official/crypto_bitcoin/tables/index.md @@ -0,0 +1,6 @@ +# BigQuery Table + +* [Bitcoin Blocks Table](blocks.md) - Details about the Bitcoin Blocks BigQuery table, including its schema. +* [Bitcoin Transaction Inputs](inputs.md) - Details about transaction inputs on the Bitcoin blockchain. +* [Bitcoin Transactions](transactions.md) - A comprehensive table detailing all transactions on the Bitcoin blockchain. +* [Outputs](outputs.md) - Outputs from all transactions in the Bitcoin blockchain. diff --git a/internal/okf/testdata/official/crypto_bitcoin/tables/inputs.md b/internal/okf/testdata/official/crypto_bitcoin/tables/inputs.md new file mode 100644 index 0000000..f3160d6 --- /dev/null +++ b/internal/okf/testdata/official/crypto_bitcoin/tables/inputs.md @@ -0,0 +1,65 @@ +--- +type: BigQuery Table +resource: https://bigquery.googleapis.com/v2/projects/bigquery-public-data/datasets/crypto_bitcoin/tables/inputs +title: Bitcoin Transaction Inputs +description: Details about transaction inputs on the Bitcoin blockchain. +tags: +- bitcoin +- blockchain +- cryptocurrency +- transactions +- inputs +- etl +timestamp: '2026-05-28T22:44:24+00:00' +--- + +This table, part of the public [crypto_bitcoin](../datasets/crypto_bitcoin.md) dataset, contains detailed information about every input used in Bitcoin transactions. Each row represents a single transaction input, which typically references an unspent output from a previous transaction. This table is crucial for tracing the flow of Bitcoin and understanding the history of transactions. It records where the coins originated (`spent_transaction_hash` and `spent_output_index`) and the associated `value` transferred. This table can be joined with the [transactions](transactions.md) table on `transaction_hash` and [outputs](outputs.md) to reconstruct the full transaction graph. + +# Schema + +* `transaction_hash`: STRING +* `block_hash`: STRING +* `block_number`: INTEGER +* `block_timestamp`: TIMESTAMP +* `index`: INTEGER +* `spent_transaction_hash`: STRING +* `spent_output_index`: INTEGER +* `script_asm`: STRING +* `script_hex`: STRING +* `sequence`: INTEGER +* `required_signatures`: INTEGER +* `type`: STRING +* `addresses`: REPEATED STRING +* `value`: NUMERIC + +# Common query patterns + +```sql +SELECT * +FROM `bigquery-public-data.crypto_bitcoin.inputs` +WHERE transaction_hash = 'YOUR_TRANSACTION_HASH_HERE' +LIMIT 10 +``` + +```sql +SELECT + block_number, + SUM(value) AS total_input_value +FROM `bigquery-public-data.crypto_bitcoin.inputs` +WHERE block_number = 600000 -- Example block number +GROUP BY block_number +``` + +```sql +SELECT DISTINCT + address +FROM `bigquery-public-data.crypto_bitcoin.inputs`, + UNNEST(addresses) AS address +WHERE block_timestamp >= '2023-01-01' +LIMIT 10 +``` + +# Citations + +[1] [BigQuery Table: inputs](https://bigquery.googleapis.com/v2/projects/bigquery-public-data/datasets/crypto_bitcoin/tables/inputs) +[2] [blockchain-etl/bitcoin-etl](https://github.com/blockchain-etl/bitcoin-etl) diff --git a/internal/okf/testdata/official/crypto_bitcoin/tables/outputs.md b/internal/okf/testdata/official/crypto_bitcoin/tables/outputs.md new file mode 100644 index 0000000..b2121b7 --- /dev/null +++ b/internal/okf/testdata/official/crypto_bitcoin/tables/outputs.md @@ -0,0 +1,68 @@ +--- +type: BigQuery Table +resource: https://bigquery.googleapis.com/v2/projects/bigquery-public-data/datasets/crypto_bitcoin/tables/outputs +title: Outputs +description: Outputs from all transactions in the Bitcoin blockchain. +tags: +- bitcoin +- blockchain +- transactions +- outputs +- etl +timestamp: '2026-05-28T22:44:32+00:00' +--- + +The `outputs` table contains records of all transaction outputs within the Bitcoin blockchain. Each row in this table represents a single output from a Bitcoin transaction, detailing the amount transferred, the destination addresses, and other script-related information. This table is crucial for understanding the flow of Bitcoin and analyzing transaction patterns, especially when linked with the `[transactions](transactions.md)` and `[inputs](inputs.md)` tables. + +# Schema + +- `transaction_hash`: The hash of the transaction this output belongs to. +- `block_hash`: The hash of the block containing this transaction. +- `block_number`: The number of the block containing this transaction. +- `block_timestamp`: The timestamp of the block containing this transaction. +- `index`: The zero-based index of this output within its transaction. +- `script_asm`: The script in assembly format. +- `script_hex`: The script in hexadecimal format. +- `required_signatures`: The number of signatures required to spend this output. +- `type`: The type of the output script. +- `addresses`: (REPEATED) Array of destination addresses for this output. +- `value`: The value of the output in satoshis. + +# Common query patterns + +```sql +SELECT + t.* +FROM + `bigquery-public-data.crypto_bitcoin.outputs` AS t +WHERE + t.transaction_hash = 'some_transaction_hash' +``` + +```sql +SELECT + SUM(t.value) AS total_output_value +FROM + `bigquery-public-data.crypto_bitcoin.outputs` AS t +WHERE + t.block_number = 123456 +``` + +```sql +SELECT + t.type, + COUNT(*) AS output_count +FROM + `bigquery-public-data.crypto_bitcoin.outputs` AS t +WHERE + t.block_timestamp BETWEEN TIMESTAMP('2023-01-01') AND TIMESTAMP('2023-01-31') +GROUP BY + t.type +ORDER BY + output_count DESC +``` + +# Citations + +[1] [Outputs Table](https://bigquery.googleapis.com/v2/projects/bigquery-public-data/datasets/crypto_bitcoin/tables/outputs) +[2] [Bitcoin ETL on GitHub](https://github.com/blockchain-etl/bitcoin-etl) diff --git a/internal/okf/testdata/official/crypto_bitcoin/tables/transactions.md b/internal/okf/testdata/official/crypto_bitcoin/tables/transactions.md new file mode 100644 index 0000000..1ec7471 --- /dev/null +++ b/internal/okf/testdata/official/crypto_bitcoin/tables/transactions.md @@ -0,0 +1,112 @@ +--- +type: BigQuery Table +resource: https://bigquery.googleapis.com/v2/projects/bigquery-public-data/datasets/crypto_bitcoin/tables/transactions +title: Bitcoin Transactions +description: A comprehensive table detailing all transactions on the Bitcoin blockchain. +tags: +- bitcoin +- blockchain +- transactions +- crypto +- public data +- etl +timestamp: '2026-05-28T22:45:04+00:00' +--- + +The `transactions` table in the [crypto_bitcoin](../datasets/crypto_bitcoin.md) dataset provides a complete record of every transaction ever processed on the Bitcoin blockchain. Each row represents a single transaction, offering granular details such as its hash, size, associated [block](blocks.md) information (hash, number, timestamp), and the total input and output values. Importantly, it includes detailed arrays for both [inputs](inputs.md) and [outputs](outputs.md), each specifying spent transaction details, script information, involved addresses, and values. This table is essential for in-depth analysis of transaction flows, tracing funds, and understanding the economic activity within the Bitcoin network. The grain is one row per transaction, with data spanning the entire history of the Bitcoin blockchain, partitioned by `block_timestamp_month`. + +# Schema +- `hash` STRING REQUIRED: The hash of this transaction +- `size` INTEGER: The size of this transaction in bytes +- `virtual_size` INTEGER: The virtual transaction size (differs from size for witness transactions) +- `version` INTEGER: Protocol version specified in block which contained this transaction +- `lock_time` INTEGER: Earliest time that miners can include the transaction in their hashing of the Merkle root to attach it in the latest block of the blockchain +- `block_hash` STRING REQUIRED: Hash of the block which contains this transaction +- `block_number` INTEGER REQUIRED: Number of the block which contains this transaction +- `block_timestamp` TIMESTAMP REQUIRED: Timestamp of the block which contains this transaction +- `block_timestamp_month` DATE REQUIRED: Month of the block which contains this transaction +- `input_count` INTEGER: The number of inputs in the transaction +- `output_count` INTEGER: The number of outputs in the transaction +- `input_value` NUMERIC: Total value of inputs in the transaction +- `output_value` NUMERIC: Total value of outputs in the transaction +- `is_coinbase` BOOLEAN: True if this transaction is a coinbase transaction +- `fee` NUMERIC: The fee paid by this transaction +- `inputs` RECORD REPEATED: Transaction inputs + - `index` INTEGER REQUIRED: 0-indexed number of an input within a transaction + - `spent_transaction_hash` STRING: The hash of the transaction which contains the output that this input spends + - `spent_output_index` INTEGER: The index of the output this input spends + - `script_asm` STRING: Symbolic representation of the bitcoin's script language op-codes + - `script_hex` STRING: Hexadecimal representation of the bitcoin's script language op-codes + - `sequence` INTEGER: A number intended to allow unconfirmed time-locked transactions to be updated before being finalized + - `required_signatures` INTEGER: The number of signatures required to authorize the spent output + - `type` STRING: The address type of the spent output + - `addresses` STRING REPEATED: Addresses which own the spent output + - `value` NUMERIC: The value in base currency attached to the spent output +- `outputs` RECORD REPEATED: Transaction outputs + - `index` INTEGER REQUIRED: 0-indexed number of an output within a transaction + - `script_asm` STRING: Symbolic representation of the bitcoin's script language op-codes + - `script_hex` STRING: Hexadecimal representation of the bitcoin's script language op-codes + - `required_signatures` INTEGER: The number of signatures required to authorize spending of this output + - `type` STRING: The address type of the output + - `addresses` STRING REPEATED: Addresses which own this output + - `value` NUMERIC: The value in base currency attached to this output + +# Common query patterns +```sql +-- Get the total number of transactions per day +SELECT + DATE(block_timestamp) AS transaction_date, + COUNT(hash) AS transaction_count +FROM + `bigquery-public-data.crypto_bitcoin.transactions` +WHERE + block_timestamp BETWEEN '2023-01-01' AND '2023-01-31' +GROUP BY + transaction_date +ORDER BY + transaction_date DESC; +``` +```sql +-- Find transactions involving a specific address as an output +SELECT + t.hash AS transaction_hash, + t.block_timestamp +FROM + `bigquery-public-data.crypto_bitcoin.transactions` AS t, + UNNEST(t.outputs) AS output +WHERE + '1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa' IN UNNEST(output.addresses) +LIMIT 10; +``` +```sql +-- Calculate the total fees collected in a given month +SELECT + FORMAT_TIMESTAMP('%Y-%m', block_timestamp) AS transaction_month, + SUM(fee) AS total_fee +FROM + `bigquery-public-data.crypto_bitcoin.transactions` +WHERE + block_timestamp BETWEEN '2023-01-01' AND '2023-01-31' +GROUP BY + transaction_month; +``` +```sql +-- Find duplicate transactions (anomaly detection) +SELECT +   * +FROM ( + SELECT +   hash, +   COUNT(hash) AS dup_transaction_count + FROM +   `bigquery-public-data.crypto_bitcoin.transactions` + GROUP BY +   hash) +WHERE + dup_transaction_count > 1 +``` + +# Citations +[1] [Bitcoin Transactions](https://bigquery.googleapis.com/v2/projects/bigquery-public-data/datasets/crypto_bitcoin/tables/transactions) +[2] [Bitcoin ETL](https://github.com/blockchain-etl/bitcoin-etl) +[3] [Bitcoin in BigQuery: blockchain analytics on public data](https://cloud.google.com/blog/products/gcp/bitcoin-in-bigquery-blockchain-analytics-on-public-data) diff --git a/internal/okf/testdata/official/crypto_bitcoin/viz.html b/internal/okf/testdata/official/crypto_bitcoin/viz.html new file mode 100644 index 0000000..e065b19 --- /dev/null +++ b/internal/okf/testdata/official/crypto_bitcoin/viz.html @@ -0,0 +1,448 @@ +<!DOCTYPE html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<title>OKF Bundle Viewer + + + + + +
+
+ + OKF bundle +
+
+ + + + +
+
+ +
+
+
+
Click a node to see its details.
+ +
+
+ + + + + diff --git a/internal/okf/testdata/official/ga4/datasets/ga4_obfuscated_sample_ecommerce.md b/internal/okf/testdata/official/ga4/datasets/ga4_obfuscated_sample_ecommerce.md new file mode 100644 index 0000000..9e20eef --- /dev/null +++ b/internal/okf/testdata/official/ga4/datasets/ga4_obfuscated_sample_ecommerce.md @@ -0,0 +1,41 @@ +--- +type: BigQuery Dataset +resource: https://bigquery.googleapis.com/v2/projects/bigquery-public-data/datasets/ga4_obfuscated_sample_ecommerce +title: BigQuery sample dataset for Google Analytics ecommerce web implementation +description: A sample of obfuscated Google Analytics BigQuery event export data for + three months from the Google Merchandise Store is available as a public dataset + in BigQuery. +tags: +- ecommerce +- web analytics +- Google Analytics +- BigQuery +- public dataset +timestamp: '2026-05-28T22:49:59+00:00' +--- + +# Overview +The `ga4_obfuscated_sample_ecommerce` dataset contains obfuscated Google Analytics BigQuery event export data for three months (November 2020 to January 2021) from the Google Merchandise Store. This public dataset is available in BigQuery and emulates a real-world dataset. + +# Pre-requisites +To work with this dataset, you need access to a Google Cloud project with the BigQuery API enabled. You can use BigQuery Sandbox mode or the Free usage tier for exploration and sample queries. + +# Limitations +The dataset contains obfuscated data with placeholder values like ``, `NULL`, and `''`. Due to obfuscation, the internal consistency of the dataset might be somewhat limited. It cannot be compared to the Google Analytics Demo Account. + +# Using the dataset +You can access the `ga4_obfuscated_sample_ecommerce` dataset via the BigQuery UI in the Cloud Console. + +## Sample Query +The following query shows the number of unique events, users, and days in the dataset: + +```sql +SELECT + COUNT(*) AS event_count, + COUNT(DISTINCT user_pseudo_id) AS user_count, + COUNT(DISTINCT event_date) AS day_count +FROM `bigquery-public-data.ga4_obfuscated_sample_ecommerce.events_*` +``` + +# Citations +- https://developers.google.com/analytics/bigquery/web-ecommerce-demo-dataset diff --git a/internal/okf/testdata/official/ga4/datasets/index.md b/internal/okf/testdata/official/ga4/datasets/index.md new file mode 100644 index 0000000..16acf0e --- /dev/null +++ b/internal/okf/testdata/official/ga4/datasets/index.md @@ -0,0 +1,3 @@ +# BigQuery Dataset + +* [BigQuery sample dataset for Google Analytics ecommerce web implementation](ga4_obfuscated_sample_ecommerce.md) - A sample of obfuscated Google Analytics BigQuery event export data for three months from the Google Merchandise Store is available as a public dataset in BigQuery. diff --git a/internal/okf/testdata/official/ga4/index.md b/internal/okf/testdata/official/ga4/index.md new file mode 100644 index 0000000..2526024 --- /dev/null +++ b/internal/okf/testdata/official/ga4/index.md @@ -0,0 +1,5 @@ +# Subdirectories + +* [datasets](datasets/index.md) - A sample of obfuscated Google Analytics BigQuery event export data for three months from the Google Merchandise Store is available as a public dataset in BigQuery. +* [references](references/index.md) - This directory contains specifications for data joins and definitions for user activity and purchase metrics. +* [tables](tables/index.md) - Contains Google Analytics event export data from the `ga4_obfuscated_sample_ecommerce` dataset. diff --git a/internal/okf/testdata/official/ga4/references/index.md b/internal/okf/testdata/official/ga4/references/index.md new file mode 100644 index 0000000..56904b3 --- /dev/null +++ b/internal/okf/testdata/official/ga4/references/index.md @@ -0,0 +1,4 @@ +# Subdirectories + +* [joins](joins/index.md) - Join Google Analytics event data with Google Ads click data. +* [metrics](metrics/index.md) - This directory contains definitions for metrics related to user activity, events, and purchase behavior. diff --git a/internal/okf/testdata/official/ga4/references/joins/events___ads_clickstats.md b/internal/okf/testdata/official/ga4/references/joins/events___ads_clickstats.md new file mode 100644 index 0000000..9aaac5d --- /dev/null +++ b/internal/okf/testdata/official/ga4/references/joins/events___ads_clickstats.md @@ -0,0 +1,19 @@ +--- +type: Reference +resource: https://developers.google.com/analytics/bigquery/basic-queries +title: Join Google Analytics Events to Google Ads Clicks +description: Join Google Analytics event data with Google Ads click data. +tags: +- join +- Google Ads +timestamp: '2026-05-28T22:51:46+00:00' +--- + +Join Google Analytics event data with Google Ads click data. + +```sql +GA_EVENTS.collected_traffic_source.gclid = ADS_CLICKS.gclid +``` + +# Citations +- https://developers.google.com/analytics/bigquery/basic-queries diff --git a/internal/okf/testdata/official/ga4/references/joins/index.md b/internal/okf/testdata/official/ga4/references/joins/index.md new file mode 100644 index 0000000..fc53988 --- /dev/null +++ b/internal/okf/testdata/official/ga4/references/joins/index.md @@ -0,0 +1,3 @@ +# Reference + +* [Join Google Analytics Events to Google Ads Clicks](events___ads_clickstats.md) - Join Google Analytics event data with Google Ads click data. diff --git a/internal/okf/testdata/official/ga4/references/metrics/avg_pageviews.md b/internal/okf/testdata/official/ga4/references/metrics/avg_pageviews.md new file mode 100644 index 0000000..4380efe --- /dev/null +++ b/internal/okf/testdata/official/ga4/references/metrics/avg_pageviews.md @@ -0,0 +1,19 @@ +--- +type: Reference +resource: https://developers.google.com/analytics/bigquery/basic-queries +title: Average Pageviews +description: The average number of pageviews per user. +tags: +- metric +timestamp: '2026-05-28T22:51:43+00:00' +--- + +The average number of pageviews per user. + +```sql +SUM(page_view_count) / COUNT(*) +-- where page_view_count is COUNTIF(event_name = 'page_view') per user +``` + +# Citations +- https://developers.google.com/analytics/bigquery/basic-queries diff --git a/internal/okf/testdata/official/ga4/references/metrics/avg_spend_per_purchase_session_by_user.md b/internal/okf/testdata/official/ga4/references/metrics/avg_spend_per_purchase_session_by_user.md new file mode 100644 index 0000000..39495b9 --- /dev/null +++ b/internal/okf/testdata/official/ga4/references/metrics/avg_spend_per_purchase_session_by_user.md @@ -0,0 +1,21 @@ +--- +type: Reference +resource: https://developers.google.com/analytics/bigquery/advanced-queries +title: Average Spend Per Purchase Session By User +description: The average amount of money spent per purchase session for each individual + user. +tags: +- metric +- ecommerce +timestamp: '2026-05-28T22:52:29+00:00' +--- + +The average amount of money spent per purchase session for each individual user. + +```sql +AVG(total_session_spend) +-- where total_session_spend is SUM(COALESCE(...)) for event_name = 'purchase' events within a session, grouped by user_pseudo_id and ga_session_id +``` + +# Citations +- https://developers.google.com/analytics/bigquery/advanced-queries diff --git a/internal/okf/testdata/official/ga4/references/metrics/avg_transactions_per_purchaser.md b/internal/okf/testdata/official/ga4/references/metrics/avg_transactions_per_purchaser.md new file mode 100644 index 0000000..3109234 --- /dev/null +++ b/internal/okf/testdata/official/ga4/references/metrics/avg_transactions_per_purchaser.md @@ -0,0 +1,20 @@ +--- +type: Reference +resource: https://developers.google.com/analytics/bigquery/basic-queries +title: Average Transactions Per Purchaser +description: The average number of transactions made by purchasers. +tags: +- metric +- ecommerce +timestamp: '2026-05-28T22:51:41+00:00' +--- + +The average number of transactions made by purchasers. + +```sql +COUNT(*) / COUNT(DISTINCT user_pseudo_id) +-- for events where event_name IN ('in_app_purchase', 'purchase') +``` + +# Citations +- https://developers.google.com/analytics/bigquery/basic-queries diff --git a/internal/okf/testdata/official/ga4/references/metrics/day_count.md b/internal/okf/testdata/official/ga4/references/metrics/day_count.md new file mode 100644 index 0000000..46e2c55 --- /dev/null +++ b/internal/okf/testdata/official/ga4/references/metrics/day_count.md @@ -0,0 +1,18 @@ +--- +type: Reference +resource: https://developers.google.com/analytics/bigquery/web-ecommerce-demo-dataset +title: Day Count +description: Total number of unique days. +tags: +- metric +timestamp: '2026-05-28T22:50:10+00:00' +--- + +Total number of unique days. + +```sql +COUNT(DISTINCT event_date) +``` + +# Citations +- https://developers.google.com/analytics/bigquery/web-ecommerce-demo-dataset diff --git a/internal/okf/testdata/official/ga4/references/metrics/event_count.md b/internal/okf/testdata/official/ga4/references/metrics/event_count.md new file mode 100644 index 0000000..34ada29 --- /dev/null +++ b/internal/okf/testdata/official/ga4/references/metrics/event_count.md @@ -0,0 +1,18 @@ +--- +type: Reference +resource: https://developers.google.com/analytics/bigquery/web-ecommerce-demo-dataset +title: Event Count +description: Total number of events. +tags: +- metric +timestamp: '2026-05-28T22:50:07+00:00' +--- + +Total number of events. + +```sql +COUNT(*) +``` + +# Citations +- https://developers.google.com/analytics/bigquery/web-ecommerce-demo-dataset diff --git a/internal/okf/testdata/official/ga4/references/metrics/index.md b/internal/okf/testdata/official/ga4/references/metrics/index.md new file mode 100644 index 0000000..d61cf65 --- /dev/null +++ b/internal/okf/testdata/official/ga4/references/metrics/index.md @@ -0,0 +1,10 @@ +# Reference + +* [Average Pageviews](avg_pageviews.md) - The average number of pageviews per user. +* [Average Spend Per Purchase Session By User](avg_spend_per_purchase_session_by_user.md) - The average amount of money spent per purchase session for each individual user. +* [Average Transactions Per Purchaser](avg_transactions_per_purchaser.md) - The average number of transactions made by purchasers. +* [Day Count](day_count.md) - Total number of unique days. +* [Event Count](event_count.md) - Total number of events. +* [New User Count](new_user_count.md) - The number of unique users who triggered a first_visit or first_open event. +* [Overall Average Spend Per Purchase Session](overall_avg_spend_per_purchase_session.md) - The overall average amount spent across all unique purchase sessions. +* [User Count](user_count.md) - Total number of unique users. diff --git a/internal/okf/testdata/official/ga4/references/metrics/new_user_count.md b/internal/okf/testdata/official/ga4/references/metrics/new_user_count.md new file mode 100644 index 0000000..78f44bc --- /dev/null +++ b/internal/okf/testdata/official/ga4/references/metrics/new_user_count.md @@ -0,0 +1,20 @@ +--- +type: Reference +resource: https://developers.google.com/analytics/bigquery/basic-queries +title: New User Count +description: The number of unique users who triggered a first_visit or first_open + event. +tags: +- metric +timestamp: '2026-05-28T22:51:38+00:00' +--- + +The number of unique users who triggered a `first_visit` or `first_open` event. + +```sql +SUM(is_new_user) +-- where is_new_user is MAX(IF(event_name IN ('first_visit', 'first_open'), 1, 0)) grouped by user_pseudo_id +``` + +# Citations +- https://developers.google.com/analytics/bigquery/basic-queries diff --git a/internal/okf/testdata/official/ga4/references/metrics/overall_avg_spend_per_purchase_session.md b/internal/okf/testdata/official/ga4/references/metrics/overall_avg_spend_per_purchase_session.md new file mode 100644 index 0000000..fe8383e --- /dev/null +++ b/internal/okf/testdata/official/ga4/references/metrics/overall_avg_spend_per_purchase_session.md @@ -0,0 +1,20 @@ +--- +type: Reference +resource: https://developers.google.com/analytics/bigquery/advanced-queries +title: Overall Average Spend Per Purchase Session +description: The overall average amount spent across all unique purchase sessions. +tags: +- metric +- ecommerce +timestamp: '2026-05-28T22:52:32+00:00' +--- + +The overall average amount spent across all unique purchase sessions. + +```sql +AVG(total_session_spend) +-- where total_session_spend is SUM(COALESCE(...)) for event_name = 'purchase' events within a session, grouped by user_pseudo_id and ga_session_id +``` + +# Citations +- https://developers.google.com/analytics/bigquery/advanced-queries diff --git a/internal/okf/testdata/official/ga4/references/metrics/user_count.md b/internal/okf/testdata/official/ga4/references/metrics/user_count.md new file mode 100644 index 0000000..cc009d4 --- /dev/null +++ b/internal/okf/testdata/official/ga4/references/metrics/user_count.md @@ -0,0 +1,18 @@ +--- +type: Reference +resource: https://developers.google.com/analytics/bigquery/web-ecommerce-demo-dataset +title: User Count +description: Total number of unique users. +tags: +- metric +timestamp: '2026-05-28T22:50:09+00:00' +--- + +Total number of unique users. + +```sql +COUNT(DISTINCT user_pseudo_id) +``` + +# Citations +- https://developers.google.com/analytics/bigquery/web-ecommerce-demo-dataset diff --git a/internal/okf/testdata/official/ga4/tables/events_.md b/internal/okf/testdata/official/ga4/tables/events_.md new file mode 100644 index 0000000..a4d2cf6 --- /dev/null +++ b/internal/okf/testdata/official/ga4/tables/events_.md @@ -0,0 +1,313 @@ +--- +type: BigQuery Table +resource: https://bigquery.googleapis.com/v2/projects/bigquery-public-data/datasets/ga4_obfuscated_sample_ecommerce/tables/events_* +title: Events table (Google Analytics BigQuery Export) +description: Contains Google Analytics event export data from the `ga4_obfuscated_sample_ecommerce` + dataset. +tags: +- events +- Google Analytics +- BigQuery +- ecommerce +- schema +- basic queries +- advanced queries +timestamp: '2026-05-28T22:53:05+00:00' +--- + +# Overview +The `events_` table is a sharded BigQuery table containing Google Analytics event export data from the `ga4_obfuscated_sample_ecommerce` dataset. + +# Metrics +- [Event Count](../references/metrics/event_count.md) — Total number of events. +- [User Count](../references/metrics/user_count.md) — Total number of unique users. +- [Day Count](../references/metrics/day_count.md) — Total number of unique days. +- [New User Count](../references/metrics/new_user_count.md) — The number of unique users who triggered a first_visit or first_open event. +- [Average Transactions Per Purchaser](../references/metrics/avg_transactions_per_purchaser.md) — The average number of transactions made by purchasers. +- [Average Pageviews](../references/metrics/avg_pageviews.md) — The average number of pageviews per user. +- [Average Spend Per Purchase Session By User](../references/metrics/avg_spend_per_purchase_session_by_user.md) — The average amount of money spent per purchase session for each individual user. +- [Overall Average Spend Per Purchase Session](../references/metrics/overall_avg_spend_per_purchase_session.md) — The overall average amount spent across all unique purchase sessions. + +# Schema +The `events_YYYYMMDD` table, created daily, and `events_intraday_YYYYMMDD` (for streaming export) contain the following fields: + +## event +The event fields contain information that uniquely identifies an event. + +- `batch_event_index` (INTEGER): A number indicating the sequential order of each event within a batch based on their order of occurrence on the device. +- `batch_ordering_id` (INTEGER): A monotonically increasing number that is incremented each time a network request is sent from a given page. +- `batch_page_id` (INTEGER): A sequential number assigned to a page that increases for each subsequent page within an engagement. +- `event_date` (STRING): The date when the event was logged (YYYYMMDD format in the registered timezone of your app). +- `event_timestamp` (INTEGER): The time (in microseconds, UTC) when the event was received by Google Analytics. Multiple events can share the same timestamp if sent in the same request. +- `event_previous_timestamp` (INTEGER): The time (in microseconds, UTC) when the previous event happened. +- `event_name` (STRING): The name of the event. +- `event_value_in_usd` (FLOAT): The currency-converted value (in USD) of the event's "value" parameter. +- `event_bundle_sequence_id` (INTEGER): The sequential ID of the bundle in which these events were uploaded. +- `event_server_timestamp_offset` (INTEGER): Timestamp offset between collection time and upload time in micros. + +### event_params RECORD +The `event_params` RECORD can store campaign-level and contextual event parameters as well as any user-defined event parameters. The `event_params` RECORD is repeated for each key that is associated with an event. + +- `event_params.key` (STRING): The name of the event parameter. +- `event_params.value` (RECORD): A record containing the event parameter's value. + - `event_params.value.string_value` (STRING): If the event parameter is represented by a string, such as a URL or campaign name, it is populated in this field. + - `event_params.value.int_value` (INTEGER): If the event parameter is represented by an integer, it is populated in this field. + - `event_params.value.double_value` (FLOAT): If the event parameter is represented by a double value, it is populated in this field. + - `event_params.value.float_value` (FLOAT): If the event parameter is represented by a floating point value, it is populated in this field. This field is not currently in use. +- `event_params.key = 'page_location'` (STRING): The URL of the page viewed. +- `event_params.key = 'page_title'` (STRING): The title of the page viewed. +- `event_params.key = 'ga_session_id'` (INTEGER): The ID of the Google Analytics session. +- `event_params.key = 'ga_session_number'` (INTEGER): The sequential number of the session for a user. + +### item_params RECORD +The `item_params` RECORD can store item parameters as well as any user-defined item parameters. + +- `item_params.key` (STRING): The name of the item parameter. +- `item_params.value` (RECORD): A record containing the item parameter’s value. + - `item_params.value.string_value` (STRING): If the item parameter is represented by a string, it is populated in this field. + - `item_params.value.int_value` (INTEGER): If the item parameter is represented by an integer, it is populated in this field. + - `item_params.value.double_value` (FLOAT): If the item parameter is represented by a double value, it is populated in this field. + - `item_params.value.float_value` (FLOAT): If the item parameter is represented by a floating point value, it is populated in this field. + +## user +The user fields contain information that uniquely identifies the user associated with the event. + +- `is_active_user` (BOOLEAN): Whether the user was active (True) or inactive (False) at any point in the calendar day. This field is only populated in the daily tables (`events_YYYYMMDD`). +- `user_id` (STRING): The unique ID assigned to a user. +- `user_pseudo_id` (STRING): The pseudonymous id (e.g., app instance ID) for the user. A unique identifier that is assigned to a user when they first open the app or visit the site. +- `user_first_touch_timestamp` (INTEGER): The time (in microseconds) at which the user first opened the app or visited the site. + +### privacy_info fields +The `privacy_info` fields contain information based on the consent status of a user when consent mode is enabled. + +- `privacy_info.ads_storage` (STRING): Whether ad targeting is enabled for a user. Possible values: Yes, No, Unset +- `privacy_info.analytics_storage` (STRING): Whether Analytics storage is enabled for the user. Possible values: Yes, No, Unset +- `privacy_info.uses_transient_token` (STRING): Whether a web user has denied Analytics storage and the developer has enabled measurement without cookies based on transient tokens in server data. Possible values: Yes, No, Unset + +### user_properties RECORD +The `user_properties` RECORD contains any user properties that you have set. It is repeated for each key that is associated with a user. + +- `user_properties.key` (STRING): The name of the user property. +- `user_properties.value` (RECORD): A record for the user property value. + - `user_properties.value.string_value` (STRING): The string value of the user property. + - `user_properties.value.int_value` (INTEGER): The integer value of the user property. + - `user_properties.value.double_value` (FLOAT): The double value of the user property. + - `user_properties.value.float_value` (FLOAT): This field is currently unused. + - `user_properties.value.set_timestamp_micros` (INTEGER): The time (in microseconds) at which the user property was last set. + +### user_ltv RECORD +The `user_ltv` RECORD contains Lifetime Value information about the user. This RECORD is not populated in intraday tables. + +- `user_ltv.revenue` (FLOAT): The Lifetime Value (revenue) of the user. This field is not populated in intraday tables. +- `user_ltv.currency` (STRING): The Lifetime Value (currency) of the user. This field is not populated in intraday tables. + +## device +The device RECORD contains information about the device from which the event originated. + +- `device.category` (STRING): The device category (mobile, tablet, desktop). +- `device.mobile_brand_name` (STRING): The device brand name. +- `device.mobile_model_name` (STRING): The device model name. +- `device.mobile_marketing_name` (STRING): The device marketing name. +- `device.mobile_os_hardware_model` (STRING): The device model information retrieved directly from the operating system. +- `device.operating_system` (STRING): The operating system of the device. +- `device.operating_system_version` (STRING): The OS version. +- `device.vendor_id` (STRING): IDFV (present only if IDFA is not collected). +- `device.advertising_id` (STRING): Advertising ID/IDFA. +- `device.language` (STRING): The OS language. +- `device.time_zone_offset_seconds` (INTEGER): The offset from GMT in seconds. +- `device.is_limited_ad_tracking` (BOOLEAN): The device's Limit Ad Tracking setting. On iOS14+, returns false if the IDFA is non-zero. +- `device.web_info.browser` (STRING): The browser in which the user viewed content. +- `device.web_info.browser_version` (STRING): The version of the browser in which the user viewed content. +- `device.web_info.hostname` (STRING): The hostname associated with the logged event. + +## geo +The geo RECORD contains information about the geographic location where the event was initiated. + +- `geo.continent` (STRING): The continent from which events were reported, based on IP address. +- `geo.sub_continent` (STRING): The subcontinent from which events were reported, based on IP address. +- `geo.country` (STRING): The country from which events were reported, based on IP address. +- `geo.region` (STRING): The region from which events were reported, based on IP address. +- `geo.metro` (STRING): The metro from which events were reported, based on IP address. +- `geo.city` (STRING): The city from which events were reported, based on IP address. + +## app_info +The `app_info` RECORD contains information about the app in which the event was initiated. + +- `app_info.id` (STRING): The package name or bundle ID of the app. +- `app_info.firebase_app_id` (STRING): The Firebase App ID associated with the app. +- `app_info.install_source` (STRING): The store that installed the app. +- `app_info.version` (STRING): The app's versionName (Android) or short bundle version. + +## collected_traffic_source +The `collected_traffic_source` RECORD contains the traffic source data that was present within the events that were collected. + +- `manual_campaign_id` (STRING): The manual campaign id (utm_id) that was collected with the event. +- `manual_campaign_name` (STRING): The manual campaign name (utm_campaign) that was collected with the event. +- `manual_source` (STRING): The manual campaign source (utm_source) that was collected with the event. Also includes parsed parameters from referral params, not just UTM values. +- `manual_medium` (STRING): The manual campaign medium (utm_medium) that was collected with the event. Also includes parsed parameters from referral params, not just UTM values. +- `manual_term` (STRING): The manual campaign keyword/term (utm_term) that was collected with the event. +- `manual_content` (STRING): The additional manual campaign metadata (utm_content) that was collected with the event. +- `manual_creative_format` (STRING): The manual campaign creative format (utm_creative_format) that was collected with the event. +- `manual_marketing_tactic` (STRING): The manual campaign marketing tactic (utm_marketing_tactic) that was collected with the event. +- `manual_source_platform` (STRING): The manual campaign source platform (utm_source_platform) that was collected with the event. +- `gclid` (STRING): The Google click identifier that was collected with the event. +- `dclid` (STRING): The DoubleClick Click Identifier for Display and Video 360 and Campaign Manager 360 that was collected with the event. +- `srsltid` (STRING): The Google Merchant Center identifier that was collected with the event. + +## session_traffic_source_last_click +The `session_traffic_source_last_click` RECORD contains the last-click attributed session traffic source data across Google ads and manual contexts, where available. + +- `session_traffic_source_last_click.manual_campaign.campaign_id` (STRING): The ID of the last clicked manual campaign. +- `session_traffic_source_last_click.manual_campaign.campaign_name` (STRING): The name of the last clicked manual campaign. +- `session_traffic_source_last_click.manual_campaign.medium` (STRING): The medium of the last clicked manual campaign (e.g., paid search, organic search, email). +- `session_traffic_source_last_click.manual_campaign.term` (STRING): The keyword/search term of the last clicked manual campaign. +- `session_traffic_source_last_click.manual_campaign.content` (STRING): Additional metadata of the last clicked manual campaign. +- `session_traffic_source_last_click.manual_campaign.source_platform` (STRING): The platform of the last clicked manual campaign (e.g., search engine, social media). +- `session_traffic_source_last_click.manual_campaign.source` (STRING): The specific source within the platform of the last clicked manual campaign. +- `session_traffic_source_last_click.manual_campaign.creative_format` (STRING): The format of the creative of the last clicked manual campaign. +- `session_traffic_source_last_click.manual_campaign.marketing_tactic` (STRING): The marketing tactic of the last clicked manual campaign. +- `session_traffic_source_last_click.google_ads_campaign.customer_id` (STRING): The customer ID associated with the Google Ads account. +- `session_traffic_source_last_click.google_ads_campaign.account_name` (STRING): The name of the Google Ads account. +- `session_traffic_source_last_click.google_ads_campaign.campaign_id` (STRING): The ID of the Google Ads campaign. +- `session_traffic_source_last_click.google_ads_campaign.campaign_name` (STRING): The name of the Google Ads campaign. +- `session_traffic_source_last_click.google_ads_campaign.ad_group_id` (STRING): The ID of the ad group within the Google Ads campaign. +- `session_traffic_source_last_click.google_ads_campaign.ad_group_name` (STRING): The name of the ad group within the Google Ads campaign. +- `session_traffic_source_last_click.cross_channel_campaign.campaign_name` (STRING): The name of the last clicked cross-channel campaign. +- `session_traffic_source_last_click.cross_channel_campaign.campaign_id` (STRING): The ID of the last clicked cross-channel campaign. +- `session_traffic_source_last_click.cross_channel_campaign.source_platform` (STRING): The platform of the last clicked cross-channel campaign. +- `session_traffic_source_last_click.cross_channel_campaign.source` (STRING): The specific source within the platform of the last clicked cross-channel campaign. +- `session_traffic_source_last_click.cross_channel_campaign.medium` (STRING): The medium of the last clicked cross-channel campaign. +- `session_traffic_source_last_click.sa360_campaign.campaign_name` (STRING): The name of the last clicked SA360 campaign. +- `session_traffic_source_last_click.sa360_campaign.source` (STRING): The specific source within the platform of the last clicked SA360 campaign. +- `session_traffic_source_last_click.sa360_campaign.medium` (STRING): The medium of the last clicked SA360 campaign. +- `session_traffic_source_last_click.sa360_campaign.ad_group_id` (STRING): The ID of the ad group within the SA360 campaign. +- `session_traffic_source_last_click.sa360_campaign.ad_group_name` (STRING): The name of the ad group within the SA360 campaign. +- `session_traffic_source_last_click.sa360_campaign.campaign_id` (STRING): The ID of the last clicked SA360 campaign. +- `session_traffic_source_last_click.sa360_campaign.creative_format` (STRING): The format of the creative of the last clicked SA360 campaign. +- `session_traffic_source_last_click.sa360_campaign.engine_account_name` (STRING): The name of the SA360 engine account. +- `session_traffic_source_last_click.sa360_campaign.engine_account_type` (STRING): The type of engine account containing the SA360 campaign. +- `session_traffic_source_last_click.sa360_campaign.manager_account_name` (STRING): The name of the SA360 manager account. +- `session_traffic_source_last_click.dv360_campaign.advertiser_id` (STRING): The ID of the DV360 advertiser. +- `session_traffic_source_last_click.dv360_campaign.advertiser_name` (STRING): The name of the DV360 advertiser. +- `session_traffic_source_last_click.dv360_campaign.campaign_id` (STRING): The ID of the last clicked DV360 campaign. +- `session_traffic_source_last_click.dv360_campaign.campaign_name` (STRING): The name of the last clicked DV360 campaign. +- `session_traffic_source_last_click.dv360_campaign.creative_id` (STRING): The ID of the creative of the last clicked DV360 campaign. +- `session_traffic_source_last_click.dv360_campaign.creative_format` (STRING): The format of the creative of the last clicked DV360 campaign. +- `session_traffic_source_last_click.dv360_campaign.creative_name` (STRING): The name of the creative of the last clicked DV360 campaign. +- `session_traffic_source_last_click.dv360_campaign.marketing_tactic` (STRING): The marketing tactic of the last clicked DV360 campaign. +- `session_traffic_source_last_click.dv360_campaign.exchange_id` (STRING): The exchange ID of the last clicked DV360 campaign. +- `session_traffic_source_last_click.dv360_campaign.exchange_name` (STRING): The exchange name of the last clicked DV360 campaign. +- `session_traffic_source_last_click.dv360_campaign.insertion_order_id` (STRING): The ID of the insertion order in the last clicked DV360 campaign. +- `session_traffic_source_last_click.dv360_campaign.insertion_order_name` (STRING): The name of the insertion order in the last clicked DV360 campaign. +- `session_traffic_source_last_click.dv360_campaign.line_item_id` (STRING): The ID of the line item in the last clicked DV360 campaign. +- `session_traffic_source_last_click.dv360_campaign.line_item_name` (STRING): The name of the line item in the last clicked DV360 campaign. +- `session_traffic_source_last_click.dv360_campaign.partner_id` (STRING): The ID of the DV360 partner. +- `session_traffic_source_last_click.dv360_campaign.partner_name` (STRING): The name of the DV360 partner. +- `session_traffic_source_last_click.dv360_campaign.source` (STRING): The specific source within the platform of the last clicked DV360 campaign. +- `session_traffic_source_last_click.dv360_campaign.medium` (STRING): The medium of the last clicked DV360 campaign. +- `session_traffic_source_last_click.cm360_campaign.account_id` (STRING): The ID of the CM360 account. +- `session_traffic_source_last_click.cm360_campaign.account_name` (STRING): The name of the CM360 account. +- `session_traffic_source_last_click.cm360_campaign.advertiser_id` (STRING): The ID of the CM360 advertiser. +- `session_traffic_source_last_click.cm360_campaign.advertiser_name` (STRING): The name of the CM360 advertiser. +- `session_traffic_source_last_click.cm360_campaign.campaign_id` (STRING): The ID of the last clicked CM360 campaign. +- `session_traffic_source_last_click.cm360_campaign.campaign_name` (STRING): The name of the last clicked CM360 campaign. +- `session_traffic_source_last_click.cm360_campaign.creative_id` (STRING): The ID of the creative of the last clicked CM360 campaign. +- `session_traffic_source_last_click.cm360_campaign.creative_format` (STRING): The format of the creative of the last clicked CM360 campaign. +- `session_traffic_source_last_click.cm360_campaign.creative_name` (STRING): The name of the creative of the last clicked CM360 campaign. +- `session_traffic_source_last_click.cm360_campaign.creative_type` (STRING): The creative type of the last clicked CM360 campaign. +- `session_traffic_source_last_click.cm360_campaign.creative_type_id` (STRING): The creative type ID of the last clicked CM360 campaign. +- `session_traffic_source_last_click.cm360_campaign.creative_version` (STRING): The creative version of the last clicked CM360 campaign. +- `session_traffic_source_last_click.cm360_campaign.placement_id` (STRING): The ID of the placement of the last clicked CM360 campaign. +- `session_traffic_source_last_click.cm360_campaign.placement_cost_structure` (STRING): The placement cost structure of the last clicked CM360 campaign. +- `session_traffic_source_last_click.cm360_campaign.placement_name` (STRING): The name of the placement of the last clicked CM360 campaign. +- `session_traffic_source_last_click.cm360_campaign.rendering_id` (STRING): The rendering ID of the last clicked CM360 campaign. +- `session_traffic_source_last_click.cm360_campaign.site_id` (STRING): The site ID of the last clicked CM360 campaign. +- `session_traffic_source_last_click.cm360_campaign.site_name` (STRING): The site name of the last clicked CM360 campaign. +- `session_traffic_source_last_click.cm360_campaign.source` (STRING): The specific source of the last clicked CM360 campaign. +- `session_traffic_source_last_click.cm360_campaign.medium` (STRING): The medium of the last clicked CM360 campaign. + +## traffic_source +The `traffic_source` RECORD contains information about the traffic source that first acquired the user. This record is not populated in intraday tables. + +- `traffic_source.name` (STRING): Name of the marketing campaign that first acquired the user. This field is not populated in intraday tables. +- `traffic_source.medium` (STRING): Name of the medium (paid search, organic search, email, etc.) that first acquired the user. This field is not populated in intraday tables. +- `traffic_source.source` (STRING): Name of the network that first acquired the user. This field is not populated in intraday tables. + +## stream and platform +The stream and platform fields contain information about the stream and the app platform. + +- `stream_id` (STRING): The numeric ID of the data stream from which the event originated. +- `platform` (STRING): The data stream platform (Web, IOS or Android) from which the event originated. + +## ecommerce +This ecommerce RECORD contains information about any ecommerce events that have been setup on a website or app. + +- `ecommerce.total_item_quantity` (INTEGER): Total number of items in this event, which is the sum of items.quantity. +- `ecommerce.purchase_revenue_in_usd` (FLOAT): Purchase revenue of this event, represented in USD with standard unit. Populated for purchase event only. +- `ecommerce.purchase_revenue` (FLOAT): Purchase revenue of this event, represented in local currency with standard unit. Populated for purchase event only. +- `ecommerce.refund_value_in_usd` (FLOAT): The amount of refund in this event, represented in USD with standard unit. Populated for refund event only. +- `ecommerce.refund_value` (FLOAT): The amount of refund in this event, represented in local currency with standard unit. Populated for refund event only. +- `ecommerce.shipping_value_in_usd` (FLOAT): The shipping cost in this event, represented in USD with standard unit. +- `ecommerce.shipping_value` (FLOAT): The shipping cost in this event, represented in local currency. +- `ecommerce.tax_value_in_usd` (FLOAT): The tax value in this event, represented in USD with standard unit. +- `ecommerce.tax_value` (FLOAT): The tax value in this event, represented in local currency with standard unit. +- `ecommerce.transaction_id` (STRING): The transaction ID of the ecommerce transaction. +- `ecommerce.unique_items` (INTEGER): The number of unique items in this event, based on item_id, item_name, and item_brand. + +## items +The `items` RECORD contains information about items included in an event. It is repeated for each item. + +- `items.item_id` (STRING): The ID of the item. +- `items.item_name` (STRING): The name of the item. +- `items.item_brand` (STRING): The brand of the item. +- `items.item_variant` (STRING): The variant of the item. +- `items.item_category` (STRING): The category of the item. +- `items.item_category2` (STRING): The sub category of the item. +- `items.item_category3` (STRING): The sub category of the item. +- `items.item_category4` (STRING): The sub category of the item. +- `items.item_category5` (STRING): The sub category of the item. +- `items.price_in_usd` (FLOAT): The price of the item, in USD with standard unit. +- `items.price` (FLOAT): The price of the item in local currency. +- `items.quantity` (INTEGER): The quantity of the item. Quantity set to 1 if not specified. +- `items.item_revenue_in_usd` (FLOAT): The revenue of this item, calculated as price_in_usd * quantity. It is populated for purchase events only, in USD with standard unit. +- `items.item_revenue` (FLOAT): The revenue of this item, calculated as price * quantity. It is populated for purchase events only, in local currency with standard unit. +- `items.item_refund_in_usd` (FLOAT): The refund value of this item, calculated as price_in_usd * quantity. It is populated for refund events only, in USD with standard unit. +- `items.item_refund` (FLOAT): The refund value of this item, calculated as price * quantity. It is populated for refund events only, in local currency with standard unit. +- `items.coupon` (STRING): Coupon code applied to this item. +- `items.affiliation` (STRING): A product affiliation to designate a supplying company or brick and mortar store location. +- `items.location_id` (STRING): The location associated with the item. +- `items.item_list_id` (STRING): The ID of the list in which the item was presented to the user. +- `items.item_list_name` (STRING): The name of the list in which the item was presented to the user. +- `items.item_list_index` (STRING): The position of the item in a list. +- `items.promotion_id` (STRING): The ID of a product promotion. +- `items.promotion_name` (STRING): The name of a product promotion. +- `items.creative_name` (STRING): The name of a creative used in a promotional spot. +- `items.creative_slot` (STRING): The name of a creative slot. + +### item_params RECORD (nested under items) +The `item_params` RECORD stores the custom item parameters that you defined. + +- `items.item_params.key` (STRING): The name of the item parameter. +- `items.item_params.value` (RECORD): A record containing the item parameter’s value. + - `items.item_params.value.string_value` (STRING): If the item parameter is represented by a string, it is populated in this field. + - `items.item_params.value.int_value` (INTEGER): If the item parameter is represented by an integer, it is populated in this field. + - `items.item_params.value.double_value` (FLOAT): If the item parameter is represented by a double value, it is populated in this field. + - `items.item_params.value.float_value` (FLOAT): If the item parameter is represented by a floating point value, it is populated in this field. + +## publisher (Early access only) +The publisher RECORD contains information about events sourced from a publisher integration related to the display of ads, that is, AdMob. + +- `publisher` (RECORD): A record of publisher data coming from AdMob. This field is not populated in intraday tables and Fresh Daily BigQuery export. +- `publisher.ad_revenue_in_usd` (FLOAT): Estimated ad revenue resulting from this event, represented in USD. Populated for ad impression events only. This field is not populated in intraday tables and Fresh Daily BigQuery export. +- `publisher.ad_format` (STRING): Describes the way ads appeared and where they were located. Typical formats include ‘Interstitial’, ‘Banner’, ‘Rewarded’, and ‘Native advanced’. This field is not populated in intraday tables and Fresh Daily BigQuery export. +- `publisher.ad_source_name` (STRING): The source network that served an ad. Typical sources include, ‘AdMob Network’, ‘Meta audience Network’, and ‘Mediated house ads’. This field is not populated in intraday tables and Fresh Daily BigQuery export. +- `publisher.ad_unit_id` (STRING): The name you chose to describe this Ad unit. Ad units are containers that you place in your apps to show ads to users. This field is not populated in intraday tables and Fresh Daily BigQuery export. + +# Joins +- [Google Analytics Events to Google Ads Clicks](../references/joins/events___ads_clickstats.md) — join on `collected_traffic_source.gclid` to attach Google Ads data to events. + +# Citations +- https://developers.google.com/analytics/bigquery/web-ecommerce-demo-dataset +- https://support.google.com/analytics/answer/7029846 +- https://developers.google.com/analytics/bigquery/basic-queries +- https://developers.google.com/analytics/bigquery/advanced-queries diff --git a/internal/okf/testdata/official/ga4/tables/index.md b/internal/okf/testdata/official/ga4/tables/index.md new file mode 100644 index 0000000..ecca6fe --- /dev/null +++ b/internal/okf/testdata/official/ga4/tables/index.md @@ -0,0 +1,3 @@ +# BigQuery Table + +* [Events table (Google Analytics BigQuery Export)](events_.md) - Contains Google Analytics event export data from the `ga4_obfuscated_sample_ecommerce` dataset. diff --git a/internal/okf/testdata/official/ga4/viz.html b/internal/okf/testdata/official/ga4/viz.html new file mode 100644 index 0000000..7a57c17 --- /dev/null +++ b/internal/okf/testdata/official/ga4/viz.html @@ -0,0 +1,448 @@ + + + + +OKF Bundle Viewer + + + + + +
+
+ + OKF bundle +
+
+ + + + +
+
+ +
+
+
+
Click a node to see its details.
+ +
+
+ + + + + diff --git a/internal/okf/testdata/official/stackoverflow/datasets/index.md b/internal/okf/testdata/official/stackoverflow/datasets/index.md new file mode 100644 index 0000000..3464807 --- /dev/null +++ b/internal/okf/testdata/official/stackoverflow/datasets/index.md @@ -0,0 +1,3 @@ +# BigQuery Dataset + +* [Stack Overflow Public Dataset](stackoverflow.md) - The Stack Overflow public dataset contains a variety of tables related to Stack Overflow user activity, posts, and tags. This dataset is no longer actively updated. diff --git a/internal/okf/testdata/official/stackoverflow/datasets/stackoverflow.md b/internal/okf/testdata/official/stackoverflow/datasets/stackoverflow.md new file mode 100644 index 0000000..fea6d31 --- /dev/null +++ b/internal/okf/testdata/official/stackoverflow/datasets/stackoverflow.md @@ -0,0 +1,59 @@ +--- +type: BigQuery Dataset +resource: https://bigquery.googleapis.com/v2/projects/bigquery-public-data/datasets/stackoverflow +title: Stack Overflow Public Dataset +description: The Stack Overflow public dataset contains a variety of tables related + to Stack Overflow user activity, posts, and tags. This dataset is no longer actively + updated. +tags: Stack Overflow, public data, community, Q&A +timestamp: '2026-05-28T23:25:15+00:00' +--- + +The Stack Overflow public dataset in BigQuery provides a comprehensive collection of data from the popular question and answer platform. This dataset is a valuable resource for researchers and developers interested in analyzing trends in programming questions, user interactions, and the evolution of technology discussions. It encompasses various aspects of the platform, including user profiles, questions, answers, comments, votes, badges awarded, and associated tags. + +This dataset was last updated on 2022-11-25, and it is important to note that it is no longer actively maintained. Despite this, it remains a rich historical archive of Stack Overflow activity. The dataset is hosted in the `US` multi-region. + +# Schema + +As a BigQuery Dataset, `stackoverflow` serves as a container for numerous tables rather than having a schema itself. The tables within this dataset collectively represent the structure of Stack Overflow data. Key tables include: + +* [badges](../tables/badges.md): Information about badges awarded to users. +* [comments](../tables/comments.md): User comments on posts. +* [post_history](../tables/post_history.md): Historical revisions and events for posts. +* [posts_answers](../tables/posts_answers.md): Detailed information about answers. +* [posts_questions](../tables/posts_questions.md): Detailed information about questions. +* [tags](../tables/tags.md): Information about tags used to categorize posts. +* [users](../tables/users.md): User profiles and statistics. +* [votes](../tables/votes.md): Records of upvotes and downvotes on posts and comments. + +# Common query patterns + +List all tables within the `stackoverflow` dataset: + +```sql +SELECT table_id +FROM `bigquery-public-data.stackoverflow.INFORMATION_SCHEMA.TABLES` +WHERE table_schema = 'stackoverflow'; +``` + +Example of querying the `posts_questions` table: + +```sql +SELECT + id, + title, + view_count, + creation_date +FROM + `bigquery-public-data.stackoverflow.posts_questions` +WHERE + creation_date BETWEEN '2021-01-01' AND '2021-01-31' +ORDER BY + view_count DESC +LIMIT 5; +``` + +# Citations + +[1] [Stack Overflow Public Dataset](https://bigquery.googleapis.com/v2/projects/bigquery-public-data/datasets/stackoverflow) +[2] [Stack Overflow Official Website](https://stackoverflow.com/) diff --git a/internal/okf/testdata/official/stackoverflow/index.md b/internal/okf/testdata/official/stackoverflow/index.md new file mode 100644 index 0000000..fdc4970 --- /dev/null +++ b/internal/okf/testdata/official/stackoverflow/index.md @@ -0,0 +1,5 @@ +# Subdirectories + +* [datasets](datasets/index.md) - The Stack Overflow public dataset contains a variety of tables related to Stack Overflow user activity, posts, and tags. This dataset is no longer actively updated. +* [references](references/index.md) - This directory contains enumerated types, data structures, and internal references for Stack Exchange content, moderation, and the Data Explorer. +* [tables](tables/index.md) - The directory contains structured data tables describing Stack Overflow content, users, and activity. diff --git a/internal/okf/testdata/official/stackoverflow/references/badge_classes.md b/internal/okf/testdata/official/stackoverflow/references/badge_classes.md new file mode 100644 index 0000000..56eb42f --- /dev/null +++ b/internal/okf/testdata/official/stackoverflow/references/badge_classes.md @@ -0,0 +1,23 @@ +--- +type: Reference +resource: https://meta.stackexchange.com/questions/2677/database-schema-documentation-for-the-public-data-dump-and-sede +title: Badge Classes +description: Enumerated classes for badges awarded on Stack Exchange sites. +tags: +- badges +- classes +- enum +- stackoverflow +timestamp: '2026-05-28T23:32:40+00:00' +--- + +# Badge Classes + +This document defines the enumerated classes for badges awarded on Stack Exchange sites. + +- `1`: Gold +- `2`: Silver +- `3`: Bronze + +# Citations +- [Database schema documentation for the public data dump and SEDE](https://meta.stackexchange.com/questions/2677/database-schema-documentation-for-the-public-data-dump-and-sede) diff --git a/internal/okf/testdata/official/stackoverflow/references/close_as_off_topic_reason_types.md b/internal/okf/testdata/official/stackoverflow/references/close_as_off_topic_reason_types.md new file mode 100644 index 0000000..da6590d --- /dev/null +++ b/internal/okf/testdata/official/stackoverflow/references/close_as_off_topic_reason_types.md @@ -0,0 +1,34 @@ +--- +type: Reference +resource: https://meta.stackexchange.com/questions/2677/database-schema-documentation-for-the-public-data-dump-and-sede +title: Close As Off-Topic Reason Types +description: Defines the types and guidance for reasons why a post might be closed + as off-topic. +tags: +- close reasons +- off-topic +- enum +- schema +- data dump +timestamp: '2026-05-28T23:32:53+00:00' +--- + +This table defines the enumerated reasons and associated guidance for why a post might be closed as off-topic. + +## Schema +- `Id` (INTEGER) - Unique identifier for the reason type. +- `IsUniversal` (BOOLEAN) - Indicates if the reason is universally applicable. +- `InputTitle` (STRING) - The title shown for input. +- `MarkdownInputGuidance` (STRING) - Markdown guidance shown while flagging/voting. +- `MarkdownPostOwnerGuidance` (STRING) - Markdown guidance shown to the original poster when closed. +- `MarkdownPublicGuidance` (STRING) - Markdown guidance shown to privileged users when closed. +- `MarkdownConcensusDescription` (STRING) - Markdown description shown above the public or post owner guidance. Nullable. +- `CreationDate` (TIMESTAMP) - The date and time the reason type was created. +- `CreationModeratorId` (INTEGER) - The ID of the moderator who created the reason type. Links to the `users` table. +- `ApprovalDate` (TIMESTAMP) - The date and time the reason type was approved. +- `ApprovalModeratorId` (INTEGER) - The ID of the moderator who approved the reason type. +- `DeactivationDate` (TIMESTAMP) - The date and time the reason type was deactivated. +- `DeactivationModeratorId` (INTEGER) - The ID of the moderator who deactivated the reason type. Links to the `users` table. + +# Citations +- [Database schema documentation for the public data dump and SEDE](https://meta.stackexchange.com/questions/2677/database-schema-documentation-for-the-public-data-dump-and-sede) diff --git a/internal/okf/testdata/official/stackoverflow/references/close_reason_types.md b/internal/okf/testdata/official/stackoverflow/references/close_reason_types.md new file mode 100644 index 0000000..84600ee --- /dev/null +++ b/internal/okf/testdata/official/stackoverflow/references/close_reason_types.md @@ -0,0 +1,34 @@ +--- +type: Reference +resource: https://meta.stackexchange.com/questions/2677/database-schema-documentation-for-the-public-data-dump-and-sede +title: Close Reason Types +description: Enumerated types for reasons why a post might be closed on Stack Exchange. +tags: +- close reasons +- moderation +- enum +- schema +- data dump +timestamp: '2026-05-28T23:33:07+00:00' +--- + +This document defines the enumerated types for reasons why a post might be closed on Stack Exchange sites, as found in the `PendingFlags` and `PostHistory` tables. + +## Old Close Reasons +- `1`: Exact Duplicate +- `2`: Off-topic +- `3`: Subjective and argumentative +- `4`: Not a real question +- `7`: Too localized +- `10`: General reference +- `20`: Noise or pointless (Meta sites only) + +## Current Close Reasons +- `101`: Duplicate +- `102`: Off-topic +- `103`: Unclear what you\'re asking +- `104`: Too broad +- `105`: Primarily opinion-based + +# Citations +- [Database schema documentation for the public data dump and SEDE](https://meta.stackexchange.com/questions/2677/database-schema-documentation-for-the-public-data-dump-and-sede) diff --git a/internal/okf/testdata/official/stackoverflow/references/creative_commons_by_sa_4_0_license.md b/internal/okf/testdata/official/stackoverflow/references/creative_commons_by_sa_4_0_license.md new file mode 100644 index 0000000..e2c2727 --- /dev/null +++ b/internal/okf/testdata/official/stackoverflow/references/creative_commons_by_sa_4_0_license.md @@ -0,0 +1,28 @@ +--- +type: Reference +resource: https://archive.org/details/stackexchange +title: Creative Commons Attribution-ShareAlike 4.0 International License +description: Details the attribution requirements for content licensed under CC BY-SA + 4.0 for the Stack Exchange network. +tags: +- licensing +- creative commons +- attribution +- data dump +timestamp: '2026-05-28T23:36:58+00:00' +--- + +All user content contributed to the Stack Exchange network is licensed under the Creative Commons Attribution-ShareAlike 4.0 International License (CC BY-SA 4.0). While this license is permissive, it requires attribution. + +## Attribution Requirements for Stack Exchange Content: +1. Visually display or otherwise indicate the source of the content as coming from the Stack Exchange Network. This requirement is satisfied with a discreet text blurb, or some other unobtrusive but clear visual indication. +2. Ensure that any Internet use of the content includes a hyperlink directly to the original question on the source site on the Network (e.g., `http://stackoverflow.com/questions/12345`). +3. Visually display or otherwise clearly indicate the author names for every question and answer used. +4. Ensure that any Internet use of the content includes a hyperlink for each author name directly back to his or her user profile page on the source site on the Network (e.g., `http://stackoverflow.com/users/12345/username`), directly to the Stack Exchange domain, in standard HTML (i.e. not through a Tinyurl or other such indirect hyperlink, form of obfuscation or redirection), without any “nofollow” command or any other such means of avoiding detection by search engines, and visible even with JavaScript disabled. + +For more information, see the [Stack Exchange Terms of Service](https://stackexchange.com/legal/terms-of-service). + +# Citations +- [Stack Exchange Data Dump : Internet Archive](https://archive.org/details/stackexchange) +- [Creative Commons Attribution-ShareAlike 4.0 International License](https://creativecommons.org/licenses/by-sa/4.0/) +- [Stack Exchange Terms of Service](https://stackexchange.com/legal/terms-of-service) diff --git a/internal/okf/testdata/official/stackoverflow/references/flag_types.md b/internal/okf/testdata/official/stackoverflow/references/flag_types.md new file mode 100644 index 0000000..c718d22 --- /dev/null +++ b/internal/okf/testdata/official/stackoverflow/references/flag_types.md @@ -0,0 +1,22 @@ +--- +type: Reference +resource: https://meta.stackexchange.com/questions/2677/database-schema-documentation-for-the-public-data-dump-and-sede +title: Flag Types +description: Enumerated types for flags related to post moderation on Stack Exchange. +tags: +- flags +- moderation +- enum +- schema +- data dump +timestamp: '2026-05-28T23:32:58+00:00' +--- + +This document defines the enumerated types for flags related to post moderation on Stack Exchange sites, as found in the `PendingFlags` table. + +- `13`: Canned flag for closure +- `14`: Vote to close +- `15`: Vote to reopen + +# Citations +- [Database schema documentation for the public data dump and SEDE](https://meta.stackexchange.com/questions/2677/database-schema-documentation-for-the-public-data-dump-and-sede) diff --git a/internal/okf/testdata/official/stackoverflow/references/index.md b/internal/okf/testdata/official/stackoverflow/references/index.md new file mode 100644 index 0000000..cc948a1 --- /dev/null +++ b/internal/okf/testdata/official/stackoverflow/references/index.md @@ -0,0 +1,34 @@ +# Reference + +* [Badge Classes](badge_classes.md) - Enumerated classes for badges awarded on Stack Exchange sites. +* [Close As Off-Topic Reason Types](close_as_off_topic_reason_types.md) - Defines the types and guidance for reasons why a post might be closed as off-topic. +* [Close Reason Types](close_reason_types.md) - Enumerated types for reasons why a post might be closed on Stack Exchange. +* [Creative Commons Attribution-ShareAlike 4.0 International License](creative_commons_by_sa_4_0_license.md) - Details the attribution requirements for content licensed under CC BY-SA 4.0 for the Stack Exchange network. +* [Flag Types](flag_types.md) - Enumerated types for flags related to post moderation on Stack Exchange. +* [Link Types](link_types.md) - Enumerated types for links between posts on Stack Exchange. +* [Pending Flags](pending_flags.md) - Stores information about pending flags and votes related to post moderation. +* [Post Feedback](post_feedback.md) - Collects up and down votes from anonymous visitors and unregistered users. +* [Post History Type IDs](post_history_type_ids.md) - Enumerated types for post history events on Stack Exchange. +* [Post Notice Class IDs](post_notice_class_ids.md) - Enumerated class identifiers for different types of post notices. +* [Post Notice Duration IDs](post_notice_duration_ids.md) - Enumerated duration identifiers for post notices. +* [Post Notice Type Definitions](post_notice_type_definitions.md) - Defines the structure and properties of different types of post notices. +* [Post Notice Types](post_notice_types.md) - Enumerated types for notices that can be applied to posts on Stack Exchange. +* [Post Notices](post_notices.md) - Stores information about notices applied to posts, including their type, dates, and associated users. +* [Post Type IDs](post_type_ids.md) - Enumerated IDs for different types of posts on Stack Exchange. +* [Related Questions](related_questions.md) - Provides a mapping between questions and other related questions based on various factors. +* [Review Rejection Reasons](review_rejection_reasons.md) - Defines canned rejection reasons for suggested edits. +* [Review Task Result Types](review_task_result_types.md) - Enumerated types for the outcomes of review tasks on Stack Exchange. +* [Review Task Results](review_task_results.md) - Stores the outcomes and details of completed review tasks. +* [Review Task States](review_task_states.md) - Enumerated states for review tasks on Stack Exchange. +* [Review Task Types](review_task_types.md) - Enumerated types for various review tasks on Stack Exchange. +* [Review Tasks](review_tasks.md) - Stores information about moderation review tasks on Stack Exchange, such as suggested edits or close votes. +* [SEDE Databases](sede_databases.md) - Details the structure of the internal SEDE database metadata table. +* [SEDE Databases Markdown](sede_databases_markdown.md) - Describes a SEDE internal table containing markdown content for database information. +* [SEDE In Each DB Stored Procedure](sede_ineachdb.md) - Describes the SEDE internal stored procedure for running SQL commands across multiple databases. +* [SEDE Sites](sede_sites.md) - Details the structure of the internal SEDE sites metadata table. +* [SEDE Tables](sede_tables.md) - Details the structure of the internal SEDE tables metadata table. +* [SEDE Users](sede_users.md) - Details the structure of the internal SEDE users metadata table. +* [Suggested Edit Votes](suggested_edit_votes.md) - Records votes cast on suggested edits, indicating approval or rejection. +* [Suggested Edits](suggested_edits.md) - Stores information about suggested edits to posts that are awaiting review. +* [Tag Synonyms](tag_synonyms.md) - Stores relationships between synonymous tags on Stack Exchange. +* [Vote Types](vote_types.md) - Enumerated types for votes on posts and other entities across Stack Exchange sites. diff --git a/internal/okf/testdata/official/stackoverflow/references/link_types.md b/internal/okf/testdata/official/stackoverflow/references/link_types.md new file mode 100644 index 0000000..8fd1018 --- /dev/null +++ b/internal/okf/testdata/official/stackoverflow/references/link_types.md @@ -0,0 +1,20 @@ +--- +type: Reference +resource: https://meta.stackexchange.com/questions/2677/database-schema-documentation-for-the-public-data-dump-and-sede +title: Link Types +description: Enumerated types for links between posts on Stack Exchange. +tags: +- links +- enum +- schema +- data dump +timestamp: '2026-05-28T23:34:01+00:00' +--- + +This document defines the enumerated types for links between posts, as found in the `PostLinks` table. + +- `1`: Linked (`PostId` contains a link to `RelatedPostId`) +- `3`: Duplicate (`PostId` is a duplicate of `RelatedPostId`) + +# Citations +- [Database schema documentation for the public data dump and SEDE](https://meta.stackexchange.com/questions/2677/database-schema-documentation-for-the-public-data-dump-and-sede) diff --git a/internal/okf/testdata/official/stackoverflow/references/pending_flags.md b/internal/okf/testdata/official/stackoverflow/references/pending_flags.md new file mode 100644 index 0000000..44cebe9 --- /dev/null +++ b/internal/okf/testdata/official/stackoverflow/references/pending_flags.md @@ -0,0 +1,30 @@ +--- +type: Reference +resource: https://meta.stackexchange.com/questions/2677/database-schema-documentation-for-the-public-data-dump-and-sede +title: Pending Flags +description: Stores information about pending flags and votes related to post moderation. +tags: +- flags +- moderation +- schema +- data dump +timestamp: '2026-05-28T23:33:15+00:00' +--- + +This table contains information about pending flags and votes related to post moderation, despite its name focusing on "PendingFlags". It includes close-related flags and votes. + +## Schema +- `Id` (INTEGER) - Unique identifier for the pending flag. +- `FlagTypeId` (INTEGER) - The type of flag. See the [Flag Types reference](flag_types.md) for possible values: + - `13`: canned flag for closure + - `14`: vote to close + - `15`: vote to reopen +- `PostId` (INTEGER) - The ID of the post the flag is associated with. Links to the `posts` tables (`posts_questions` or `posts_answers`). +- `CreationDate` (TIMESTAMP) - The date and time the flag was created. +- `CloseReasonTypeId` (INTEGER) - The type of close reason, if applicable. See the [Close Reason Types reference](close_reason_types.md) for possible values. +- `CloseAsOffTopicReasonTypeId` (INTEGER) - The specific off-topic reason ID, if `CloseReasonTypeId = 102` (off-topic). Links to the [Close As Off-Topic Reason Types reference](close_as_off_topic_reason_types.md). +- `DuplicateOfQuestionId` (INTEGER) - The ID of the duplicate question, if `CloseReasonTypeId` is `1` or `101` (old or current duplicate). Links to the `posts_questions` table. +- `BelongsOnBaseHostAddress` (STRING) - Indicates the base host address for votes to close and migrate. + +# Citations +- [Database schema documentation for the public data dump and SEDE](https://meta.stackexchange.com/questions/2677/database-schema-documentation-for-the-public-data-dump-and-sede) diff --git a/internal/okf/testdata/official/stackoverflow/references/post_feedback.md b/internal/okf/testdata/official/stackoverflow/references/post_feedback.md new file mode 100644 index 0000000..52fa9cf --- /dev/null +++ b/internal/okf/testdata/official/stackoverflow/references/post_feedback.md @@ -0,0 +1,25 @@ +--- +type: Reference +resource: https://meta.stackexchange.com/questions/2677/database-schema-documentation-for-the-public-data-dump-and-sede +title: Post Feedback +description: Collects up and down votes from anonymous visitors and unregistered users. +tags: +- feedback +- votes +- anonymous +- schema +- data dump +timestamp: '2026-05-28T23:33:30+00:00' +--- + +This table collects up and down votes from anonymous visitors and/or unregistered users. + +## Schema +- `Id` (INTEGER) - Unique identifier for the post feedback entry. +- `PostId` (INTEGER) - The ID of the post (`posts_questions` or `posts_answers`) the feedback is for. +- `IsAnonymous` (BOOLEAN) - Indicates if the feedback was given anonymously. +- `VoteTypeId` (INTEGER) - The type of vote. Specifically `2` for UpMod and `3` for DownMod in this table. See the [Vote Types reference](vote_types.md) for a comprehensive list. +- `CreationDate` (TIMESTAMP) - The date and time the feedback was recorded. + +# Citations +- [Database schema documentation for the public data dump and SEDE](https://meta.stackexchange.com/questions/2677/database-schema-documentation-for-the-public-data-dump-and-sede) diff --git a/internal/okf/testdata/official/stackoverflow/references/post_history_type_ids.md b/internal/okf/testdata/official/stackoverflow/references/post_history_type_ids.md new file mode 100644 index 0000000..d930a2d --- /dev/null +++ b/internal/okf/testdata/official/stackoverflow/references/post_history_type_ids.md @@ -0,0 +1,62 @@ +--- +type: Reference +resource: https://meta.stackexchange.com/questions/2677/database-schema-documentation-for-the-public-data-dump-and-sede +title: Post History Type IDs +description: Enumerated types for post history events on Stack Exchange. +tags: +- post history +- enum +- moderation +- schema +- data dump +timestamp: '2026-05-28T23:33:44+00:00' +--- + +This document defines the enumerated types for various events recorded in the `PostHistory` table, tracking changes and moderation actions on posts across Stack Exchange sites. + +- `1`: Initial Title - initial title (questions only) +- `2`: Initial Body - initial post raw body text +- `3`: Initial Tags - initial list of tags (questions only) +- `4`: Edit Title - modified title (questions only) +- `5`: Edit Body - modified post body (raw markdown) +- `6`: Edit Tags - modified list of tags (questions only) +- `7`: Rollback Title - reverted title (questions only) +- `8`: Rollback Body - reverted body (raw markdown) +- `9`: Rollback Tags - reverted list of tags (questions only) +- `10`: Post Closed - post voted to be closed +- `11`: Post Reopened - post voted to be reopened +- `12`: Post Deleted - post voted to be removed +- `13`: Post Undeleted - post voted to be restored +- `14`: Post Locked - post locked by moderator +- `15`: Post Unlocked - post unlocked by moderator +- `16`: Community Owned - post now community owned +- `17`: Post Migrated - post migrated - now replaced by 35/36 (away/here) +- `18`: Question Merged - question merged with deleted question +- `19`: Question Protected - question was protected by a moderator. +- `20`: Question Unprotected - question was unprotected by a moderator. +- `21`: Post Disassociated - OwnerUserId removed from post by admin +- `22`: Question Unmerged - answers/votes restored to previously merged question +- `24`: Suggested Edit Applied +- `25`: Post Tweeted +- `31`: Comment discussion moved to chat +- `33`: Post notice added - `comment` contains foreign key to PostNotices +- `34`: Post notice removed - `comment` contains foreign key to PostNotices +- `35`: Post migrated away - replaces id 17 +- `36`: Post migrated here - replaces id 17 +- `37`: Post merge source +- `38`: Post merge destination +- `50`: Bumped by Community User +- `52`: Question became hot network question (main) / Hot Meta question (meta) +- `53`: Question removed from hot network/meta questions by a moderator +- `66`: Created from Ask Wizard + +## Older Dumps (Likely no longer present) +- `23`: Unknown dev related event +- `26`: Vote nullification by dev (ERM?) +- `27`: Post unmigrated/hidden moderator migration? +- `28`: Unknown suggestion event +- `29`: Unknown moderator event (possibly de-wikification?) +- `30`: Unknown event (too rare to guess) + +# Citations +- [Database schema documentation for the public data dump and SEDE](https://meta.stackexchange.com/questions/2677/database-schema-documentation-for-the-public-data-dump-and-sede) diff --git a/internal/okf/testdata/official/stackoverflow/references/post_notice_class_ids.md b/internal/okf/testdata/official/stackoverflow/references/post_notice_class_ids.md new file mode 100644 index 0000000..5567a4a --- /dev/null +++ b/internal/okf/testdata/official/stackoverflow/references/post_notice_class_ids.md @@ -0,0 +1,23 @@ +--- +type: Reference +resource: https://meta.stackexchange.com/questions/2677/database-schema-documentation-for-the-public-data-dump-and-sede +title: Post Notice Class IDs +description: Enumerated class identifiers for different types of post notices. +tags: +- notices +- classes +- enum +- schema +- data dump +timestamp: '2026-05-28T23:34:34+00:00' +--- + +This document defines the enumerated class identifiers for different types of post notices. + +- `0`: DMCA or Redditted +- `1`: Historical lock +- `2`: Bounty +- `4`: Moderator notice (lock) + +# Citations +- [Database schema documentation for the public data dump and SEDE](https://meta.stackexchange.com/questions/2677/database-schema-documentation-for-the-public-data-dump-and-sede) diff --git a/internal/okf/testdata/official/stackoverflow/references/post_notice_duration_ids.md b/internal/okf/testdata/official/stackoverflow/references/post_notice_duration_ids.md new file mode 100644 index 0000000..6d17bb6 --- /dev/null +++ b/internal/okf/testdata/official/stackoverflow/references/post_notice_duration_ids.md @@ -0,0 +1,21 @@ +--- +type: Reference +resource: https://meta.stackexchange.com/questions/2677/database-schema-documentation-for-the-public-data-dump-and-sede +title: Post Notice Duration IDs +description: Enumerated duration identifiers for post notices. +tags: +- notices +- duration +- enum +- schema +- data dump +timestamp: '2026-05-28T23:34:37+00:00' +--- + +This document defines the enumerated duration identifiers for post notices. + +- `-1`: No duration specified +- `1`: 7 days (bounty) + +# Citations +- [Database schema documentation for the public data dump and SEDE](https://meta.stackexchange.com/questions/2677/database-schema-documentation-for-the-public-data-dump-and-sede) diff --git a/internal/okf/testdata/official/stackoverflow/references/post_notice_type_definitions.md b/internal/okf/testdata/official/stackoverflow/references/post_notice_type_definitions.md new file mode 100644 index 0000000..6556442 --- /dev/null +++ b/internal/okf/testdata/official/stackoverflow/references/post_notice_type_definitions.md @@ -0,0 +1,27 @@ +--- +type: Reference +resource: https://meta.stackexchange.com/questions/2677/database-schema-documentation-for-the-public-data-dump-and-sede +title: Post Notice Type Definitions +description: Defines the structure and properties of different types of post notices. +tags: +- notices +- types +- definitions +- schema +- data dump +timestamp: '2026-05-28T23:34:42+00:00' +--- + +This table defines the structure and properties of different types of post notices that can be applied to posts. + +## Schema +- `Id` (INTEGER) - Unique identifier for the post notice type. +- `ClassId` (INTEGER) - The class of the notice. See the [Post Notice Class IDs reference](post_notice_class_ids.md) for possible values. +- `Name` (STRING) - The name of the post notice type. +- `Body` (STRING) - Contains the default notice text. Nullable. +- `IsHidden` (BOOLEAN) - Indicates if the notice type is hidden. +- `Predefined` (BOOLEAN) - Indicates if the notice type is predefined. +- `PostNoticeDurationId` (INTEGER) - The duration identifier for the notice. See the [Post Notice Duration IDs reference](post_notice_duration_ids.md) for possible values. + +# Citations +- [Database schema documentation for the public data dump and SEDE](https://meta.stackexchange.com/questions/2677/database-schema-documentation-for-the-public-data-dump-and-sede) diff --git a/internal/okf/testdata/official/stackoverflow/references/post_notice_types.md b/internal/okf/testdata/official/stackoverflow/references/post_notice_types.md new file mode 100644 index 0000000..c2587e3 --- /dev/null +++ b/internal/okf/testdata/official/stackoverflow/references/post_notice_types.md @@ -0,0 +1,39 @@ +--- +type: Reference +resource: https://meta.stackexchange.com/questions/2677/database-schema-documentation-for-the-public-data-dump-and-sede +title: Post Notice Types +description: Enumerated types for notices that can be applied to posts on Stack Exchange. +tags: +- notices +- posts +- enum +- schema +- data dump +timestamp: '2026-05-28T23:34:22+00:00' +--- + +This document defines the enumerated types for notices that can be applied to posts on Stack Exchange sites, as found in the `PostNotices` table. + +- `1`: Citation needed +- `2`: Current event +- `3`: Insufficient explanation +- `10`: Current answers are outdated +- `11`: Draw attention +- `12`: Improve details +- `13`: Authoritative reference needed +- `14`: Canonical answer required +- `15`: Reward existing answer +- `20`: Content dispute +- `21`: Offtopic comments +- `22`: Historical significance +- `23`: Wiki Answer +- `24`: Policy Lock (SO Collectives) +- `25`: Recommended Answer (SO Collectives) +- `26`: Posted by Recognized Member/Admin (SO Collectives) +- `27`: Endorsed Edit (SO Collectives) +- `28`: Obsolete (SO Collectives) +- `1000`: Redditted (SO) +- `9001`: DMCA Takedown + +# Citations +- [Database schema documentation for the public data dump and SEDE](https://meta.stackexchange.com/questions/2677/database-schema-documentation-for-the-public-data-dump-and-sede) diff --git a/internal/okf/testdata/official/stackoverflow/references/post_notices.md b/internal/okf/testdata/official/stackoverflow/references/post_notices.md new file mode 100644 index 0000000..0f5ba7d --- /dev/null +++ b/internal/okf/testdata/official/stackoverflow/references/post_notices.md @@ -0,0 +1,30 @@ +--- +type: Reference +resource: https://meta.stackexchange.com/questions/2677/database-schema-documentation-for-the-public-data-dump-and-sede +title: Post Notices +description: Stores information about notices applied to posts, including their type, + dates, and associated users. +tags: +- notices +- posts +- moderation +- schema +- data dump +timestamp: '2026-05-28T23:34:29+00:00' +--- + +This table stores information about notices applied to posts, such as "Citation needed" or "Duplicate". + +## Schema +- `Id` (INTEGER) - Unique identifier for the post notice. +- `PostId` (INTEGER) - The ID of the post the notice is applied to. Links to the `posts` tables (`posts_questions` or `posts_answers`). +- `PostNoticeTypeId` (INTEGER) - The type of post notice. See the [Post Notice Types reference](post_notice_types.md) for possible values. +- `CreationDate` (TIMESTAMP) - The date and time the notice was created. +- `DeletionDate` (TIMESTAMP) - The date and time the notice was deleted. Nullable. +- `ExpiryDate` (TIMESTAMP) - The date and time the notice is set to expire. Nullable. +- `Body` (STRING) - When present, contains the custom text shown with the notice. Nullable. +- `OwnerUserId` (INTEGER) - The ID of the user who created the notice. Links to the `users` table. Nullable. +- `DeletionUserId` (INTEGER) - The ID of the user who deleted the notice. Links to the `users` table. Nullable. + +# Citations +- [Database schema documentation for the public data dump and SEDE](https://meta.stackexchange.com/questions/2677/database-schema-documentation-for-the-public-data-dump-and-sede) diff --git a/internal/okf/testdata/official/stackoverflow/references/post_type_ids.md b/internal/okf/testdata/official/stackoverflow/references/post_type_ids.md new file mode 100644 index 0000000..fba967c --- /dev/null +++ b/internal/okf/testdata/official/stackoverflow/references/post_type_ids.md @@ -0,0 +1,31 @@ +--- +type: Reference +resource: https://meta.stackexchange.com/questions/2677/database-schema-documentation-for-the-public-data-dump-and-sede +title: Post Type IDs +description: Enumerated IDs for different types of posts on Stack Exchange. +tags: +- post +- types +- enum +- stackoverflow +timestamp: '2026-05-28T23:31:12+00:00' +--- + +# Post Type IDs + +This document defines the enumerated IDs for different types of posts found in the Stack Exchange data dump and SEDE. + +- `1`: Question +- `2`: Answer +- `3`: Orphaned Tag Wiki +- `4`: Tag Wiki +- `5`: Tag Wiki Excerpt +- `6`: Moderator Nomination +- `7`: Privilege Wiki +- `8`: Status Wiki +- `10`: Deleted Question (not in public data dump) +- `11`: Section +- `12`: Article + +# Citations +- [Database schema documentation for the public data dump and SEDE](https://meta.stackexchange.com/questions/2677/database-schema-documentation-for-the-public-data-dump-and-sede) diff --git a/internal/okf/testdata/official/stackoverflow/references/related_questions.md b/internal/okf/testdata/official/stackoverflow/references/related_questions.md new file mode 100644 index 0000000..9832a59 --- /dev/null +++ b/internal/okf/testdata/official/stackoverflow/references/related_questions.md @@ -0,0 +1,24 @@ +--- +type: Reference +resource: https://meta.stackexchange.com/questions/2677/database-schema-documentation-for-the-public-data-dump-and-sede +title: Related Questions +description: Provides a mapping between questions and other related questions based + on various factors. +tags: +- questions +- related +- schema +- data dump +timestamp: '2026-05-28T23:34:15+00:00' +--- + +This table provides a mapping between questions and other related questions, based on factors like content similarity or user behavior. + +## Schema +- `PostId` (INTEGER) - The ID of the primary question. Links to the `posts_questions` table. +- `RelatedPostId` (INTEGER) - The ID of a related question. Links to the `posts_questions` table. +- `Position` (INTEGER) - The position or ranking of the related question. +- `Score` (INTEGER) - A score indicating the relevance or strength of the relationship. + +# Citations +- [Database schema documentation for the public data dump and SEDE](https://meta.stackexchange.com/questions/2677/database-schema-documentation-for-the-public-data-dump-and-sede) diff --git a/internal/okf/testdata/official/stackoverflow/references/review_rejection_reasons.md b/internal/okf/testdata/official/stackoverflow/references/review_rejection_reasons.md new file mode 100644 index 0000000..509722f --- /dev/null +++ b/internal/okf/testdata/official/stackoverflow/references/review_rejection_reasons.md @@ -0,0 +1,25 @@ +--- +type: Reference +resource: https://meta.stackexchange.com/questions/2677/database-schema-documentation-for-the-public-data-dump-and-sede +title: Review Rejection Reasons +description: Defines canned rejection reasons for suggested edits. +tags: +- review +- rejection +- reasons +- suggested edits +- schema +- data dump +timestamp: '2026-05-28T23:34:58+00:00' +--- + +This table defines canned rejection reasons for suggested edits. + +## Schema +- `Id` (INTEGER) - Unique identifier for the rejection reason. +- `Name` (STRING) - The name of the rejection reason. +- `Description` (STRING) - A detailed description of the rejection reason. +- `PostTypeId` (INTEGER) - The type of post the reason applies to (e.g., `5` for Wiki, `6` for Excerpt). Otherwise, it is null. Links to the [Post Type IDs reference](post_type_ids.md). + +# Citations +- [Database schema documentation for the public data dump and SEDE](https://meta.stackexchange.com/questions/2677/database-schema-documentation-for-the-public-data-dump-and-sede) diff --git a/internal/okf/testdata/official/stackoverflow/references/review_task_result_types.md b/internal/okf/testdata/official/stackoverflow/references/review_task_result_types.md new file mode 100644 index 0000000..d17544c --- /dev/null +++ b/internal/okf/testdata/official/stackoverflow/references/review_task_result_types.md @@ -0,0 +1,38 @@ +--- +type: Reference +resource: https://meta.stackexchange.com/questions/2677/database-schema-documentation-for-the-public-data-dump-and-sede +title: Review Task Result Types +description: Enumerated types for the outcomes of review tasks on Stack Exchange. +tags: +- review +- tasks +- results +- enum +- schema +- data dump +timestamp: '2026-05-28T23:35:05+00:00' +--- + +This document defines the enumerated types for the outcomes of various review tasks on Stack Exchange sites. + +- `1`: Skip +- `2`: Approve (suggested edits) +- `3`: Reject (suggested edits) +- `4`: Delete (low quality) +- `5`: Edit (first posts, late answers, low quality) +- `6`: Close (close, low quality) +- `7`: Looks OK (low quality) +- `8`: Do Not Close (close) +- `9`: Recommend Deletion (low quality answer) +- `10`: Recommend Close (low quality question) +- `11`: Other Action (first posts), previously "I\'m Done" +- `12`: Reopen (reopen) +- `13`: Leave Closed (reopen) +- `14`: Edit and Reopen (reopen) +- `15`: Excellent (community evaluation) +- `16`: Satisfactory (community evaluation) +- `17`: Needs Improvement (community evaluation) +- `18`: No Action Needed (first posts, late answers) + +# Citations +- [Database schema documentation for the public data dump and SEDE](https://meta.stackexchange.com/questions/2677/database-schema-documentation-for-the-public-data-dump-and-sede) diff --git a/internal/okf/testdata/official/stackoverflow/references/review_task_results.md b/internal/okf/testdata/official/stackoverflow/references/review_task_results.md new file mode 100644 index 0000000..5a5443c --- /dev/null +++ b/internal/okf/testdata/official/stackoverflow/references/review_task_results.md @@ -0,0 +1,26 @@ +--- +type: Reference +resource: https://meta.stackexchange.com/questions/2677/database-schema-documentation-for-the-public-data-dump-and-sede +title: Review Task Results +description: Stores the outcomes and details of completed review tasks. +tags: +- review +- tasks +- results +- schema +- data dump +timestamp: '2026-05-28T23:35:11+00:00' +--- + +This table stores the outcomes and details of completed review tasks, such as approvals, rejections, or edits. + +## Schema +- `Id` (INTEGER) - Unique identifier for the review task result. +- `ReviewTaskId` (INTEGER) - The ID of the review task this result is for. Links to the `ReviewTasks` table. +- `ReviewTaskResultTypeId` (INTEGER) - The type of result. See the [Review Task Result Types reference](review_task_result_types.md) for possible values. +- `CreationDate` (TIMESTAMP) - The date when the result was recorded. Time data is purposefully removed to protect user privacy. +- `RejectionReasonId` (INTEGER) - The ID of the rejection reason, specifically for suggested edits. Links to the [Review Rejection Reasons reference](review_rejection_reasons.md). Nullable. +- `Comment` (STRING) - A comment associated with the review result. Nullable. + +# Citations +- [Database schema documentation for the public data dump and SEDE](https://meta.stackexchange.com/questions/2677/database-schema-documentation-for-the-public-data-dump-and-sede) diff --git a/internal/okf/testdata/official/stackoverflow/references/review_task_states.md b/internal/okf/testdata/official/stackoverflow/references/review_task_states.md new file mode 100644 index 0000000..036612a --- /dev/null +++ b/internal/okf/testdata/official/stackoverflow/references/review_task_states.md @@ -0,0 +1,23 @@ +--- +type: Reference +resource: https://meta.stackexchange.com/questions/2677/database-schema-documentation-for-the-public-data-dump-and-sede +title: Review Task States +description: Enumerated states for review tasks on Stack Exchange. +tags: +- review +- tasks +- states +- enum +- schema +- data dump +timestamp: '2026-05-28T23:35:21+00:00' +--- + +This document defines the enumerated states for review tasks on Stack Exchange sites. + +- `1`: Active +- `2`: Completed +- `3`: Invalidated + +# Citations +- [Database schema documentation for the public data dump and SEDE](https://meta.stackexchange.com/questions/2677/database-schema-documentation-for-the-public-data-dump-and-sede) diff --git a/internal/okf/testdata/official/stackoverflow/references/review_task_types.md b/internal/okf/testdata/official/stackoverflow/references/review_task_types.md new file mode 100644 index 0000000..3658f40 --- /dev/null +++ b/internal/okf/testdata/official/stackoverflow/references/review_task_types.md @@ -0,0 +1,32 @@ +--- +type: Reference +resource: https://meta.stackexchange.com/questions/2677/database-schema-documentation-for-the-public-data-dump-and-sede +title: Review Task Types +description: Enumerated types for various review tasks on Stack Exchange. +tags: +- review +- tasks +- enum +- schema +- data dump +timestamp: '2026-05-28T23:35:18+00:00' +--- + +This document defines the enumerated types for various review tasks available on Stack Exchange sites. + +- `1`: Suggested Edit +- `2`: Close Votes +- `3`: Low Quality Posts +- `4`: First Post +- `5`: Late Answer +- `6`: Reopen Vote +- `7`: Community Evaluation +- `8`: Link Validation +- `9`: Flagged Posts +- `10`: Triage +- `11`: Helper +- `12`: First Questions +- `13`: First Answers + +# Citations +- [Database schema documentation for the public data dump and SEDE](https://meta.stackexchange.com/questions/2677/database-schema-documentation-for-the-public-data-dump-and-sede) diff --git a/internal/okf/testdata/official/stackoverflow/references/review_tasks.md b/internal/okf/testdata/official/stackoverflow/references/review_tasks.md new file mode 100644 index 0000000..1e6378d --- /dev/null +++ b/internal/okf/testdata/official/stackoverflow/references/review_tasks.md @@ -0,0 +1,29 @@ +--- +type: Reference +resource: https://meta.stackexchange.com/questions/2677/database-schema-documentation-for-the-public-data-dump-and-sede +title: Review Tasks +description: Stores information about moderation review tasks on Stack Exchange, such + as suggested edits or close votes. +tags: +- review +- tasks +- moderation +- schema +- data dump +timestamp: '2026-05-28T23:35:29+00:00' +--- + +This table stores information about moderation review tasks on Stack Exchange, such as suggested edits or close votes. + +## Schema +- `Id` (INTEGER) - Unique identifier for the review task. +- `ReviewTaskTypeId` (INTEGER) - The type of review task. See the [Review Task Types reference](review_task_types.md) for possible values. +- `CreationDate` (TIMESTAMP) - The date when the review task was created. Time data is typically removed to protect user privacy. +- `DeletionDate` (TIMESTAMP) - The date when the review task was deleted. Time data is typically removed to protect user privacy. Nullable. +- `ReviewTaskStateId` (INTEGER) - The current state of the review task. See the [Review Task States reference](review_task_states.md) for possible values. +- `PostId` (INTEGER) - The ID of the post associated with the review task. Links to the `posts` tables (`posts_questions` or `posts_answers`). Nullable. +- `SuggestedEditId` (INTEGER) - The ID of the suggested edit associated with the review task, if applicable. Links to the `SuggestedEdits` table. Nullable. +- `CompletedByReviewTaskId` (INTEGER) - The ID associated with the `ReviewTaskResult` that stores the outcome of a completed review. Links to the [Review Task Results reference](review_task_results.md). Nullable. + +# Citations +- [Database schema documentation for the public data dump and SEDE](https://meta.stackexchange.com/questions/2677/database-schema-documentation-for-the-public-data-dump-and-sede) diff --git a/internal/okf/testdata/official/stackoverflow/references/sede_databases.md b/internal/okf/testdata/official/stackoverflow/references/sede_databases.md new file mode 100644 index 0000000..39c96c3 --- /dev/null +++ b/internal/okf/testdata/official/stackoverflow/references/sede_databases.md @@ -0,0 +1,36 @@ +--- +type: Reference +resource: https://meta.stackexchange.com/questions/2677/database-schema-documentation-for-the-public-data-dump-and-sede +title: SEDE Databases +description: Details the structure of the internal SEDE database metadata table. +tags: +- SEDE +- internal +- databases +- schema +- data dump +timestamp: '2026-05-28T23:36:12+00:00' +--- + +This internal table in the Stack Exchange Data Explorer (SEDE) provides metadata about each Stack Exchange site\'s database within SEDE. + +## Schema +- `database_id` (INTEGER) - Unique identifier for the database. +- `database_name` (STRING) - The name of the database. +- `site_name` (STRING) - The short name of the site. +- `tiny_name` (STRING) - A very short name for the site. +- `long_name` (STRING) - The full name of the site. +- `site_type` (STRING) - Type of site, either \'main_site\' or \'meta_site\'. +- `site_url` (STRING) - The base URL of the Stack Exchange site. +- `sede_url` (STRING) - The URL to the site\'s data in SEDE. +- `api_site_parameter` (STRING) - The parameter used for the site in the Stack Exchange API. +- `initialized` (TIMESTAMP) - Datetime when populating that database started. +- `made_available` (TIMESTAMP) - Datetime when the database came online and was ready for queries. +- `processing_time` (STRING) - The duration between `initialized` and `made_available`, in `hh:mm:ss.fff` format. +- `questions` (INTEGER) - Total number of questions in this site at the time of the current refresh. +- `answers` (INTEGER) - Total number of answers in this site at the time of the current refresh. +- `latest_post` (TIMESTAMP) - The timestamp of the last non-deleted post captured in this refresh. +- `notes` (STRING) - This will be non-NULL when a database is in transition. Nullable. + +# Citations +- [Database schema documentation for the public data dump and SEDE](https://meta.stackexchange.com/questions/2677/database-schema-documentation-for-the-public-data-dump-and-sede) diff --git a/internal/okf/testdata/official/stackoverflow/references/sede_databases_markdown.md b/internal/okf/testdata/official/stackoverflow/references/sede_databases_markdown.md new file mode 100644 index 0000000..06d5ba1 --- /dev/null +++ b/internal/okf/testdata/official/stackoverflow/references/sede_databases_markdown.md @@ -0,0 +1,23 @@ +--- +type: Reference +resource: https://meta.stackexchange.com/questions/2677/database-schema-documentation-for-the-public-data-dump-and-sede +title: SEDE Databases Markdown +description: Describes a SEDE internal table containing markdown content for database + information. +tags: +- SEDE +- internal +- markdown +- schema +- data dump +timestamp: '2026-05-28T23:36:16+00:00' +--- + +This internal SEDE table contains markdown content related to database information, likely for display purposes. + +## Schema +- `rn` (INTEGER) - Row number, used for sorting by the \'long name\' of the database. +- `content` (STRING) - Markdown content, potentially including headers, for database descriptions. + +# Citations +- [Database schema documentation for the public data dump and SEDE](https://meta.stackexchange.com/questions/2677/database-schema-documentation-for-the-public-data-dump-and-sede) diff --git a/internal/okf/testdata/official/stackoverflow/references/sede_ineachdb.md b/internal/okf/testdata/official/stackoverflow/references/sede_ineachdb.md new file mode 100644 index 0000000..7afc241 --- /dev/null +++ b/internal/okf/testdata/official/stackoverflow/references/sede_ineachdb.md @@ -0,0 +1,29 @@ +--- +type: Reference +resource: https://meta.stackexchange.com/questions/2677/database-schema-documentation-for-the-public-data-dump-and-sede +title: SEDE In Each DB Stored Procedure +description: Describes the SEDE internal stored procedure for running SQL commands + across multiple databases. +tags: +- SEDE +- internal +- stored procedure +- SQL +- data dump +timestamp: '2026-05-28T23:36:39+00:00' +--- + +This is an internal stored procedure in the Stack Exchange Data Explorer (SEDE) used to execute SQL commands across multiple databases (i.e., Stack Exchange sites). + +## Parameters +| Parameter | Data Type | Default | Description | +|---------------------|----------------|---------|---------------------------------------------------------------------------| +| `@SQLCommand` | `nvarchar(4000)` | | The SQL statement to run. | +| `@IncludeMainSites` | `bit` | `1` | Set to `1` to include all non-meta sites. | +| `@IncludeMetaSites` | `bit` | `1` | Set to `1` to include all meta sites. | +| `@IncludeMainMeta` | `bit` | `1` | Set to `1` to include `StackExchange.Meta`. | +| `@CollectResultsForMe` | `bit` | `1` | For standard `SELECT` queries, this will attempt to put each database's results into a `#temp` table. | +| `@ErrorOnSkippedSites` | `bit` | `0` | Set this to `1` if you want execution to halt in the event any site is missing due to transition change. | + +# Citations +- [Database schema documentation for the public data dump and SEDE](https://meta.stackexchange.com/questions/2677/database-schema-documentation-for-the-public-data-dump-and-sede) diff --git a/internal/okf/testdata/official/stackoverflow/references/sede_sites.md b/internal/okf/testdata/official/stackoverflow/references/sede_sites.md new file mode 100644 index 0000000..2fbf791 --- /dev/null +++ b/internal/okf/testdata/official/stackoverflow/references/sede_sites.md @@ -0,0 +1,25 @@ +--- +type: Reference +resource: https://meta.stackexchange.com/questions/2677/database-schema-documentation-for-the-public-data-dump-and-sede +title: SEDE Sites +description: Details the structure of the internal SEDE sites metadata table. +tags: +- SEDE +- internal +- sites +- schema +- data dump +timestamp: '2026-05-28T23:36:26+00:00' +--- + +This internal table in the Stack Exchange Data Explorer (SEDE) provides metadata about each Stack Exchange site. + +## Schema +- `site_name` (STRING) - The short name of the site. +- `site_url` (STRING) - The base URL of the Stack Exchange site. +- `database_name` (STRING) - The name of the corresponding database in SEDE. +- `long_name` (STRING) - The full name of the site. +- `site_id` (INTEGER) - Unique identifier for the site. + +# Citations +- [Database schema documentation for the public data dump and SEDE](https://meta.stackexchange.com/questions/2677/database-schema-documentation-for-the-public-data-dump-and-sede) diff --git a/internal/okf/testdata/official/stackoverflow/references/sede_tables.md b/internal/okf/testdata/official/stackoverflow/references/sede_tables.md new file mode 100644 index 0000000..82aeeea --- /dev/null +++ b/internal/okf/testdata/official/stackoverflow/references/sede_tables.md @@ -0,0 +1,28 @@ +--- +type: Reference +resource: https://meta.stackexchange.com/questions/2677/database-schema-documentation-for-the-public-data-dump-and-sede +title: SEDE Tables +description: Details the structure of the internal SEDE tables metadata table. +tags: +- SEDE +- internal +- tables +- schema +- data dump +timestamp: '2026-05-28T23:36:22+00:00' +--- + +This internal table in the Stack Exchange Data Explorer (SEDE) provides metadata about each table within SEDE databases. + +## Schema +- `database_id` (INTEGER) - The ID of the database, originating from `sys.databases`. +- `database_name` (STRING) - The name of the database, originating from `sys.databases`. +- `table_name` (STRING) - The name of the table, originating from `sys.tables`. +- `latest_date` (TIMESTAMP) - The latest timestamp (e.g., `CreationDate`) for tables that have such a column. Nullable. +- `row_count` (INTEGER) - The number of rows in the table, according to `sys.partitions`. For `Posts`, this includes `PostsWithDeleted`; for non-deleted `Posts`, refer to `answers + questions` from `dbo.sede_databases`. +- `initialized` (TIMESTAMP) - Timestamp of initial table creation. +- `made_available` (TIMESTAMP) - Timestamp of the final operation against the table. +- `processing_time` (STRING) - Duration of `initialized` -> `made_available`, in `{hh:mm:ss.fff}` format. + +# Citations +- [Database schema documentation for the public data dump and SEDE](https://meta.stackexchange.com/questions/2677/database-schema-documentation-for-the-public-data-dump-and-sede) diff --git a/internal/okf/testdata/official/stackoverflow/references/sede_users.md b/internal/okf/testdata/official/stackoverflow/references/sede_users.md new file mode 100644 index 0000000..121a2c6 --- /dev/null +++ b/internal/okf/testdata/official/stackoverflow/references/sede_users.md @@ -0,0 +1,26 @@ +--- +type: Reference +resource: https://meta.stackexchange.com/questions/2677/database-schema-documentation-for-the-public-data-dump-and-sede +title: SEDE Users +description: Details the structure of the internal SEDE users metadata table. +tags: +- SEDE +- internal +- users +- schema +- data dump +timestamp: '2026-05-28T23:36:32+00:00' +--- + +This internal table in the Stack Exchange Data Explorer (SEDE) provides user-specific metadata across sites. + +## Schema +- `account_id` (INTEGER) - The global Stack Exchange Network account ID. +- `site_id` (INTEGER) - The ID of the site the user is associated with. Links to `references/sede_sites.md`. +- `user_id` (INTEGER) - The local user ID on that specific site. Links to the `users` table. +- `reputation` (INTEGER) - The user\'s reputation on that specific site. +- `question_count` (INTEGER) - The number of questions asked by the user on that specific site. +- `answer_count` (INTEGER) - The number of answers provided by the user on that specific site. + +# Citations +- [Database schema documentation for the public data dump and SEDE](https://meta.stackexchange.com/questions/2677/database-schema-documentation-for-the-public-data-dump-and-sede) diff --git a/internal/okf/testdata/official/stackoverflow/references/suggested_edit_votes.md b/internal/okf/testdata/official/stackoverflow/references/suggested_edit_votes.md new file mode 100644 index 0000000..9af09c7 --- /dev/null +++ b/internal/okf/testdata/official/stackoverflow/references/suggested_edit_votes.md @@ -0,0 +1,26 @@ +--- +type: Reference +resource: https://meta.stackexchange.com/questions/2677/database-schema-documentation-for-the-public-data-dump-and-sede +title: Suggested Edit Votes +description: Records votes cast on suggested edits, indicating approval or rejection. +tags: +- suggested edits +- votes +- schema +- data dump +timestamp: '2026-05-28T23:35:46+00:00' +--- + +This table records votes cast on suggested edits, indicating approval or rejection. + +## Schema +- `Id` (INTEGER) - Unique identifier for the suggested edit vote. +- `SuggestedEditId` (INTEGER) - The ID of the suggested edit that was voted on. Links to the [Suggested Edits reference](suggested_edits.md). +- `UserId` (INTEGER) - The ID of the user who cast the vote. Links to the `users` table. +- `VoteTypeId` (INTEGER) - The type of vote. Specifically `2` for Approve (UpMod) and `3` for Reject (DownMod). See the [Vote Types reference](vote_types.md) for a comprehensive list. +- `CreationDate` (TIMESTAMP) - The date and time the vote was cast. +- `TargetUserId` (INTEGER) - The ID of the target user for the vote (e.g., the user whose reputation changed). Links to the `users` table. Nullable. +- `TargetRepChange` (INTEGER) - The change in reputation for the target user as a result of this vote. Nullable. + +# Citations +- [Database schema documentation for the public data dump and SEDE](https://meta.stackexchange.com/questions/2677/database-schema-documentation-for-the-public-data-dump-and-sede) diff --git a/internal/okf/testdata/official/stackoverflow/references/suggested_edits.md b/internal/okf/testdata/official/stackoverflow/references/suggested_edits.md new file mode 100644 index 0000000..7f75d0e --- /dev/null +++ b/internal/okf/testdata/official/stackoverflow/references/suggested_edits.md @@ -0,0 +1,30 @@ +--- +type: Reference +resource: https://meta.stackexchange.com/questions/2677/database-schema-documentation-for-the-public-data-dump-and-sede +title: Suggested Edits +description: Stores information about suggested edits to posts that are awaiting review. +tags: +- suggested edits +- posts +- schema +- data dump +timestamp: '2026-05-28T23:35:39+00:00' +--- + +This table stores information about suggested edits to posts that are awaiting review. If both `ApprovalDate` and `RejectionDate` are null, then the edit is still in review. + +## Schema +- `Id` (INTEGER) - Unique identifier for the suggested edit. +- `PostId` (INTEGER) - The ID of the post to which the suggested edit applies. Links to the `posts` tables (`posts_questions` or `posts_answers`). +- `CreationDate` (TIMESTAMP) - The date and time the suggested edit was created. +- `ApprovalDate` (TIMESTAMP) - The date and time the suggested edit was approved. Null if not yet approved. +- `RejectionDate` (TIMESTAMP) - The date and time the suggested edit was rejected. Null if not yet rejected. +- `OwnerUserId` (INTEGER) - The ID of the user who suggested the edit. Links to the `users` table. +- `Comment` (STRING) - A comment provided with the suggested edit. Nullable. +- `Text` (STRING) - The proposed new body text for the post. Nullable. +- `Title` (STRING) - The proposed new title for the post. Nullable. +- `Tags` (STRING) - The proposed new tags for the post. Nullable. +- `RevisionGUID` (STRING) - A GUID for the revision. + +# Citations +- [Database schema documentation for the public data dump and SEDE](https://meta.stackexchange.com/questions/2677/database-schema-documentation-for-the-public-data-dump-and-sede) diff --git a/internal/okf/testdata/official/stackoverflow/references/tag_synonyms.md b/internal/okf/testdata/official/stackoverflow/references/tag_synonyms.md new file mode 100644 index 0000000..405c6cf --- /dev/null +++ b/internal/okf/testdata/official/stackoverflow/references/tag_synonyms.md @@ -0,0 +1,29 @@ +--- +type: Reference +resource: https://meta.stackexchange.com/questions/2677/database-schema-documentation-for-the-public-data-dump-and-sede +title: Tag Synonyms +description: Stores relationships between synonymous tags on Stack Exchange. +tags: +- tags +- synonyms +- schema +- data dump +timestamp: '2026-05-28T23:35:52+00:00' +--- + +This table stores relationships between synonymous tags, allowing for automatic renaming or suggestion of alternative tags. + +## Schema +- `Id` (INTEGER) - Unique identifier for the tag synonym. +- `SourceTagName` (STRING) - The name of the tag that is considered a synonym. Nullable. Links to the `tags` table\'s `tag_name` field. +- `TargetTagName` (STRING) - The name of the canonical tag to which `SourceTagName` refers. Nullable. Links to the `tags` table\'s `tag_name` field. +- `CreationDate` (TIMESTAMP) - The date and time the tag synonym was created. +- `OwnerUserId` (INTEGER) - The ID of the user who created the tag synonym. Nullable. Links to the `users` table. +- `AutoRenameCount` (INTEGER) - The number of times this synonym has resulted in an automatic tag rename. +- `LastAutoRename` (TIMESTAMP) - The date and time of the last automatic rename using this synonym. Nullable. +- `Score` (INTEGER) - A score indicating the quality or consensus of the synonym. +- `ApprovedByUserId` (INTEGER) - The ID of the user who approved the tag synonym. Nullable. Links to the `users` table. +- `ApprovalDate` (TIMESTAMP) - The date and time the tag synonym was approved. Nullable. + +# Citations +- [Database schema documentation for the public data dump and SEDE](https://meta.stackexchange.com/questions/2677/database-schema-documentation-for-the-public-data-dump-and-sede) diff --git a/internal/okf/testdata/official/stackoverflow/references/vote_types.md b/internal/okf/testdata/official/stackoverflow/references/vote_types.md new file mode 100644 index 0000000..4614353 --- /dev/null +++ b/internal/okf/testdata/official/stackoverflow/references/vote_types.md @@ -0,0 +1,56 @@ +--- +type: Reference +resource: https://meta.stackexchange.com/questions/2677/database-schema-documentation-for-the-public-data-dump-and-sede +title: Vote Types +description: Enumerated types for votes on posts and other entities across Stack Exchange + sites. +tags: +- votes +- enum +- moderation +- schema +- data dump +timestamp: '2026-05-28T23:33:26+00:00' +--- + +This document defines the enumerated types for various votes that can occur on posts and other entities across Stack Exchange sites. + +- `-1`: InformModerator +- `0`: UndoMod +- `1`: AcceptedByOriginator +- `2`: UpMod (Upvote) +- `3`: DownMod (Downvote) +- `4`: Offensive +- `5`: Favorite (Bookmark) - feature removed after October 2022, replaced by Saves. +- `6`: Close - Close votes are only stored in the `PostHistory` table after 2013-06-25. +- `7`: Reopen +- `8`: BountyStart +- `9`: BountyClose +- `10`: Deletion +- `11`: Undeletion +- `12`: Spam +- `15`: ModeratorReview - a moderator looking at a flagged post. +- `16`: ApproveEditSuggestion +- `17`: Reaction1 (Teams: celebrate) +- `18`: Helpful +- `19`: ThankYou +- `20`: WellWritten +- `21`: Follow +- `22`: Reaction2 (Teams: smile) +- `23`: Reaction3 (Teams: mind blown) +- `24`: Reaction4 (Teams: clap) +- `25`: Reaction5 (Teams: heart) +- `26`: Reaction6 (Teams: fire) +- `27`: Reaction7 (Teams: trophy) +- `28`: Reaction8 (Teams: wave) +- `29`: Outdated +- `30`: NotOutdated +- `31`: PreVote +- `32`: CollectiveDiscussionUpvote +- `33`: CollectiveDiscussionDownvote (no longer used) +- `35`: privateAiAnswerCorrect +- `36`: privateAiAnswerIncorrect +- `37`: privateAiAnswerPartiallyCorrect + +# Citations +- [Database schema documentation for the public data dump and SEDE](https://meta.stackexchange.com/questions/2677/database-schema-documentation-for-the-public-data-dump-and-sede) diff --git a/internal/okf/testdata/official/stackoverflow/tables/badges.md b/internal/okf/testdata/official/stackoverflow/tables/badges.md new file mode 100644 index 0000000..5341425 --- /dev/null +++ b/internal/okf/testdata/official/stackoverflow/tables/badges.md @@ -0,0 +1,74 @@ +--- +type: BigQuery Table +resource: https://bigquery.googleapis.com/v2/projects/bigquery-public-data/datasets/stackoverflow/tables/badges +title: Badges +description: This table contains information about badges awarded to users on the + Stack Overflow platform, including details about the badge name, class, and whether + it is tag-based. +tags: +- stackoverflow +- badges +- users +- awards +- schema +- data dump +timestamp: '2026-05-28T23:32:46+00:00' +--- + +The `badges` table records all badges awarded to users on the Stack Overflow Q&A platform. Each row represents a single badge awarded, detailing its unique identifier, name, the date it was awarded, the ID of the user who received it, its class (e.g., gold, silver, bronze), and whether it is a tag-based badge. The data spans from October 26, 2016, up to November 24, 2022. This table can be joined with the [users](users.md) table on `user_id` to get more information about the badge recipients. + +# Schema + +- `id` (INTEGER) - Unique identifier for the badge award. +- `name` (STRING) - Name of the badge. +- `date` (TIMESTAMP) - Timestamp when the badge was awarded (e.g., `2008-09-15T08:55:03.923`). +- `user_id` (INTEGER) - Identifier of the user who received the badge. +- `class` (INTEGER) - The class of the badge. See the [Badge Classes reference](../references/badge_classes.md) for possible values. +- `tag_based` (BOOLEAN) - `True` if the badge is for a tag, otherwise it is a named badge. + +# Common query patterns + +```sql +SELECT + name, + COUNT(*) AS badge_count + FROM + `bigquery-public-data.stackoverflow.badges` + GROUP BY + name + ORDER BY + badge_count DESC + LIMIT 5; +``` + +```sql +SELECT + b.name, + b.date + FROM + `bigquery-public-data.stackoverflow.badges` AS b + JOIN `bigquery-public-data.stackoverflow.users` AS u ON b.user_id = u.id + WHERE + u.display_name = \'Jon Skeet\' + ORDER BY + b.date DESC; +``` + +```sql +SELECT + DATE(date) AS award_date, + COUNT(*) AS badges_awarded + FROM + `bigquery-public-data.stackoverflow.badges` + GROUP BY + award_date + ORDER BY + award_date DESC + LIMIT 10; +``` + +# Citations + +[1] [BigQuery Public Data: stackoverflow.badges](https://bigquery.googleapis.com/v2/projects/bigquery-public-data/datasets/stackoverflow/tables/badges) +[2] [Stack Overflow](https://stackoverflow.com/) +[3] [Database schema documentation for the public data dump and SEDE](https://meta.stackexchange.com/questions/2677/database-schema-documentation-for-the-public-data-dump-and-sede) diff --git a/internal/okf/testdata/official/stackoverflow/tables/comments.md b/internal/okf/testdata/official/stackoverflow/tables/comments.md new file mode 100644 index 0000000..5c7ad20 --- /dev/null +++ b/internal/okf/testdata/official/stackoverflow/tables/comments.md @@ -0,0 +1,78 @@ +--- +type: BigQuery Table +resource: https://bigquery.googleapis.com/v2/projects/bigquery-public-data/datasets/stackoverflow/tables/comments +title: Comments +description: This table contains comments made by users on posts within the Stack + Overflow platform, including details about the comment text, score, and author, + along with licensing information. +tags: +- stackoverflow +- comments +- community +- user-generated content +- schema +- data dump +timestamp: '2026-05-28T23:32:34+00:00' +--- + +The `comments` table in the [Stack Overflow dataset](../datasets/stackoverflow.md) captures all user-generated comments on various posts across the platform. Each row in this table represents a unique comment, providing details such as the comment\'s text, its creation timestamp, and a score reflecting its community reception. Comments are linked to the specific [posts](posts_questions.md) (questions or answers) they belong to via `post_id` and to the [users](users.md) who authored them via `user_id`. This table is crucial for analyzing user engagement, content quality feedback, and conversational threads on the platform. + +# Schema +- `id` (INTEGER) - Unique identifier for the comment. +- `post_id` (INTEGER) - The ID of the post (question or answer) to which the comment belongs. +- `score` (INTEGER) - The score of the comment, reflecting upvotes and downvotes. +- `text` (STRING) - The content of the comment (Comment body). +- `creation_date` (TIMESTAMP) - The date and time the comment was posted. +- `user_display_name` (STRING) - The display name of the user who made the comment. Nullable. +- `user_id` (INTEGER) - The ID of the user who made the comment. Optional, absent if user has been deleted. +- `content_license` (STRING) - Indicates the Creative Commons license under which the content is licensed. + +# Common query patterns +```sql +-- Retrieve the 10 most recent comments +SELECT + id, + text, + creation_date, + user_display_name +FROM + `bigquery-public-data.stackoverflow.comments` +ORDER BY + creation_date DESC +LIMIT 10; +``` + +```sql +-- Count comments per user +SELECT + user_display_name, + COUNT(id) AS total_comments +FROM + `bigquery-public-data.stackoverflow.comments` +WHERE + user_display_name IS NOT NULL +GROUP BY + user_display_name +ORDER BY + total_comments DESC +LIMIT 10; +``` + +```sql +-- Find comments with a high score for a specific post +SELECT + id, + text, + score, + creation_date +FROM + `bigquery-public-data.stackoverflow.comments` +WHERE + post_id = 45651 AND score > 5 +ORDER BY + score DESC; +``` + +# Citations +[1] [BigQuery Public Dataset: Stack Overflow Comments](https://bigquery.googleapis.com/v2/projects/bigquery-public-data/datasets/stackoverflow/tables/comments) +[2] [Database schema documentation for the public data dump and SEDE](https://meta.stackexchange.com/questions/2677/database-schema-documentation-for-the-public-data-dump-and-sede) diff --git a/internal/okf/testdata/official/stackoverflow/tables/index.md b/internal/okf/testdata/official/stackoverflow/tables/index.md new file mode 100644 index 0000000..5bc336e --- /dev/null +++ b/internal/okf/testdata/official/stackoverflow/tables/index.md @@ -0,0 +1,18 @@ +# BigQuery Table + +* [Badges](badges.md) - This table contains information about badges awarded to users on the Stack Overflow platform, including details about the badge name, class, and whether it is tag-based. +* [Comments](comments.md) - This table contains comments made by users on posts within the Stack Overflow platform, including details about the comment text, score, and author, along with licensing information. +* [Orphaned Tag Wiki Posts](posts_orphaned_tag_wiki.md) - This table contains Tag Wiki posts that are no longer associated with an active tag on Stack Overflow. +* [Post History](post_history.md) - Tracks the revision history of posts on Stack Overflow, including details about event types, associated users, and content changes. +* [Post Links](post_links.md) - Contains information about links between posts on Stack Overflow, including the type of link and the posts involved. +* [Posts Answers](posts_answers.md) - Contains information about answers to questions posted on Stack Overflow, including detailed schema fields, their descriptions, and licensing information. +* [Posts Moderator Nomination](posts_moderator_nomination.md) - Contains posts where users nominate themselves or others to become moderators on the Stack Overflow platform. +* [Posts Privilege Wiki](posts_privilege_wiki.md) - This table contains posts describing various privileges on Stack Overflow, such as editing questions and retagging. +* [Posts Tag Wiki](posts_tag_wiki.md) - Contains detailed wiki descriptions for tags on Stack Overflow. +* [Posts Tag Wiki Excerpt](posts_tag_wiki_excerpt.md) - Contains excerpts from tag wikis on Stack Overflow. +* [Posts Wiki Placeholder](posts_wiki_placeholder.md) - Placeholder table for Stack Overflow wiki posts, containing community-maintained help and information. +* [Stack Overflow Posts (Legacy)](stackoverflow_posts.md) - A legacy table containing all posts from Stack Overflow. This table is deprecated; use `posts_answers`, `posts_questions`, or other `posts_*` tables instead. +* [Stack Overflow Questions](posts_questions.md) - This table contains information about all question posts on Stack Overflow, including detailed schema fields, their descriptions, and licensing information. +* [Tags](tags.md) - A table containing information about tags used on Stack Overflow, including tag names, counts, and references to their excerpt and wiki posts, along with moderation settings. +* [Users](users.md) - This table contains information about users registered on Stack Overflow, including detailed profile information, activity metrics, and network-wide identifiers. +* [Votes](votes.md) - This table contains information about votes cast on posts within the Stack Overflow platform, including the vote type, associated post and user, and bounty details. diff --git a/internal/okf/testdata/official/stackoverflow/tables/post_history.md b/internal/okf/testdata/official/stackoverflow/tables/post_history.md new file mode 100644 index 0000000..cc3ece7 --- /dev/null +++ b/internal/okf/testdata/official/stackoverflow/tables/post_history.md @@ -0,0 +1,89 @@ +--- +type: BigQuery Table +resource: https://bigquery.googleapis.com/v2/projects/bigquery-public-data/datasets/stackoverflow/tables/post_history +title: Post History +description: Tracks the revision history of posts on Stack Overflow, including details + about event types, associated users, and content changes. +tags: +- stackoverflow +- post history +- revisions +- edits +- schema +- data dump +timestamp: '2026-05-28T23:33:54+00:00' +--- + +This table, part of the [Stack Overflow dataset](../datasets/stackoverflow.md), records every historical event related to posts, such as edits, rollbacks, and changes in post type. Each row represents a single historical event for a particular post, identified by `post_id`, with the event\'s timestamp recorded in `creation_date`. This allows for a comprehensive audit trail of how content on Stack Overflow evolves over time. + +### Schema + +- `id` (INTEGER) - Unique identifier for the post history entry. +- `post_history_type_id` (INTEGER) - An integer representing the type of history event. See the [Post History Type IDs reference](../references/post_history_type_ids.md) for possible values. +- `post_id` (INTEGER) - The ID of the post to which this history event belongs. Links to `posts_questions` or `posts_answers` tables. +- `revision_guid` (STRING) - A GUID that groups multiple history records caused by a single action. +- `creation_date` (TIMESTAMP) - The timestamp when the history event occurred (e.g., `2009-03-05T22:28:34.823`). +- `user_id` (INTEGER) - The ID of the user who performed the action, if applicable. Links to the `users` table. +- `user_display_name` (STRING) - Populated if a user has been removed and is no longer referenced by `user_id`; also happens to the author of a migrated post. Nullable. +- `comment` (STRING) - A brief comment describing the history event. This field can also contain specific IDs based on `PostHistoryTypeId`: + - If `PostHistoryTypeId = 10`, it contains the `CloseReasonId`. See the [Close Reason Types reference](../references/close_reason_types.md). + - If `PostHistoryTypeId` is `33` or `34`, it contains the `PostNoticeId`. +- `text` (STRING) - A raw version of the new value for a given revision. This field can also contain specific JSON encoded strings based on `PostHistoryTypeId`: + - If `PostHistoryTypeId` is `10, 11, 12, 13, 14, 15, 19, 20, 35`, it contains a JSON encoded string with all users who have voted for that `PostHistoryTypeId`. + - If it is a duplicate close vote, the JSON string contains an array of original questions as `OriginalQuestionIds`. + - If `PostHistoryTypeId = 17`, it contains migration details (`from ` or `to `). +- `content_license` (STRING) - Indicates the Creative Commons license under which the content is licensed. + +### Common query patterns + +```sql +SELECT + creation_date, + post_history_type_id, + user_id, + comment, + text +FROM + `bigquery-public-data.stackoverflow.post_history` +WHERE + post_id = 456789 -- Example post ID +ORDER BY + creation_date DESC +LIMIT 10; +``` + +```sql +SELECT + post_history_type_id, + COUNT(*) AS event_count +FROM + `bigquery-public-data.stackoverflow.post_history` +GROUP BY + post_history_type_id +ORDER BY + event_count DESC; +``` + +```sql +SELECT + t2.display_name, + COUNT(t1.id) AS edit_count +FROM + `bigquery-public-data.stackoverflow.post_history` AS t1 +INNER JOIN + `bigquery-public-data.stackoverflow.users` AS t2 +ON + t1.user_id = t2.id +WHERE + t1.post_history_type_id = 2 -- Assuming \'2\' means \'Post Edited\' +GROUP BY + t2.display_name +ORDER BY + edit_count DESC +LIMIT 5; +``` + +### Citations + +[1] [BigQuery Public Dataset: Stack Overflow Post History](https://bigquery.googleapis.com/v2/projects/bigquery-public-data/datasets/stackoverflow/tables/post_history) +[2] [Database schema documentation for the public data dump and SEDE](https://meta.stackexchange.com/questions/2677/database-schema-documentation-for-the-public-data-dump-and-sede) diff --git a/internal/okf/testdata/official/stackoverflow/tables/post_links.md b/internal/okf/testdata/official/stackoverflow/tables/post_links.md new file mode 100644 index 0000000..161df74 --- /dev/null +++ b/internal/okf/testdata/official/stackoverflow/tables/post_links.md @@ -0,0 +1,56 @@ +--- +type: BigQuery Table +resource: https://bigquery.googleapis.com/v2/projects/bigquery-public-data/datasets/stackoverflow/tables/post_links +title: Post Links +description: Contains information about links between posts on Stack Overflow, including + the type of link and the posts involved. +tags: +- stackoverflow +- posts +- links +- schema +- data dump +timestamp: '2026-05-28T23:34:10+00:00' +--- + +The `post_links` table in the [stackoverflow](../datasets/stackoverflow.md) dataset records relationships between posts on the Stack Overflow platform. Each row represents a single link between two posts, identifying the `post_id` and `related_post_id`. This table can be used to understand how discussions evolve, identify duplicate questions, or find related content across the platform. The `link_type_id` categorizes the nature of the relationship, such as "Linked" or "Duplicate". + +# Schema + +* `id` (INTEGER) - Unique identifier for the post link (primary key). +* `creation_date` (TIMESTAMP) - The timestamp when the link was created. +* `link_type_id` (INTEGER) - The type of link. See the [Link Types reference](../references/link_types.md) for possible values. +* `post_id` (INTEGER) - The ID of the source post. Links to the `id` in the `posts_questions` or `posts_answers` tables. +* `related_post_id` (INTEGER) - The ID of the target/related post. Links to the `id` in the `posts_questions` or `posts_answers` tables. + +# Common query patterns + +```sql +SELECT + pl.post_id, + pl.related_post_id, + pl.creation_date, + pl.link_type_id +FROM + `bigquery-public-data.stackoverflow.post_links` AS pl +WHERE + pl.post_id = 12345 +LIMIT 100; +``` + +```sql +SELECT + link_type_id, + COUNT(*) AS link_count +FROM + `bigquery-public-data.stackoverflow.post_links` +GROUP BY + link_type_id +ORDER BY + link_count DESC; +``` + +# Citations + +[1] [BigQuery Public Dataset: Stack Overflow Post Links](https://bigquery.googleapis.com/v2/projects/bigquery-public-data/datasets/stackoverflow/tables/post_links) +[2] [Database schema documentation for the public data dump and SEDE](https://meta.stackexchange.com/questions/2677/database-schema-documentation-for-the-public-data-dump-and-sede) diff --git a/internal/okf/testdata/official/stackoverflow/tables/posts_answers.md b/internal/okf/testdata/official/stackoverflow/tables/posts_answers.md new file mode 100644 index 0000000..12c1b92 --- /dev/null +++ b/internal/okf/testdata/official/stackoverflow/tables/posts_answers.md @@ -0,0 +1,91 @@ +--- +type: BigQuery Table +resource: https://bigquery.googleapis.com/v2/projects/bigquery-public-data/datasets/stackoverflow/tables/posts_answers +title: Posts Answers +description: Contains information about answers to questions posted on Stack Overflow, + including detailed schema fields, their descriptions, and licensing information. +tags: +- stackoverflow +- posts +- answers +- community +- programming +- schema +- data dump +timestamp: '2026-05-28T23:32:11+00:00' +--- + +The `posts_answers` table in the [stackoverflow](../datasets/stackoverflow.md) dataset contains all posts identified as answers to questions on the Stack Overflow platform. Each row in this table represents a single answer, providing details such as the answer\'s body, its score, creation date, and the ID of the parent question it answers. This table can be joined with other tables like [posts_questions](posts_questions.md) on `parent_id` to link answers to their respective questions. It\'s a valuable resource for analyzing answer quality, user contributions, and engagement patterns within the Stack Overflow community. + +# Schema +- `id` (INTEGER) - Unique identifier for the answer. +- `title` (STRING) - The title of the post. This is typically NULL for answers. +- `body` (STRING) - The body of the post, as rendered HTML, not Markdown. +- `accepted_answer_id` (INTEGER) - The ID of the answer that was accepted for a question. This field is only applicable to questions, so it is typically NULL for answers. +- `answer_count` (INTEGER) - The number of answers. This field is only applicable to questions, so it is typically NULL for answers. +- `closed_date` (TIMESTAMP) - The date and time the post was closed; present only if the post is closed. This is typically NULL for answers. +- `comment_count` (INTEGER) - The number of comments. +- `community_owned_date` (TIMESTAMP) - The date and time the post became community wiki\'d. +- `creation_date` (TIMESTAMP) - The date and time the answer was created. +- `favorite_count` (INTEGER) - The number of times the post has been favorited. Nullable. This is typically NULL for answers. +- `last_activity_date` (TIMESTAMP) - The datetime of the post\'s most recent activity. +- `last_edit_date` (TIMESTAMP) - The date and time of the most recent edit to the answer. +- `last_editor_display_name` (STRING) - The display name of the last editor. Nullable. +- `last_editor_user_id` (INTEGER) - The ID of the last editor user. Nullable. +- `owner_display_name` (STRING) - The display name of the answer\'s owner. Nullable. +- `owner_user_id` (INTEGER) - The ID of the answer\'s owner. Present only if user has not been deleted. +- `parent_id` (INTEGER) - The ID of the question this answer belongs to. Present only if `PostTypeId = 2`. +- `post_type_id` (INTEGER) - The type of post. For answers, this is `2`. See the [Post Type IDs reference](../references/post_type_ids.md) for possible values. +- `score` (INTEGER) - The score of the answer; generally non-zero for Questions, Answers, and Moderator Nominations. +- `tags` (STRING) - Tags associated with the post. This is typically NULL for answers, as tags are associated with the parent question. +- `view_count` (INTEGER) - The number of times the post was viewed. This field is only applicable to questions, so it is typically NULL for answers. +- `content_license` (STRING) - Indicates the Creative Commons license under which the content is licensed. + +# Common query patterns +1. Get the 10 most highly-scored answers in a specific date range: +```sql +SELECT + id, + score, + creation_date, + body +FROM + `bigquery-public-data.stackoverflow.posts_answers` +WHERE + creation_date BETWEEN \'2023-01-01\' AND \'2023-01-31\' +ORDER BY + score DESC +LIMIT 10 +``` +2. Find the total number of answers by a specific user: +```sql +SELECT + owner_user_id, + COUNT(id) AS total_answers +FROM + `bigquery-public-data.stackoverflow.posts_answers` +WHERE + owner_user_id = 12345 -- Replace with a valid user ID +GROUP BY + owner_user_id +``` +3. Join answers with their corresponding questions: +```sql +SELECT + q.title AS question_title, + a.body AS answer_body, + a.score AS answer_score +FROM + `bigquery-public-data.stackoverflow.posts_answers` AS a +JOIN + `bigquery-public-data.stackoverflow.posts_questions` AS q +ON + a.parent_id = q.id +WHERE + q.id = 5000 -- Example question ID +``` + +# Citations +[1] [Stack Overflow Posts Answers Table](https://bigquery.googleapis.com/v2/projects/bigquery-public-data/datasets/stackoverflow/tables/posts_answers) +[2] [Stack Overflow Public Data Explorer](https://data.stackexchange.com/) +[3] [Database schema documentation for the public data dump and SEDE](https://meta.stackexchange.com/questions/2677/database-schema-documentation-for-the-public-data-dump-and-sede) diff --git a/internal/okf/testdata/official/stackoverflow/tables/posts_moderator_nomination.md b/internal/okf/testdata/official/stackoverflow/tables/posts_moderator_nomination.md new file mode 100644 index 0000000..0e45fb1 --- /dev/null +++ b/internal/okf/testdata/official/stackoverflow/tables/posts_moderator_nomination.md @@ -0,0 +1,73 @@ +--- +type: BigQuery Table +resource: https://bigquery.googleapis.com/v2/projects/bigquery-public-data/datasets/stackoverflow/tables/posts_moderator_nomination +title: Posts Moderator Nomination +description: Contains posts where users nominate themselves or others to become moderators + on the Stack Overflow platform. +tags: stackoverflow, posts, moderator, nomination +timestamp: '2026-05-28T23:27:37+00:00' +--- + +This table contains posts submitted by users nominating themselves or others to become moderators on the Stack Overflow platform. Each row represents a single moderator nomination post, detailing the candidate's qualifications or reasons for nomination. + +The `creation_date` field indicates when the nomination post was created, providing a historical record of moderator elections and community engagement. + +# Schema + +- `id`: Unique identifier for the post. +- `title`: Title of the nomination post (often null for this post type). +- `body`: The content of the nomination post, describing the candidate's qualifications. +- `accepted_answer_id`: (STRING) ID of the accepted answer, if applicable. +- `answer_count`: (STRING) Number of answers to the post. +- `comment_count`: Number of comments on the post. +- `community_owned_date`: Timestamp when the post became community-owned. +- `creation_date`: Timestamp when the post was created. +- `favorite_count`: (STRING) Number of times the post has been favorited. +- `last_activity_date`: Timestamp of the last activity on the post. +- `last_edit_date`: Timestamp of the last edit to the post. +- `last_editor_display_name`: Display name of the last editor. +- `last_editor_user_id`: User ID of the last editor. +- `owner_display_name`: Display name of the post owner. +- `owner_user_id`: User ID of the post owner. This links to the [/users](users.md) table. +- `parent_id`: (STRING) ID of the parent post, if this is a reply. +- `post_type_id`: Type of the post (e.g., 6 for moderator nomination). +- `score`: The score of the post. +- `tags`: (STRING) Tags associated with the post. +- `view_count`: (STRING) Number of views the post has received. + +# Common query patterns + +```sql +SELECT + id, + creation_date, + owner_user_id, + body + FROM + `bigquery-public-data.stackoverflow.posts_moderator_nomination` + WHERE + creation_date BETWEEN '2021-01-01' AND '2021-12-31' + ORDER BY + creation_date DESC + LIMIT 100 +``` + +```sql +SELECT + p.id, + p.creation_date, + u.display_name AS nominator_name, + p.body + FROM + `bigquery-public-data.stackoverflow.posts_moderator_nomination` AS p + JOIN + `bigquery-public-data.stackoverflow.users` AS u + ON p.owner_user_id = u.id + WHERE + p.creation_date >= '2022-01-01' + LIMIT 10 +``` + +# Citations + +[1] [BigQuery Public Data: Stack Overflow Posts Moderator Nomination](https://bigquery.googleapis.com/v2/projects/bigquery-public-data/datasets/stackoverflow/tables/posts_moderator_nomination) diff --git a/internal/okf/testdata/official/stackoverflow/tables/posts_orphaned_tag_wiki.md b/internal/okf/testdata/official/stackoverflow/tables/posts_orphaned_tag_wiki.md new file mode 100644 index 0000000..a4e94b7 --- /dev/null +++ b/internal/okf/testdata/official/stackoverflow/tables/posts_orphaned_tag_wiki.md @@ -0,0 +1,70 @@ +--- +type: BigQuery Table +resource: https://bigquery.googleapis.com/v2/projects/bigquery-public-data/datasets/stackoverflow/tables/posts_orphaned_tag_wiki +title: Orphaned Tag Wiki Posts +description: This table contains Tag Wiki posts that are no longer associated with + an active tag on Stack Overflow. +tags: stackoverflow, posts, tag, wiki, orphaned +timestamp: '2026-05-28T23:27:56+00:00' +--- + +This table, part of the public [Stack Overflow dataset](../datasets/stackoverflow.md), contains posts that were originally created as Tag Wikis but are no longer linked to an existing tag within the Stack Overflow platform. These 'orphaned' Tag Wiki entries typically describe the purpose and usage of a specific programming language, technology, or concept. Each row represents a single Tag Wiki post, identified by a unique `id`. The `post_type_id` for these entries is typically `3`. The table includes the body of the wiki, creation dates, and metadata about the last editor and activity. It can be used to analyze historical Tag Wiki content that has become disassociated from its original tag. + +# Schema + +- `id`: Unique identifier for the post. +- `title`: Title of the post (often null for Tag Wikis). +- `body`: The main content of the Tag Wiki, often including HTML. +- `accepted_answer_id`: (Always null for this post type). +- `answer_count`: (Always null for this post type). +- `comment_count`: Number of comments on the post. +- `community_owned_date`: Timestamp when the post became community-owned. +- `creation_date`: Timestamp when the post was created. +- `favorite_count`: Number of times the post has been favorited. +- `last_activity_date`: Timestamp of the last activity on the post. +- `last_edit_date`: Timestamp of the last edit to the post. +- `last_editor_display_name`: Display name of the last user to edit the post. +- `last_editor_user_id`: User ID of the last user to edit the post. +- `owner_display_name`: Display name of the user who owns the post. +- `owner_user_id`: User ID of the user who owns the post. +- `parent_id`: (Always null for this post type). +- `post_type_id`: Type of the post. For this table, it's typically `3` (Tag Wiki). +- `score`: The score of the post. +- `tags`: Tags associated with the post (often null as these are orphaned). +- `view_count`: Number of times the post has been viewed. + +# Common query patterns + +```sql +SELECT + id, + creation_date, + body + FROM + `bigquery-public-data.stackoverflow.posts_orphaned_tag_wiki` + WHERE + creation_date < '2015-01-01' + LIMIT 100; +``` + +```sql +SELECT + id, + body + FROM + `bigquery-public-data.stackoverflow.posts_orphaned_tag_wiki` + WHERE + id = 4164933; +``` + +```sql +SELECT + count(id) AS total_orphaned_tag_wikis + FROM + `bigquery-public-data.stackoverflow.posts_orphaned_tag_wiki`; +``` + +# Citations + +[1] [BigQuery Public Data: Stack Overflow posts_orphaned_tag_wiki](https://bigquery.googleapis.com/v2/projects/bigquery-public-data/datasets/stackoverflow/tables/posts_orphaned_tag_wiki) +[2] [Stack Overflow](https://stackoverflow.com/) diff --git a/internal/okf/testdata/official/stackoverflow/tables/posts_privilege_wiki.md b/internal/okf/testdata/official/stackoverflow/tables/posts_privilege_wiki.md new file mode 100644 index 0000000..c4115fc --- /dev/null +++ b/internal/okf/testdata/official/stackoverflow/tables/posts_privilege_wiki.md @@ -0,0 +1,58 @@ +--- +type: BigQuery Table +resource: https://bigquery.googleapis.com/v2/projects/bigquery-public-data/datasets/stackoverflow/tables/posts_privilege_wiki +title: Posts Privilege Wiki +description: This table contains posts describing various privileges on Stack Overflow, + such as editing questions and retagging. +tags: stackoverflow, posts, wiki, privileges, community, documentation +timestamp: '2026-05-28T23:28:24+00:00' +--- + +This BigQuery table, `posts_privilege_wiki`, contains posts that describe various user privileges within the Stack Overflow community. These entries serve as informational guides, explaining what a privilege entails, when and how to use it, and related community guidelines. Examples include details on editing questions and answers, or the process of retagging questions. Each row represents a specific privilege explanation, detailing its purpose and usage, contributing to the self-documentation of the platform's community features. + +# Schema +- `id`: Unique identifier for the post. +- `title`: The title of the post. For these privilege wiki entries, titles are often null, with the main content in the body. +- `body`: The full content of the privilege explanation, often including HTML formatting. +- `accepted_answer_id`: (Always null for these types of posts) +- `answer_count`: (Always null for these types of posts) +- `comment_count`: Number of comments on the post. +- `community_owned_date`: Date when the post became community-owned. +- `creation_date`: Timestamp when the post was created. +- `favorite_count`: (Always null for these types of posts) +- `last_activity_date`: Timestamp of the last activity on the post. +- `last_edit_date`: Timestamp of the last edit to the post. +- `last_editor_display_name`: Display name of the last user who edited the post. +- `last_editor_user_id`: User ID of the last user who edited the post. +- `owner_display_name`: Display name of the post's owner. +- `owner_user_id`: User ID of the post's owner. Often -1 for community posts. +- `parent_id`: (Always null for these types of posts) +- `post_type_id`: Type of post, typically 8 for wiki entries. +- `score`: The score (upvotes minus downvotes) of the post. +- `tags`: (Always null for these types of posts) +- `view_count`: (Always null for these types of posts) + +# Common query patterns +```sql +SELECT + id, + body +FROM + `bigquery-public-data.stackoverflow.posts_privilege_wiki` +WHERE + creation_date >= '2013-01-01' +LIMIT 100 +``` +```sql +SELECT + id, + body, + creation_date +FROM + `bigquery-public-data.stackoverflow.posts_privilege_wiki` +WHERE + body LIKE '%edit questions and answers%' +``` + +# Citations +[1] [BigQuery Public Dataset: Stack Overflow Posts Privilege Wiki](https://bigquery.googleapis.com/v2/projects/bigquery-public-data/datasets/stackoverflow/tables/posts_privilege_wiki) diff --git a/internal/okf/testdata/official/stackoverflow/tables/posts_questions.md b/internal/okf/testdata/official/stackoverflow/tables/posts_questions.md new file mode 100644 index 0000000..b70aaeb --- /dev/null +++ b/internal/okf/testdata/official/stackoverflow/tables/posts_questions.md @@ -0,0 +1,90 @@ +--- +type: BigQuery Table +resource: https://bigquery.googleapis.com/v2/projects/bigquery-public-data/datasets/stackoverflow/tables/posts_questions +title: Stack Overflow Questions +description: This table contains information about all question posts on Stack Overflow, + including detailed schema fields, their descriptions, and licensing information. +tags: +- Stack Overflow +- questions +- posts +- programming +- community +- schema +- data dump +timestamp: '2026-05-28T23:31:54+00:00' +--- + +This table, part of the public [Stack Overflow dataset](../datasets/stackoverflow.md), contains comprehensive data for every question asked on the Stack Overflow platform. Each row represents a unique question post, including its content, metadata such as creation and last activity dates, view counts, and score. It serves as a primary resource for analyzing user activity, popular topics, and the overall dynamics of the Q&A community. The table can be joined with other tables like [posts_answers](posts_answers.md) to link questions to their corresponding answers, or with [users](users.md) to retrieve information about the question\'s author. + +# Schema +The schema contains fields related to the question\'s content, status, and associated user data. Key fields include `id` for unique identification, `title` and `body` for the question\'s content, `creation_date` for when the question was posted, and `tags` for categorizing the question. + +- `id` (INTEGER) - Unique identifier for the post. +- `title` (STRING) - The title of the question (for `PostTypeId = 1`). +- `body` (STRING) - The body of the post, as rendered HTML, not Markdown. +- `accepted_answer_id` (INTEGER) - The ID of the accepted answer; present only if `PostTypeId = 1`. +- `answer_count` (INTEGER) - The number of undeleted answers; present only if `PostTypeId = 1`. +- `closed_date` (TIMESTAMP) - The date and time the post was closed; present only if the post is closed. +- `comment_count` (INTEGER) - The number of comments. +- `community_owned_date` (TIMESTAMP) - The date and time the post became community wiki\'d. +- `creation_date` (TIMESTAMP) - The date and time the post was created. +- `favorite_count` (INTEGER) - The number of times the post has been favorited. Nullable. +- `last_activity_date` (TIMESTAMP) - The datetime of the post\'s most recent activity. +- `last_edit_date` (TIMESTAMP) - The date and time of the most recent edit to the post. +- `last_editor_display_name` (STRING) - The display name of the last editor. Nullable. +- `last_editor_user_id` (INTEGER) - The ID of the last editor user. Nullable. +- `owner_display_name` (STRING) - The display name of the post owner. Nullable. +- `owner_user_id` (INTEGER) - The ID of the post owner. Present only if user has not been deleted; always -1 for tag wiki entries. +- `parent_id` (STRING) - Parent ID. For questions (`PostTypeId = 1`), this field is typically NULL. It is mainly used for answers (`PostTypeId = 2`) to link to their parent question. +- `post_type_id` (INTEGER) - The type of the post. For questions, this is `1`. See the [Post Type IDs reference](../references/post_type_ids.md) for possible values. +- `score` (INTEGER) - The score of the post; generally non-zero for Questions, Answers, and Moderator Nominations. +- `tags` (STRING) - The tags associated with the question (for `PostTypeId = 1`). +- `view_count` (INTEGER) - The number of times the post has been viewed. Nullable. +- `content_license` (STRING) - Indicates the Creative Commons license under which the content is licensed. + +# Common query patterns +1. **Find the most viewed questions in a specific tag:** + ```sql + SELECT + title, + view_count + FROM + `bigquery-public-data.stackoverflow.posts_questions` + WHERE + tags LIKE \'%%' + ORDER BY + view_count DESC + LIMIT 10 + ``` +2. **Count questions posted per year:** + ```sql + SELECT + EXTRACT(YEAR FROM creation_date) AS year, + COUNT(id) AS question_count + FROM + `bigquery-public-data.stackoverflow.posts_questions` + GROUP BY + year + ORDER BY + year DESC + ``` +3. **Get questions with accepted answers and their answer count:** + ```sql + SELECT + id, + title, + accepted_answer_id, + answer_count + FROM + `bigquery-public-data.stackoverflow.posts_questions` + WHERE + accepted_answer_id IS NOT NULL + ORDER BY + answer_count DESC + LIMIT 5 + ``` + +# Citations +[1] [posts_questions](https://bigquery.googleapis.com/v2/projects/bigquery-public-data/datasets/stackoverflow/tables/posts_questions) +[2] [Database schema documentation for the public data dump and SEDE](https://meta.stackexchange.com/questions/2677/database-schema-documentation-for-the-public-data-dump-and-sede) diff --git a/internal/okf/testdata/official/stackoverflow/tables/posts_tag_wiki.md b/internal/okf/testdata/official/stackoverflow/tables/posts_tag_wiki.md new file mode 100644 index 0000000..415ed2b --- /dev/null +++ b/internal/okf/testdata/official/stackoverflow/tables/posts_tag_wiki.md @@ -0,0 +1,73 @@ +--- +type: BigQuery Table +resource: https://bigquery.googleapis.com/v2/projects/bigquery-public-data/datasets/stackoverflow/tables/posts_tag_wiki +title: Posts Tag Wiki +description: Contains detailed wiki descriptions for tags on Stack Overflow. +tags: stackoverflow, tag, wiki, posts, bigquery +timestamp: '2026-05-28T23:29:03+00:00' +--- + +The `posts_tag_wiki` table in the BigQuery Stack Overflow dataset stores detailed wiki descriptions for various [tags](tags.md) used on the platform. Each row represents a wiki entry associated with a specific tag, providing comprehensive information about its purpose, usage, and related concepts. This table is useful for understanding the context and definition of tags, which helps in categorizing questions and answers accurately. The `body` field contains the rich text content of the wiki. This table is a valuable resource for anyone analyzing the structure and content organization of [Stack Overflow](../datasets/stackoverflow.md). The grain of this table is one row per tag wiki entry. + +# Schema +- `id`: INTEGER, Unique identifier for the tag wiki post. +- `title`: STRING, Title of the tag wiki (often null for actual tag wiki posts). +- `body`: STRING, The main content of the tag wiki, containing detailed description. +- `accepted_answer_id`: STRING, ID of the accepted answer, if applicable. +- `answer_count`: STRING, Number of answers. +- `comment_count`: INTEGER, Number of comments on the post. +- `community_owned_date`: TIMESTAMP, Date when the post became community-owned. +- `creation_date`: TIMESTAMP, Date and time the post was created. +- `favorite_count`: STRING, Number of times the post has been favorited. +- `last_activity_date`: TIMESTAMP, Date and time of the last activity on the post. +- `last_edit_date`: TIMESTAMP, Date and time of the last edit to the post. +- `last_editor_display_name`: STRING, Display name of the last editor. +- `last_editor_user_id`: INTEGER, User ID of the last editor. +- `owner_display_name`: STRING, Display name of the post owner. +- `owner_user_id`: INTEGER, User ID of the post owner. +- `parent_id`: STRING, ID of the parent post, if applicable. +- `post_type_id`: INTEGER, Identifier for the type of post (e.g., 5 for Tag Wiki). +- `score`: INTEGER, The score of the post. +- `tags`: STRING, Tags associated with the post (usually null for tag wiki posts, as the wiki *is* for a tag). +- `view_count`: STRING, Number of views the post has received. + +# Common query patterns + +To retrieve the body of a specific tag wiki: +```sql +SELECT + body + FROM + `bigquery-public-data.stackoverflow.posts_tag_wiki` + WHERE + id = 5046395 +``` + +To find tag wiki entries created after a certain date: +```sql +SELECT + id, + creation_date, + body + FROM + `bigquery-public-data.stackoverflow.posts_tag_wiki` + WHERE + creation_date > '2020-01-01' + LIMIT 100 +``` + +To count the number of tag wiki entries per creation year: +```sql +SELECT + EXTRACT(YEAR FROM creation_date) AS creation_year, + COUNT(id) AS number_of_wikis + FROM + `bigquery-public-data.stackoverflow.posts_tag_wiki` + GROUP BY + creation_year + ORDER BY + creation_year DESC +``` + +# Citations +[1] [BigQuery Public Dataset: Stack Overflow Posts Tag Wiki](https://bigquery.googleapis.com/v2/projects/bigquery-public-data/datasets/stackoverflow/tables/posts_tag_wiki) diff --git a/internal/okf/testdata/official/stackoverflow/tables/posts_tag_wiki_excerpt.md b/internal/okf/testdata/official/stackoverflow/tables/posts_tag_wiki_excerpt.md new file mode 100644 index 0000000..223b144 --- /dev/null +++ b/internal/okf/testdata/official/stackoverflow/tables/posts_tag_wiki_excerpt.md @@ -0,0 +1,69 @@ +--- +type: BigQuery Table +resource: https://bigquery.googleapis.com/v2/projects/bigquery-public-data/datasets/stackoverflow/tables/posts_tag_wiki_excerpt +title: Posts Tag Wiki Excerpt +description: Contains excerpts from tag wikis on Stack Overflow. +tags: stackoverflow, posts, tags, wiki, excerpt +timestamp: '2026-05-28T23:29:18+00:00' +--- + +The `posts_tag_wiki_excerpt` table in the BigQuery Stack Overflow public dataset contains summary information, or excerpts, from the wiki pages associated with various tags. Each row represents a single tag wiki excerpt, providing details such as the excerpt's body, its associated tag, and metadata about its creation and last modification. This table is useful for understanding the brief descriptions provided for tags on the Stack Overflow platform, offering quick insights into what a particular tag represents. + +# Schema +- `id`: Unique identifier for the tag wiki excerpt. +- `title`: Title of the tag wiki excerpt. +- `body`: The content of the tag wiki excerpt. +- `accepted_answer_id`: (STRING) +- `answer_count`: (STRING) +- `comment_count`: Number of comments on the excerpt. +- `community_owned_date`: When the excerpt became community-owned. +- `creation_date`: When the excerpt was created. +- `favorite_count`: (STRING) +- `last_activity_date`: Last activity date on the excerpt. +- `last_edit_date`: Last edit date of the excerpt. +- `last_editor_display_name`: Display name of the last editor. +- `last_editor_user_id`: User ID of the last editor. +- `owner_display_name`: Display name of the owner. +- `owner_user_id`: User ID of the owner. +- `parent_id`: (STRING) +- `post_type_id`: Type of post. +- `score`: Score of the excerpt. +- `tags`: The tag associated with this wiki excerpt (e.g., ``). +- `view_count`: (STRING) + +# Common query patterns + +```sql +SELECT + title, + body + FROM + `bigquery-public-data.stackoverflow.posts_tag_wiki_excerpt` + WHERE + id = 12345 +``` + +```sql +SELECT + title, + tags + FROM + `bigquery-public-data.stackoverflow.posts_tag_wiki_excerpt` + WHERE + body LIKE '%python%' +``` + +```sql +SELECT + title, + creation_date, + last_editor_display_name + FROM + `bigquery-public-data.stackoverflow.posts_tag_wiki_excerpt` + ORDER BY + creation_date DESC + LIMIT 10 +``` + +# Citations +[1] [BigQuery Public Data: Stack Overflow posts_tag_wiki_excerpt](https://bigquery.googleapis.com/v2/projects/bigquery-public-data/datasets/stackoverflow/tables/posts_tag_wiki_excerpt) diff --git a/internal/okf/testdata/official/stackoverflow/tables/posts_wiki_placeholder.md b/internal/okf/testdata/official/stackoverflow/tables/posts_wiki_placeholder.md new file mode 100644 index 0000000..9ac40b6 --- /dev/null +++ b/internal/okf/testdata/official/stackoverflow/tables/posts_wiki_placeholder.md @@ -0,0 +1,71 @@ +--- +type: BigQuery Table +resource: https://bigquery.googleapis.com/v2/projects/bigquery-public-data/datasets/stackoverflow/tables/posts_wiki_placeholder +title: Posts Wiki Placeholder +description: Placeholder table for Stack Overflow wiki posts, containing community-maintained + help and information. +tags: stackoverflow, posts, wiki, help, community, documentation +timestamp: '2026-05-28T23:29:41+00:00' +--- + +This table serves as a placeholder for various wiki posts within the Stack Overflow community, containing community-maintained help, guidelines, and other informational content. Each row in this table represents a single wiki post, identified by a unique `id`. These posts often include details about election processes, help center articles, and general community guidelines. + +# Schema + +- `id`: INTEGER, Unique identifier for the wiki post. +- `title`: STRING, Title of the wiki post. +- `body`: STRING, The main content of the wiki post. +- `accepted_answer_id`: STRING +- `answer_count`: STRING +- `comment_count`: INTEGER, Number of comments on the post. +- `community_owned_date`: STRING +- `creation_date`: TIMESTAMP, Timestamp when the post was created. +- `favorite_count`: STRING +- `last_activity_date`: TIMESTAMP, Timestamp of the last activity on the post. +- `last_edit_date`: TIMESTAMP, Timestamp when the post was last edited. +- `last_editor_display_name`: STRING +- `last_editor_user_id`: INTEGER, User ID of the last editor. +- `owner_display_name`: STRING +- `owner_user_id`: INTEGER, User ID of the post owner. +- `parent_id`: STRING +- `post_type_id`: INTEGER, Type of the post (e.g., 7 for Wiki). +- `score`: INTEGER, Score of the post. +- `tags`: STRING, Tags associated with the post. +- `view_count`: STRING + +# Common query patterns + +```sql +SELECT + id, title, body + FROM + `bigquery-public-data.stackoverflow.posts_wiki_placeholder` + WHERE + id = 8041931 +``` + +```sql +SELECT + id, title + FROM + `bigquery-public-data.stackoverflow.posts_wiki_placeholder` + WHERE + body LIKE '%election process%' + LIMIT 100 +``` + +```sql +SELECT + EXTRACT(YEAR FROM creation_date) AS creation_year, + COUNT(id) AS post_count + FROM + `bigquery-public-data.stackoverflow.posts_wiki_placeholder` + GROUP BY + creation_year + ORDER BY + creation_year DESC +``` + +# Citations + +[1] [BigQuery Public Data: Stack Overflow posts_wiki_placeholder](https://bigquery.googleapis.com/v2/projects/bigquery-public-data/datasets/stackoverflow/tables/posts_wiki_placeholder) diff --git a/internal/okf/testdata/official/stackoverflow/tables/stackoverflow_posts.md b/internal/okf/testdata/official/stackoverflow/tables/stackoverflow_posts.md new file mode 100644 index 0000000..7e6d412 --- /dev/null +++ b/internal/okf/testdata/official/stackoverflow/tables/stackoverflow_posts.md @@ -0,0 +1,54 @@ +--- +type: BigQuery Table +resource: https://bigquery.googleapis.com/v2/projects/bigquery-public-data/datasets/stackoverflow/tables/stackoverflow_posts +title: Stack Overflow Posts (Legacy) +description: A legacy table containing all posts from Stack Overflow. This table is + deprecated; use `posts_answers`, `posts_questions`, or other `posts_*` tables instead. +tags: Stack Overflow, posts, legacy, deprecated +timestamp: '2026-05-28T23:30:00+00:00' +--- + +This table, `stackoverflow_posts`, is a legacy table from the [stackoverflow](../datasets/stackoverflow.md) dataset in Google Cloud's BigQuery public datasets. It contains all posts (questions and answers) from Stack Overflow. **This table is deprecated and should not be used for new queries.** Users are advised to use the more specific and updated tables like [posts_questions](posts_questions.md) for questions and [posts_answers](posts_answers.md) for answers. Each row in this table represents a single post, with fields such as `id`, `title`, `body`, `creation_date`, and user information. The table contains approximately 31 million rows and over 31 GB of data. + +# Schema + +- `id`: Unique identifier for the post. +- `title`: The title of the post (for questions). +- `body`: The main content of the post. +- `accepted_answer_id`: The ID of the accepted answer (for questions). +- `answer_count`: Number of answers for a question. +- `comment_count`: Number of comments on the post. +- `community_owned_date`: Timestamp when the post became community-owned. +- `creation_date`: Timestamp when the post was created. +- `favorite_count`: Number of times the post has been favorited. +- `last_activity_date`: Timestamp of the last activity on the post. +- `last_edit_date`: Timestamp of the last edit. +- `last_editor_display_name`: Display name of the last editor. +- `last_editor_user_id`: User ID of the last editor. +- `owner_display_name`: Display name of the post owner. +- `owner_user_id`: User ID of the post owner. +- `parent_id`: For answers, the ID of the question it answers. +- `post_type_id`: Type of the post (e.g., 1 for question, 2 for answer). +- `score`: The score of the post (upvotes - downvotes). +- `tags`: Comma-separated tags associated with the post (for questions). +- `view_count`: Number of views for the post (for questions). + +# Common query patterns + +```sql +SELECT + id, + title, + creation_date +FROM + `bigquery-public-data.stackoverflow.stackoverflow_posts` +WHERE + creation_date BETWEEN '2008-01-01' AND '2008-01-31' + AND post_type_id = 1 -- Questions only +LIMIT 100; +``` + +# Citations + +[1] [BigQuery Console - stackoverflow_posts](https://bigquery.googleapis.com/v2/projects/bigquery-public-data/datasets/stackoverflow/tables/stackoverflow_posts) +[2] [BigQuery Public Datasets - Stack Overflow](https://cloud.google.com/bigquery/public-data/stackoverflow) diff --git a/internal/okf/testdata/official/stackoverflow/tables/tags.md b/internal/okf/testdata/official/stackoverflow/tables/tags.md new file mode 100644 index 0000000..20a67fe --- /dev/null +++ b/internal/okf/testdata/official/stackoverflow/tables/tags.md @@ -0,0 +1,77 @@ +--- +type: BigQuery Table +resource: https://bigquery.googleapis.com/v2/projects/bigquery-public-data/datasets/stackoverflow/tables/tags +title: Tags +description: A table containing information about tags used on Stack Overflow, including + tag names, counts, and references to their excerpt and wiki posts, along with moderation + settings. +tags: +- stackoverflow +- tags +- community +- metadata +- schema +- data dump +timestamp: '2026-05-28T23:34:53+00:00' +--- + +This table, `tags`, from the [stackoverflow](../datasets/stackoverflow.md) dataset, provides a comprehensive list of all tags used across the Stack Overflow platform. Each row represents a unique tag, detailing its name, the total count of times it has been used, and references to its corresponding excerpt and wiki posts. This table is essential for understanding the topics and technologies discussed on Stack Overflow, allowing for analysis of tag popularity, trends, and content organization. + +# Schema +The `tags` table contains the following fields: +- `id` (INTEGER) - Unique identifier for the tag. +- `tag_name` (STRING) - The name of the tag (e.g., \'python\', \'java\', \'javascript\'). +- `count` (INTEGER) - The total number of times this tag has been used. +- `excerpt_post_id` (INTEGER) - The ID of the Post that holds the excerpt text of the tag. Nullable. +- `wiki_post_id` (INTEGER) - The ID of the Post that holds the wiki text of the tag. Nullable. +- `is_moderator_only` (BOOLEAN) - Indicates if the tag can only be used by moderators. +- `is_required` (BOOLEAN) - Indicates if the tag is required for certain post types. + +# Common query patterns + +1. Find the most popular tags: +```sql +SELECT + tag_name, + count + FROM + `bigquery-public-data.stackoverflow.tags` + GROUP BY + tag_name + ORDER BY + count DESC + LIMIT 10 +``` + +2. Get details for a specific tag: +```sql +SELECT + id, + tag_name, + count, + excerpt_post_id, + wiki_post_id + FROM + `bigquery-public-data.stackoverflow.tags` + WHERE + tag_name = \'python\' +``` + +3. Join with posts to find posts related to a popular tag (example uses `posts_questions` which is related to `stackoverflow_posts`): +```sql +SELECT + p.title, + t.tag_name + FROM + `bigquery-public-data.stackoverflow.posts_questions` AS p, + `bigquery-public-data.stackoverflow.tags` AS t + WHERE + p.tags LIKE CONCAT(\'%<\', t.tag_name, \'>%\' ) + AND t.tag_name = \'javascript\' + LIMIT 5 +``` + +# Citations +[1] [BigQuery Public Dataset: Stack Overflow Tags](https://bigquery.googleapis.com/v2/projects/bigquery-public-data/datasets/stackoverflow/tables/tags) +[2] [Stack Overflow Dataset on BigQuery](https://console.cloud.google.com/marketplace/details/stackoverflow/stackoverflow) +[3] [Database schema documentation for the public data dump and SEDE](https://meta.stackexchange.com/questions/2677/database-schema-documentation-for-the-public-data-dump-and-sede) diff --git a/internal/okf/testdata/official/stackoverflow/tables/users.md b/internal/okf/testdata/official/stackoverflow/tables/users.md new file mode 100644 index 0000000..819102e --- /dev/null +++ b/internal/okf/testdata/official/stackoverflow/tables/users.md @@ -0,0 +1,80 @@ +--- +type: BigQuery Table +resource: https://bigquery.googleapis.com/v2/projects/bigquery-public-data/datasets/stackoverflow/tables/users +title: Users +description: This table contains information about users registered on Stack Overflow, + including detailed profile information, activity metrics, and network-wide identifiers. +tags: +- Stack Overflow +- users +- profiles +- community +- schema +- data dump +timestamp: '2026-05-28T23:32:24+00:00' +--- + +This table, `users`, from the [stackoverflow](../datasets/stackoverflow.md) dataset, stores profiles of registered users on the Stack Overflow platform. Each row represents a unique user, identified by their `id`. The table includes details such as display name, creation date, last access date, reputation, and vote counts. It provides insights into the activity and characteristics of the Stack Overflow community members. + +# Schema + +* `id` (INTEGER) - Unique identifier for the user. +* `display_name` (STRING) - The publicly visible name of the user. +* `about_me` (STRING) - User-provided free-form text about themselves. Nullable. +* `age` (INTEGER) - User-provided age. Nullable. +* `account_id` (INTEGER) - User\'s Stack Exchange Network profile ID; NULL if the user has hidden this community in their profile. Nullable. +* `creation_date` (TIMESTAMP) - Timestamp when the user account was created. +* `email_hash` (STRING) - Gravatar email hash, now always NULL and will not appear as an attribute in the data dump XML. Nullable. +* `last_access_date` (TIMESTAMP) - Datetime user last loaded a page; updated every 30 minutes at most. +* `location` (STRING) - User-provided geographical location. Nullable. +* `reputation` (INTEGER) - The user\'s reputation score. +* `up_votes` (INTEGER) - How many upvotes the user has cast. +* `down_votes` (INTEGER) - Total number of downvotes received by the user. +* `views` (INTEGER) - Number of times the user\'s profile has been viewed. +* `profile_image_url` (STRING) - URL of the user\'s profile picture. Nullable. +* `website_url` (STRING) - URL of the user\'s personal website. Nullable. + +# Common query patterns + +1. Find the top 10 users by reputation: + ```sql + SELECT + id, + display_name, + reputation + FROM + `bigquery-public-data.stackoverflow.users` + ORDER BY + reputation DESC + LIMIT 10 + ``` +2. Count users created per year: + ```sql + SELECT + EXTRACT(YEAR FROM creation_date) AS creation_year, + COUNT(id) AS user_count + FROM + `bigquery-public-data.stackoverflow.users` + GROUP BY + creation_year + ORDER BY + creation_year + ``` +3. Find users with a high number of upvotes given: + ```sql + SELECT + display_name, + up_votes + FROM + `bigquery-public-data.stackoverflow.users` + WHERE + up_votes > 1000 + ORDER BY + up_votes DESC + LIMIT 5 + ``` + +# Citations + +[1] [Stack Overflow Users Table](https://bigquery.googleapis.com/v2/projects/bigquery-public-data/datasets/stackoverflow/tables/users) +[2] [Database schema documentation for the public data dump and SEDE](https://meta.stackexchange.com/questions/2677/database-schema-documentation-for-the-public-data-dump-and-sede) diff --git a/internal/okf/testdata/official/stackoverflow/tables/votes.md b/internal/okf/testdata/official/stackoverflow/tables/votes.md new file mode 100644 index 0000000..10cf0e9 --- /dev/null +++ b/internal/okf/testdata/official/stackoverflow/tables/votes.md @@ -0,0 +1,76 @@ +--- +type: BigQuery Table +resource: https://bigquery.googleapis.com/v2/projects/bigquery-public-data/datasets/stackoverflow/tables/votes +title: Votes +description: This table contains information about votes cast on posts within the + Stack Overflow platform, including the vote type, associated post and user, and + bounty details. +tags: +- votes +- posts +- stackoverflow +- community +- engagement +- schema +- data dump +timestamp: '2026-05-28T23:36:03+00:00' +--- + +This table, `votes`, from the [stackoverflow](../datasets/stackoverflow.md) dataset, records individual voting events on various posts (questions, answers, etc.) within the Stack Overflow platform. Each row represents a single vote, providing details such as the unique vote identifier, the timestamp of the vote, the ID of the post that received the vote, and the type of vote (e.g., upvote, downvote, favorite). This table is crucial for analyzing user engagement, post popularity, and the overall quality assessment mechanisms within the Stack Overflow community. The grain of this table is one row per vote event, and it captures historical voting data from its creation date up to the last modification. + +# Schema + +- `id` (INTEGER) - Unique identifier for the vote. +- `post_id` (INTEGER) - The ID of the post to which the vote was cast. Links to `posts_questions` or `posts_answers` tables. +- `vote_type_id` (INTEGER) - The type of vote. See the [Vote Types reference](../references/vote_types.md) for a comprehensive list. +- `user_id` (INTEGER) - The ID of the user who cast the vote. Present only if `VoteTypeId` is `5` (Favorite/Bookmark) or `8` (BountyStart); -1 if user is deleted. Nullable. Links to the `users` table. +- `creation_date` (TIMESTAMP) - The date when the vote was cast. Time data is purposefully removed to protect user privacy. +- `bounty_amount` (INTEGER) - The amount of bounty associated with the vote. Present only if `VoteTypeId` is `8` (BountyStart) or `9` (BountyClose). Nullable. + +# Common query patterns + +```sql +-- Get the total number of votes for each vote type +SELECT + vote_type_id, + COUNT(id) AS total_votes +FROM + `bigquery-public-data.stackoverflow.votes` +GROUP BY + vote_type_id +ORDER BY + total_votes DESC; +``` + +```sql +-- Find the top 10 most voted posts by summing up all votes (assuming all vote_type_ids contribute positively) +SELECT + post_id, + COUNT(id) AS total_votes +FROM + `bigquery-public-data.stackoverflow.votes` +GROUP BY + post_id +ORDER BY + total_votes DESC +LIMIT 10; +``` + +```sql +-- Count votes over time +SELECT + DATE(creation_date) AS vote_date, + COUNT(id) AS daily_votes +FROM + `bigquery-public-data.stackoverflow.votes` +WHERE + creation_date >= \'2020-01-01\' AND creation_date < \'2021-01-01\' +GROUP BY + vote_date +ORDER BY + vote_date; +``` + +# Citations +[1] [BigQuery Public Dataset: Stack Overflow Votes Table](https://bigquery.googleapis.com/v2/projects/bigquery-public-data/datasets/stackoverflow/tables/votes) +[2] [Database schema documentation for the public data dump and SEDE](https://meta.stackexchange.com/questions/2677/database-schema-documentation-for-the-public-data-dump-and-sede) diff --git a/internal/okf/testdata/official/stackoverflow/viz.html b/internal/okf/testdata/official/stackoverflow/viz.html new file mode 100644 index 0000000..045f868 --- /dev/null +++ b/internal/okf/testdata/official/stackoverflow/viz.html @@ -0,0 +1,448 @@ + + + + +OKF Bundle Viewer + + + + + +
+
+ + OKF bundle +
+
+ + + + +
+
+ +
+
+
+
Click a node to see its details.
+ +
+
+ + + + + diff --git a/internal/topic/assets/okf-claude-template.md b/internal/topic/assets/okf-claude-template.md new file mode 100644 index 0000000..3a18157 --- /dev/null +++ b/internal/topic/assets/okf-claude-template.md @@ -0,0 +1,24 @@ +# TOPIC_TITLE + +**Topic scope:** portable Open Knowledge Format bundle for `TOPIC_DOMAIN`. + +**Domain:** `TOPIC_DOMAIN` + +This topic is an OKF bundle, not an LLM-wiki research lab. Concepts live as typed markdown files at the bundle root for the MVP, with standard markdown links and YAML frontmatter. + +## Bundle contract + +- Every concept markdown file has `type`, `title`, `description`, and `timestamp` frontmatter. +- `index.md` is generated by `kb` and groups concepts by type. +- `log.md` records bundle updates using ISO date headings. +- Links should be relative markdown links so the bundle renders in GitHub and other markdown tools. + +## Operations + +- Promote a wiki concept into this bundle with `kb promote --to TOPIC_SLUG --type `. +- Check conformance with `kb okf check TOPIC_SLUG`. + +## Catalog gaps + +- Gap 1 +- Gap 2 diff --git a/internal/topic/topic.go b/internal/topic/topic.go index 1097c4e..5cf52db 100644 --- a/internal/topic/topic.go +++ b/internal/topic/topic.go @@ -23,6 +23,7 @@ const ( conceptIndexTemplatePath = "assets/concept-index-template.md" dashboardTemplatePath = "assets/dashboard-template.md" logTemplatePath = "assets/log-template.md" + okfClaudeTemplatePath = "assets/okf-claude-template.md" sourceIndexTemplatePath = "assets/source-index-template.md" topicMarkerFile = "CLAUDE.md" topicMetadataFileName = "topic.yaml" @@ -105,6 +106,7 @@ type topicMetadataFile struct { Slug string `yaml:"slug"` Title string `yaml:"title"` Domain string `yaml:"domain"` + Mode string `yaml:"mode,omitempty"` Category string `yaml:"category,omitempty"` Path string `yaml:"path,omitempty"` QMDCollection string `yaml:"qmd_collection,omitempty"` @@ -112,7 +114,12 @@ type topicMetadataFile struct { // New scaffolds a new topic underneath the provided vault root. func New(vaultPath, slug, title, domain string) (models.TopicInfo, error) { - return newWithDate(vaultPath, slug, title, domain, time.Now()) + return NewWithMode(vaultPath, slug, title, domain, models.TopicModeWiki) +} + +// NewWithMode scaffolds a new topic with an explicit lifecycle mode. +func NewWithMode(vaultPath, slug, title, domain string, mode models.TopicMode) (models.TopicInfo, error) { + return newWithDateWithMode(vaultPath, slug, title, domain, mode, time.Now()) } // List returns the topics discovered under the provided vault root. @@ -184,6 +191,10 @@ func Info(vaultPath, slug string) (models.TopicInfo, error) { } func newWithDate(vaultPath, slug, title, domain string, now time.Time) (models.TopicInfo, error) { + return newWithDateWithMode(vaultPath, slug, title, domain, models.TopicModeWiki, now) +} + +func newWithDateWithMode(vaultPath, slug, title, domain string, mode models.TopicMode, now time.Time) (models.TopicInfo, error) { cleanVaultPath, err := normalizeVaultPath(vaultPath) if err != nil { return models.TopicInfo{}, fmt.Errorf("new topic: %w", err) @@ -195,6 +206,10 @@ func newWithDate(vaultPath, slug, title, domain string, now time.Time) (models.T } cleanTitle := strings.TrimSpace(title) cleanDomain := strings.TrimSpace(domain) + cleanMode, err := normalizeTopicMode(mode) + if err != nil { + return models.TopicInfo{}, fmt.Errorf("new topic: %w", err) + } switch { case cleanTitle == "": @@ -225,10 +240,10 @@ func newWithDate(vaultPath, slug, title, domain string, now time.Time) (models.T Today: now.Format(frontmatter.DateLayout), } - if err := installTemplates(topicPath, context); err != nil { + if err := installTemplatesWithMode(topicPath, context, cleanMode); err != nil { return models.TopicInfo{}, fmt.Errorf("new topic: install templates: %w", err) } - if err := writeMetadataFile(topicPath, topicMetadataForRef(topicRef, cleanTitle, cleanDomain)); err != nil { + if err := writeMetadataFile(topicPath, topicMetadataForRef(topicRef, cleanTitle, cleanDomain, cleanMode)); err != nil { return models.TopicInfo{}, fmt.Errorf("new topic: write topic metadata: %w", err) } if err := ensureAgentsSymlink(topicPath); err != nil { @@ -237,7 +252,12 @@ func newWithDate(vaultPath, slug, title, domain string, now time.Time) (models.T if err := ensureGitkeeps(topicPath); err != nil { return models.TopicInfo{}, fmt.Errorf("new topic: ensure gitkeep files: %w", err) } - if err := appendScaffoldEntry(filepath.Join(topicPath, "log.md"), context); err != nil { + logPath := filepath.Join(topicPath, "log.md") + if cleanMode == models.TopicModeOKF { + if err := appendOKFScaffoldEntry(logPath, context); err != nil { + return models.TopicInfo{}, fmt.Errorf("new topic: append scaffold log entry: %w", err) + } + } else if err := appendScaffoldEntry(logPath, context); err != nil { return models.TopicInfo{}, fmt.Errorf("new topic: append scaffold log entry: %w", err) } @@ -485,7 +505,17 @@ func RenderSourceIndexTemplate(slug, title, domain, today string) (string, error } func installTemplates(topicPath string, context templateContext) error { - for _, file := range topicTemplates { + return installTemplatesWithMode(topicPath, context, models.TopicModeWiki) +} + +func installTemplatesWithMode(topicPath string, context templateContext, mode models.TopicMode) error { + templates := topicTemplates + if mode == models.TopicModeOKF { + templates = []templateFile{ + {assetPath: okfClaudeTemplatePath, outputPath: "CLAUDE.md"}, + } + } + for _, file := range templates { rendered, err := renderTemplate(file.assetPath, context) if err != nil { return err @@ -497,6 +527,19 @@ func installTemplates(topicPath string, context templateContext) error { } } + if mode == models.TopicModeOKF { + index, err := frontmatter.Generate(map[string]any{"okf_version": "0.1"}, "# OKF Bundle Index\n") + if err != nil { + return fmt.Errorf("generate OKF index: %w", err) + } + if err := os.WriteFile(filepath.Join(topicPath, "index.md"), []byte(index), 0o644); err != nil { + return fmt.Errorf("write OKF index: %w", err) + } + if err := os.WriteFile(filepath.Join(topicPath, "log.md"), []byte("# Directory Update Log\n"), 0o644); err != nil { + return fmt.Errorf("write OKF log: %w", err) + } + } + return nil } @@ -506,14 +549,26 @@ func WriteMetadataFile(topicPath, slug, title, domain string) error { if err != nil { return fmt.Errorf("validate topic metadata slug: %w", err) } - return writeMetadataFile(topicPath, topicMetadataForRef(topicRef, title, domain)) + mode := models.TopicModeWiki + existing, err := readTopicYAMLMetadata(filepath.Join(topicPath, topicMetadataFileName)) + if err != nil { + return fmt.Errorf("read existing topic metadata: %w", err) + } + if existing.Mode != "" { + mode, err = normalizeTopicMode(models.TopicMode(existing.Mode)) + if err != nil { + return fmt.Errorf("validate existing topic mode: %w", err) + } + } + return writeMetadataFile(topicPath, topicMetadataForRef(topicRef, title, domain, mode)) } -func topicMetadataForRef(topicRef TopicRef, title, domain string) topicMetadataFile { +func topicMetadataForRef(topicRef TopicRef, title, domain string, mode models.TopicMode) topicMetadataFile { metadata := topicMetadataFile{ Slug: topicRef.Leaf, Title: strings.TrimSpace(title), Domain: strings.TrimSpace(domain), + Mode: string(mode), } if topicRef.Category != "" { metadata.Category = topicRef.Category @@ -694,6 +749,32 @@ func appendScaffoldEntry(logPath string, context templateContext) error { return nil } +func appendOKFScaffoldEntry(logPath string, context templateContext) error { + content, err := os.ReadFile(logPath) + if err != nil { + return fmt.Errorf("read %q: %w", logPath, err) + } + + entry := strings.Join([]string{ + fmt.Sprintf("## %s", context.Today), + "", + fmt.Sprintf("* **Initialization**: Created OKF bundle `%s` for `%s`.", context.Slug, context.Domain), + "", + }, "\n") + + trimmed := strings.TrimSpace(string(content)) + var next string + if trimmed == "" { + next = "# Directory Update Log\n\n" + entry + } else { + next = trimmed + "\n\n" + entry + } + if err := os.WriteFile(logPath, []byte(next), 0o644); err != nil { + return fmt.Errorf("write %q: %w", logPath, err) + } + return nil +} + func hasTopicSkeleton(topicPath string) (bool, error) { info, err := os.Stat(topicPath) if errors.Is(err, os.ErrNotExist) { @@ -719,19 +800,14 @@ func hasTopicSkeleton(topicPath string) (bool, error) { } func infoAtPath(topicPath, slug string) (models.TopicInfo, error) { - title, domain, err := readTopicMetadata(topicPath, slug) + title, domain, mode, err := readTopicMetadata(topicPath, slug) if err != nil { return models.TopicInfo{}, fmt.Errorf("read topic metadata: %w", err) } - articleCount, err := countMarkdownFiles(filepath.Join(topicPath, "wiki", "concepts")) - if err != nil { - return models.TopicInfo{}, fmt.Errorf("count wiki articles: %w", err) - } - - sourceCount, err := countVisibleFiles(filepath.Join(topicPath, "raw")) + articleCount, sourceCount, err := topicCounts(topicPath, mode) if err != nil { - return models.TopicInfo{}, fmt.Errorf("count raw sources: %w", err) + return models.TopicInfo{}, fmt.Errorf("count topic files: %w", err) } lastLogEntry, err := readLastLogEntry(filepath.Join(topicPath, "log.md")) @@ -743,6 +819,7 @@ func infoAtPath(topicPath, slug string) (models.TopicInfo, error) { Slug: slug, Title: title, Domain: domain, + Mode: mode, RootPath: topicPath, ArticleCount: articleCount, SourceCount: sourceCount, @@ -750,21 +827,25 @@ func infoAtPath(topicPath, slug string) (models.TopicInfo, error) { }, nil } -func readTopicMetadata(claudePath, slug string) (string, string, error) { +func readTopicMetadata(claudePath, slug string) (string, string, models.TopicMode, error) { yamlMetadata, err := readTopicYAMLMetadata(filepath.Join(claudePath, topicMetadataFileName)) if err != nil { - return "", "", err + return "", "", "", err } claudeTitle, claudeDomain, err := readClaudeMetadata(filepath.Join(claudePath, topicMarkerFile), slug) if err != nil { - return "", "", err + return "", "", "", err } title := firstNonEmpty(yamlMetadata.Title, claudeTitle, humanizeSlug(slug)) domain := firstNonEmpty(yamlMetadata.Domain, claudeDomain, slug) + mode, err := normalizeTopicMode(models.TopicMode(yamlMetadata.Mode)) + if err != nil { + return "", "", "", err + } - return title, domain, nil + return title, domain, mode, nil } func readTopicYAMLMetadata(metadataPath string) (topicMetadataFile, error) { @@ -784,9 +865,52 @@ func readTopicYAMLMetadata(metadataPath string) (topicMetadataFile, error) { metadata.Slug = strings.TrimSpace(metadata.Slug) metadata.Title = strings.TrimSpace(metadata.Title) metadata.Domain = strings.TrimSpace(metadata.Domain) + metadata.Mode = strings.TrimSpace(metadata.Mode) return metadata, nil } +func normalizeTopicMode(mode models.TopicMode) (models.TopicMode, error) { + switch strings.TrimSpace(string(mode)) { + case "", string(models.TopicModeWiki): + return models.TopicModeWiki, nil + case string(models.TopicModeOKF): + return models.TopicModeOKF, nil + default: + return "", fmt.Errorf("topic mode must be wiki or okf: %q", mode) + } +} + +func topicCounts(topicPath string, mode models.TopicMode) (int, int, error) { + if mode == models.TopicModeOKF { + concepts, err := countOKFConceptFiles(topicPath) + return concepts, 0, err + } + articleCount, err := countMarkdownFiles(filepath.Join(topicPath, "wiki", "concepts")) + if err != nil { + return 0, 0, fmt.Errorf("count wiki articles: %w", err) + } + sourceCount, err := countVisibleFiles(filepath.Join(topicPath, "raw")) + if err != nil { + return 0, 0, fmt.Errorf("count raw sources: %w", err) + } + return articleCount, sourceCount, nil +} + +func countOKFConceptFiles(root string) (int, error) { + return countFiles(root, func(entry fs.DirEntry) bool { + name := entry.Name() + if !strings.HasSuffix(strings.ToLower(name), ".md") || strings.HasPrefix(name, ".") { + return false + } + switch strings.ToLower(name) { + case "index.md", "log.md", "claude.md", "agents.md", "readme.md", "license.md", "notice.md", "attribution.md": + return false + default: + return true + } + }) +} + func readClaudeMetadata(claudePath, slug string) (string, string, error) { content, err := os.ReadFile(claudePath) if err != nil { @@ -827,7 +951,7 @@ func readLastLogEntry(logPath string) (string, error) { last := "" for line := range strings.SplitSeq(string(content), "\n") { trimmed := strings.TrimSpace(line) - if strings.HasPrefix(trimmed, "## [") { + if strings.HasPrefix(trimmed, "## [") || isOKFDateHeading(trimmed) { last = trimmed } } @@ -835,6 +959,18 @@ func readLastLogEntry(logPath string) (string, error) { return last, nil } +func isOKFDateHeading(line string) bool { + if !strings.HasPrefix(line, "## ") { + return false + } + value := strings.TrimSpace(strings.TrimPrefix(line, "## ")) + if len(value) != len(frontmatter.DateLayout) { + return false + } + _, err := time.Parse(frontmatter.DateLayout, value) + return err == nil +} + func countMarkdownFiles(root string) (int, error) { return countFiles(root, func(entry fs.DirEntry) bool { name := entry.Name() diff --git a/internal/topic/topic_test.go b/internal/topic/topic_test.go index 2730efa..10cb283 100644 --- a/internal/topic/topic_test.go +++ b/internal/topic/topic_test.go @@ -8,6 +8,7 @@ import ( "time" "github.com/compozy/kb/internal/frontmatter" + "github.com/compozy/kb/internal/models" ) func TestNewCreatesTopicSkeletonAndTemplates(t *testing.T) { @@ -35,6 +36,9 @@ func TestNewCreatesTopicSkeletonAndTemplates(t *testing.T) { if info.Domain != "rust" { t.Fatalf("domain = %q, want rust", info.Domain) } + if info.Mode != models.TopicModeWiki { + t.Fatalf("mode = %q, want wiki", info.Mode) + } if info.ArticleCount != 0 { t.Fatalf("article count = %d, want 0", info.ArticleCount) } @@ -156,6 +160,7 @@ func TestNewCreatesCategorizedTopic(t *testing.T) { "slug: asimov-academy", "title: Asimov Academy", "domain: youtube-channel", + "mode: wiki", "category: yt-channels", "path: yt-channels/asimov-academy", "qmd_collection: asimov-academy", @@ -207,6 +212,7 @@ func TestNewCreatesClaudeAndAgentsSymlink(t *testing.T) { "slug: distributed-systems", "title: Distributed Systems", "domain: distributed", + "mode: wiki", } { if !strings.Contains(metadataContent, fragment) { t.Fatalf("topic.yaml missing %q:\n%s", fragment, metadataContent) @@ -227,6 +233,127 @@ func TestNewCreatesClaudeAndAgentsSymlink(t *testing.T) { } } +func TestNewWithModeCreatesOKFTopicSkeleton(t *testing.T) { + t.Parallel() + + t.Run("Should create OKF topic with standard skeleton directories", func(t *testing.T) { + vaultPath := t.TempDir() + + info, err := newWithDateWithMode( + vaultPath, + "ops-catalog", + "Operations Catalog", + "operations", + models.TopicModeOKF, + time.Date(2026, 6, 27, 12, 0, 0, 0, time.UTC), + ) + if err != nil { + t.Fatalf("newWithDateWithMode returned error: %v", err) + } + if info.Mode != models.TopicModeOKF { + t.Fatalf("mode = %q, want okf", info.Mode) + } + if info.ArticleCount != 0 || info.SourceCount != 0 { + t.Fatalf("counts = articles %d sources %d, want 0/0", info.ArticleCount, info.SourceCount) + } + if info.LastLogEntry != "## 2026-06-27" { + t.Fatalf("last log entry = %q, want OKF date heading", info.LastLogEntry) + } + + topicPath := filepath.Join(vaultPath, "ops-catalog") + for _, relativePath := range []string{ + "CLAUDE.md", + "AGENTS.md", + "index.md", + "log.md", + "topic.yaml", + "raw/articles/.gitkeep", + "raw/bookmarks/.gitkeep", + "raw/codebase/files/.gitkeep", + "raw/codebase/symbols/.gitkeep", + "raw/github/.gitkeep", + "raw/youtube/.gitkeep", + "wiki/codebase/concepts/.gitkeep", + "wiki/codebase/index/.gitkeep", + "wiki/concepts/.gitkeep", + "outputs/queries/.gitkeep", + "outputs/briefings/.gitkeep", + "outputs/diagrams/.gitkeep", + "outputs/reports/.gitkeep", + "bases/.gitkeep", + } { + assertFileExists(t, filepath.Join(topicPath, filepath.FromSlash(relativePath))) + } + + metadataContent := readFile(t, filepath.Join(topicPath, "topic.yaml")) + for _, fragment := range []string{ + "slug: ops-catalog", + "title: Operations Catalog", + "domain: operations", + "mode: okf", + } { + if !strings.Contains(metadataContent, fragment) { + t.Fatalf("topic.yaml missing %q:\n%s", fragment, metadataContent) + } + } + + indexValues, indexBody := parseFrontmatterFile(t, filepath.Join(topicPath, "index.md")) + if got := indexValues["okf_version"]; got != "0.1" { + t.Fatalf("index okf_version = %#v, want 0.1", got) + } + if !strings.Contains(indexBody, "# OKF Bundle Index") { + t.Fatalf("index body missing heading:\n%s", indexBody) + } + logContent := readFile(t, filepath.Join(topicPath, "log.md")) + if !strings.Contains(logContent, "## 2026-06-27") || strings.Contains(logContent, "## [2026-06-27]") { + t.Fatalf("log.md does not use OKF date heading:\n%s", logContent) + } + }) +} + +func TestReadTopicMetadataDefaultsMissingModeToWiki(t *testing.T) { + t.Parallel() + + t.Run("Should default missing topic mode to wiki", func(t *testing.T) { + topicPath := t.TempDir() + writeFile(t, filepath.Join(topicPath, "topic.yaml"), "slug: old-topic\ntitle: Old Topic\ndomain: legacy\n") + writeFile(t, filepath.Join(topicPath, "CLAUDE.md"), "# Old Topic\n\n**Domain:** `legacy`\n") + + info, err := infoAtPath(topicPath, "old-topic") + if err != nil { + t.Fatalf("infoAtPath returned error: %v", err) + } + if info.Mode != models.TopicModeWiki { + t.Fatalf("mode = %q, want wiki", info.Mode) + } + }) +} + +func TestWriteMetadataFilePreservesExistingMode(t *testing.T) { + t.Parallel() + + t.Run("Should keep OKF mode when rewriting metadata", func(t *testing.T) { + topicPath := t.TempDir() + writeFile(t, filepath.Join(topicPath, "topic.yaml"), "slug: ops-catalog\ntitle: Old\ndomain: old\nmode: okf\n") + + if err := WriteMetadataFile(topicPath, "ops-catalog", "Operations Catalog", "operations"); err != nil { + t.Fatalf("WriteMetadataFile returned error: %v", err) + } + + metadataContent := readFile(t, filepath.Join(topicPath, "topic.yaml")) + for _, fragment := range []string{ + "slug: ops-catalog", + "title: Operations Catalog", + "domain: operations", + "mode: okf", + } { + if !strings.Contains(metadataContent, fragment) { + t.Fatalf("topic.yaml missing %q:\n%s", fragment, metadataContent) + } + } + }) +} + func TestNewAppendsScaffoldEntryToLog(t *testing.T) { t.Parallel() diff --git a/internal/vault/pathutils.go b/internal/vault/pathutils.go index d356e3c..002b4df 100644 --- a/internal/vault/pathutils.go +++ b/internal/vault/pathutils.go @@ -4,6 +4,7 @@ package vault import ( "fmt" "path" + "path/filepath" "regexp" "strings" @@ -251,6 +252,73 @@ func ToTopicWikiLink(topicSlug, documentPath, label string) string { return fmt.Sprintf("[[%s]]", target) } +// LinkFormatter renders a link from one topic-relative document to another. +type LinkFormatter interface { + Link(fromDir, targetPath, label string) string +} + +// WikiLinkFormatter renders Obsidian topic-scoped wikilinks. +type WikiLinkFormatter struct { + Slug string +} + +// Link renders a wikilink exactly like ToTopicWikiLink. +func (f WikiLinkFormatter) Link(_, targetPath, label string) string { + return ToTopicWikiLink(f.Slug, targetPath, label) +} + +// OKFLinkFormatter renders relative Markdown links for OKF bundles. +type OKFLinkFormatter struct{} + +// Link renders a GitHub-safe relative Markdown link. +func (OKFLinkFormatter) Link(fromDir, targetPath, label string) string { + cleanTarget := strings.TrimPrefix(ToPosixPath(strings.TrimSpace(targetPath)), "/") + displayLabel := strings.TrimSpace(label) + if displayLabel == "" { + displayLabel = HumanizeSlug(SlugifySegment(strings.TrimSuffix(path.Base(cleanTarget), ".md"))) + } + + fragment := "" + if hashIndex := strings.Index(cleanTarget, "#"); hashIndex >= 0 { + fragment = cleanTarget[hashIndex:] + cleanTarget = cleanTarget[:hashIndex] + } + + cleanFromDir := strings.TrimPrefix(ToPosixPath(strings.TrimSpace(fromDir)), "/") + if cleanFromDir == "" { + cleanFromDir = "." + } + + relativePath, err := filepath.Rel(filepath.FromSlash(cleanFromDir), filepath.FromSlash(cleanTarget)) + if err != nil { + relativePath = cleanTarget + } + relativePath = ToPosixPath(relativePath) + if relativePath == "." || relativePath == "" { + relativePath = path.Base(cleanTarget) + } + relativePath = strings.TrimPrefix(relativePath, "./") + relativePath = markdownLinkTarget(relativePath + fragment) + return fmt.Sprintf("[%s](%s)", displayLabel, relativePath) +} + +// LinkFormatterFor selects the formatter from the topic mode. +func LinkFormatterFor(topic models.TopicMetadata) LinkFormatter { + if topic.Mode == models.TopicModeOKF { + return OKFLinkFormatter{} + } + return WikiLinkFormatter{Slug: topic.Slug} +} + +func markdownLinkTarget(target string) string { + replacer := strings.NewReplacer( + " ", "%20", + "(", "%28", + ")", "%29", + ) + return replacer.Replace(target) +} + func stripWikiConceptFilePrefix(articleTitle string) string { normalizedTitle := strings.TrimSpace(articleTitle) return strings.TrimSpace(strings.TrimPrefix(normalizedTitle, legacyWikiConceptPrefix)) diff --git a/internal/vault/pathutils_test.go b/internal/vault/pathutils_test.go index 927c7d4..31e52a3 100644 --- a/internal/vault/pathutils_test.go +++ b/internal/vault/pathutils_test.go @@ -196,6 +196,48 @@ func TestTopicWikiLinkHelpers(t *testing.T) { } } +func TestLinkFormatters(t *testing.T) { + t.Parallel() + + wiki := vault.WikiLinkFormatter{Slug: "topic-slug"} + if got := wiki.Link("ignored", "wiki/codebase/concepts/Codebase Overview.md", "Overview"); got != vault.ToTopicWikiLink("topic-slug", "wiki/codebase/concepts/Codebase Overview.md", "Overview") { + t.Fatalf("wiki formatter = %q", got) + } + + okf := vault.OKFLinkFormatter{} + testCases := []struct { + name string + from string + target string + label string + want string + }{ + {name: "Should link within the same directory", from: "", target: "orders.md", label: "Orders", want: "[Orders](orders.md)"}, + {name: "Should link to a child directory", from: "", target: "tables/orders.md", label: "Orders", want: "[Orders](tables/orders.md)"}, + {name: "Should link to a parent sibling with fragment", from: "references", target: "tables/customer orders.md#schema", label: "Customers", want: "[Customers](../tables/customer%20orders.md#schema)"}, + {name: "Should link to a sibling directory", from: "tables", target: "references/joins.md", label: "Joins", want: "[Joins](../references/joins.md)"}, + } + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + if got := okf.Link(testCase.from, testCase.target, testCase.label); got != testCase.want { + t.Fatalf("OKF link = %q, want %q", got, testCase.want) + } + }) + } + + t.Run("Should select wiki formatter for wiki topics", func(t *testing.T) { + if got := vault.LinkFormatterFor(models.TopicMetadata{Slug: "demo", Mode: models.TopicModeWiki}).Link("", "x.md", "X"); got != "[[demo/x|X]]" { + t.Fatalf("wiki LinkFormatterFor = %q", got) + } + }) + t.Run("Should select OKF formatter for OKF topics", func(t *testing.T) { + if got := vault.LinkFormatterFor(models.TopicMetadata{Mode: models.TopicModeOKF}).Link("", "x.md", "X"); got != "[X](x.md)" { + t.Fatalf("OKF LinkFormatterFor = %q", got) + } + }) +} + func TestPathHelpersHandleEmptyInputs(t *testing.T) { t.Parallel() diff --git a/internal/vault/render.go b/internal/vault/render.go index 374133e..35bbd5d 100644 --- a/internal/vault/render.go +++ b/internal/vault/render.go @@ -183,8 +183,20 @@ func renderMarkdownDocument(document models.RenderedDocument) string { }, "\n") } -func toSourceWikiLink(topic models.TopicMetadata, relativePath, label string) string { - return ToTopicWikiLink(topic.Slug, relativePath, label) +func documentDir(relativePath string) string { + dir := path.Dir(ToPosixPath(relativePath)) + if dir == "." { + return "" + } + return dir +} + +func toSourceWikiLink(topic models.TopicMetadata, fromDir, relativePath, label string) string { + return linkFor(topic, fromDir, relativePath, label) +} + +func linkFor(topic models.TopicMetadata, fromDir, relativePath, label string) string { + return LinkFormatterFor(topic).Link(fromDir, relativePath, label) } func createDocumentLookup(graph models.GraphSnapshot) map[string]string { @@ -211,13 +223,14 @@ func createExternalNodeLookup(externalNodes []models.ExternalNode) map[string]mo func linkForNode( topic models.TopicMetadata, + fromDir string, nodeID string, documentLookup map[string]string, externalNodes map[string]models.ExternalNode, fallbackLabel string, ) string { if documentPath, exists := documentLookup[nodeID]; exists { - return toSourceWikiLink(topic, documentPath, fallbackLabel) + return linkFor(topic, fromDir, documentPath, fallbackLabel) } if externalNode, exists := externalNodes[nodeID]; exists { @@ -233,6 +246,7 @@ func linkForNode( func renderRelationList( topic models.TopicMetadata, + fromDir string, relations []models.RelationEdge, documentLookup map[string]string, externalNodes map[string]models.ExternalNode, @@ -265,7 +279,7 @@ func renderRelationList( targetLabel = after } - target := linkForNode(topic, relation.ToID, documentLookup, externalNodes, targetLabel) + target := linkForNode(topic, fromDir, relation.ToID, documentLookup, externalNodes, targetLabel) lines = append(lines, fmt.Sprintf("- `%s` (%s) -> %s", relation.Type, relation.Confidence, target)) } @@ -274,6 +288,7 @@ func renderRelationList( func renderBacklinkList( topic models.TopicMetadata, + fromDir string, relations []models.RelationEdge, documentLookup map[string]string, externalNodes map[string]models.ExternalNode, @@ -303,7 +318,7 @@ func renderBacklinkList( sourceLabel = after } - source := linkForNode(topic, relation.FromID, documentLookup, externalNodes, sourceLabel) + source := linkForNode(topic, fromDir, relation.FromID, documentLookup, externalNodes, sourceLabel) lines = append(lines, fmt.Sprintf("- %s via `%s` (%s)", source, relation.Type, relation.Confidence)) } @@ -332,12 +347,15 @@ func renderRawFileDocument( documentLookup map[string]string, externalNodes map[string]models.ExternalNode, ) models.RenderedDocument { + relativePath := GetRawFileDocumentPath(file.FilePath) + fromDir := documentDir(relativePath) symbolLines := []string{"None"} if len(fileSymbols) > 0 { symbolLines = make([]string, 0, len(fileSymbols)) for _, symbol := range fileSymbols { symbolLink := toSourceWikiLink( topic, + fromDir, GetRawSymbolDocumentPath(symbol), fmt.Sprintf("%s (%s)", symbol.Name, symbol.SymbolKind), ) @@ -376,17 +394,17 @@ func renderRawFileDocument( "", "## Outgoing Relations", ) - sections = append(sections, renderRelationList(topic, outgoingRelations, documentLookup, externalNodes)...) + sections = append(sections, renderRelationList(topic, fromDir, outgoingRelations, documentLookup, externalNodes)...) sections = append(sections, "", "## Backlinks", ) - sections = append(sections, renderBacklinkList(topic, incomingRelations, documentLookup, externalNodes)...) + sections = append(sections, renderBacklinkList(topic, fromDir, incomingRelations, documentLookup, externalNodes)...) return models.RenderedDocument{ Kind: models.DocRaw, ManagedArea: models.AreaRawCodebase, - RelativePath: GetRawFileDocumentPath(file.FilePath), + RelativePath: relativePath, Frontmatter: map[string]any{ "afferent_coupling": fileMetrics.AfferentCoupling, "domain": topic.Domain, @@ -470,7 +488,9 @@ func renderRawSymbolDocument( documentLookup map[string]string, externalNodes map[string]models.ExternalNode, ) models.RenderedDocument { - fileLink := toSourceWikiLink(topic, GetRawFileDocumentPath(symbol.FilePath), symbol.FilePath) + relativePath := GetRawSymbolDocumentPath(symbol) + fromDir := documentDir(relativePath) + fileLink := toSourceWikiLink(topic, fromDir, GetRawFileDocumentPath(symbol.FilePath), symbol.FilePath) signatureBlock := "None" if strings.TrimSpace(symbol.Signature) != "" { @@ -520,17 +540,17 @@ func renderRawSymbolDocument( "", "## Outgoing Relations", ) - sections = append(sections, renderRelationList(topic, outgoingRelations, documentLookup, externalNodes)...) + sections = append(sections, renderRelationList(topic, fromDir, outgoingRelations, documentLookup, externalNodes)...) sections = append(sections, "", "## Backlinks", ) - sections = append(sections, renderBacklinkList(topic, incomingRelations, documentLookup, externalNodes)...) + sections = append(sections, renderBacklinkList(topic, fromDir, incomingRelations, documentLookup, externalNodes)...) return models.RenderedDocument{ Kind: models.DocRaw, ManagedArea: models.AreaRawCodebase, - RelativePath: GetRawSymbolDocumentPath(symbol), + RelativePath: relativePath, Frontmatter: createSymbolFrontmatter(topic, symbol, symbolMetrics, incomingRelations, outgoingRelations), Body: strings.Join(sections, "\n"), } @@ -543,6 +563,8 @@ func renderRawDirectoryIndex( symbols []models.SymbolNode, directoryMetrics models.DirectoryMetrics, ) models.RenderedDocument { + relativePath := GetRawDirectoryIndexPath(directoryPath) + fromDir := documentDir(relativePath) orderedFiles := append([]models.GraphFile(nil), files...) sort.Slice(orderedFiles, func(i, j int) bool { return orderedFiles[i].FilePath < orderedFiles[j].FilePath @@ -552,7 +574,7 @@ func renderRawDirectoryIndex( if len(orderedFiles) > 0 { fileLinks = make([]string, 0, len(orderedFiles)) for _, file := range orderedFiles { - fileLinks = append(fileLinks, "- "+toSourceWikiLink(topic, GetRawFileDocumentPath(file.FilePath), file.FilePath)) + fileLinks = append(fileLinks, "- "+toSourceWikiLink(topic, fromDir, GetRawFileDocumentPath(file.FilePath), file.FilePath)) } } @@ -565,6 +587,7 @@ func renderRawDirectoryIndex( for _, symbol := range orderedSymbols { symbolLinks = append(symbolLinks, "- "+toSourceWikiLink( topic, + fromDir, GetRawSymbolDocumentPath(symbol), fmt.Sprintf("%s (%s)", symbol.Name, symbol.SymbolKind), )) @@ -593,7 +616,7 @@ func renderRawDirectoryIndex( return models.RenderedDocument{ Kind: models.DocRaw, ManagedArea: models.AreaRawCodebase, - RelativePath: GetRawDirectoryIndexPath(directoryPath), + RelativePath: relativePath, Frontmatter: map[string]any{ "afferent_coupling": directoryMetrics.AfferentCoupling, "domain": topic.Domain, @@ -620,6 +643,8 @@ func renderRawLanguageIndex( symbols []models.SymbolNode, javaDiagnostics javaDiagnosticSummary, ) models.RenderedDocument { + relativePath := GetRawLanguageIndexPath(language) + fromDir := documentDir(relativePath) orderedFiles := append([]models.GraphFile(nil), files...) sort.Slice(orderedFiles, func(i, j int) bool { return orderedFiles[i].FilePath < orderedFiles[j].FilePath @@ -629,7 +654,7 @@ func renderRawLanguageIndex( if len(orderedFiles) > 0 { fileLinks = make([]string, 0, len(orderedFiles)) for _, file := range orderedFiles { - fileLinks = append(fileLinks, "- "+toSourceWikiLink(topic, GetRawFileDocumentPath(file.FilePath), file.FilePath)) + fileLinks = append(fileLinks, "- "+toSourceWikiLink(topic, fromDir, GetRawFileDocumentPath(file.FilePath), file.FilePath)) } } @@ -642,6 +667,7 @@ func renderRawLanguageIndex( for _, symbol := range orderedSymbols { symbolLinks = append(symbolLinks, "- "+toSourceWikiLink( topic, + fromDir, GetRawSymbolDocumentPath(symbol), fmt.Sprintf("%s (%s)", symbol.Name, symbol.SymbolKind), )) @@ -693,7 +719,7 @@ func renderRawLanguageIndex( return models.RenderedDocument{ Kind: models.DocRaw, ManagedArea: models.AreaRawCodebase, - RelativePath: GetRawLanguageIndexPath(language), + RelativePath: relativePath, Frontmatter: frontmatter, Body: strings.Join(sections, "\n"), } diff --git a/internal/vault/render_test.go b/internal/vault/render_test.go index fb6ba13..849025b 100644 --- a/internal/vault/render_test.go +++ b/internal/vault/render_test.go @@ -263,6 +263,32 @@ func TestRenderDocumentsUseTopicWikiLinkSyntax(t *testing.T) { } } +func TestRenderDocumentsUseOKFMarkdownLinkSyntax(t *testing.T) { + t.Parallel() + + graph := testGraphFixture() + metricResult := metrics.ComputeMetrics(graph) + topic := testTopicFixture() + topic.Mode = models.TopicModeOKF + documents := vault.RenderDocuments(graph, metricResult, topic) + + rawDocument := findDocument(t, documents, "raw/codebase/files/commands/run.ts.md") + if strings.Contains(rawDocument.Body, "[[") { + t.Fatalf("expected OKF raw document to avoid wikilinks, got:\n%s", rawDocument.Body) + } + if !strings.Contains(rawDocument.Body, "[main (function)](../../symbols/main--commands-run-ts-l1.md)") { + t.Fatalf("expected OKF raw document to contain relative markdown link, got:\n%s", rawDocument.Body) + } + + dashboard := findDocument(t, documents, vault.GetWikiIndexPath(vault.CodebaseDashboardTitle)) + if strings.Contains(dashboard.Body, "[[") { + t.Fatalf("expected OKF dashboard to avoid wikilinks, got:\n%s", dashboard.Body) + } + if !strings.Contains(dashboard.Body, "[Codebase Overview](../concepts/Codebase%20Overview.md)") { + t.Fatalf("expected OKF dashboard to link with escaped relative markdown path, got:\n%s", dashboard.Body) + } +} + func renderFixtureDocuments(t *testing.T) []models.RenderedDocument { t.Helper() diff --git a/internal/vault/render_wiki.go b/internal/vault/render_wiki.go index 2c3978a..96d4b6d 100644 --- a/internal/vault/render_wiki.go +++ b/internal/vault/render_wiki.go @@ -44,9 +44,10 @@ func makeWikiFrontmatter( topic models.TopicMetadata, article starterWikiArticle, ) map[string]any { + fromDir := documentDir(GetWikiConceptPath(article.Title)) sources := make([]string, 0, len(article.Sources)) for _, source := range article.Sources { - sources = append(sources, toSourceWikiLink(topic, source, "")) + sources = append(sources, toSourceWikiLink(topic, fromDir, source, "")) } return map[string]any{ @@ -77,6 +78,7 @@ func renderDashboard( graph models.GraphSnapshot, articles []starterWikiArticle, ) models.RenderedDocument { + fromDir := documentDir(GetWikiIndexPath(CodebaseDashboardTitle)) directoryPaths := make(map[string]struct{}, len(graph.Files)) languages := make(map[string]struct{}, len(graph.Files)) @@ -105,7 +107,7 @@ func renderDashboard( for _, article := range articles { lines = append(lines, fmt.Sprintf( "- %s - %s", - ToTopicWikiLink(topic.Slug, GetWikiConceptPath(article.Title), article.Title), + linkFor(topic, fromDir, GetWikiConceptPath(article.Title), article.Title), article.Summary, )) } @@ -113,8 +115,8 @@ func renderDashboard( lines = append(lines, "", "## Navigation", - "- "+ToTopicWikiLink(topic.Slug, GetWikiIndexPath(CodebaseConceptIndexTitle), CodebaseConceptIndexTitle), - "- "+ToTopicWikiLink(topic.Slug, GetWikiIndexPath(CodebaseSourceIndexTitle), CodebaseSourceIndexTitle), + "- "+linkFor(topic, fromDir, GetWikiIndexPath(CodebaseConceptIndexTitle), CodebaseConceptIndexTitle), + "- "+linkFor(topic, fromDir, GetWikiIndexPath(CodebaseSourceIndexTitle), CodebaseSourceIndexTitle), ) return models.RenderedDocument{ @@ -135,6 +137,7 @@ func renderConceptIndex( topic models.TopicMetadata, articles []starterWikiArticle, ) models.RenderedDocument { + fromDir := documentDir(GetWikiIndexPath(CodebaseConceptIndexTitle)) orderedArticles := append([]starterWikiArticle(nil), articles...) sort.Slice(orderedArticles, func(i, j int) bool { return orderedArticles[i].Title < orderedArticles[j].Title @@ -144,7 +147,7 @@ func renderConceptIndex( for _, article := range orderedArticles { rows = append(rows, fmt.Sprintf( "| %s | %s |", - ToTopicWikiLink(topic.Slug, GetWikiConceptPath(article.Title), article.Title), + linkFor(topic, fromDir, GetWikiConceptPath(article.Title), article.Title), article.Summary, )) } @@ -176,6 +179,7 @@ func renderConceptIndex( } func renderSourceIndex(topic models.TopicMetadata, articles []starterWikiArticle) models.RenderedDocument { + fromDir := documentDir(GetWikiIndexPath(CodebaseSourceIndexTitle)) citedBySource := make(map[string][]string) for _, article := range articles { @@ -191,12 +195,12 @@ func renderSourceIndex(topic models.TopicMetadata, articles []starterWikiArticle links := make([]string, 0, len(titles)) for _, title := range titles { - links = append(links, ToTopicWikiLink(topic.Slug, GetWikiConceptPath(title), title)) + links = append(links, linkFor(topic, fromDir, GetWikiConceptPath(title), title)) } rows = append(rows, fmt.Sprintf( "| %s | %s |", - toSourceWikiLink(topic, source, ""), + toSourceWikiLink(topic, fromDir, source, ""), strings.Join(links, ", "), )) } @@ -235,6 +239,7 @@ func createCodebaseOverviewArticle( filesByDirectory map[string][]models.GraphFile, hotspotFiles []models.GraphFile, ) starterWikiArticle { + fromDir := documentDir(GetWikiConceptPath("Codebase Overview")) languageRows := make([]string, 0, len(filesByLanguage)) for _, language := range sortedMapKeys(filesByLanguage) { files := filesByLanguage[language] @@ -247,7 +252,7 @@ func createCodebaseOverviewArticle( languageRows = append(languageRows, fmt.Sprintf( "| %s | %d | %d |", - toSourceWikiLink(topic, GetRawLanguageIndexPath(language), language), + toSourceWikiLink(topic, fromDir, GetRawLanguageIndexPath(language), language), len(files), symbolCount, )) @@ -267,7 +272,7 @@ func createCodebaseOverviewArticle( directoryMetric := metrics.Directories[directoryPath] directoryRows = append(directoryRows, fmt.Sprintf( "- %s · %d files · instability=%s", - toSourceWikiLink(topic, GetRawDirectoryIndexPath(directoryPath), directoryPath), + toSourceWikiLink(topic, fromDir, GetRawDirectoryIndexPath(directoryPath), directoryPath), len(files), strconv.FormatFloat(directoryMetric.Instability, 'f', -1, 64), )) @@ -277,7 +282,7 @@ func createCodebaseOverviewArticle( if len(hotspotFiles) > 0 { hotspotLines = make([]string, 0, len(hotspotFiles)) for _, file := range hotspotFiles { - hotspotLines = append(hotspotLines, "- "+toSourceWikiLink(topic, GetRawFileDocumentPath(file.FilePath), file.FilePath)) + hotspotLines = append(hotspotLines, "- "+toSourceWikiLink(topic, fromDir, GetRawFileDocumentPath(file.FilePath), file.FilePath)) } } @@ -295,7 +300,7 @@ func createCodebaseOverviewArticle( sourceLines := make([]string, 0, len(sources)) for _, source := range sources { - sourceLines = append(sourceLines, "- "+toSourceWikiLink(topic, source, "")) + sourceLines = append(sourceLines, "- "+toSourceWikiLink(topic, fromDir, source, "")) } bodyLines := []string{ @@ -311,9 +316,9 @@ func createCodebaseOverviewArticle( "", fmt.Sprintf( "Start with %s for coupling, %s for function-level complexity, and %s for likely cleanup candidates.", - ToTopicWikiLink(topic.Slug, GetWikiConceptPath("Module Health"), "Module Health"), - ToTopicWikiLink(topic.Slug, GetWikiConceptPath("Complexity Hotspots"), "Complexity Hotspots"), - ToTopicWikiLink(topic.Slug, GetWikiConceptPath("Dead Code Report"), "Dead Code Report"), + linkFor(topic, fromDir, GetWikiConceptPath("Module Health"), "Module Health"), + linkFor(topic, fromDir, GetWikiConceptPath("Complexity Hotspots"), "Complexity Hotspots"), + linkFor(topic, fromDir, GetWikiConceptPath("Dead Code Report"), "Dead Code Report"), ), "", "## Language Coverage", @@ -352,6 +357,7 @@ func createDirectoryMapArticle( symbolsByDirectory map[string][]models.SymbolNode, metrics models.MetricsResult, ) starterWikiArticle { + fromDir := documentDir(GetWikiConceptPath("Directory Map")) rows := make([]string, 0, len(filesByDirectory)) sources := make([]string, 0, len(filesByDirectory)) @@ -361,7 +367,7 @@ func createDirectoryMapArticle( directoryMetric := metrics.Directories[directoryPath] rows = append(rows, fmt.Sprintf( "| %s | %d | %d | %s |", - toSourceWikiLink(topic, GetRawDirectoryIndexPath(directoryPath), directoryPath), + toSourceWikiLink(topic, fromDir, GetRawDirectoryIndexPath(directoryPath), directoryPath), len(files), len(symbols), strconv.FormatFloat(directoryMetric.Instability, 'f', -1, 64), @@ -392,12 +398,12 @@ func createDirectoryMapArticle( "", fmt.Sprintf( "Cross-check unstable directories against %s and hotspots against %s.", - ToTopicWikiLink(topic.Slug, GetWikiConceptPath("Module Health"), "Module Health"), - ToTopicWikiLink(topic.Slug, GetWikiConceptPath("Dependency Hotspots"), "Dependency Hotspots"), + linkFor(topic, fromDir, GetWikiConceptPath("Module Health"), "Module Health"), + linkFor(topic, fromDir, GetWikiConceptPath("Dependency Hotspots"), "Dependency Hotspots"), ), "", "## Sources and Further Reading", - renderSourceBulletList(topic, sources, "- No directories were extracted."), + renderSourceBulletList(topic, fromDir, sources, "- No directories were extracted."), }, "\n"), } } @@ -408,6 +414,7 @@ func createSymbolTaxonomyArticle( symbolsByKind map[string][]models.SymbolNode, filesByLanguage map[string][]models.GraphFile, ) starterWikiArticle { + fromDir := documentDir(GetWikiConceptPath("Symbol Taxonomy")) kindRows := make([]string, 0, len(symbolsByKind)) for _, kind := range sortedMapKeys(symbolsByKind) { symbols := append([]models.SymbolNode(nil), symbolsByKind[kind]...) @@ -415,7 +422,7 @@ func createSymbolTaxonomyArticle( exampleLink := "None" if len(symbols) > 0 { - exampleLink = toSourceWikiLink(topic, GetRawSymbolDocumentPath(symbols[0]), symbols[0].Name) + exampleLink = toSourceWikiLink(topic, fromDir, GetRawSymbolDocumentPath(symbols[0]), symbols[0].Name) } kindRows = append(kindRows, fmt.Sprintf("| `%s` | %d | %s |", kind, len(symbols), exampleLink)) @@ -458,12 +465,12 @@ func createSymbolTaxonomyArticle( "", fmt.Sprintf( "Use %s to find bottlenecks and %s to locate function-level smells.", - ToTopicWikiLink(topic.Slug, GetWikiConceptPath("High-Impact Symbols"), "High-Impact Symbols"), - ToTopicWikiLink(topic.Slug, GetWikiConceptPath("Code Smells"), "Code Smells"), + linkFor(topic, fromDir, GetWikiConceptPath("High-Impact Symbols"), "High-Impact Symbols"), + linkFor(topic, fromDir, GetWikiConceptPath("Code Smells"), "Code Smells"), ), "", "## Sources and Further Reading", - renderSourceBulletList(topic, sources, "- No symbol taxonomy sources were extracted."), + renderSourceBulletList(topic, fromDir, sources, "- No symbol taxonomy sources were extracted."), }, "\n"), } } @@ -472,6 +479,7 @@ func createDependencyHotspotsArticle( topic models.TopicMetadata, graph models.GraphSnapshot, ) starterWikiArticle { + fromDir := documentDir(GetWikiConceptPath("Dependency Hotspots")) relationCounts := make(map[string]int) for _, relation := range graph.Relations { relationCounts[relation.FromID]++ @@ -510,7 +518,7 @@ func createDependencyHotspotsArticle( for _, entry := range hotspots { rows = append(rows, fmt.Sprintf( "| %s | %d |", - toSourceWikiLink(topic, GetRawFileDocumentPath(entry.File.FilePath), entry.File.FilePath), + toSourceWikiLink(topic, fromDir, GetRawFileDocumentPath(entry.File.FilePath), entry.File.FilePath), entry.RelationCount, )) sources = append(sources, GetRawFileDocumentPath(entry.File.FilePath)) @@ -535,11 +543,11 @@ func createDependencyHotspotsArticle( "", fmt.Sprintf( "A high relation count usually indicates a coordination layer, a shared utility, or an entry point. Cross-check these files against %s to distinguish stable modules from unstable ones.", - ToTopicWikiLink(topic.Slug, GetWikiConceptPath("Module Health"), "Module Health"), + linkFor(topic, fromDir, GetWikiConceptPath("Module Health"), "Module Health"), ), "", "## Sources and Further Reading", - renderSourceBulletList(topic, sources, "- No relation hotspots were extracted."), + renderSourceBulletList(topic, fromDir, sources, "- No relation hotspots were extracted."), }, "\n"), } } @@ -548,6 +556,7 @@ func createComplexityHotspotsArticle( topic models.TopicMetadata, graph models.GraphSnapshot, ) starterWikiArticle { + fromDir := documentDir(GetWikiConceptPath("Complexity Hotspots")) functions := make([]models.SymbolNode, 0, len(graph.Symbols)) for _, symbol := range graph.Symbols { if !isFunctionLike(symbol.SymbolKind) || symbol.CyclomaticComplexity == 0 { @@ -586,10 +595,10 @@ func createComplexityHotspotsArticle( loc := maxInt(symbol.EndLine-symbol.StartLine+1, 0) rows = append(rows, fmt.Sprintf( "| %s | %d | %d | %s |", - toSourceWikiLink(topic, GetRawSymbolDocumentPath(symbol), symbol.Name), + toSourceWikiLink(topic, fromDir, GetRawSymbolDocumentPath(symbol), symbol.Name), maxInt(symbol.CyclomaticComplexity, 1), loc, - toSourceWikiLink(topic, GetRawFileDocumentPath(symbol.FilePath), symbol.FilePath), + toSourceWikiLink(topic, fromDir, GetRawFileDocumentPath(symbol.FilePath), symbol.FilePath), )) sources = append(sources, GetRawSymbolDocumentPath(symbol), GetRawFileDocumentPath(symbol.FilePath)) } @@ -613,7 +622,7 @@ func createComplexityHotspotsArticle( "", fmt.Sprintf( "Compare these hotspots against %s to distinguish locally complex functions from high-blast-radius functions.", - ToTopicWikiLink(topic.Slug, GetWikiConceptPath("High-Impact Symbols"), "High-Impact Symbols"), + linkFor(topic, fromDir, GetWikiConceptPath("High-Impact Symbols"), "High-Impact Symbols"), ), }, "\n"), } @@ -624,6 +633,7 @@ func createDeadCodeReportArticle( graph models.GraphSnapshot, metrics models.MetricsResult, ) starterWikiArticle { + fromDir := documentDir(GetWikiConceptPath("Dead Code Report")) deadExportsByDirectory := make(map[string][]string) orphanFilesByDirectory := make(map[string][]string) @@ -635,7 +645,7 @@ func createDeadCodeReportArticle( directoryPath := path.Dir(symbol.FilePath) deadExportsByDirectory[directoryPath] = append(deadExportsByDirectory[directoryPath], - "- "+toSourceWikiLink(topic, GetRawSymbolDocumentPath(symbol), symbol.Name), + "- "+toSourceWikiLink(topic, fromDir, GetRawSymbolDocumentPath(symbol), symbol.Name), ) } @@ -647,7 +657,7 @@ func createDeadCodeReportArticle( directoryPath := path.Dir(file.FilePath) orphanFilesByDirectory[directoryPath] = append(orphanFilesByDirectory[directoryPath], - "- "+toSourceWikiLink(topic, GetRawFileDocumentPath(file.FilePath), file.FilePath), + "- "+toSourceWikiLink(topic, fromDir, GetRawFileDocumentPath(file.FilePath), file.FilePath), ) } @@ -694,6 +704,7 @@ func createModuleHealthArticle( graph models.GraphSnapshot, metrics models.MetricsResult, ) starterWikiArticle { + fromDir := documentDir(GetWikiConceptPath("Module Health")) type fileEntry struct { File models.GraphFile Metric models.FileMetrics @@ -724,7 +735,7 @@ func createModuleHealthArticle( for _, entry := range fileEntries { fileRows = append(fileRows, fmt.Sprintf( "| %s | %d | %d | %s |", - toSourceWikiLink(topic, GetRawFileDocumentPath(entry.File.FilePath), entry.File.FilePath), + toSourceWikiLink(topic, fromDir, GetRawFileDocumentPath(entry.File.FilePath), entry.File.FilePath), entry.Metric.AfferentCoupling, entry.Metric.EfferentCoupling, strconv.FormatFloat(entry.Metric.Instability, 'f', -1, 64), @@ -739,7 +750,7 @@ func createModuleHealthArticle( directoryMetric := metrics.Directories[directoryPath] directoryRows = append(directoryRows, fmt.Sprintf( "| %s | %d | %d | %s |", - toSourceWikiLink(topic, GetRawDirectoryIndexPath(directoryPath), directoryPath), + toSourceWikiLink(topic, fromDir, GetRawDirectoryIndexPath(directoryPath), directoryPath), directoryMetric.AfferentCoupling, directoryMetric.EfferentCoupling, strconv.FormatFloat(directoryMetric.Instability, 'f', -1, 64), @@ -786,6 +797,7 @@ func createCodeSmellsArticle( graph models.GraphSnapshot, metrics models.MetricsResult, ) starterWikiArticle { + fromDir := documentDir(GetWikiConceptPath("Code Smells")) smellEntries := make(map[string][]smellEntry) for _, symbol := range graph.Symbols { @@ -832,7 +844,7 @@ func createCodeSmellsArticle( for _, entry := range entries { sections = append(sections, fmt.Sprintf( "- %s · %s", - toSourceWikiLink(topic, entry.RelativePath, entry.Label), + toSourceWikiLink(topic, fromDir, entry.RelativePath, entry.Label), entry.FilePath, )) } @@ -867,6 +879,7 @@ func createCircularDependenciesArticle( graph models.GraphSnapshot, metrics models.MetricsResult, ) starterWikiArticle { + fromDir := documentDir(GetWikiConceptPath("Circular Dependencies")) sources := make([]string, 0) cycles := []string{"No circular dependencies detected."} @@ -882,7 +895,7 @@ func createCircularDependenciesArticle( links := make([]string, 0, len(group)) for _, filePath := range group { sources = append(sources, GetRawFileDocumentPath(filePath)) - links = append(links, toSourceWikiLink(topic, GetRawFileDocumentPath(filePath), filePath)) + links = append(links, toSourceWikiLink(topic, fromDir, GetRawFileDocumentPath(filePath), filePath)) } cycles = append(cycles, fmt.Sprintf("%d. %s", index+1, strings.Join(links, " · "))) } @@ -901,6 +914,7 @@ func createHighImpactSymbolsArticle( graph models.GraphSnapshot, metrics models.MetricsResult, ) starterWikiArticle { + fromDir := documentDir(GetWikiConceptPath("High-Impact Symbols")) type symbolEntry struct { Symbol models.SymbolNode Metric models.SymbolMetrics @@ -942,10 +956,10 @@ func createHighImpactSymbolsArticle( for _, entry := range symbolEntries { rows = append(rows, fmt.Sprintf( "| %s | %d | %d | %s |", - toSourceWikiLink(topic, GetRawSymbolDocumentPath(entry.Symbol), entry.Symbol.Name), + toSourceWikiLink(topic, fromDir, GetRawSymbolDocumentPath(entry.Symbol), entry.Symbol.Name), entry.Metric.BlastRadius, entry.Metric.DirectDependents, - toSourceWikiLink(topic, GetRawFileDocumentPath(entry.Symbol.FilePath), entry.Symbol.FilePath), + toSourceWikiLink(topic, fromDir, GetRawFileDocumentPath(entry.Symbol.FilePath), entry.Symbol.FilePath), )) sources = append(sources, GetRawSymbolDocumentPath(entry.Symbol), GetRawFileDocumentPath(entry.Symbol.FilePath)) } @@ -982,14 +996,14 @@ func renderGroupedLinks(groups map[string][]string, emptyMessage string) []strin return lines } -func renderSourceBulletList(topic models.TopicMetadata, sources []string, emptyMessage string) string { +func renderSourceBulletList(topic models.TopicMetadata, fromDir string, sources []string, emptyMessage string) string { if len(sources) == 0 { return emptyMessage } lines := make([]string, 0, len(sources)) for _, source := range uniqueStrings(sources) { - lines = append(lines, "- "+toSourceWikiLink(topic, source, "")) + lines = append(lines, "- "+toSourceWikiLink(topic, fromDir, source, "")) } return strings.Join(lines, "\n") diff --git a/internal/vault/writer.go b/internal/vault/writer.go index 571c946..7032074 100644 --- a/internal/vault/writer.go +++ b/internal/vault/writer.go @@ -446,9 +446,9 @@ func buildTopicClaudeManagedBlock( "", "### Generated codebase navigation", "", - "- " + ToTopicWikiLink(topic.Slug, GetWikiIndexPath(CodebaseDashboardTitle), CodebaseDashboardTitle), - "- " + ToTopicWikiLink(topic.Slug, GetWikiIndexPath(CodebaseConceptIndexTitle), CodebaseConceptIndexTitle), - "- " + ToTopicWikiLink(topic.Slug, GetWikiIndexPath(CodebaseSourceIndexTitle), CodebaseSourceIndexTitle), + "- " + linkFor(topic, "", GetWikiIndexPath(CodebaseDashboardTitle), CodebaseDashboardTitle), + "- " + linkFor(topic, "", GetWikiIndexPath(CodebaseConceptIndexTitle), CodebaseConceptIndexTitle), + "- " + linkFor(topic, "", GetWikiIndexPath(CodebaseSourceIndexTitle), CodebaseSourceIndexTitle), "", "### Generated codebase articles", "", @@ -457,7 +457,7 @@ func buildTopicClaudeManagedBlock( lines = append(lines, "_None generated._") } else { for _, articleTitle := range conceptDocuments { - lines = append(lines, "- "+ToTopicWikiLink(topic.Slug, GetWikiConceptPath(articleTitle), articleTitle)) + lines = append(lines, "- "+linkFor(topic, "", GetWikiConceptPath(articleTitle), articleTitle)) } } lines = append(lines, "", codebaseManagedBlockEnd) @@ -466,15 +466,16 @@ func buildTopicClaudeManagedBlock( } func buildTopicIndexBridgeManagedBlock(topic models.TopicMetadata) string { + fromDir := documentDir(GetTopicIndexPath(TopicDashboardTitle)) lines := []string{ codebaseIndexBlockStart, "## Generated codebase analysis (managed)", "", "Latest `kb ingest codebase` output is published under `wiki/codebase/` so the top-level topic indexes can stay manually curated.", "", - "- " + ToTopicWikiLink(topic.Slug, GetWikiIndexPath(CodebaseDashboardTitle), CodebaseDashboardTitle), - "- " + ToTopicWikiLink(topic.Slug, GetWikiIndexPath(CodebaseConceptIndexTitle), CodebaseConceptIndexTitle), - "- " + ToTopicWikiLink(topic.Slug, GetWikiIndexPath(CodebaseSourceIndexTitle), CodebaseSourceIndexTitle), + "- " + linkFor(topic, fromDir, GetWikiIndexPath(CodebaseDashboardTitle), CodebaseDashboardTitle), + "- " + linkFor(topic, fromDir, GetWikiIndexPath(CodebaseConceptIndexTitle), CodebaseConceptIndexTitle), + "- " + linkFor(topic, fromDir, GetWikiIndexPath(CodebaseSourceIndexTitle), CodebaseSourceIndexTitle), "", codebaseIndexBlockEnd, } diff --git a/internal/vault/writer_test.go b/internal/vault/writer_test.go index d499a96..845e04b 100644 --- a/internal/vault/writer_test.go +++ b/internal/vault/writer_test.go @@ -210,6 +210,43 @@ func TestWriteVaultReportsProgressForPersistedFiles(t *testing.T) { } } +func TestWriteVaultUsesRelativeOKFTopicIndexBridgeLinks(t *testing.T) { + t.Run("Should use relative OKF topic index bridge links", func(t *testing.T) { + t.Parallel() + + topic, graph, _, baseFiles := testWriteVaultInputs(t) + topic.Mode = models.TopicModeOKF + documents := vault.RenderDocuments(graph, metrics.ComputeMetrics(graph), topic) + if err := os.MkdirAll(topic.TopicPath, 0o755); err != nil { + t.Fatalf("create topic path: %v", err) + } + if err := os.WriteFile( + filepath.Join(topic.TopicPath, "topic.yaml"), + []byte("slug: demo-repo\ntitle: Demo Repo\ndomain: demo-repo\nmode: okf\n"), + 0o644, + ); err != nil { + t.Fatalf("write topic metadata: %v", err) + } + + if _, err := vault.WriteVault(context.Background(), vault.WriteVaultOptions{ + Topic: topic, + Graph: graph, + Documents: documents, + BaseFiles: baseFiles, + }); err != nil { + t.Fatalf("WriteVault returned error: %v", err) + } + + dashboard := readFile(t, filepath.Join(topic.TopicPath, filepath.FromSlash(vault.GetTopicIndexPath(vault.TopicDashboardTitle)))) + if !strings.Contains(dashboard, "[Codebase Dashboard](../codebase/index/Codebase%20Dashboard.md)") { + t.Fatalf("topic dashboard missing relative OKF bridge link:\n%s", dashboard) + } + if strings.Contains(dashboard, "(wiki/codebase/index/Codebase%20Dashboard.md)") { + t.Fatalf("topic dashboard contains root-scoped OKF bridge link:\n%s", dashboard) + } + }) +} + func TestWriteVaultResetsManagedCodebaseSubtreeOnly(t *testing.T) { t.Parallel() diff --git a/skills/kb/SKILL.md b/skills/kb/SKILL.md index 5e6a577..37679f5 100644 --- a/skills/kb/SKILL.md +++ b/skills/kb/SKILL.md @@ -1,13 +1,20 @@ --- name: kb -description: Comprehensive skill for the `kb` CLI and the Karpathy Knowledge Base pattern. Covers the full KB lifecycle — topic scaffolding, multi-source ingestion (URLs, files, YouTube videos and channels, Instagram reels, bookmarks, codebases), wiki article compilation, cross-article querying with file-back, lint-and-heal passes, QMD indexing, and hybrid search. Also covers codebase-specific analysis via inspect commands for complexity, coupling, blast radius, dead code, circular dependencies, symbol/file lookups, backlinks, and code smells. Use when working with kb CLI commands, knowledge base workflows, code vault generation, code graph analysis, code metrics inspection, wiki compilation, or the ingest-compile-query-lint cycle. Do not use for general code review, linting, formatting, building Go projects, or writing application code. +description: Comprehensive skill for the `kb` CLI and the Karpathy Knowledge Base pattern. Covers the full KB lifecycle — topic scaffolding, multi-source ingestion (URLs, files, YouTube videos and channels, Instagram reels, bookmarks, codebases), wiki article compilation, cross-article querying with file-back, lint-and-heal passes, QMD indexing, and hybrid search. Also covers the OKF (Open Knowledge Format) dual-mode lifecycle: per-topic `mode: wiki|okf`, scaffolding portable OKF bundles, promoting compiled wiki concepts into a typed catalog with `kb promote`, the four-producer-field + relative-link contract, a local concept-type vocabulary, and OKF v0.1 conformance checking with `kb okf check`. Also covers codebase-specific analysis via inspect commands for complexity, coupling, blast radius, dead code, circular dependencies, symbol/file lookups, backlinks, and code smells. Use when working with kb CLI commands, knowledge base workflows, code vault generation, code graph analysis, code metrics inspection, wiki compilation, the ingest-compile-query-lint cycle, or the wiki→OKF distill loop (promote + conformance). Do not use for general code review, linting, formatting, building Go projects, or writing application code. --- # kb CLI and Knowledge Base Pattern Build and maintain a self-compiling Obsidian markdown knowledge base using the `kb` CLI. The LLM reads raw sources, writes cross-linked wiki articles, files Q&A results back into the corpus, and runs lint-and-heal passes. The CLI also supports codebase ingestion with deep inspection commands for code quality, architecture health, and symbol relationships. -Each **topic** lives in its own folder inside the Obsidian vault, either directly at the vault root (e.g. `go-best-practices/`) or nested by configured glob (e.g. `harness/goclaw/`). A topic contains `raw/`, `wiki/`, `outputs/`, and `bases/` subtrees plus topic-level `CLAUDE.md`, `topic.yaml`, and `log.md`. All topics share a single Obsidian vault, commonly the repo root. Read `references/architecture.md` for the full rationale and the four-phase pipeline (ingest → compile → query → lint). +Each **topic** lives in its own folder inside the Obsidian vault, either directly at the vault root (e.g. `go-best-practices/`) or nested by configured glob (e.g. `harness/goclaw/`). All topics share a single Obsidian vault, commonly the repo root. Read `references/architecture.md` for the full rationale and the four-phase pipeline (ingest → compile → query → lint). + +Every topic has a lifecycle **`mode`** recorded in `topic.yaml` — **`wiki`** (the default) or **`okf`**: + +- A **wiki topic** is the Karpathy research lab described throughout this skill. It contains `raw/`, `wiki/`, `outputs/`, and `bases/` subtrees plus topic-level `CLAUDE.md`, `topic.yaml`, and `log.md`, and follows the ingest → compile → query → lint loop. +- An **OKF topic** is a portable Open Knowledge Format catalog (a "bundle") that other people's agents and tools consume. It is **flat**: typed concept files at the bundle root, plus a generated `index.md`, a `log.md`, an OKF-flavored `CLAUDE.md`, and the `AGENTS.md` symlink — no `raw/`/`wiki/`/`outputs/` pyramid. See the **OKF Dual-Mode** section below and `references/okf-mode.md`. + +Mode is opt-in per topic and invisible to existing users: absent/empty `mode` normalizes to `wiki`, so every existing topic behaves exactly as before. The topic's **`CLAUDE.md`** is the **schema document** and topic marker — it tells the LLM the scope, conventions, current articles, and research gaps for that topic. `topic.yaml` is the structured source of truth for topic metadata (`slug`, `title`, `domain`). `AGENTS.md` may symlink to `CLAUDE.md` for Codex parity, but the valid-topic marker is `CLAUDE.md`. @@ -41,6 +48,35 @@ Based on Andrej Karpathy's LLM Wiki pattern, the KB treats the LLM as a **compil Read `references/architecture.md` for the full rationale, context-window vs RAG tradeoffs, and multi-topic vault design. +## OKF Dual-Mode + +`kb` supports two distinct knowledge lifecycles, selected per topic by `mode`: + +- **`wiki`** (default) — a research **lab**: `ingest → compile → query → lint`. Obsidian `[[wikilinks]]`, the wiki frontmatter schema, the `raw/`+`wiki/`+`outputs/` pyramid. This is the lifecycle the rest of this skill documents. +- **`okf`** — a portable **catalog**: `declare → consume`. A flat bundle of typed concepts that conforms to the **Open Knowledge Format (OKF) v0.1**, meant to be shared and consumed by *other* people's agents and tools. Plain relative markdown links, the OKF producer-field contract, a generated `index.md`. + +The two are not derived from each other; the high-leverage move is **distilling research into the catalog** — turning a finished wiki concept into a typed OKF concept. `kb` makes that one command (`kb promote`), mechanical and non-LLM, then validates the result (`kb okf check`). + +**OKF bundle layout** (what `kb topic new --mode okf` scaffolds): + +``` +/ + CLAUDE.md # OKF-flavored schema/marker (topic marker) + AGENTS.md # symlink → CLAUDE.md + topic.yaml # mode: okf + index.md # generated; frontmatter okf_version: "0.1"; concepts grouped by type + log.md # "# Directory Update Log"; ISO-date (## YYYY-MM-DD) headings, newest first + .md # typed concept files, flat at the bundle root (added by promote/authoring) +``` + +There is no `raw/`/`wiki/`/`outputs/`/`bases/` in OKF mode. `index.md` and `log.md` are **auto-maintained by `kb`** (regenerated on scaffold and on every `promote`). + +**OKF concept contract** — every concept `.md` carries four producer fields in frontmatter (emitted alphabetically): `description`, `timestamp` (RFC3339, UTC), `title`, `type`, plus optional `tags`. `type` is the only field OKF v0.1 strictly requires; the other three and the relative-link style are two deliberate, documented deviations matching Google's reference tooling. Concept bodies use **relative markdown links** (`[label](other-concept.md)`), never `[[wikilinks]]`. + +Two deviations from the written OKF spec are intentional (ADR-002): emit the four producer fields (spec mandates only `type`) and emit relative links (spec recommends absolute `/path.md`, which breaks GitHub rendering). + +Read `references/okf-mode.md` for the full bundle layout, frontmatter contract, promote transform rules, conformance ruleset, and the concept-path/link model. + ## Related Skills This skill orchestrates several companion skills for the LLM-driven phases: @@ -54,11 +90,36 @@ This skill orchestrates several companion skills for the LLM-driven phases: ### Topic management ```bash -kb topic new <domain> # scaffold a new topic -kb topic list # list all topics in the vault -kb topic info <topic-id> # topic metadata (counts, last log entry) +kb topic new <slug> <title> <domain> # scaffold a new wiki topic (mode: wiki, default) +kb topic new <slug> <title> <domain> --mode okf # scaffold a flat OKF bundle (mode: okf) +kb topic list # list all topics in the vault +kb topic info <topic-id> # topic metadata (counts, last log entry) +``` + +`--mode` accepts `wiki` (default) or `okf`. An OKF topic scaffolds the flat bundle layout (see the OKF Dual-Mode section), not the wiki pyramid. + +### OKF bundles (promote + conformance) + +```bash +kb promote <wiki-doc> --to <okf-topic> --type <Type> # distill a wiki concept into an OKF bundle +kb promote <wiki-doc> --to <okf-topic> --type <Type> --description "…" # override the generated description +kb okf check <okf-topic> # validate OKF v0.1 conformance (lenient) +kb okf check <okf-topic> --strict --format json # promote local-standard warnings to errors (CI gate) +``` + +`kb promote` is **mechanical, non-LLM, and non-destructive**: it reads a compiled wiki document, remaps its frontmatter to the OKF producer contract, rewrites `[[wikilinks]]` to relative markdown links, writes a new typed concept at the bundle root, regenerates the bundle's `index.md`, and inserts a newest-first `log.md` entry. **The source wiki document is left untouched.** Both `--to` and `--type` are required, and `--to` must resolve to a `mode: okf` topic or `promote` errors before writing. The concept filename is a slug of the source document's base name (collisions get a `-2`, `-3` suffix), so its filename and every inbound link share one canonical key. `kb promote` emits the JSON `ConceptResult` (`writtenPath`, `type`, `linksRewritten`, `unresolvedLinks`, `warnings`). + +`kb okf check` is **lenient by default** per OKF §9 (tolerates broken cross-links, unknown `type` values, missing optional fields) so externally produced bundles pass. It emits diagnostics as `severity, kind, filePath, target, message` and exits non-zero when any **error** is present (and on **warnings** too under `--strict`). Hard errors include a concept with a missing/empty `type` and unparseable frontmatter; local-standard **warnings** include a missing producer field (`title`/`description`/`timestamp`) and a `type` outside the configured vocabulary. The type vocabulary is the local standard in `kb.toml`: + +```toml +[okf] +# Local OKF concept type vocabulary. Empty (the default) means `kb okf check` +# never warns about unknown types until you opt into a local standard. +types = ["Voice Profile", "Offer", "Playbook"] ``` +When `[okf].types` is non-empty, a `--type` (or existing concept type) outside the list is a warning — `--strict` turns it into a CI-failing error, preventing type drift (`Voice Profile` vs `voice-profile`). + ### Ingestion (auto-generates frontmatter, auto-appends to log.md) ```bash @@ -149,7 +210,7 @@ kb search "<query>" --lex --topic <topic-id> # keyword-only search kb search "<query>" --vec --topic <topic-id> # vector-only search ``` -After running `kb ingest` or `kb lint --save`, the CLI auto-appends entries to `<topic>/log.md`. Manual log entries are still needed for compile, query, promote, and split operations (Procedure 5). +After running `kb ingest` or `kb lint --save`, the CLI auto-appends entries to `<topic>/log.md`. `kb promote` (wiki→OKF) also auto-maintains the **OKF bundle's** `log.md` and `index.md`. Manual log entries are still needed for compile, query, the wiki-internal query→wiki promotion, and split operations (Procedure 5). ## Command Dispatch @@ -157,7 +218,11 @@ Map the user's intent to the correct command: | Intent | Command | |--------|---------| -| Scaffold a new topic | `kb topic new <slug> <title> <domain>` | +| Scaffold a new wiki topic | `kb topic new <slug> <title> <domain>` | +| Scaffold a new OKF bundle | `kb topic new <slug> <title> <domain> --mode okf` | +| Distill a wiki concept into an OKF bundle | `kb promote <wiki-doc> --to <okf-topic> --type <Type>` | +| Check an OKF bundle for conformance | `kb okf check <okf-topic>` | +| Gate OKF conformance in CI | `kb okf check <okf-topic> --strict --format json` | | List all topics | `kb topic list` | | Scrape a web URL | `kb ingest url <url> --topic <topic-id>` | | Ingest a local file (PDF, DOCX, etc.) | `kb ingest file <path> --topic <topic-id>` | @@ -436,7 +501,7 @@ After the heal pass, append `## [YYYY-MM-DD] lint | <N> issues found, <M> fixed` ### Procedure 5: Append to log.md -The `kb` CLI auto-appends log entries for `ingest` and `lint --save` operations. Manual entries are needed for **compile**, **query**, **promote**, and **split** operations. +The `kb` CLI auto-appends log entries for `ingest`, `lint --save`, and `kb promote` (the latter writes to the **target OKF bundle's** `log.md`). Manual entries are needed for **compile**, **query**, **promote** (the wiki-internal query→wiki promotion of Procedure 3, distinct from the `kb promote` CLI command), and **split** operations. **Format** -- each entry is a single H2 heading with a consistent prefix so the log stays grep-able: @@ -467,6 +532,24 @@ grep "^## \[2026-04" <topic>/log.md # April 2026 events Keep `log.md` at the topic root (not inside `wiki/` or `outputs/`) so it sits alongside `CLAUDE.md` as a first-class topic artifact. +### Procedure 6: Distill a wiki concept into an OKF bundle + +The wiki→OKF distill loop turns finished research into a portable, typed catalog. It is mechanical and CLI-driven; `kb` does the structural work and any intelligent rewriting/condensing is left to you or an external agent. + +1. **Ensure a target OKF bundle exists.** If not, scaffold one: `kb topic new <slug> <title> <domain> --mode okf`. The target of `promote` **must** be a `mode: okf` topic. +2. **Pick the source.** Choose a compiled, durable `<topic>/wiki/concepts/<Article>.md` worth declaring in the catalog (promote operates on a single document; ingest-stage `raw/` files are not the intended source). +3. **Choose the type.** Pass `--type <Type>` from your local vocabulary (`[okf].types` in `kb.toml`). Off-vocabulary types are allowed but warn under `kb okf check`; extend the vocabulary by editing `kb.toml` rather than inventing drifted variants. +4. **Promote.** + ```bash + kb promote "<topic>/wiki/concepts/<Article>.md" --to <okf-topic> --type "<Type>" --description "<one-line summary>" + ``` + `kb` writes the new concept (four producer fields + relative links) at the bundle root, regenerates `index.md`, and appends the bundle's `log.md`. The source wiki document is untouched. Omit `--description` to let `kb` fall back to the first body sentence (it warns if the body has none). +5. **Resolve links.** Inspect `unresolvedLinks` in the JSON result — these are `[[wikilinks]]` whose target concept has not been promoted yet (the link is still emitted and tolerated per §9). Promote those targets too when you want the link to resolve. +6. **Verify conformance.** Run `kb okf check <okf-topic>`; fix any errors and any warnings you care about. Use `--strict` in CI to enforce the producer fields and the type vocabulary. +7. **No manual log entry is needed** — `kb promote` already appended the OKF bundle's `log.md`. + +Read `references/okf-mode.md` for the frontmatter remap table, the wikilink→markdown transform rules, the concept-path/collision model, and the full conformance ruleset. + ## Output Format Selection All `inspect` and `search` commands support `--format`: @@ -492,6 +575,10 @@ Read `references/output-formats.md` for format examples and empty result handlin | `--title and --domain are bootstrap-only` | Remove those flags when re-ingesting an existing topic | | `no symbols matched "<query>"` | Use `inspect smells` or `inspect complexity` to discover valid names | | `no file matched "<path>"` | Use exact source-relative path from vault frontmatter (e.g. `src/config.ts` not `./src/config.ts`) | +| `promote: target topic must use mode okf` | `--to` points at a wiki topic. Pass an existing `mode: okf` topic, or scaffold one with `kb topic new <slug> <title> <domain> --mode okf` | +| `promote: --type is required` / `required flag(s) "to", "type" not set` | Pass both `--to <okf-topic>` and `--type <Type>` | +| `promote: source document not found` | Use a vault-relative path to an existing wiki document, e.g. `<topic>/wiki/concepts/<Article>.md` | +| `okf check: found N issue(s)` (non-zero exit) | Read the diagnostics rows; fix `severity=error` concepts (missing/empty `type`, bad frontmatter). Under `--strict`, warnings (missing producer fields, off-vocabulary types) also fail | ### KB Workflow Errors @@ -516,7 +603,10 @@ Read `references/error-handling.md` for the full error catalog with causes and r - Read `references/compilation-guide.md` before writing wiki articles - Run backlink audits after every article compile (Procedure 1, step 7) - File query answers to `outputs/queries/` (Procedure 3) -- Append manual log entries for compile, query, promote, and split operations +- Append manual log entries for compile, query, the wiki-internal query→wiki promotion, and split operations +- Use an existing `mode: okf` topic (or scaffold one with `--mode okf`) as the `--to` target of `kb promote` +- Pass both `--to` and `--type` to `kb promote`; keep `--type` within `[okf].types` to avoid type drift +- Run `kb okf check` after promoting; use `--strict` in CI to enforce producer fields and the type vocabulary ### MUST NOT DO - Pass both `--lex` and `--vec` to `search` @@ -527,3 +617,6 @@ Read `references/error-handling.md` for the full error catalog with causes and r - Answer wiki queries from general knowledge -- the wiki is the source of truth - Skip the backlink audit when compiling articles - Batch-apply lint fixes without proposing diffs first +- Promote into a wiki topic — `kb promote --to` must be a `mode: okf` topic +- Hand-edit an OKF bundle's `index.md` (it is regenerated by `kb promote`) or use `[[wikilinks]]`/absolute links in OKF concepts (use relative markdown links) +- Expect `kb promote` to rewrite or condense prose — it is mechanical and non-LLM; do any intelligent distillation yourself or with an external agent diff --git a/skills/kb/references/architecture.md b/skills/kb/references/architecture.md index 3bdaf4e..5eca26c 100644 --- a/skills/kb/references/architecture.md +++ b/skills/kb/references/architecture.md @@ -142,7 +142,7 @@ The pattern's trajectory: wiki → synthetic QA pairs → QLoRA fine-tune → do ## Multi-topic vaults -Each folder matched by the vault's topic globs is a **topic** — a self-contained subject with its own `raw/`, `wiki/`, `outputs/`, `bases/` subtrees plus `CLAUDE.md`, `topic.yaml`, and `log.md` at the topic root. Topics may be direct children of the vault root (`go-best-practices/`) or nested by configured glob (`harness/goclaw/`). Use the path relative to the vault root as the topic id, e.g. `--topic harness/goclaw`. +Each folder matched by the vault's topic globs is a **topic** — a self-contained subject with `CLAUDE.md`, `topic.yaml`, and `log.md` at the topic root. A **wiki** topic (the default, `mode: wiki`) also has `raw/`, `wiki/`, `outputs/`, and `bases/` subtrees and follows the four-phase pipeline above. An **OKF** topic (`mode: okf`) is instead a flat portable bundle of typed concepts (see `references/okf-mode.md`); the rest of this document describes the wiki lifecycle. Topics may be direct children of the vault root (`go-best-practices/`) or nested by configured glob (`harness/goclaw/`). Use the path relative to the vault root as the topic id, e.g. `--topic harness/goclaw`. All topics share one Obsidian vault at the root, so cross-topic wikilinks work naturally (e.g., a `harness/goclaw` article can link to a `go-best-practices` article). Topics stay self-contained in terms of content but contribute to a unified knowledge graph. diff --git a/skills/kb/references/frontmatter-schemas.md b/skills/kb/references/frontmatter-schemas.md index db6b59e..487d896 100644 --- a/skills/kb/references/frontmatter-schemas.md +++ b/skills/kb/references/frontmatter-schemas.md @@ -17,10 +17,13 @@ Conventions: slug: harness/goclaw title: Goclaw domain: goclaw +mode: wiki # wiki (default) | okf — omit/empty normalizes to wiki ``` `topic.yaml` is the primary source of truth for topic title/domain. `CLAUDE.md` remains the topic marker and schema document, but prose formatting in `CLAUDE.md` should not be treated as canonical metadata when `topic.yaml` exists. +`mode` selects the topic's lifecycle: `wiki` (the research lab schemas below) or `okf` (a portable OKF bundle — see *OKF concept* and `references/okf-mode.md`). An absent or empty `mode` normalizes to `wiki`. + --- ## Wiki article — `<topic>/wiki/concepts/<Article Title>.md` @@ -244,6 +247,23 @@ updated: YYYY-MM-DD --- ``` +## OKF concept — `<okf-bundle>/<concept>.md` (mode: okf) + +OKF bundles use a different contract from wiki topics. Each concept carries the **four producer fields**, emitted **alphabetically** (`kb` sorts frontmatter keys), with optional `tags`: + +```yaml +--- +description: One-line summary of the concept. +tags: + - example # optional — only when the source carried tags +timestamp: 2026-06-27T14:03:11Z # RFC3339, UTC +title: Concept Title +type: Voice Profile # OKF concept type (the only field OKF v0.1 requires) +--- +``` + +Concept bodies use **relative markdown links** (`[label](other-concept.md)`), never `[[wikilinks]]`. Wiki stage markers (`stage`, wiki `type`, `domain`, `sources`, `created`/`updated`) are dropped on promote. The bundle-root `index.md` carries only `okf_version: "0.1"` frontmatter and is regenerated by `kb`. See `references/okf-mode.md` for the full contract, the promote remap table, and the conformance ruleset. + ## Quick reference | File type | Path | type | stage | @@ -259,3 +279,5 @@ updated: YYYY-MM-DD | Diagram | `outputs/diagrams/` | `output` | `diagram` | | Lint report | `outputs/reports/` | `output` | `lint-report` | | Index | `wiki/index/` | `index` | — | +| OKF concept | `<okf-bundle>/` | `<concept type>` | — (uses `timestamp`, not `stage`) | +| OKF bundle index | `<okf-bundle>/index.md` | — (`okf_version` only) | — | diff --git a/skills/kb/references/okf-mode.md b/skills/kb/references/okf-mode.md new file mode 100644 index 0000000..1d6885b --- /dev/null +++ b/skills/kb/references/okf-mode.md @@ -0,0 +1,184 @@ +# OKF Dual-Mode Reference + +`kb` runs two distinct knowledge lifecycles, selected per topic by a `mode` field: + +- **`wiki`** (default) — the Karpathy research **lab**: `ingest → compile → query → lint`, Obsidian `[[wikilinks]]`, the `raw/`+`wiki/`+`outputs/` pyramid. Documented across the rest of this skill. +- **`okf`** — a portable **catalog** conforming to the **Open Knowledge Format (OKF) v0.1** (Google Cloud, 2026): a flat bundle of typed concepts, plain relative markdown links, meant to be *declared and shared* so other people's agents and tools can consume it. + +They are two lifecycles, not one derived from the other. The daily high-leverage move is **distilling research into the catalog** — `kb promote` turns a finished wiki concept into a typed OKF concept; `kb okf check` validates the bundle. + +Mode lives in `topic.yaml` as `mode: wiki|okf`. Absent/empty `mode` normalizes to `wiki`, so every pre-existing topic is unchanged (zero regression). + +> **Scope (MVP).** This reference covers the shipped MVP: `mode`, OKF scaffold, `kb promote`, `kb okf check`, and the `[okf].types` vocabulary. Out of scope for now: `kb okf export` (wiki→bundle), `kb okf ingest` (external bundle→research), and codebase→OKF emission. An OKF bundle is **one topic** with concepts **flat at the bundle root**; type-grouped or nested concept directories are a later-phase option. + +--- + +## Bundle layout + +`kb topic new <slug> <title> <domain> --mode okf` scaffolds a **flat** bundle (no wiki pyramid, no `.gitkeep` files): + +``` +<bundle>/ + CLAUDE.md # OKF-flavored schema document + topic marker + AGENTS.md # symlink → CLAUDE.md (Codex parity) + topic.yaml # slug, title, domain, mode: okf + index.md # generated; frontmatter `okf_version: "0.1"`; body "# OKF Bundle Index" + log.md # "# Directory Update Log" + an Initialization entry under "## YYYY-MM-DD" + <concept>.md # typed concept files at the bundle root (added by promote / authoring) +``` + +The topic marker is still `CLAUDE.md` (as for wiki topics). `index.md` and `log.md` are **auto-maintained by `kb`** — regenerated on scaffold and on every `promote`. The OKF `CLAUDE.md` documents the bundle contract and the `kb promote` / `kb okf check` operations instead of the wiki pipeline. + +--- + +## OKF concept frontmatter contract + +Every concept `.md` carries the **four producer fields**. `kb` emits them **alphabetically** (`frontmatter.Generate` sorts keys), so a promoted concept looks like: + +```yaml +--- +description: One-line summary of the concept. +tags: # optional — only when the source carried tags + - example +timestamp: 2026-06-27T14:03:11Z # RFC3339, UTC +title: Concept Title +type: Voice Profile +--- +``` + +- `type` is the **only** field OKF v0.1 strictly requires. The other three producer fields and the relative-link style are **two deliberate, documented deviations** (ADR-002) that match Google's reference tooling: + 1. emit the four producer fields (the spec mandates only `type`); + 2. emit **relative** markdown links (the spec recommends absolute `/path.md`, which breaks GitHub rendering). +- Do **not** assert a `type`-first field order in tooling — YAML maps are unordered and `kb` sorts keys alphabetically. +- Wiki stage markers (`stage`, wiki `type: wiki`, `domain`, `sources`, `created`/`updated`) are **dropped**; they do not belong in an OKF concept. + +### `promote` frontmatter remap + +`kb promote` derives each OKF field deterministically from the source wiki document: + +| OKF field | Source | Fallback | +| --- | --- | --- | +| `type` | `--type` (validated against `[okf].types`) | none — **required**; error if absent | +| `title` | source `title` | humanized concept key (slug of the source base name) | +| `description` | `--description` flag | first non-empty body sentence (markdown-stripped) → else empty **+ warning** | +| `timestamp` | promote-time clock, RFC3339 / UTC | — (promotion *is* the last meaningful change; source `scraped`/`created` are date-only and not used) | +| `tags` | source `tags` when present | omitted | + +`promote` is **mechanical, non-LLM, and non-destructive** — the source wiki document is never modified. Any intelligent rewriting/condensing is left to the operator or an external agent. + +--- + +## Concept path & link model + +A **single canonical key** drives both the concept filename and every inbound link, so they can never diverge. + +- **Canonical key:** `vault.SlugifySegment(base(source-path))` — the existing slug primitive (lowercase `[a-z0-9-]`, drops accents so `Conteúdo` → `conte-do`, empty → `item`). +- **Filename:** a concept promoted from `SourceDocPath` is written to `<bundle-root>/<key>.md`. Filename collisions get a numeric suffix (`-2`, `-3`, …). +- **Wikilink → markdown transform:** each `[[target|label]]` in the body becomes a relative markdown link. The target is normalized (strip a leading `/`, strip the source topic slug prefix, slugify the base name) to `<key>.md`, i.e. the **same** key the target concept's own filename uses — so a link to an already-promoted concept resolves to its real file. With a flat root the link is `[label](<key>.md)`. + - A `#anchor` fragment is preserved: `[label](<key>.md#anchor)`. + - A missing label falls back to the humanized key. +- **Unresolved targets:** when no promoted counterpart exists yet, the link is still emitted to the would-be path (tolerated per OKF §9) and recorded in `ConceptResult.UnresolvedLinks` for follow-up. + +### `kb promote` JSON result (`ConceptResult`) + +```json +{ + "writtenPath": "voice-profile.md", + "type": "Voice Profile", + "linksRewritten": 4, + "unresolvedLinks": ["offer-ladder.md"], + "warnings": ["type \"Voice Profile\" is outside the configured OKF vocabulary"] +} +``` + +--- + +## Auto-maintained `index.md` and `log.md` + +**`index.md`** is regenerated on every `promote`. It carries only `okf_version` frontmatter (preserved from the existing root index, defaulting to `0.1`) and a body that groups concepts by `type`, sorted by type then title then path: + +```markdown +--- +okf_version: "0.1" +--- +# OKF Bundle Index + +## Offer + +* [Founder Offer](founder-offer.md) - The flagship offer for founders. + +## Voice Profile + +* [Brand Voice](brand-voice.md) - How the brand sounds across channels. +``` + +Bullets use relative markdown links; the trailing `- <description>` is included when the concept has one. **Do not hand-edit `index.md`** — your edits are overwritten on the next promote. + +**`log.md`** uses ISO-date `## YYYY-MM-DD` headings, newest first, with bullet entries: + +```markdown +# Directory Update Log + +## 2026-06-27 + +* **Creation**: Promoted [Brand Voice](brand-voice.md) from `research/wiki/concepts/Brand Voice.md`. + +## 2026-06-20 + +* **Initialization**: Created OKF bundle `second-brain` for `operations`. +``` + +`kb promote` inserts the entry under the current date heading (creating it if needed). No manual log entry is required after `promote`. + +--- + +## Conformance checking (`kb okf check`) + +```bash +kb okf check <okf-topic> [--strict] [--format table|json|tsv] +``` + +`kb okf check` walks the bundle and validates OKF v0.1 **leniently** (§9): it tolerates broken cross-links, unknown `type` values, and missing optional fields, so externally produced bundles pass. Diagnostics are emitted as columns `severity, kind, filePath, target, message`. The command **exits non-zero** when any `error` is present (and, under `--strict`, when any `warning` is present too) — suitable as a CI gate. + +### Excluded files (never require a `type`) + +`index.md`, `log.md`, `CLAUDE.md`, `AGENTS.md`, symlinks, and `README` / `LICENSE` / `NOTICE` / `ATTRIBUTION` markdown files, plus dot-directories. Everything else under the bundle is a concept file. + +### Errors (always fail; `severity=error`) + +- A concept `.md` with **missing or empty `type`**. +- A concept (or `index.md`) with **unparseable frontmatter**, or a concept with **no leading YAML frontmatter**. +- A non-root file named `index.md` declaring `okf_version`, or the root `index.md` declaring any key other than `okf_version`. +- A `log.md` `## ` heading that is not a valid `YYYY-MM-DD` date. + +### Warnings (lenient by default; become errors under `--strict`) + +- A concept missing a producer field: `title`, `description`, or `timestamp`. +- A concept `type` **outside** the configured `[okf].types` vocabulary (only checked when the vocabulary is non-empty). + +--- + +## Type vocabulary (`[okf].types`) + +The local concept-type standard, configured in `kb.toml`: + +```toml +[okf] +# Local OKF concept type vocabulary. Empty (the default) means neither +# `kb promote` nor `kb okf check` warns about unknown types. +types = ["Voice Profile", "Offer", "Playbook"] +``` + +OKF has no global type registry by design; this is a **local** standard that prevents drift (`Voice Profile` vs `voice-profile`). When the list is empty, the type check is a no-op (no false warnings). When non-empty, an off-vocabulary `--type` warns at `promote` time and at `okf check` time; `--strict` makes it a CI-failing error. Extend the standard by editing `kb.toml`, not by inventing drifted variants. + +--- + +## CLI surface + +| Command | Args / Flags | Behavior | +| --- | --- | --- | +| `kb topic new <slug> <title> <domain>` | `--mode wiki\|okf` (default `wiki`) | Wiki scaffold (unchanged) or flat OKF bundle. | +| `kb promote <wiki-doc>` | `--to <okf-topic>` (req), `--type <T>` (req), `--description <text>` | Mechanical, non-destructive wiki→OKF concept; `--to` must be a `mode: okf` topic. Emits `ConceptResult` JSON. | +| `kb okf check <okf-topic>` | `--strict`, `--format table\|json\|tsv` | OKF v0.1 conformance + local-standard warnings; non-zero exit on errors (and warnings under `--strict`). | + +> The `kb okf` group grows in later phases (`kb okf export`, `kb okf ingest`), which are out of MVP scope.